mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-25 12:54:09 +02:00
Identify collapsed values and open them in a viewer (#533)
This commit is contained in:
Generated
+1
@@ -10982,6 +10982,7 @@ dependencies = [
|
|||||||
name = "yaak-app-client"
|
name = "yaak-app-client"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"charset",
|
"charset",
|
||||||
"chrono",
|
"chrono",
|
||||||
"cookie",
|
"cookie",
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { contextMenusAtom, hideContextMenu } from "../lib/contextMenu";
|
||||||
|
import { ContextMenu } from "./core/Dropdown";
|
||||||
|
import { ErrorBoundary } from "./ErrorBoundary";
|
||||||
|
|
||||||
|
/** Renders menus opened by {@link showContextMenu}, the way {@link Dialogs} renders dialogs. */
|
||||||
|
export function ContextMenus() {
|
||||||
|
const menus = useAtomValue(contextMenusAtom);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{menus.map(({ id, items, triggerPosition, triggerRect }) => (
|
||||||
|
<ErrorBoundary key={id} name={`ContextMenu ${id}`}>
|
||||||
|
<ContextMenu
|
||||||
|
items={items}
|
||||||
|
triggerPosition={triggerPosition}
|
||||||
|
triggerRect={triggerRect}
|
||||||
|
onClose={() => hideContextMenu(id)}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||||
|
import { Banner, Button, HStack, LoadingIcon } from "@yaakapp-internal/ui";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { SniffedValue } from "./core/Editor/sniffValue";
|
||||||
|
import { showDialog } from "../lib/dialog";
|
||||||
|
import { decodeValue, largeValueActions } from "../lib/largeValue";
|
||||||
|
import { Dropdown } from "./core/Dropdown";
|
||||||
|
import { AudioViewer } from "./responseViewers/AudioViewer";
|
||||||
|
import { ImageViewer } from "./responseViewers/ImageViewer";
|
||||||
|
import { SvgViewer } from "./responseViewers/SvgViewer";
|
||||||
|
import { VideoViewer } from "./responseViewers/VideoViewer";
|
||||||
|
|
||||||
|
const PdfViewer = lazy(() =>
|
||||||
|
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The whole value, exactly as it reads in the document */
|
||||||
|
text: string;
|
||||||
|
sniffed: SniffedValue | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a value the editor collapsed, in whatever viewer its type calls for.
|
||||||
|
*
|
||||||
|
* The editor only ever read the value's head, so this is where it is decoded in full — on an
|
||||||
|
* explicit click, behind a spinner, rather than during layout.
|
||||||
|
*/
|
||||||
|
export function LargeValueDialog({ text, sniffed }: Props) {
|
||||||
|
const viewer = sniffed == null ? null : viewerFor(sniffed.mime);
|
||||||
|
const decoded = useDecoded(text, viewer == null ? null : sniffed);
|
||||||
|
|
||||||
|
// Nothing recognised it, so there is nothing to decode it into
|
||||||
|
if (sniffed == null || viewer == null) {
|
||||||
|
return (
|
||||||
|
<PagedText text={text}>
|
||||||
|
{sniffed != null && (
|
||||||
|
<Banner color="info" className="mb-3">
|
||||||
|
{sniffed.label} content cannot be previewed, so it is shown as it appears in the
|
||||||
|
response.
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
</PagedText>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded == null) {
|
||||||
|
return (
|
||||||
|
<HStack className="h-full" alignItems="center" justifyContent="center">
|
||||||
|
<LoadingIcon />
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("error" in decoded) {
|
||||||
|
return (
|
||||||
|
<PagedText text={text}>
|
||||||
|
<Banner color="danger" className="mb-3">
|
||||||
|
Failed to decode this {sniffed.label} value: {decoded.error}
|
||||||
|
</Banner>
|
||||||
|
</PagedText>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bytes } = decoded;
|
||||||
|
|
||||||
|
switch (viewer) {
|
||||||
|
case "svg":
|
||||||
|
return <DecodedSvg bytes={bytes} />;
|
||||||
|
case "image":
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto flex items-center justify-center">
|
||||||
|
<ImageViewer data={toArrayBuffer(bytes)} mimeType={sniffed.mime} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "audio":
|
||||||
|
return <AudioViewer data={bytes} mimeType={sniffed.mime} />;
|
||||||
|
case "video":
|
||||||
|
return <VideoViewer data={bytes} mimeType={sniffed.mime} />;
|
||||||
|
case "pdf":
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingIcon />}>
|
||||||
|
<PdfViewer data={bytes} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
case "text":
|
||||||
|
return <DecodedText bytes={bytes} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoding megabytes is worth doing once, not on every render — and the viewers below key their
|
||||||
|
* blob URLs off the string, so a fresh one each time would rebuild them for nothing.
|
||||||
|
*/
|
||||||
|
function DecodedSvg({ bytes }: { bytes: Uint8Array }) {
|
||||||
|
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
|
||||||
|
return <SvgViewer text={text} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DecodedText({ bytes }: { bytes: Uint8Array }) {
|
||||||
|
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
|
||||||
|
return <PagedText text={text} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same menu the tag in the editor carries, minus the one entry that would only reopen this.
|
||||||
|
*
|
||||||
|
* It lives in the dialog's title rather than above the content, which would cost a band of
|
||||||
|
* whitespace the width of the dialog to hold one small button.
|
||||||
|
*/
|
||||||
|
function LargeValueActions({ text, sniffed }: Props) {
|
||||||
|
const items = useMemo(
|
||||||
|
() =>
|
||||||
|
largeValueActions({
|
||||||
|
value: () => text,
|
||||||
|
sniffed,
|
||||||
|
// The tag copies only what it hides; in here, what you see is the whole value
|
||||||
|
copyText: () => text,
|
||||||
|
}),
|
||||||
|
[text, sniffed],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown items={items}>
|
||||||
|
<Button size="xs" variant="border" forDropdown>
|
||||||
|
Actions
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Viewer = "image" | "svg" | "audio" | "video" | "pdf" | "text";
|
||||||
|
|
||||||
|
/** Which viewer a media type calls for, or null if none of them can show it. */
|
||||||
|
function viewerFor(mime: string): Viewer | null {
|
||||||
|
if (mime.startsWith("image/svg")) return "svg";
|
||||||
|
if (mime.startsWith("image/")) return "image";
|
||||||
|
if (mime.startsWith("audio/")) return "audio";
|
||||||
|
if (mime.startsWith("video/")) return "video";
|
||||||
|
if (mime === "application/pdf") return "pdf";
|
||||||
|
if (mime.startsWith("text/") || /\b(json|xml|javascript|csv)\b/.test(mime)) return "text";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Decoded = { bytes: Uint8Array } | { error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value's bytes, once they exist.
|
||||||
|
*
|
||||||
|
* Decoding a few megabytes takes long enough to be seen, so it happens in an effect rather than
|
||||||
|
* during render — the dialog paints with a spinner first, instead of opening late.
|
||||||
|
*/
|
||||||
|
function useDecoded(text: string, sniffed: SniffedValue | null): Decoded | null {
|
||||||
|
const [decoded, setDecoded] = useState<Decoded | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sniffed == null) return;
|
||||||
|
|
||||||
|
setDecoded(null);
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
let result: Decoded;
|
||||||
|
try {
|
||||||
|
result = { bytes: decodeValue(text, sniffed) };
|
||||||
|
} catch (err) {
|
||||||
|
result = { error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
if (!cancelled) setDecoded(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [text, sniffed]);
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A copy, since a `Uint8Array` may be a view onto a larger buffer */
|
||||||
|
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||||
|
return bytes.slice().buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Characters shown at once. A page this size lays out instantly once its lines are short. */
|
||||||
|
const PAGE_CHARS = 50_000;
|
||||||
|
|
||||||
|
/** Where a line is broken. The long-line cost this whole feature avoids is per line. */
|
||||||
|
const WRAP_CHARS = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The raw text, in pages of short lines.
|
||||||
|
*
|
||||||
|
* What is left when nothing can render the value. Handing it to an editor whole would walk
|
||||||
|
* straight back into the layout stall that collapsed it in the first place, so it is paged and
|
||||||
|
* hard-wrapped instead: no line is ever long, and no page is ever big.
|
||||||
|
*/
|
||||||
|
function PagedText({ text, children }: { text: string; children?: ReactNode }) {
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const pages = Math.max(1, Math.ceil(text.length / PAGE_CHARS));
|
||||||
|
const from = page * PAGE_CHARS;
|
||||||
|
const to = Math.min(from + PAGE_CHARS, text.length);
|
||||||
|
const body = useMemo(() => wrap(text.slice(from, to)), [text, from, to]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3">
|
||||||
|
<div>{children}</div>
|
||||||
|
<pre className="overflow-auto font-mono text-sm text-text-subtle select-text">{body}</pre>
|
||||||
|
<HStack space={2} alignItems="center" className="text-sm text-text-subtle">
|
||||||
|
<span>
|
||||||
|
{(to - from).toLocaleString()} of {text.length.toLocaleString()} characters
|
||||||
|
</span>
|
||||||
|
{pages > 1 && (
|
||||||
|
<HStack space={2} alignItems="center" className="ml-auto">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="border"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span>
|
||||||
|
Page {page + 1} of {pages.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="border"
|
||||||
|
disabled={page >= pages - 1}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Breaks every line at {@link WRAP_CHARS}, leaving the ones already shorter alone. */
|
||||||
|
function wrap(text: string): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
if (line.length <= WRAP_CHARS) {
|
||||||
|
lines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < line.length; i += WRAP_CHARS) {
|
||||||
|
lines.push(line.slice(i, i + WRAP_CHARS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showLargeValueDialog({ text, sniffed }: Props) {
|
||||||
|
showDialog({
|
||||||
|
id: "large-value",
|
||||||
|
size: "lg",
|
||||||
|
className: "h-[calc(100vh-10rem)]",
|
||||||
|
// Everything in one line: the same words the tag uses, and the same menu it carries. A
|
||||||
|
// separate description and a row of its own for the button cost three bands to say this.
|
||||||
|
title: (
|
||||||
|
<HStack space={3} alignItems="center">
|
||||||
|
<span>
|
||||||
|
{sniffed == null ? "Hidden Value" : sniffed.label} · {formatSize(text.length)}
|
||||||
|
</span>
|
||||||
|
<LargeValueActions text={text} sniffed={sniffed} />
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
render: () => <LargeValueDialog text={text} sniffed={sniffed} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -233,23 +233,33 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
|
|||||||
|
|
||||||
export interface ContextMenuProps {
|
export interface ContextMenuProps {
|
||||||
triggerPosition: { x: number; y: number } | null;
|
triggerPosition: { x: number; y: number } | null;
|
||||||
|
/**
|
||||||
|
* The box the menu belongs to, when the trigger has one.
|
||||||
|
*
|
||||||
|
* Placement aligns to the trigger's left or right edge depending on the room beside it, so a
|
||||||
|
* menu opened from an element wants its real rect. Without one the position is treated as a
|
||||||
|
* zero-width point, which is right for a menu opened at the cursor and wrong for one opened
|
||||||
|
* from a button.
|
||||||
|
*/
|
||||||
|
triggerRect?: Pick<DOMRect, "top" | "bottom" | "left" | "right">;
|
||||||
className?: string;
|
className?: string;
|
||||||
items: DropdownProps["items"];
|
items: DropdownProps["items"];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function ContextMenu(
|
export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function ContextMenu(
|
||||||
{ triggerPosition, className, items, onClose },
|
{ triggerPosition, triggerRect, className, items, onClose },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const triggerShape = useMemo(
|
const triggerShape = useMemo(
|
||||||
() => ({
|
() =>
|
||||||
top: triggerPosition?.y ?? 0,
|
triggerRect ?? {
|
||||||
bottom: triggerPosition?.y ?? 0,
|
top: triggerPosition?.y ?? 0,
|
||||||
left: triggerPosition?.x ?? 0,
|
bottom: triggerPosition?.y ?? 0,
|
||||||
right: triggerPosition?.x ?? 0,
|
left: triggerPosition?.x ?? 0,
|
||||||
}),
|
right: triggerPosition?.x ?? 0,
|
||||||
[triggerPosition],
|
},
|
||||||
|
[triggerPosition, triggerRect],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (triggerPosition == null) return null;
|
if (triggerPosition == null) return null;
|
||||||
@@ -259,6 +269,9 @@ export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function Co
|
|||||||
isOpen={true} // Always open because we return null if not
|
isOpen={true} // Always open because we return null if not
|
||||||
className={className}
|
className={className}
|
||||||
defaultSelectedIndex={null}
|
defaultSelectedIndex={null}
|
||||||
|
// A menu opened from an element points back at it. One opened at the cursor has nothing
|
||||||
|
// to point at, so it goes without.
|
||||||
|
showTriangle={triggerRect != null}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
items={items}
|
items={items}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
|||||||
@@ -121,16 +121,24 @@
|
|||||||
@apply cursor-default;
|
@apply cursor-default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* An icon button any tag can carry, like the copy on a large value */
|
/* The chevron on a tag that opens a menu. The tag itself is the button, so this is
|
||||||
|
only the affordance that says so. */
|
||||||
.tag-action {
|
.tag-action {
|
||||||
@apply inline-flex items-center align-middle ml-1 cursor-pointer!;
|
@apply inline-flex items-center align-middle ml-1;
|
||||||
@apply text-text-subtlest hover:text-text;
|
@apply text-text-subtlest;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
@apply w-[0.9em] h-[0.9em];
|
@apply w-[0.9em] h-[0.9em];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A preview of the value a tag stands for. Sized before the image loads, so arriving
|
||||||
|
never changes the line's height. */
|
||||||
|
.tag-thumbnail {
|
||||||
|
@apply inline-block align-middle mr-1 rounded-xs;
|
||||||
|
@apply w-[1.1em] h-[1.1em] object-cover bg-surface-highlight;
|
||||||
|
}
|
||||||
|
|
||||||
.fn {
|
.fn {
|
||||||
@apply inline-block;
|
@apply inline-block;
|
||||||
.fn-inner {
|
.fn-inner {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { EditorState } from "@codemirror/state";
|
import { EditorState } from "@codemirror/state";
|
||||||
import { jsonc } from "@shopify/lang-jsonc";
|
import { jsonc } from "@shopify/lang-jsonc";
|
||||||
import { text } from "./text/extension";
|
import { text } from "./text/extension";
|
||||||
import { describe, expect, test, vi } from "vite-plus/test";
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
import {
|
import {
|
||||||
|
COLLAPSE_MEDIA_CHARS,
|
||||||
COLLAPSE_TOKEN_CHARS,
|
COLLAPSE_TOKEN_CHARS,
|
||||||
largeValueField,
|
collapseDecorations,
|
||||||
largeValues,
|
largeValues,
|
||||||
MAX_VISIBLE_LINE_CHARS,
|
MAX_VISIBLE_LINE_CHARS,
|
||||||
} from "./largeValues";
|
} from "./largeValues";
|
||||||
|
import type { SniffedValue } from "./sniffValue";
|
||||||
vi.mock("../../../lib/copy", () => ({ copyToClipboard: () => {} }));
|
|
||||||
|
|
||||||
const BIG = "A".repeat(1_000_000);
|
const BIG = "A".repeat(1_000_000);
|
||||||
|
|
||||||
@@ -19,9 +19,19 @@ const jsonState = (doc: string) => EditorState.create({ doc, extensions: [jsonc(
|
|||||||
/** Without a grammar, so only the column rule applies */
|
/** Without a grammar, so only the column rule applies */
|
||||||
const plainState = (doc: string) => EditorState.create({ doc, extensions: largeValues });
|
const plainState = (doc: string) => EditorState.create({ doc, extensions: largeValues });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The decorations as if the whole document were on screen.
|
||||||
|
*
|
||||||
|
* In the editor the plugin passes the viewport instead, which is the same call with narrower
|
||||||
|
* ranges — the rules themselves don't know the difference.
|
||||||
|
*/
|
||||||
|
function decorationsFor(state: EditorState) {
|
||||||
|
return collapseDecorations(state, [{ from: 0, to: state.doc.length }]);
|
||||||
|
}
|
||||||
|
|
||||||
function collapsedRanges(state: EditorState) {
|
function collapsedRanges(state: EditorState) {
|
||||||
const ranges: { from: number; to: number }[] = [];
|
const ranges: { from: number; to: number }[] = [];
|
||||||
const iter = state.field(largeValueField).decorations.iter();
|
const iter = decorationsFor(state).iter();
|
||||||
while (iter.value != null) {
|
while (iter.value != null) {
|
||||||
ranges.push({ from: iter.from, to: iter.to });
|
ranges.push({ from: iter.from, to: iter.to });
|
||||||
iter.next();
|
iter.next();
|
||||||
@@ -191,6 +201,67 @@ describe("undelimited tokens", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sniffing the collapsed value", () => {
|
||||||
|
/** The widget standing in for each collapse, in document order */
|
||||||
|
function widgets(state: EditorState) {
|
||||||
|
const found: { valueFrom: number; sniffed: SniffedValue | null }[] = [];
|
||||||
|
const iter = decorationsFor(state).iter();
|
||||||
|
while (iter.value != null) {
|
||||||
|
found.push(iter.value.spec.widget);
|
||||||
|
iter.next();
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A base64 value that starts with a PNG signature and runs on well past the limits */
|
||||||
|
function pngBase64(length = 1_000_000) {
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("names the format of a base64 value inside JSON", () => {
|
||||||
|
const state = jsonState(`{"image":"${pngBase64()}"}`);
|
||||||
|
expect(widgets(state)[0]?.sniffed).toMatchObject({ mime: "image/png", label: "PNG" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("names the format of a data URI", () => {
|
||||||
|
const doc = `{"image":"data:image/jpeg;base64,${BIG}"}`;
|
||||||
|
expect(widgets(jsonState(doc))[0]?.sniffed).toMatchObject({ label: "JPEG" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("names nothing when the value is just text", () => {
|
||||||
|
expect(widgets(jsonState(`{"image":"${BIG}"}`))[0]?.sniffed).toBeNull();
|
||||||
|
expect(widgets(plainState(BIG))[0]?.sniffed).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads a column cut from the start of its line, not from the cut", () => {
|
||||||
|
// A body that is nothing but one base64 blob: the tail is hidden, but the value it
|
||||||
|
// belongs to begins at the start of the line, which is where the signature is
|
||||||
|
const state = plainState(pngBase64());
|
||||||
|
const [widget] = widgets(state);
|
||||||
|
|
||||||
|
expect(widget?.valueFrom).toBe(0);
|
||||||
|
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads a token collapse from the value itself", () => {
|
||||||
|
const doc = `{"image":"${pngBase64()}"}`;
|
||||||
|
const [widget] = widgets(jsonState(doc));
|
||||||
|
|
||||||
|
// Just past the opening quote, so the signature is the first thing it sees
|
||||||
|
expect(widget?.valueFrom).toBe(doc.indexOf('"', doc.indexOf("image") + 6) + 1);
|
||||||
|
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("names nothing when a column cut lands mid-line after other text", () => {
|
||||||
|
const state = plainState(`some prefix text ${pngBase64()}`);
|
||||||
|
expect(widgets(state)[0]?.sniffed).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("recomputing", () => {
|
describe("recomputing", () => {
|
||||||
test("updates when the document changes", () => {
|
test("updates when the document changes", () => {
|
||||||
const state = plainState('{"image":"short"}');
|
const state = plainState('{"image":"short"}');
|
||||||
@@ -202,3 +273,101 @@ describe("recomputing", () => {
|
|||||||
expect(collapsedRanges(next)).toHaveLength(1);
|
expect(collapsedRanges(next)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("media collapsing, below the length rules", () => {
|
||||||
|
/** A real PNG signature, at a size that no length rule would touch */
|
||||||
|
function png(bytes: number) {
|
||||||
|
const b = new Uint8Array(bytes);
|
||||||
|
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of b) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Well under every length threshold, so only the media rule can collapse it */
|
||||||
|
const SMALL = png(3_000);
|
||||||
|
|
||||||
|
test("collapses a small image on a short line", () => {
|
||||||
|
expect(SMALL.length).toBeLessThan(MAX_VISIBLE_LINE_CHARS);
|
||||||
|
expect(SMALL.length).toBeLessThan(COLLAPSE_TOKEN_CHARS);
|
||||||
|
|
||||||
|
const state = jsonState(`{\n "avatar": "${SMALL}"\n}`);
|
||||||
|
const ranges = collapsedRanges(state);
|
||||||
|
|
||||||
|
expect(ranges).toHaveLength(1);
|
||||||
|
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(SMALL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("names it, so the tag can say what it is", () => {
|
||||||
|
const state = jsonState(`{"avatar":"${SMALL}"}`);
|
||||||
|
const iter = decorationsFor(state).iter();
|
||||||
|
expect(iter.value?.spec.widget.sniffed).toMatchObject({ mime: "image/png", label: "PNG" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves a value of the same size alone when nothing recognises it", () => {
|
||||||
|
// The only difference from the case above is what the bytes turn out to be
|
||||||
|
const prose = "x".repeat(SMALL.length);
|
||||||
|
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves anything under the media threshold alone, recognised or not", () => {
|
||||||
|
const tiny = png(400);
|
||||||
|
expect(tiny.length).toBeLessThan(COLLAPSE_MEDIA_CHARS);
|
||||||
|
expect(collapsedRanges(jsonState(`{"icon":"${tiny}"}`))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves text alone even when its first bytes match a signature", () => {
|
||||||
|
// `Qk0` decodes to `BM`, the whole of the BMP signature. Two bytes come up by chance often
|
||||||
|
// enough that the sniff alone must not be allowed to hide something.
|
||||||
|
const prose = `Qk0 ${"the quick brown fox jumps over the lazy dog. ".repeat(40)}`;
|
||||||
|
expect(prose.length).toBeGreaterThan(COLLAPSE_MEDIA_CHARS);
|
||||||
|
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still collapses it once it is long enough to be a rendering problem", () => {
|
||||||
|
// Past the length rule the sniff no longer decides anything, so this is hidden for its size
|
||||||
|
const prose = "Qk0 " + "words and more words ".repeat(COLLAPSE_TOKEN_CHARS);
|
||||||
|
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collapses a data URI on a short line", () => {
|
||||||
|
const state = jsonState(`{"avatar":"data:image/jpeg;base64,${SMALL}"}`);
|
||||||
|
expect(collapsedRanges(state)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still leaves an ordinary document completely untouched", () => {
|
||||||
|
const doc = Array.from({ length: 5_000 }, (_, i) => ` { "id": ${i}, "name": "item" },`).join(
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
expect(collapsedRanges(jsonState(doc))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("viewport scoping", () => {
|
||||||
|
const doc = () => {
|
||||||
|
const value = "A".repeat(COLLAPSE_TOKEN_CHARS * 2);
|
||||||
|
return `{\n${["a", "b", "c"].map((k) => ` "${k}": "${value}"`).join(",\n")}\n}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("decorates only the ranges it is given", () => {
|
||||||
|
const state = jsonState(doc());
|
||||||
|
const secondLine = state.doc.line(3);
|
||||||
|
|
||||||
|
const all = collapseDecorations(state, [{ from: 0, to: state.doc.length }]);
|
||||||
|
const one = collapseDecorations(state, [{ from: secondLine.from, to: secondLine.to }]);
|
||||||
|
|
||||||
|
expect(all.size).toBe(3);
|
||||||
|
expect(one.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("covers a line the range only partly overlaps", () => {
|
||||||
|
// The viewport can start mid-line; the collapse still has to span the whole value
|
||||||
|
const state = jsonState(doc());
|
||||||
|
const line = state.doc.line(2);
|
||||||
|
const partial = collapseDecorations(state, [{ from: line.from + 5, to: line.from + 6 }]);
|
||||||
|
|
||||||
|
expect(partial.size).toBe(1);
|
||||||
|
const iter = partial.iter();
|
||||||
|
expect(iter.to).toBeGreaterThan(line.from + 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
|
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
|
||||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||||
import type { EditorState, Extension, Range } from "@codemirror/state";
|
import type { EditorState, Extension, Range } from "@codemirror/state";
|
||||||
import { StateField } from "@codemirror/state";
|
|
||||||
import type { Tree as SyntaxTree } from "@lezer/common";
|
import type { Tree as SyntaxTree } from "@lezer/common";
|
||||||
import type { DecorationSet } from "@codemirror/view";
|
import type { DecorationSet, ViewUpdate } from "@codemirror/view";
|
||||||
import { Decoration, EditorView, WidgetType } from "@codemirror/view";
|
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
|
||||||
import { copyToClipboard } from "../../../lib/copy";
|
import { fireAndForget } from "../../../lib/fireAndForget";
|
||||||
|
import type { SniffedValue } from "./sniffValue";
|
||||||
|
import { isEncodedRun, SNIFF_HEAD_CHARS, sniffValue } from "./sniffValue";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How much of a line may be rendered before the rest is collapsed.
|
* How much of a line may be rendered before the rest is collapsed.
|
||||||
@@ -17,53 +18,131 @@ import { copyToClipboard } from "../../../lib/copy";
|
|||||||
export const MAX_VISIBLE_LINE_CHARS = 10_000;
|
export const MAX_VISIBLE_LINE_CHARS = 10_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A quoted value longer than this on an over-long line is collapsed whole, ahead of the column
|
* A quoted value longer than this is collapsed whole, whatever it turns out to hold. Long
|
||||||
* cut, so the structure around it stays visible. Needs a grammar to find.
|
* enough that nothing a person might actually read is ever hidden on length alone.
|
||||||
*/
|
*/
|
||||||
export const COLLAPSE_TOKEN_CHARS = 5_000;
|
export const COLLAPSE_TOKEN_CHARS = 5_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps over-long lines from reaching layout, which is what makes the editor stall on a
|
* A quoted value longer than this is collapsed too, but only when {@link sniffValue} can say
|
||||||
* base64 blob or a minified payload.
|
* what it is. Encoded media is never worth reading, so once we can name it and offer a viewer,
|
||||||
|
* a tag beats a wall of base64 well below the length that would make it a rendering problem —
|
||||||
|
* this covers the avatars and icons that make up most base64 in real payloads. Anything we
|
||||||
|
* can't identify stays visible until it's big enough to be a problem on its own.
|
||||||
|
*/
|
||||||
|
export const COLLAPSE_MEDIA_CHARS = 1_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapses what can't be read: over-long lines, which stall layout, and encoded media, which
|
||||||
|
* is only noise on screen.
|
||||||
*
|
*
|
||||||
* Cost tracks the length of the longest line, not the size of the document. A 1 MB response of
|
* Layout cost tracks the length of the longest line, not the size of the document. A 1 MB
|
||||||
* ordinary multi-line JSON renders fine, while the same 1 MB on a single line stalls the UI,
|
* response of ordinary multi-line JSON renders fine, while the same 1 MB on a single line stalls
|
||||||
* because soft wrap has to measure the whole line end to end to find its break points.
|
* the UI, because soft wrap has to measure the whole line end to end to find its break points.
|
||||||
* Measured in WKWebView, the engine macOS ships: 221 ms per render pass at 1 MB and 968 ms at
|
* Measured in WKWebView, the engine macOS ships: 221 ms per render pass at 1 MB and 968 ms at
|
||||||
* 3 MB, against 51 ms and 138 ms once collapsed, with soft wrap left on.
|
* 3 MB, against 51 ms and 138 ms once collapsed, with soft wrap left on.
|
||||||
*
|
*
|
||||||
* Two rules, applied only to lines over {@link MAX_VISIBLE_LINE_CHARS}, so ordinary documents
|
* Three rules:
|
||||||
* are untouched:
|
|
||||||
*
|
*
|
||||||
* 1. Collapse quoted values over {@link COLLAPSE_TOKEN_CHARS} whole, so a base64 string reads
|
* 1. Collapse quoted values over {@link COLLAPSE_TOKEN_CHARS} whole, so a base64 string reads
|
||||||
* as `"image": "999.8 KB hidden…"` with its key intact. A minified body with several large values
|
* as `"image": "PNG · 999.8 KB"` with its key intact. A minified body with several large
|
||||||
* keeps every one of its keys. Needs a grammar to find the value.
|
* values keeps every one of its keys. Needs a grammar to find the value.
|
||||||
* 2. Collapse whatever is still past the column limit. This needs no grammar, so it covers
|
* 2. Collapse quoted values over {@link COLLAPSE_MEDIA_CHARS} that sniff as a known format,
|
||||||
* plain text, undelimited tokens, and any line that is simply long.
|
* which is what catches an ordinary embedded image long before it is a rendering problem.
|
||||||
|
* 3. On a line still over {@link MAX_VISIBLE_LINE_CHARS}, collapse whatever is left past the
|
||||||
|
* column limit. This needs no grammar, so it covers plain text, undelimited tokens, and any
|
||||||
|
* line that is simply long.
|
||||||
|
*
|
||||||
|
* Only what the viewport covers is examined, and only lines long enough to hold a collapse are
|
||||||
|
* looked at, so a document of ordinary short lines costs a length check per visible line and
|
||||||
|
* nothing else — no grammar is consulted and no tree is walked.
|
||||||
*
|
*
|
||||||
* Nothing leaves the document. Copy, filter and save all still see the full text, and the tag
|
* Nothing leaves the document. Copy, filter and save all still see the full text, and the tag
|
||||||
* carries a button that copies exactly what it hides.
|
* itself is a button, opening a menu that views, copies or saves exactly what it stands for.
|
||||||
|
*
|
||||||
|
* The value's own head says what it is — a `data:` URI names its media type, and raw base64
|
||||||
|
* gives its format up in the first dozen bytes — so the tag can name the format and open the
|
||||||
|
* value in the matching viewer without any of it being read. See {@link sniffValue}.
|
||||||
*
|
*
|
||||||
* Read-only editors only. Hiding part of a document someone is editing would mean editing
|
* Read-only editors only. Hiding part of a document someone is editing would mean editing
|
||||||
* text they can't see.
|
* text they can't see.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** A hidden range. The tag stands in for exactly this text, and the dialog shows it. */
|
/**
|
||||||
|
* A hidden range, the value it belongs to, and what that value turned out to be.
|
||||||
|
*
|
||||||
|
* The hidden range and the value differ under the column rule, where the value starts at the
|
||||||
|
* beginning of the line but only what runs past the limit is hidden. The tag stands in for
|
||||||
|
* [from, to) and copies exactly that; the viewer needs the whole value, [valueFrom, to).
|
||||||
|
*/
|
||||||
interface Collapse {
|
interface Collapse {
|
||||||
from: number;
|
from: number;
|
||||||
to: number;
|
to: number;
|
||||||
|
valueFrom: number;
|
||||||
|
sniffed: SniffedValue | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lucide's `chevron-down`, inlined because the widget builds its DOM synchronously and
|
||||||
|
* rendering React here would leave it empty while CodeMirror measures line heights.
|
||||||
|
*/
|
||||||
|
const CHEVRON_DOWN =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" ' +
|
||||||
|
'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ' +
|
||||||
|
'aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the tag as the thing that opens the menu.
|
||||||
|
*
|
||||||
|
* The whole tag is the target rather than the chevron alone: there is only one action, and a
|
||||||
|
* label you can't click next to a hit area a few pixels wide is a worse button than the tag
|
||||||
|
* itself. The chevron stays as the affordance that says so.
|
||||||
|
*
|
||||||
|
* A span with a role rather than a real button, because a button's box model makes the line
|
||||||
|
* taller — the one thing this extension exists to keep from happening.
|
||||||
|
*/
|
||||||
|
function makeTagButton(el: HTMLElement, title: string, onOpen: (at: DOMRect) => void) {
|
||||||
|
el.role = "button";
|
||||||
|
el.ariaHasPopup = "menu";
|
||||||
|
el.tabIndex = 0;
|
||||||
|
el.title = title;
|
||||||
|
el.ariaLabel = title;
|
||||||
|
|
||||||
|
// Keep the editor from moving the cursor when the tag is pressed
|
||||||
|
el.addEventListener("mousedown", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
el.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpen(el.getBoundingClientRect());
|
||||||
|
});
|
||||||
|
el.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key !== "Enter" && e.key !== " ") return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpen(el.getBoundingClientRect());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class LargeValueWidget extends WidgetType {
|
class LargeValueWidget extends WidgetType {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly from: number,
|
private readonly from: number,
|
||||||
private readonly to: number,
|
private readonly to: number,
|
||||||
|
private readonly valueFrom: number,
|
||||||
|
private readonly sniffed: SniffedValue | null,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
eq(other: LargeValueWidget) {
|
eq(other: LargeValueWidget) {
|
||||||
return other.from === this.from && other.to === this.to;
|
return (
|
||||||
|
other.from === this.from &&
|
||||||
|
other.to === this.to &&
|
||||||
|
other.valueFrom === this.valueFrom &&
|
||||||
|
other.sniffed?.mime === this.sniffed?.mime
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toDOM(view: EditorView) {
|
toDOM(view: EditorView) {
|
||||||
@@ -75,68 +154,166 @@ class LargeValueWidget extends WidgetType {
|
|||||||
// tag-sized border is meant to be near invisible.
|
// tag-sized border is meant to be near invisible.
|
||||||
el.className = "x-theme-templateTag x-theme-templateTag--secondary template-tag";
|
el.className = "x-theme-templateTag x-theme-templateTag--secondary template-tag";
|
||||||
|
|
||||||
|
const thumbnail = buildThumbnail(view, this.valueFrom, this.to, this.sniffed);
|
||||||
|
if (thumbnail != null) {
|
||||||
|
el.appendChild(thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.textContent = `${formatSize(length)} hidden…`;
|
// A named type reads as a stand-in for the value, so it only needs to say what and how big.
|
||||||
|
// Without a name there is nothing to show but the fact that something is missing.
|
||||||
|
label.textContent =
|
||||||
|
this.sniffed == null
|
||||||
|
? `${formatSize(length)} hidden…`
|
||||||
|
: `${this.sniffed.label} · ${formatSize(length)}`;
|
||||||
el.appendChild(label);
|
el.appendChild(label);
|
||||||
|
|
||||||
// A span rather than a button: a button's box model makes the line taller. It keeps the
|
const chevron = document.createElement("span");
|
||||||
// role and name so assistive tech still sees it as an action.
|
chevron.className = "tag-action";
|
||||||
const copy = document.createElement("span");
|
chevron.ariaHidden = "true";
|
||||||
copy.role = "button";
|
chevron.innerHTML = CHEVRON_DOWN;
|
||||||
copy.className = "tag-action";
|
el.appendChild(chevron);
|
||||||
copy.title = `Copy hidden text (${length.toLocaleString()} characters)`;
|
|
||||||
copy.ariaLabel = copy.title;
|
makeTagButton(
|
||||||
// Lucide's `copy` icon, inlined because the widget builds its DOM synchronously and
|
el,
|
||||||
// rendering React here would leave it empty while CodeMirror measures line heights
|
this.sniffed == null ? "Value actions" : `${this.sniffed.label} actions`,
|
||||||
copy.innerHTML =
|
(rect) => fireAndForget(this.openMenu(view, rect)),
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" ' +
|
);
|
||||||
'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ' +
|
|
||||||
'aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>' +
|
|
||||||
'<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
|
|
||||||
// Keep the editor from moving the cursor when the button is pressed
|
|
||||||
copy.addEventListener("mousedown", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
copy.addEventListener("click", (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
copyToClipboard(view.state.sliceDoc(this.from, this.to));
|
|
||||||
});
|
|
||||||
el.appendChild(copy);
|
|
||||||
|
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy, view and save, in a menu opened against the button.
|
||||||
|
*
|
||||||
|
* Everything the menu needs is pulled in on click. The editor loads on every response, and
|
||||||
|
* neither the React menu nor the viewers behind the dialog are worth carrying until someone
|
||||||
|
* asks for them.
|
||||||
|
*/
|
||||||
|
private async openMenu(
|
||||||
|
view: EditorView,
|
||||||
|
rect: Pick<DOMRect, "top" | "bottom" | "left" | "right">,
|
||||||
|
) {
|
||||||
|
const [{ showContextMenu }, { showLargeValueDialog }, { encodingLabel, largeValueActions }] =
|
||||||
|
await Promise.all([
|
||||||
|
import("../../../lib/contextMenu"),
|
||||||
|
import("../../LargeValueDialog"),
|
||||||
|
import("../../../lib/largeValue"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { sniffed } = this;
|
||||||
|
// Sliced when an action runs rather than now, so opening the menu never touches the value
|
||||||
|
const value = () => view.state.sliceDoc(this.valueFrom, this.to);
|
||||||
|
const head = view.state.sliceDoc(
|
||||||
|
this.valueFrom,
|
||||||
|
Math.min(this.valueFrom + SNIFF_HEAD_CHARS, this.to),
|
||||||
|
);
|
||||||
|
|
||||||
|
showContextMenu({
|
||||||
|
id: "large-value",
|
||||||
|
// The whole tag, so the menu can align to whichever edge has room beside it
|
||||||
|
triggerPosition: { x: rect.left, y: rect.bottom },
|
||||||
|
triggerRect: rect,
|
||||||
|
items: [
|
||||||
|
{ type: "separator", label: encodingLabel(head, sniffed, this.to - this.from) },
|
||||||
|
...largeValueActions({
|
||||||
|
value,
|
||||||
|
sniffed,
|
||||||
|
// Exactly what the tag stands in for, which under the column rule is only the tail
|
||||||
|
copyText: () => view.state.sliceDoc(this.from, this.to),
|
||||||
|
onView: () => showLargeValueDialog({ text: value(), sniffed }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ignoreEvent() {
|
ignoreEvent() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much encoded image a tag will preview. Roughly 3 MB of file, base64 being 4 bytes to 3.
|
||||||
|
*
|
||||||
|
* The decode runs on WebKit's image thread, but the bitmap it produces is sized by the image's
|
||||||
|
* own dimensions, not by the box we draw it in — a 4000×3000 photo costs 48 MB however small the
|
||||||
|
* thumbnail. So there has to be a limit, even though encoded size is only a proxy for the one
|
||||||
|
* that matters: a well-compressed photo can decode larger than a lossless screenshot twice its
|
||||||
|
* file size. This is set high enough to cover ordinary screenshots and wallpapers, since a tag
|
||||||
|
* that previews some images and not others is worse than one that previews none. Only widgets in
|
||||||
|
* the viewport are ever built, so a screenful is the most that decode at once.
|
||||||
|
*/
|
||||||
|
const THUMBNAIL_MAX_CHARS = 4_000_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A preview of the value, at a size fixed before it loads.
|
||||||
|
*
|
||||||
|
* The box is a fixed square from the moment it's inserted, so the image arriving never changes
|
||||||
|
* the line's height or the tag's width. A growing line box is the layout cost this whole
|
||||||
|
* extension exists to avoid, and an image of unknown dimensions is the classic way to cause one.
|
||||||
|
*
|
||||||
|
* The src is the value's own text handed straight to the decoder as a data URI. Decoding the
|
||||||
|
* base64 ourselves first would mean walking megabytes on the main thread, which is the one
|
||||||
|
* thing that must not happen here.
|
||||||
|
*/
|
||||||
|
function buildThumbnail(
|
||||||
|
view: EditorView,
|
||||||
|
valueFrom: number,
|
||||||
|
to: number,
|
||||||
|
sniffed: SniffedValue | null,
|
||||||
|
): HTMLElement | null {
|
||||||
|
if (sniffed == null || !sniffed.mime.startsWith("image/")) return null;
|
||||||
|
if (sniffed.encoding !== "base64") return null;
|
||||||
|
if (to - valueFrom > THUMBNAIL_MAX_CHARS) return null;
|
||||||
|
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "tag-thumbnail";
|
||||||
|
img.alt = "";
|
||||||
|
img.ariaHidden = "true";
|
||||||
|
img.decoding = "async";
|
||||||
|
// A magic number can be wrong, and a data URI can lie. Drop the box rather than leave a
|
||||||
|
// broken-image glyph sitting in the tag.
|
||||||
|
img.addEventListener("error", () => img.remove());
|
||||||
|
// Read the document and build the data URI after the widget is measured and on screen
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!img.isConnected) return;
|
||||||
|
const payload = view.state.sliceDoc(valueFrom + sniffed.offset, to);
|
||||||
|
img.src = `data:${sniffed.mime};base64,${payload}`;
|
||||||
|
});
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
interface Line {
|
interface Line {
|
||||||
from: number;
|
from: number;
|
||||||
to: number;
|
to: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lines long enough to be a problem. Most documents have none, and we stop there. */
|
/**
|
||||||
function findLongLines(text: string): Line[] {
|
* The lines the given ranges touch, in document order and each listed once.
|
||||||
if (text.length <= MAX_VISIBLE_LINE_CHARS) {
|
*
|
||||||
return []; // No line can be longer than the whole text
|
* Only lines long enough to hold a collapse are returned. That check is what keeps this free on
|
||||||
|
* ordinary documents: no grammar is consulted and no tree is walked for a screen of short lines.
|
||||||
|
*/
|
||||||
|
function collapsibleLines(state: EditorState, ranges: readonly { from: number; to: number }[]) {
|
||||||
|
const lines: Line[] = [];
|
||||||
|
let lastFrom = -1;
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
let pos = range.from;
|
||||||
|
for (;;) {
|
||||||
|
const line = state.doc.lineAt(pos);
|
||||||
|
if (line.from > lastFrom) {
|
||||||
|
lastFrom = line.from;
|
||||||
|
if (line.to - line.from >= COLLAPSE_MEDIA_CHARS) {
|
||||||
|
lines.push({ from: line.from, to: line.to });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line.to >= range.to) break;
|
||||||
|
pos = line.to + 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines: Line[] = [];
|
return lines;
|
||||||
let from = 0;
|
|
||||||
for (;;) {
|
|
||||||
const newline = text.indexOf("\n", from);
|
|
||||||
const to = newline < 0 ? text.length : newline;
|
|
||||||
if (to - from > MAX_VISIBLE_LINE_CHARS) {
|
|
||||||
lines.push({ from, to });
|
|
||||||
}
|
|
||||||
if (newline < 0) {
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
from = newline + 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,33 +325,44 @@ function findLongLines(text: string): Line[] {
|
|||||||
*/
|
*/
|
||||||
const PARSE_TIMEOUT_MS = 100;
|
const PARSE_TIMEOUT_MS = 100;
|
||||||
|
|
||||||
/** The parsed tree covering the long lines, as far as parsing got in the time allowed. */
|
/**
|
||||||
function treeForLongLines(state: EditorState, longLines: Line[]): SyntaxTree {
|
* The parsed tree covering these lines.
|
||||||
const lastLine = longLines[longLines.length - 1];
|
*
|
||||||
if (lastLine == null) {
|
* Parsing is only ever forced for a line long enough to stall layout, where finding the values
|
||||||
|
* inside it is what saves the frame. For everything else we take whatever the background parse
|
||||||
|
* has reached and decorate again when it advances — a forced parse runs from the top of the
|
||||||
|
* document, so making one happen on every scroll would cost far more than the tags are worth.
|
||||||
|
*/
|
||||||
|
function treeForLines(state: EditorState, lines: Line[]): SyntaxTree {
|
||||||
|
const last = lines[lines.length - 1];
|
||||||
|
const stalls = lines.some((l) => l.to - l.from > MAX_VISIBLE_LINE_CHARS);
|
||||||
|
if (last == null || !stalls) {
|
||||||
return syntaxTree(state);
|
return syntaxTree(state);
|
||||||
}
|
}
|
||||||
return ensureSyntaxTree(state, lastLine.to, PARSE_TIMEOUT_MS) ?? syntaxTree(state);
|
return ensureSyntaxTree(state, last.to, PARSE_TIMEOUT_MS) ?? syntaxTree(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
const QUOTES = ['"', "'", "`"];
|
const QUOTES = ['"', "'", "`"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quoted values on this line big enough to collapse whole, in document order.
|
* Quoted values on this line worth collapsing, in document order.
|
||||||
*
|
*
|
||||||
* Everything between the quotes goes, so a long string reads as `"image": "<tag>"` and the key
|
* Everything between the quotes goes, so a long string reads as `"image": "<tag>"` and the key
|
||||||
* still tells you what it is. Undelimited tokens are left to the column cut instead: replacing
|
* still tells you what it is. Undelimited tokens are left to the column cut instead: replacing
|
||||||
* one whole would leave the line with nothing on it but a tag.
|
* one whole would leave the line with nothing on it but a tag.
|
||||||
*/
|
*/
|
||||||
function findLargeTokens(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] {
|
function findCollapsibleTokens(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] {
|
||||||
const collapses: Collapse[] = [];
|
const collapses: Collapse[] = [];
|
||||||
|
// Length alone only hides things on a line that would stall without it. On a line that renders
|
||||||
|
// fine, being long is not a reason to hide something we can't even name.
|
||||||
|
const stalls = line.to - line.from > MAX_VISIBLE_LINE_CHARS;
|
||||||
|
|
||||||
tree.iterate({
|
tree.iterate({
|
||||||
from: line.from,
|
from: line.from,
|
||||||
to: line.to,
|
to: line.to,
|
||||||
enter: (node) => {
|
enter: (node) => {
|
||||||
// A node this small can't contain anything worth collapsing
|
// A node this small can't contain anything worth collapsing, and neither can its children
|
||||||
if (node.to - node.from < COLLAPSE_TOKEN_CHARS) return false;
|
if (node.to - node.from < COLLAPSE_MEDIA_CHARS) return false;
|
||||||
// Only leaves, so we collapse the string itself rather than the object holding it
|
// Only leaves, so we collapse the string itself rather than the object holding it
|
||||||
if (node.node.firstChild != null) return true;
|
if (node.node.firstChild != null) return true;
|
||||||
|
|
||||||
@@ -182,9 +370,25 @@ function findLargeTokens(state: EditorState, tree: SyntaxTree, line: Line): Coll
|
|||||||
const to = Math.min(node.to, line.to);
|
const to = Math.min(node.to, line.to);
|
||||||
const open = state.sliceDoc(from, from + 1);
|
const open = state.sliceDoc(from, from + 1);
|
||||||
const quoted = to - from >= 2 && QUOTES.includes(open) && state.sliceDoc(to - 1, to) === open;
|
const quoted = to - from >= 2 && QUOTES.includes(open) && state.sliceDoc(to - 1, to) === open;
|
||||||
|
if (!quoted) return false;
|
||||||
|
|
||||||
if (quoted && to - 1 > from + 1) {
|
// Everything inside the quotes. The whole value is hidden, so it is its own value range
|
||||||
collapses.push({ from: from + 1, to: to - 1 });
|
const valueFrom = from + 1;
|
||||||
|
const valueTo = to - 1;
|
||||||
|
if (valueTo <= valueFrom) return false;
|
||||||
|
|
||||||
|
// The head is all it takes, so this never reads the value it describes
|
||||||
|
const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo));
|
||||||
|
const sniffed = sniffValue(head);
|
||||||
|
|
||||||
|
// Big enough to be a rendering problem on its own, or something we can both name and be
|
||||||
|
// sure is encoded — a short magic number matches by chance, and hiding text would be worse
|
||||||
|
// than showing base64
|
||||||
|
const oversized = stalls && valueTo - valueFrom >= COLLAPSE_TOKEN_CHARS;
|
||||||
|
const media =
|
||||||
|
sniffed != null && sniffed.encoding === "base64" && isEncodedRun(head, sniffed.offset);
|
||||||
|
if (oversized || media) {
|
||||||
|
collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed });
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
@@ -218,7 +422,11 @@ function findColumnCut(line: Line, tokens: Collapse[]): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function collapsesForLine(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] {
|
function collapsesForLine(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] {
|
||||||
const tokens = findLargeTokens(state, tree, line);
|
const tokens = findCollapsibleTokens(state, tree, line);
|
||||||
|
if (line.to - line.from <= MAX_VISIBLE_LINE_CHARS) {
|
||||||
|
return tokens; // Short enough to render, so there is nothing left to cut
|
||||||
|
}
|
||||||
|
|
||||||
const cut = findColumnCut(line, tokens);
|
const cut = findColumnCut(line, tokens);
|
||||||
if (cut < 0) {
|
if (cut < 0) {
|
||||||
return tokens;
|
return tokens;
|
||||||
@@ -226,56 +434,69 @@ function collapsesForLine(state: EditorState, tree: SyntaxTree, line: Line): Col
|
|||||||
|
|
||||||
// The cut always lands in a visible stretch, so it never splits a token collapse
|
// The cut always lands in a visible stretch, so it never splits a token collapse
|
||||||
const kept = tokens.filter((t) => t.to <= cut);
|
const kept = tokens.filter((t) => t.to <= cut);
|
||||||
kept.push({ from: cut, to: line.to });
|
// Only the tail is hidden, but the value it belongs to runs from the start of the line — a
|
||||||
|
// body that is nothing but one base64 blob is still recognisable from there
|
||||||
|
const head = state.sliceDoc(line.from, Math.min(line.from + SNIFF_HEAD_CHARS, line.to));
|
||||||
|
kept.push({ from: cut, to: line.to, valueFrom: line.from, sniffed: sniffValue(head) });
|
||||||
return kept;
|
return kept;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDecorations(state: EditorState, longLines: Line[]): DecorationSet {
|
/**
|
||||||
if (longLines.length === 0) {
|
* The collapses covering the given document ranges.
|
||||||
|
*
|
||||||
|
* Kept separate from the plugin so the rules can be exercised against a state directly, with no
|
||||||
|
* view and no DOM.
|
||||||
|
*/
|
||||||
|
export function collapseDecorations(
|
||||||
|
state: EditorState,
|
||||||
|
ranges: readonly { from: number; to: number }[],
|
||||||
|
): DecorationSet {
|
||||||
|
const lines = collapsibleLines(state, ranges);
|
||||||
|
if (lines.length === 0) {
|
||||||
return Decoration.none;
|
return Decoration.none;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tree = treeForLongLines(state, longLines);
|
const tree = treeForLines(state, lines);
|
||||||
const ranges: Range<Decoration>[] = [];
|
const decorations: Range<Decoration>[] = [];
|
||||||
for (const line of longLines) {
|
for (const line of lines) {
|
||||||
for (const { from, to } of collapsesForLine(state, tree, line)) {
|
for (const { from, to, valueFrom, sniffed } of collapsesForLine(state, tree, line)) {
|
||||||
ranges.push(Decoration.replace({ widget: new LargeValueWidget(from, to) }).range(from, to));
|
const widget = new LargeValueWidget(from, to, valueFrom, sniffed);
|
||||||
|
decorations.push(Decoration.replace({ widget }).range(from, to));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Decoration.set(ranges);
|
return Decoration.set(decorations);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LargeValueState {
|
/**
|
||||||
longLines: Line[];
|
* Scoped to the viewport, so the cost is a screenful however big the document is.
|
||||||
decorations: DecorationSet;
|
*
|
||||||
}
|
* Recomputed when the document changes, when the viewport moves, and when background parsing
|
||||||
|
* advances — a value can only be found once the grammar has reached it.
|
||||||
|
*/
|
||||||
|
const largeValuePlugin = ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
|
||||||
export const largeValueField = StateField.define<LargeValueState>({
|
constructor(view: EditorView) {
|
||||||
create(state) {
|
this.decorations = collapseDecorations(view.state, view.visibleRanges);
|
||||||
const longLines = findLongLines(state.doc.toString());
|
|
||||||
return { longLines, decorations: buildDecorations(state, longLines) };
|
|
||||||
},
|
|
||||||
|
|
||||||
update(value, tr) {
|
|
||||||
if (tr.docChanged) {
|
|
||||||
const longLines = findLongLines(tr.state.doc.toString());
|
|
||||||
return { longLines, decorations: buildDecorations(tr.state, longLines) };
|
|
||||||
}
|
}
|
||||||
// Parsing is incremental, so a long line may only become a known token later. Documents
|
|
||||||
// with no long line can never gain a collapse, so they skip this entirely.
|
|
||||||
if (value.longLines.length > 0 && syntaxTree(tr.startState) !== syntaxTree(tr.state)) {
|
|
||||||
return { ...value, decorations: buildDecorations(tr.state, value.longLines) };
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
|
|
||||||
provide: (f) => [
|
update(update: ViewUpdate) {
|
||||||
EditorView.decorations.from(f, (v) => v.decorations),
|
if (
|
||||||
|
update.docChanged ||
|
||||||
|
update.viewportChanged ||
|
||||||
|
syntaxTree(update.startState) !== syntaxTree(update.state)
|
||||||
|
) {
|
||||||
|
this.decorations = collapseDecorations(update.view.state, update.view.visibleRanges);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations: (v) => v.decorations,
|
||||||
// Step the cursor over a placeholder instead of stranding it inside
|
// Step the cursor over a placeholder instead of stranding it inside
|
||||||
EditorView.atomicRanges.of(
|
provide: (plugin) =>
|
||||||
(view) => view.state.field(f, false)?.decorations ?? Decoration.none,
|
EditorView.atomicRanges.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none),
|
||||||
),
|
},
|
||||||
],
|
);
|
||||||
});
|
|
||||||
|
|
||||||
export const largeValues: Extension = [largeValueField];
|
export const largeValues: Extension = [largeValuePlugin];
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { decodeBase64Prefix, labelForMime, SNIFF_HEAD_CHARS, sniffValue } from "./sniffValue";
|
||||||
|
|
||||||
|
/** A base64 value that starts with `magic` and runs on for a while, as a real one would */
|
||||||
|
function base64Of(magic: number[], length = 2_000): string {
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
bytes.set(magic);
|
||||||
|
for (let i = magic.length; i < length; i++) {
|
||||||
|
bytes[i] = i % 251;
|
||||||
|
}
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One character, one byte. These are all ASCII signatures. */
|
||||||
|
function ascii(s: string): number[] {
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
bytes.push(s.charCodeAt(i));
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Four bytes no signature looks at: a RIFF chunk length, or an ISO-BMFF box size */
|
||||||
|
const IGNORED = [0x00, 0x00, 0x00, 0x20];
|
||||||
|
|
||||||
|
const PNG = [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a];
|
||||||
|
const JPEG = [0xff, 0xd8, 0xff, 0xe0];
|
||||||
|
const PDF = ascii("%PDF-1.7");
|
||||||
|
|
||||||
|
describe("magic numbers", () => {
|
||||||
|
const cases: [string, number[], string, string][] = [
|
||||||
|
["PNG", PNG, "image/png", "PNG"],
|
||||||
|
["JPEG", JPEG, "image/jpeg", "JPEG"],
|
||||||
|
["GIF", ascii("GIF89a"), "image/gif", "GIF"],
|
||||||
|
["PDF", PDF, "application/pdf", "PDF"],
|
||||||
|
["WEBP", [...ascii("RIFF"), ...IGNORED, ...ascii("WEBP")], "image/webp", "WEBP"],
|
||||||
|
["WAV", [...ascii("RIFF"), ...IGNORED, ...ascii("WAVE")], "audio/wav", "WAV"],
|
||||||
|
["ZIP", [...ascii("PK"), 0x03, 0x04], "application/zip", "ZIP"],
|
||||||
|
["GZIP", [0x1f, 0x8b, 0x08], "application/gzip", "GZIP"],
|
||||||
|
["MP3", [...ascii("ID3"), 0x04], "audio/mpeg", "MP3"],
|
||||||
|
["OGG", ascii("OggS"), "audio/ogg", "OGG"],
|
||||||
|
["FLAC", ascii("fLaC"), "audio/flac", "FLAC"],
|
||||||
|
["WEBM", [0x1a, 0x45, 0xdf, 0xa3], "video/webm", "WEBM"],
|
||||||
|
["MP4", [...IGNORED, ...ascii("ftypisom")], "video/mp4", "MP4"],
|
||||||
|
["AVIF", [...IGNORED, ...ascii("ftypavif")], "image/avif", "AVIF"],
|
||||||
|
["HEIC", [...IGNORED, ...ascii("ftypheic")], "image/heic", "HEIC"],
|
||||||
|
["M4A", [...IGNORED, ...ascii("ftypM4A ")], "audio/mp4", "M4A"],
|
||||||
|
["MOV", [...IGNORED, ...ascii("ftypqt ")], "video/quicktime", "MOV"],
|
||||||
|
["BMP", ascii("BMxx"), "image/bmp", "BMP"],
|
||||||
|
["TIFF", [...ascii("II"), 0x2a, 0x00], "image/tiff", "TIFF"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [name, magic, mime, label] of cases) {
|
||||||
|
test(`recognises ${name}`, () => {
|
||||||
|
expect(sniffValue(base64Of(magic))).toEqual({ mime, label, offset: 0, encoding: "base64" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("recognises nothing in arbitrary base64", () => {
|
||||||
|
expect(sniffValue(btoa("just some text that happens to be encoded"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recognises nothing in text that isn't base64", () => {
|
||||||
|
expect(sniffValue("the quick brown fox jumps over the lazy dog".repeat(10))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recognises nothing in a JWT-shaped value", () => {
|
||||||
|
expect(sniffValue(`${btoa('{"alg":"HS256"}')}.${btoa('{"sub":"1"}')}.c2ln`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads the url-safe alphabet", () => {
|
||||||
|
const urlSafe = base64Of(PNG).replaceAll("+", "-").replaceAll("/", "_");
|
||||||
|
expect(sniffValue(urlSafe)?.label).toBe("PNG");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores leading whitespace, and counts it in the offset", () => {
|
||||||
|
expect(sniffValue(` ${base64Of(PNG)}`)).toMatchObject({ label: "PNG", offset: 3 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("data URIs", () => {
|
||||||
|
test("takes the declared type over the bytes", () => {
|
||||||
|
const uri = `data:image/svg+xml;base64,${btoa("<svg/>")}`;
|
||||||
|
expect(sniffValue(uri)).toEqual({
|
||||||
|
mime: "image/svg+xml",
|
||||||
|
label: "SVG",
|
||||||
|
offset: "data:image/svg+xml;base64,".length,
|
||||||
|
encoding: "base64",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("points past the header, so the payload can be decoded from there", () => {
|
||||||
|
const payload = base64Of(PNG);
|
||||||
|
const uri = `data:image/png;base64,${payload}`;
|
||||||
|
const sniffed = sniffValue(uri)!;
|
||||||
|
expect(uri.slice(sniffed.offset, sniffed.offset + 8)).toBe(payload.slice(0, 8));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to the bytes when the declared type says nothing", () => {
|
||||||
|
const uri = `data:application/octet-stream;base64,${base64Of(PDF)}`;
|
||||||
|
expect(sniffValue(uri)).toMatchObject({ mime: "application/pdf", label: "PDF" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles a header with no type at all", () => {
|
||||||
|
expect(sniffValue(`data:;base64,${base64Of(JPEG)}`)).toMatchObject({ mime: "image/jpeg" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles extra parameters", () => {
|
||||||
|
const uri = `data:text/plain;charset=utf-8;base64,${btoa("hello")}`;
|
||||||
|
expect(sniffValue(uri)).toMatchObject({ mime: "text/plain", encoding: "base64" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles a percent-encoded payload", () => {
|
||||||
|
expect(sniffValue("data:text/html,%3Ch1%3Ehi%3C/h1%3E")).toEqual({
|
||||||
|
mime: "text/html",
|
||||||
|
label: "HTML",
|
||||||
|
offset: "data:text/html,".length,
|
||||||
|
encoding: "percent",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults a bare percent-encoded payload to text", () => {
|
||||||
|
expect(sniffValue("data:,hello%20there")).toMatchObject({
|
||||||
|
mime: "text/plain",
|
||||||
|
encoding: "percent",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decoding only the head", () => {
|
||||||
|
test("reads no more of the value than the bytes asked for", () => {
|
||||||
|
const big = base64Of(PNG, 3_000_000);
|
||||||
|
// Truncating to the head must not change what is found, which is the whole point:
|
||||||
|
// callers only ever hand it a slice
|
||||||
|
expect(sniffValue(big.slice(0, SNIFF_HEAD_CHARS))).toEqual(sniffValue(big));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decodes whole 4-character groups only", () => {
|
||||||
|
// 12 bytes needs 16 characters; a 15-character slice yields the 12 bytes of 3 whole groups
|
||||||
|
expect(decodeBase64Prefix(base64Of(PNG), 0, 12)).toHaveLength(12);
|
||||||
|
expect(decodeBase64Prefix(base64Of(PNG).slice(0, 15), 0, 12)).toHaveLength(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines a value too short to hold a group", () => {
|
||||||
|
expect(decodeBase64Prefix("abc", 0, 12)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines text outside the alphabet", () => {
|
||||||
|
expect(decodeBase64Prefix("hello world, not base64!", 0, 12)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("labels", () => {
|
||||||
|
test.each([
|
||||||
|
["image/png", "PNG"],
|
||||||
|
["image/svg+xml", "SVG"],
|
||||||
|
["text/plain", "TEXT"],
|
||||||
|
["application/vnd.ms-excel", "MS-EXCEL"],
|
||||||
|
["image/x-icon", "ICON"],
|
||||||
|
["application/octet-stream", "APPLICATION"],
|
||||||
|
["font/woff2", "WOFF2"],
|
||||||
|
["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "DOCUMENT"],
|
||||||
|
])("%s reads as %s", (mime, label) => {
|
||||||
|
expect(labelForMime(mime)).toBe(label);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* What a collapsed value turns out to be.
|
||||||
|
*
|
||||||
|
* Sniffing reads the head of the value only. A `data:` URI states its own media type, and raw
|
||||||
|
* base64 gives up its type in the first few bytes, so neither needs the rest — which is the
|
||||||
|
* point, since the whole reason these values are collapsed is that touching all of one costs
|
||||||
|
* enough to stall the UI.
|
||||||
|
*/
|
||||||
|
export interface SniffedValue {
|
||||||
|
/** Full media type, e.g. `image/png`. What the dialog routes on. */
|
||||||
|
mime: string;
|
||||||
|
/** A word for the tag, e.g. `PNG`. */
|
||||||
|
label: string;
|
||||||
|
/** Where the encoded payload starts in the value, past any `data:` header. */
|
||||||
|
offset: number;
|
||||||
|
encoding: "base64" | "percent";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of a value {@link sniffValue} needs. Enough for a `data:` header of realistic
|
||||||
|
* length, and far more than the 24 base64 characters the magic numbers are read from.
|
||||||
|
*/
|
||||||
|
export const SNIFF_HEAD_CHARS = 256;
|
||||||
|
|
||||||
|
/** Bytes the longest signature needs: `RIFF????WEBP`, and an ISO-BMFF brand at offset 8. */
|
||||||
|
const SNIFF_BYTES = 12;
|
||||||
|
|
||||||
|
/** `data:[<mime>][;<param>…],` — the payload follows the comma. */
|
||||||
|
const DATA_URI = /^data:([^,;]*)((?:;[^,;]*)*),/;
|
||||||
|
|
||||||
|
/** Media types that name no format, so the bytes are worth a look even when one is declared. */
|
||||||
|
const UNINFORMATIVE = ["", "application/octet-stream", "binary/octet-stream"];
|
||||||
|
|
||||||
|
interface Signature {
|
||||||
|
label: string;
|
||||||
|
mime: string;
|
||||||
|
offset?: number;
|
||||||
|
/** Byte values, where `null` matches anything */
|
||||||
|
magic: readonly (number | null)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A magic number written as the ASCII it reads as, with `?` for a byte that varies.
|
||||||
|
*
|
||||||
|
* Indexed rather than iterated, because a signature is a sequence of bytes: one character here
|
||||||
|
* is one byte, and splitting into code points would be the wrong unit for that.
|
||||||
|
*/
|
||||||
|
function ascii(pattern: string): (number | null)[] {
|
||||||
|
const bytes: (number | null)[] = [];
|
||||||
|
for (let i = 0; i < pattern.length; i++) {
|
||||||
|
bytes.push(pattern[i] === "?" ? null : pattern.charCodeAt(i));
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNATURES: readonly Signature[] = [
|
||||||
|
{ label: "PNG", mime: "image/png", magic: [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a] },
|
||||||
|
{ label: "JPEG", mime: "image/jpeg", magic: [0xff, 0xd8, 0xff] },
|
||||||
|
{ label: "GIF", mime: "image/gif", magic: ascii("GIF8") },
|
||||||
|
{ label: "BMP", mime: "image/bmp", magic: ascii("BM") },
|
||||||
|
{ label: "WEBP", mime: "image/webp", magic: ascii("RIFF????WEBP") },
|
||||||
|
{ label: "TIFF", mime: "image/tiff", magic: [...ascii("II"), 0x2a, 0x00] },
|
||||||
|
{ label: "TIFF", mime: "image/tiff", magic: [...ascii("MM"), 0x00, 0x2a] },
|
||||||
|
{ label: "ICO", mime: "image/x-icon", magic: [0x00, 0x00, 0x01, 0x00] },
|
||||||
|
{ label: "PDF", mime: "application/pdf", magic: ascii("%PDF-") },
|
||||||
|
{ label: "WAV", mime: "audio/wav", magic: ascii("RIFF????WAVE") },
|
||||||
|
{ label: "AVI", mime: "video/x-msvideo", magic: ascii("RIFF????AVI ") },
|
||||||
|
{ label: "MP3", mime: "audio/mpeg", magic: ascii("ID3") },
|
||||||
|
// A bare MPEG frame header: 11 sync bits, then a layer III / II / I version pair
|
||||||
|
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xfb] },
|
||||||
|
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf3] },
|
||||||
|
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf2] },
|
||||||
|
{ label: "OGG", mime: "audio/ogg", magic: ascii("OggS") },
|
||||||
|
{ label: "FLAC", mime: "audio/flac", magic: ascii("fLaC") },
|
||||||
|
{ label: "WEBM", mime: "video/webm", magic: [0x1a, 0x45, 0xdf, 0xa3] },
|
||||||
|
{ label: "ZIP", mime: "application/zip", magic: [...ascii("PK"), 0x03, 0x04] },
|
||||||
|
{ label: "GZIP", mime: "application/gzip", magic: [0x1f, 0x8b] },
|
||||||
|
{ label: "7Z", mime: "application/x-7z-compressed", magic: [...ascii("7z"), 0xbc, 0xaf, 0x27] },
|
||||||
|
{ label: "RAR", mime: "application/vnd.rar", magic: ascii("Rar!") },
|
||||||
|
{ label: "GLTF", mime: "model/gltf-binary", magic: ascii("glTF") },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO base media files all start `????ftyp`, so the format is in the brand that follows rather
|
||||||
|
* than in the signature itself. Anything unlisted is some flavour of MP4.
|
||||||
|
*/
|
||||||
|
const ISO_BRANDS: Record<string, { mime: string; label: string }> = {
|
||||||
|
avif: { mime: "image/avif", label: "AVIF" },
|
||||||
|
avis: { mime: "image/avif", label: "AVIF" },
|
||||||
|
heic: { mime: "image/heic", label: "HEIC" },
|
||||||
|
heix: { mime: "image/heic", label: "HEIC" },
|
||||||
|
hevc: { mime: "image/heic", label: "HEIC" },
|
||||||
|
mif1: { mime: "image/heif", label: "HEIF" },
|
||||||
|
msf1: { mime: "image/heif", label: "HEIF" },
|
||||||
|
"M4A ": { mime: "audio/mp4", label: "M4A" },
|
||||||
|
"qt ": { mime: "video/quicktime", label: "MOV" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function matches(bytes: Uint8Array, { magic, offset = 0 }: Signature): boolean {
|
||||||
|
if (bytes.length < offset + magic.length) return false;
|
||||||
|
return magic.every((b, i) => b == null || bytes[offset + i] === b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAscii(bytes: Uint8Array, from: number, length: number): string {
|
||||||
|
return String.fromCharCode(...bytes.subarray(from, from + length));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The format the first bytes of a file identify it as, or null if they identify nothing. */
|
||||||
|
export function sniffBytes(bytes: Uint8Array): { mime: string; label: string } | null {
|
||||||
|
if (readAscii(bytes, 4, 4) === "ftyp") {
|
||||||
|
return ISO_BRANDS[readAscii(bytes, 8, 4)] ?? { mime: "video/mp4", label: "MP4" };
|
||||||
|
}
|
||||||
|
const sig = SIGNATURES.find((s) => matches(bytes, s));
|
||||||
|
return sig == null ? null : { mime: sig.mime, label: sig.label };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first `bytes` bytes of a base64 payload, or null if it isn't base64 after all.
|
||||||
|
*
|
||||||
|
* Only the characters those bytes need are decoded. The trailing partial group is dropped
|
||||||
|
* rather than padded, since `atob` rejects a group of one or two characters outright and we
|
||||||
|
* have no use for the byte a three-character group would add.
|
||||||
|
*/
|
||||||
|
export function decodeBase64Prefix(text: string, offset: number, bytes: number): Uint8Array | null {
|
||||||
|
const wanted = Math.ceil(bytes / 3) * 4;
|
||||||
|
let b64 = text.slice(offset, offset + wanted);
|
||||||
|
b64 = b64.slice(0, b64.length - (b64.length % 4));
|
||||||
|
if (b64.length === 0) return null;
|
||||||
|
|
||||||
|
// The URL-safe alphabet stands for the same bytes
|
||||||
|
b64 = b64.replaceAll("-", "+").replaceAll("_", "/");
|
||||||
|
if (!/^[A-Za-z0-9+/]+$/.test(b64)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const binary = atob(b64);
|
||||||
|
const out = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
out[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a value is, from its head alone, or null if nothing recognises it.
|
||||||
|
*
|
||||||
|
* `head` need only be the first {@link SNIFF_HEAD_CHARS} characters — pass more and the rest is
|
||||||
|
* ignored. The offsets in the result are relative to the start of the whole value.
|
||||||
|
*/
|
||||||
|
export function sniffValue(head: string): SniffedValue | null {
|
||||||
|
const lead = head.length - head.trimStart().length;
|
||||||
|
const value = head.slice(lead);
|
||||||
|
|
||||||
|
const dataUri = DATA_URI.exec(value);
|
||||||
|
if (dataUri != null) {
|
||||||
|
const declared = dataUri[1]!.toLowerCase();
|
||||||
|
const offset = lead + dataUri[0].length;
|
||||||
|
if (!dataUri[2]!.split(";").includes("base64")) {
|
||||||
|
// A percent-encoded payload states its own type or is text by definition
|
||||||
|
const mime = declared === "" ? "text/plain" : declared;
|
||||||
|
return { mime, label: labelForMime(mime), offset, encoding: "percent" };
|
||||||
|
}
|
||||||
|
if (!UNINFORMATIVE.includes(declared)) {
|
||||||
|
return { mime: declared, label: labelForMime(declared), offset, encoding: "base64" };
|
||||||
|
}
|
||||||
|
return sniffBase64At(head, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sniffBase64At(head, lead);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sniffBase64At(head: string, offset: number): SniffedValue | null {
|
||||||
|
const bytes = decodeBase64Prefix(head, offset, SNIFF_BYTES);
|
||||||
|
const sniffed = bytes == null ? null : sniffBytes(bytes);
|
||||||
|
return sniffed == null ? null : { ...sniffed, offset, encoding: "base64" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shortest run of base64 that {@link isEncodedRun} will accept as convincing */
|
||||||
|
const ENCODED_RUN_CHARS = 64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a value is one unbroken run of base64, rather than text that merely opens like it.
|
||||||
|
*
|
||||||
|
* A magic number is only a few bytes, so short ones match by chance — `BM` is two, which comes
|
||||||
|
* up about once in every 65,000 values. That was harmless while a value had to be long enough
|
||||||
|
* to hurt rendering before anything was hidden, and the sniff only chose the label. It is not
|
||||||
|
* harmless when the sniff itself decides to hide something, so that rule asks for this as well:
|
||||||
|
* prose leaves the alphabet within a few characters, and an encoded blob never does.
|
||||||
|
*/
|
||||||
|
export function isEncodedRun(head: string, offset: number): boolean {
|
||||||
|
const run = head.slice(offset);
|
||||||
|
return run.length >= ENCODED_RUN_CHARS && /^[A-Za-z0-9+/\-_]+={0,2}$/.test(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subtypes whose own name makes a poor label */
|
||||||
|
const LABEL_OVERRIDES: Record<string, string> = {
|
||||||
|
"text/plain": "TEXT",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A word short enough for a tag. The distinguishing part of a subtype is its last dotted
|
||||||
|
* segment before any `+suffix`, so `image/svg+xml` reads as SVG and `application/vnd.ms-excel`
|
||||||
|
* as MS-EXCEL. Anything still too long falls back to the top-level type.
|
||||||
|
*/
|
||||||
|
export function labelForMime(mime: string): string {
|
||||||
|
const override = LABEL_OVERRIDES[mime];
|
||||||
|
if (override != null) {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [type, subtype] = mime.split("/");
|
||||||
|
const word = (subtype ?? "").split("+")[0]!.split(".").pop()!.replace(/^x-/, "");
|
||||||
|
if (word.length > 0 && word.length <= 10 && word !== "octet-stream") {
|
||||||
|
return word.toUpperCase();
|
||||||
|
}
|
||||||
|
return (type ?? mime).toUpperCase();
|
||||||
|
}
|
||||||
@@ -4,23 +4,26 @@ import { useEffect, useState } from "react";
|
|||||||
interface Props {
|
interface Props {
|
||||||
bodyPath?: string;
|
bodyPath?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AudioViewer({ bodyPath, data }: Props) {
|
export function AudioViewer({ bodyPath, data, mimeType }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bodyPath) {
|
if (bodyPath) {
|
||||||
setSrc(convertFileSrc(bodyPath));
|
setSrc(convertFileSrc(bodyPath));
|
||||||
} else if (data) {
|
} else if (data) {
|
||||||
const blob = new Blob([new Uint8Array(data)], { type: "audio/mpeg" });
|
// The type matters here in a way it doesn't for an image: a media element goes by what
|
||||||
|
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
|
||||||
|
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
setSrc(url);
|
setSrc(url);
|
||||||
return () => URL.revokeObjectURL(url);
|
return () => URL.revokeObjectURL(url);
|
||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [bodyPath, data]);
|
}, [bodyPath, data, mimeType]);
|
||||||
|
|
||||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||||
return <audio className="w-full" controls src={src} />;
|
return <audio className="w-full" controls src={src} />;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { convertFileSrc } from "@tauri-apps/api/core";
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
type Props = { className?: string } & (
|
type Props = { className?: string; mimeType?: string } & (
|
||||||
| {
|
| {
|
||||||
bodyPath: string;
|
bodyPath: string;
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ type Props = { className?: string } & (
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export function ImageViewer({ className, ...props }: Props) {
|
export function ImageViewer({ className, mimeType, ...props }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
const bodyPath = "bodyPath" in props ? props.bodyPath : null;
|
const bodyPath = "bodyPath" in props ? props.bodyPath : null;
|
||||||
const data = "data" in props ? props.data : null;
|
const data = "data" in props ? props.data : null;
|
||||||
@@ -20,14 +20,14 @@ export function ImageViewer({ className, ...props }: Props) {
|
|||||||
if (bodyPath != null) {
|
if (bodyPath != null) {
|
||||||
setSrc(convertFileSrc(bodyPath));
|
setSrc(convertFileSrc(bodyPath));
|
||||||
} else if (data != null) {
|
} else if (data != null) {
|
||||||
const blob = new Blob([data], { type: "image/png" });
|
const blob = new Blob([data], { type: mimeType ?? "image/png" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
setSrc(url);
|
setSrc(url);
|
||||||
return () => URL.revokeObjectURL(url);
|
return () => URL.revokeObjectURL(url);
|
||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [bodyPath, data]);
|
}, [bodyPath, data, mimeType]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import "react-pdf/dist/Page/AnnotationLayer.css";
|
|||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import "./PdfViewer.css";
|
import "./PdfViewer.css";
|
||||||
import type { PDFDocumentProxy } from "pdfjs-dist";
|
import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { Document, Page } from "react-pdf";
|
import { Document, Page } from "react-pdf";
|
||||||
import { useContainerSize } from "@yaakapp-internal/ui";
|
import { useContainerSize } from "@yaakapp-internal/ui";
|
||||||
import { fireAndForget } from "../../lib/fireAndForget";
|
import { fireAndForget } from "../../lib/fireAndForget";
|
||||||
@@ -30,26 +30,32 @@ const options = {
|
|||||||
export function PdfViewer({ bodyPath, data }: Props) {
|
export function PdfViewer({ bodyPath, data }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [numPages, setNumPages] = useState<number>();
|
const [numPages, setNumPages] = useState<number>();
|
||||||
const [src, setSrc] = useState<string | { data: Uint8Array }>();
|
|
||||||
|
|
||||||
const { width: containerWidth } = useContainerSize(containerRef);
|
const { width: containerWidth } = useContainerSize(containerRef);
|
||||||
|
|
||||||
useEffect(() => {
|
// During render, not in an effect: an effect leaves the first paint with no file, and
|
||||||
|
// `Document` renders its "Failed to load PDF file" state for that frame before recovering
|
||||||
|
const src = useMemo(() => {
|
||||||
if (bodyPath) {
|
if (bodyPath) {
|
||||||
setSrc(convertFileSrc(bodyPath));
|
return convertFileSrc(bodyPath);
|
||||||
} else if (data) {
|
}
|
||||||
|
if (data) {
|
||||||
// Create a copy to avoid "Buffer is already detached" errors
|
// Create a copy to avoid "Buffer is already detached" errors
|
||||||
// This happens when the ArrayBuffer is transferred/detached elsewhere
|
// This happens when the ArrayBuffer is transferred/detached elsewhere
|
||||||
const dataCopy = new Uint8Array(data);
|
return { data: new Uint8Array(data) };
|
||||||
setSrc({ data: dataCopy });
|
|
||||||
} else {
|
|
||||||
setSrc(undefined);
|
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
}, [bodyPath, data]);
|
}, [bodyPath, data]);
|
||||||
|
|
||||||
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
|
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
|
||||||
setNumPages(nextNumPages);
|
setNumPages(nextNumPages);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Nothing to show yet, rather than the failure state `Document` renders for an empty file
|
||||||
|
if (src == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="w-full h-full overflow-y-auto">
|
<div ref={containerRef} className="w-full h-full overflow-y-auto">
|
||||||
<Document
|
<Document
|
||||||
|
|||||||
@@ -4,23 +4,25 @@ import { useEffect, useState } from "react";
|
|||||||
interface Props {
|
interface Props {
|
||||||
bodyPath?: string;
|
bodyPath?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoViewer({ bodyPath, data }: Props) {
|
export function VideoViewer({ bodyPath, data, mimeType }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bodyPath) {
|
if (bodyPath) {
|
||||||
setSrc(convertFileSrc(bodyPath));
|
setSrc(convertFileSrc(bodyPath));
|
||||||
} else if (data) {
|
} else if (data) {
|
||||||
const blob = new Blob([new Uint8Array(data)], { type: "video/mp4" });
|
// As in AudioViewer: a media element trusts the declared type instead of sniffing
|
||||||
|
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
setSrc(url);
|
setSrc(url);
|
||||||
return () => URL.revokeObjectURL(url);
|
return () => URL.revokeObjectURL(url);
|
||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [bodyPath, data]);
|
}, [bodyPath, data, mimeType]);
|
||||||
|
|
||||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||||
return <video className="w-full" controls src={src} />;
|
return <video className="w-full" controls src={src} />;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
import type { DropdownItem } from "../components/core/Dropdown";
|
||||||
|
import { jotaiStore } from "./jotai";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A menu opened from somewhere that can't render React.
|
||||||
|
*
|
||||||
|
* Most menus in the app are a `Dropdown` wrapped around their own trigger. This is for the
|
||||||
|
* cases where the trigger isn't a React component at all — a CodeMirror widget builds its DOM
|
||||||
|
* synchronously, so it can only hand over a position and a list of items. Same shape as
|
||||||
|
* {@link showDialog}.
|
||||||
|
*/
|
||||||
|
export interface ContextMenuInstance {
|
||||||
|
id: string;
|
||||||
|
/** Where to open, in viewport coordinates */
|
||||||
|
triggerPosition: { x: number; y: number };
|
||||||
|
/** The trigger's box, when it has one, so placement can align to its edges */
|
||||||
|
triggerRect?: Pick<DOMRect, "top" | "bottom" | "left" | "right">;
|
||||||
|
items: DropdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contextMenusAtom = atom<ContextMenuInstance[]>([]);
|
||||||
|
|
||||||
|
export function showContextMenu({ id, ...props }: ContextMenuInstance) {
|
||||||
|
jotaiStore.set(contextMenusAtom, (m) => [...m.filter((c) => c.id !== id), { id, ...props }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideContextMenu(id: string) {
|
||||||
|
jotaiStore.set(contextMenusAtom, (m) => m.filter((c) => c.id !== id));
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: sniffed?.mime.startsWith("image/") ? "Copy Image" : "Copy",
|
||||||
|
leftSlot: createElement(Icon, { icon: "copy" }),
|
||||||
|
onSelect: () => copyValue(value(), sniffed, 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the value: the picture itself when it is one, and the text it stands for otherwise.
|
||||||
|
*
|
||||||
|
* 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 copyValue(text: string, sniffed: SniffedValue | null, hidden: string) {
|
||||||
|
if (!isCopyableImage(sniffed) || typeof ClipboardItem === "undefined") {
|
||||||
|
copyToClipboard(hidden);
|
||||||
|
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(hidden);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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}` });
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ type TauriCmd =
|
|||||||
| "cmd_plugin_init_errors"
|
| "cmd_plugin_init_errors"
|
||||||
| "cmd_reload_plugins"
|
| "cmd_reload_plugins"
|
||||||
| "cmd_render_template"
|
| "cmd_render_template"
|
||||||
|
| "cmd_save_base64_to_binary"
|
||||||
| "cmd_save_response"
|
| "cmd_save_response"
|
||||||
| "cmd_secure_template"
|
| "cmd_secure_template"
|
||||||
| "cmd_send_ephemeral_request"
|
| "cmd_send_ephemeral_request"
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import { queryClient } from "../lib/queryClient";
|
|||||||
|
|
||||||
const Toasts = lazy(() => import("../components/Toasts").then((m) => ({ default: m.Toasts })));
|
const Toasts = lazy(() => import("../components/Toasts").then((m) => ({ default: m.Toasts })));
|
||||||
const Dialogs = lazy(() => import("../components/Dialogs").then((m) => ({ default: m.Dialogs })));
|
const Dialogs = lazy(() => import("../components/Dialogs").then((m) => ({ default: m.Dialogs })));
|
||||||
|
const ContextMenus = lazy(() =>
|
||||||
|
import("../components/ContextMenus").then((m) => ({ default: m.ContextMenus })),
|
||||||
|
);
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
@@ -29,6 +32,7 @@ function RouteComponent() {
|
|||||||
<Suspense>
|
<Suspense>
|
||||||
<Toasts />
|
<Toasts />
|
||||||
<Dialogs />
|
<Dialogs />
|
||||||
|
<ContextMenus />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Layout />
|
<Layout />
|
||||||
<GlobalHooks />
|
<GlobalHooks />
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu inst
|
|||||||
rlimit = "0.11" # Raise the launchd 256 open-file soft limit at startup
|
rlimit = "0.11" # Raise the launchd 256 open-file soft limit at startup
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64 = "0.22.1" # For writing values the editor only ever held encoded
|
||||||
charset = "0.1.5"
|
charset = "0.1.5"
|
||||||
chrono = { workspace = true, features = ["serde"] }
|
chrono = { workspace = true, features = ["serde"] }
|
||||||
cookie = "0.18.1"
|
cookie = "0.18.1"
|
||||||
|
|||||||
@@ -1434,6 +1434,30 @@ async fn cmd_export_data<R: Runtime>(
|
|||||||
})?)
|
})?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decodes base64 and writes the bytes to a file the user picked.
|
||||||
|
///
|
||||||
|
/// The webview can't do this itself: its `fs` permissions are read-only and scoped to the app
|
||||||
|
/// data directory, and widening them so it could write anywhere would be a poor trade in an app
|
||||||
|
/// whose whole job is rendering responses from servers it doesn't control.
|
||||||
|
///
|
||||||
|
/// Base64 in rather than bytes for two reasons. A `Vec<u8>` crosses the IPC boundary as a JSON
|
||||||
|
/// array of numbers, several times the size of the thing being saved. And the callers that need
|
||||||
|
/// this — values the editor collapsed — are holding base64 already, so passing it through
|
||||||
|
/// untouched means the save never decodes megabytes on the main thread.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn cmd_save_base64_to_binary<R: Runtime>(
|
||||||
|
_app_handle: AppHandle<R>,
|
||||||
|
filepath: &str,
|
||||||
|
data: &str,
|
||||||
|
) -> YaakResult<()> {
|
||||||
|
use base64::Engine;
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(data)
|
||||||
|
.map_err(|e| GenericError(format!("Data is not valid base64: {e}")))?;
|
||||||
|
fs::write(filepath, bytes).map_err(|e| GenericError(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn cmd_save_response<R: Runtime>(
|
async fn cmd_save_response<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
@@ -1863,6 +1887,7 @@ pub fn run() {
|
|||||||
cmd_reload_plugins,
|
cmd_reload_plugins,
|
||||||
cmd_render_template,
|
cmd_render_template,
|
||||||
cmd_restart,
|
cmd_restart,
|
||||||
|
cmd_save_base64_to_binary,
|
||||||
cmd_save_response,
|
cmd_save_response,
|
||||||
cmd_send_ephemeral_request,
|
cmd_send_ephemeral_request,
|
||||||
cmd_send_http_request,
|
cmd_send_http_request,
|
||||||
|
|||||||
Reference in New Issue
Block a user