diff --git a/apps/yaak-client/components/ContextMenus.tsx b/apps/yaak-client/components/ContextMenus.tsx index b9e15773..dd8bad8f 100644 --- a/apps/yaak-client/components/ContextMenus.tsx +++ b/apps/yaak-client/components/ContextMenus.tsx @@ -8,12 +8,15 @@ export function ContextMenus() { const menus = useAtomValue(contextMenusAtom); return ( <> - {menus.map(({ id, items, triggerPosition, triggerRect }) => ( + {menus.map(({ id, items, triggerPosition, triggerRect, triggerEl }) => ( hideContextMenu(id)} /> diff --git a/apps/yaak-client/components/core/Dropdown.tsx b/apps/yaak-client/components/core/Dropdown.tsx index e6de0c6c..a1836206 100644 --- a/apps/yaak-client/components/core/Dropdown.tsx +++ b/apps/yaak-client/components/core/Dropdown.tsx @@ -242,13 +242,20 @@ export interface ContextMenuProps { * from a button. */ triggerRect?: Pick; + /** + * The element the menu belongs to, so a click on it doesn't count as a click outside. + * + * Without this the trigger gets both: the outside-click handler closes the menu on mousedown, + * then the trigger's own click opens it again, and pressing it looks like it does nothing. + */ + triggerRef?: RefObject; className?: string; items: DropdownProps["items"]; onClose: () => void; } export const ContextMenu = forwardRef(function ContextMenu( - { triggerPosition, triggerRect, className, items, onClose }, + { triggerPosition, triggerRect, triggerRef, className, items, onClose }, ref, ) { const triggerShape = useMemo( @@ -275,6 +282,7 @@ export const ContextMenu = forwardRef(function Co ref={ref} items={items} onClose={onClose} + triggerRef={triggerRef} triggerShape={triggerShape} /> ); @@ -290,7 +298,9 @@ interface MenuProps { fullWidth?: boolean; isOpen: boolean; items: DropdownItem[]; - triggerRef?: RefObject; + // Any element, not just a button: a menu can be opened from anything, and this is only ever + // used to ask whether a click landed on the trigger + triggerRef?: RefObject; isSubmenu?: boolean; } diff --git a/apps/yaak-client/components/core/Editor/Editor.tsx b/apps/yaak-client/components/core/Editor/Editor.tsx index 606136af..a472f938 100644 --- a/apps/yaak-client/components/core/Editor/Editor.tsx +++ b/apps/yaak-client/components/core/Editor/Editor.tsx @@ -43,6 +43,7 @@ import { IconButton } from "../IconButton"; import "./Editor.css"; import { baseExtensions, + editableExtensions, getLanguageExtension, multiLineExtensions, readonlyExtensions, @@ -289,7 +290,7 @@ function EditorInner({ function configureReadOnly() { if (cm.current === null) return; const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension; - const next = readOnly ? readonlyExtensions : emptyExtension; + const next = readOnly ? readonlyExtensions : editableExtensions; // PERF: This is expensive with hundreds of editors on screen, so only do it when necessary if (current === next) return; @@ -412,7 +413,7 @@ function EditorInner({ keymapCompartment.current.of( keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default, ), - readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension), + readOnlyCompartment.current.of(readOnly ? readonlyExtensions : editableExtensions), ...getExtensions({ container, singleLine, diff --git a/apps/yaak-client/components/core/Editor/extensions.ts b/apps/yaak-client/components/core/Editor/extensions.ts index 95904089..29be56cb 100644 --- a/apps/yaak-client/components/core/Editor/extensions.ts +++ b/apps/yaak-client/components/core/Editor/extensions.ts @@ -59,7 +59,7 @@ import { pluralizeCount } from "../../../lib/pluralize"; import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms"; import type { EditorProps } from "./Editor"; import { jsonParseLinter } from "./json-lint"; -import { largeValues } from "./largeValues"; +import { largeValues, mediaValues } from "./largeValues"; import { pairs } from "./pairs/extension"; import { searchMatchCount } from "./searchMatchCount"; import { text } from "./text/extension"; @@ -259,10 +259,19 @@ export const baseExtensions = [ export const readonlyExtensions = [ EditorState.readOnly.of(true), EditorView.contentAttributes.of({ tabindex: "-1" }), - // Read-only only, so we never hide part of a document someone is editing + // Nobody is editing this, so every rule applies largeValues, ]; +/** + * The counterpart for a document being edited. + * + * Only encoded media collapses here: it is pasted rather than typed, and replaced rather than + * edited, so a tag stands in for it without hiding anything anyone meant to read. The rules that + * hide text by length alone stay out. See {@link mediaValues}. + */ +export const editableExtensions = [mediaValues]; + export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [ search({ top: true }), searchMatchCount(), diff --git a/apps/yaak-client/components/core/Editor/largeValues.test.ts b/apps/yaak-client/components/core/Editor/largeValues.test.ts index 59a6bd81..6df9f0aa 100644 --- a/apps/yaak-client/components/core/Editor/largeValues.test.ts +++ b/apps/yaak-client/components/core/Editor/largeValues.test.ts @@ -1,6 +1,7 @@ import { EditorState } from "@codemirror/state"; import { jsonc } from "@shopify/lang-jsonc"; import { text } from "./text/extension"; +import { twig } from "./twig/extension"; import { describe, expect, test } from "vite-plus/test"; import { COLLAPSE_MEDIA_CHARS, @@ -256,9 +257,15 @@ describe("sniffing the collapsed value", () => { 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(); + test("finds a run that starts partway through a line, and names it", () => { + // No grammar here, and the value is not the whole line. The run is found by its alphabet, + // so the prefix stays on screen and the blob is still named. + const prefix = "some prefix text "; + const state = plainState(`${prefix}${pngBase64()}`); + const [widget] = widgets(state); + + expect(widget?.valueFrom).toBe(prefix.length); + expect(widget?.sniffed).toMatchObject({ label: "PNG" }); }); }); @@ -343,6 +350,126 @@ describe("media collapsing, below the length rules", () => { }); }); +describe("media rule only, for a document being edited", () => { + /** What an editable editor gets: rule 2 alone */ + function editableRanges(state: EditorState) { + const ranges: { from: number; to: number }[] = []; + const iter = collapseDecorations(state, [{ from: 0, to: state.doc.length }], "media").iter(); + while (iter.value != null) { + ranges.push({ from: iter.from, to: iter.to }); + iter.next(); + } + return ranges; + } + + 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); + } + + test("collapses a pasted image, the one thing it is for", () => { + const value = png(3_000); + const state = jsonState(`{\n "avatar": "${value}"\n}`); + + expect(editableRanges(state)).toHaveLength(1); + expect(state.sliceDoc(editableRanges(state)[0]!.from, editableRanges(state)[0]!.to)).toBe( + value, + ); + }); + + test("leaves a long value alone when nothing can name it", () => { + // Rule 1 would take this on a stalling line. Editing it is plausible, so it stays. + const doc = `{"blob":"${"Z".repeat(COLLAPSE_TOKEN_CHARS * 3)}"}`; + expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS); + expect(editableRanges(jsonState(doc))).toEqual([]); + }); + + test("never cuts at the column limit, which would strand text out of reach", () => { + // Rule 3 territory: a minified body with no value big enough to collapse on its own + const doc = `[${Array.from({ length: 4_000 }, (_, i) => `"word-${i}"`).join(",")}]`; + expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS); + expect(editableRanges(jsonState(doc))).toEqual([]); + // The read-only rules still cut it + expect(collapsedRanges(jsonState(doc))).toHaveLength(1); + }); + + test("never swallows a template tag, collapsing only the run beside it", () => { + // A brace is not in the base64 alphabet, so the run starts after the tag and the tag stays + // on screen where it can still be read and edited + const tag = "{{ image }}"; + const doc = `{"avatar":"${tag}${png(3_000)}"}`; + const ranges = editableRanges(jsonState(doc)); + + expect(ranges).toHaveLength(1); + expect(ranges[0]!.from).toBe(doc.indexOf(tag) + tag.length); + expect(doc.slice(ranges[0]!.from, ranges[0]!.to)).not.toContain("{"); + }); + + test("finds a data URI whole, header and all", () => { + const value = `data:image/jpeg;base64,${png(3_000)}`; + const doc = `{"avatar":"${value}"}`; + const ranges = editableRanges(jsonState(doc)); + + expect(ranges).toHaveLength(1); + expect(doc.slice(ranges[0]!.from, ranges[0]!.to)).toBe(value); + }); + + test("hides no line break, so the plugin may provide it from the viewport", () => { + const state = jsonState(`{\n "a": "${png(3_000)}",\n "b": 1\n}`); + for (const { from, to } of editableRanges(state)) { + expect(state.sliceDoc(from, to)).not.toContain("\n"); + } + }); +}); + +describe("templated fields, where the grammar is an overlay", () => { + // Every editable field mixes its language with twig, which mounts the base language as an + // overlay. Overlays are not traversed by `Tree.iterate`, so a rule that walks the tree sees + // one enormous Text node and finds nothing. Rule 2 reads the text instead, and this is the + // case that has to keep working: an image pasted into a JSON request body. + const twigState = (doc: string) => + EditorState.create({ + doc, + extensions: [ + twig({ + base: jsonc(), + environmentVariables: [], + completionOptions: [], + onClickVariable: () => {}, + onClickMissingVariable: () => {}, + onClickPathParameter: () => {}, + extraExtensions: [], + }), + largeValues, + ], + }); + + 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); + } + + test("collapses a pasted image in a templated body", () => { + const value = png(3_000); + const state = twigState(`{\n "avatar": "${value}"\n}`); + const ranges: { from: number; to: number }[] = []; + const iter = collapseDecorations(state, [{ from: 0, to: state.doc.length }], "media").iter(); + while (iter.value != null) { + ranges.push({ from: iter.from, to: iter.to }); + iter.next(); + } + + expect(ranges).toHaveLength(1); + expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(value); + }); +}); + describe("viewport scoping", () => { const doc = () => { const value = "A".repeat(COLLAPSE_TOKEN_CHARS * 2); diff --git a/apps/yaak-client/components/core/Editor/largeValues.ts b/apps/yaak-client/components/core/Editor/largeValues.ts index d9635acc..49185997 100644 --- a/apps/yaak-client/components/core/Editor/largeValues.ts +++ b/apps/yaak-client/components/core/Editor/largeValues.ts @@ -64,10 +64,22 @@ export const COLLAPSE_MEDIA_CHARS = 1_000; * 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 - * text they can't see. + * An editable document gets rule 2 alone, as {@link mediaValues}. Encoded media is never typed + * by hand: it arrives pasted, and the only edit anyone makes to it is replacing it wholesale, + * which the tag already supports — {@link EditorView.atomicRanges} makes it delete as one unit. + * So collapsing it hides nothing anyone was going to read. The other two rules stay out, since + * they hide text by length alone, without being able to say what it is: under rule 3 a minified + * body would become uneditable past the column limit, which really would be editing text you + * can't see. */ +/** + * Which of the three rules to apply. + * + * `media` is the subset safe for a document being edited: collapse only what we can name. + */ +export type CollapseRules = "all" | "media"; + /** * A hidden range, the value it belongs to, and what that value turned out to be. * @@ -101,7 +113,7 @@ const CHEVRON_DOWN = * 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) { +function makeTagButton(el: HTMLElement, title: string, onOpen: () => void) { el.role = "button"; el.ariaHasPopup = "menu"; el.tabIndex = 0; @@ -116,13 +128,13 @@ function makeTagButton(el: HTMLElement, title: string, onOpen: (at: DOMRect) => el.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); - onOpen(el.getBoundingClientRect()); + onOpen(); }); el.addEventListener("keydown", (e) => { if (e.key !== "Enter" && e.key !== " ") return; e.preventDefault(); e.stopPropagation(); - onOpen(el.getBoundingClientRect()); + onOpen(); }); } @@ -177,7 +189,7 @@ class LargeValueWidget extends WidgetType { makeTagButton( el, this.sniffed == null ? "Value actions" : `${this.sniffed.label} actions`, - (rect) => fireAndForget(this.openMenu(view, rect)), + () => fireAndForget(this.openMenu(view, el)), ); return el; @@ -190,11 +202,8 @@ class LargeValueWidget extends WidgetType { * 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, - ) { - const [{ showContextMenu }, { showLargeValueDialog }, { encodingLabel, largeValueActions }] = + private async openMenu(view: EditorView, tag: HTMLElement) { + const [{ toggleContextMenu }, { showLargeValueDialog }, { encodingLabel, largeValueActions }] = await Promise.all([ import("../../../lib/contextMenu"), import("../../LargeValueDialog"), @@ -209,11 +218,13 @@ class LargeValueWidget extends WidgetType { Math.min(this.valueFrom + SNIFF_HEAD_CHARS, this.to), ); - showContextMenu({ + // The whole tag, so the menu can align to whichever edge has room beside it + const rect = tag.getBoundingClientRect(); + toggleContextMenu({ 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, + triggerEl: tag, items: [ { type: "separator", label: encodingLabel(head, sniffed, this.to - this.from) }, ...largeValueActions({ @@ -344,25 +355,68 @@ function treeForLines(state: EditorState, lines: Line[]): SyntaxTree { const QUOTES = ['"', "'", "`"]; +/** A run of base64, long enough to be worth looking at. Nothing else can be encoded media. */ +const ENCODED_RUN = new RegExp(`[A-Za-z0-9+/=_-]{${COLLAPSE_MEDIA_CHARS},}`, "g"); + +/** The header that turns a bare run into a data URI, when one sits right before it. */ +const DATA_URI_HEAD = /data:[^,;\s"']*(?:;[^,;\s"']*)*;base64,$/; + /** - * Quoted values on this line worth collapsing, in document order. + * Encoded media on this line, found by reading the text rather than the grammar. * - * Everything between the quotes goes, so a long string reads as `"image": ""` and the key - * 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. + * Deliberately grammar-free. Every editable field in the app mixes its language with the twig + * parser, which mounts the base language as an *overlay*, and overlays are not traversed by + * `Tree.iterate` — so a rule that walks the tree finds one enormous `Text` node and nothing + * inside it. Rule 2 has to work in those fields above all, since that is where images get + * pasted, and a run of base64 is a text pattern anyway: no grammar can describe it better than + * the alphabet does. + * + * A `data:` header is picked up by looking backwards from the run, so the whole URI collapses + * as one thing and its declared type is what names it. */ -function findCollapsibleTokens(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] { +function findMediaRuns(state: EditorState, line: Line): Collapse[] { + const text = state.sliceDoc(line.from, line.to); + const collapses: Collapse[] = []; + + ENCODED_RUN.lastIndex = 0; + for (let m = ENCODED_RUN.exec(text); m != null; m = ENCODED_RUN.exec(text)) { + let start = m.index; + // A data URI's own header breaks the alphabet, so the run starts after it + const header = DATA_URI_HEAD.exec(text.slice(0, start)); + if (header != null) { + start = header.index; + } + + const valueFrom = line.from + start; + const valueTo = line.from + m.index + m[0].length; + const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo)); + const sniffed = sniffValue(head); + + // Only hide what we can name. A short magic number matches by chance now and then, so the + // run itself has to look encoded too. + if (sniffed != null && sniffed.encoding === "base64" && isEncodedRun(head, sniffed.offset)) { + collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed }); + } + } + + return collapses; +} + +/** + * Quoted values big enough to be a rendering problem on their own, whatever they hold. + * + * Needs the grammar, to find where the value starts and ends, and so only runs for a document + * nobody is editing — which is also the only place this rule applies. + */ +function findLargeTokens(state: EditorState, tree: SyntaxTree, line: Line): 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({ from: line.from, to: line.to, enter: (node) => { // A node this small can't contain anything worth collapsing, and neither can its children - if (node.to - node.from < COLLAPSE_MEDIA_CHARS) return false; + if (node.to - node.from < COLLAPSE_TOKEN_CHARS) return false; // Only leaves, so we collapse the string itself rather than the object holding it if (node.node.firstChild != null) return true; @@ -375,21 +429,10 @@ function findCollapsibleTokens(state: EditorState, tree: SyntaxTree, line: Line) // Everything inside the quotes. The whole value is hidden, so it is its own value range const valueFrom = from + 1; const valueTo = to - 1; - if (valueTo <= valueFrom) return false; + if (valueTo - valueFrom < COLLAPSE_TOKEN_CHARS) 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 }); - } + collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed: sniffValue(head) }); return false; }, }); @@ -397,6 +440,23 @@ function findCollapsibleTokens(state: EditorState, tree: SyntaxTree, line: Line) return collapses; } +/** + * Both rules' findings as one list, in document order, with nothing overlapping. + * + * A big quoted base64 value satisfies both rules, and two decorations over the same text is an + * error. The token wins where they collide, because its range stops at the quotes and so leaves + * the structure around it readable. + */ +function mergeCollapses(tokens: Collapse[], runs: Collapse[]): Collapse[] { + const merged = [...tokens]; + for (const run of runs) { + if (!tokens.some((t) => run.from < t.to && t.from < run.to)) { + merged.push(run); + } + } + return merged.sort((a, b) => a.from - b.from); +} + /** * Where the line runs past the column limit, counting only what is still visible after the * token collapses, or -1 if it fits. @@ -421,10 +481,21 @@ function findColumnCut(line: Line, tokens: Collapse[]): number { return -1; } -function collapsesForLine(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] { - 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 +function collapsesForLine( + state: EditorState, + tree: SyntaxTree, + line: Line, + rules: CollapseRules, +): Collapse[] { + const runs = findMediaRuns(state, line); + // Length alone only hides things on a line that would stall without it, and never in a + // document being edited, where hiding text by size would put it out of reach + const stalls = rules === "all" && line.to - line.from > MAX_VISIBLE_LINE_CHARS; + const tokens = mergeCollapses(stalls ? findLargeTokens(state, tree, line) : [], runs); + + // Short enough to render, so there is nothing left to cut + if (!stalls) { + return tokens; } const cut = findColumnCut(line, tokens); @@ -450,6 +521,7 @@ function collapsesForLine(state: EditorState, tree: SyntaxTree, line: Line): Col export function collapseDecorations( state: EditorState, ranges: readonly { from: number; to: number }[], + rules: CollapseRules = "all", ): DecorationSet { const lines = collapsibleLines(state, ranges); if (lines.length === 0) { @@ -459,7 +531,7 @@ export function collapseDecorations( const tree = treeForLines(state, lines); const decorations: Range[] = []; for (const line of lines) { - for (const { from, to, valueFrom, sniffed } of collapsesForLine(state, tree, line)) { + for (const { from, to, valueFrom, sniffed } of collapsesForLine(state, tree, line, rules)) { const widget = new LargeValueWidget(from, to, valueFrom, sniffed); decorations.push(Decoration.replace({ widget }).range(from, to)); } @@ -473,30 +545,40 @@ export function collapseDecorations( * 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; +function collapsePlugin(rules: CollapseRules) { + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; - constructor(view: EditorView) { - this.decorations = collapseDecorations(view.state, view.visibleRanges); - } - - update(update: ViewUpdate) { - if ( - update.docChanged || - update.viewportChanged || - syntaxTree(update.startState) !== syntaxTree(update.state) - ) { - this.decorations = collapseDecorations(update.view.state, update.view.visibleRanges); + constructor(view: EditorView) { + this.decorations = collapseDecorations(view.state, view.visibleRanges, rules); } - } - }, - { - decorations: (v) => v.decorations, - // Step the cursor over a placeholder instead of stranding it inside - provide: (plugin) => - EditorView.atomicRanges.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none), - }, -); -export const largeValues: Extension = [largeValuePlugin]; + update(update: ViewUpdate) { + if ( + update.docChanged || + update.viewportChanged || + syntaxTree(update.startState) !== syntaxTree(update.state) + ) { + this.decorations = collapseDecorations( + update.view.state, + update.view.visibleRanges, + rules, + ); + } + } + }, + { + decorations: (v) => v.decorations, + // Step the cursor over a placeholder instead of stranding it inside, and delete it whole + provide: (plugin) => + EditorView.atomicRanges.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none), + }, + ); +} + +/** All three rules. For a document nobody is editing. */ +export const largeValues: Extension = [collapsePlugin("all")]; + +/** Only what we can name, which is the part that is safe to hide while someone is editing. */ +export const mediaValues: Extension = [collapsePlugin("media")]; diff --git a/apps/yaak-client/lib/contextMenu.ts b/apps/yaak-client/lib/contextMenu.ts index c465fa3d..cfff79f4 100644 --- a/apps/yaak-client/lib/contextMenu.ts +++ b/apps/yaak-client/lib/contextMenu.ts @@ -16,6 +16,8 @@ export interface ContextMenuInstance { triggerPosition: { x: number; y: number }; /** The trigger's box, when it has one, so placement can align to its edges */ triggerRect?: Pick; + /** The element it belongs to, so clicking that doesn't read as a click outside */ + triggerEl?: HTMLElement | null; items: DropdownItem[]; } @@ -25,6 +27,20 @@ export function showContextMenu({ id, ...props }: ContextMenuInstance) { jotaiStore.set(contextMenusAtom, (m) => [...m.filter((c) => c.id !== id), { id, ...props }]); } +/** + * Opens the menu, or closes it if this one is already open. + * + * What a trigger wants: pressing it a second time should put the menu away. Same shape as + * {@link toggleDialog}. + */ +export function toggleContextMenu({ id, ...props }: ContextMenuInstance) { + if (jotaiStore.get(contextMenusAtom).some((c) => c.id === id)) { + hideContextMenu(id); + } else { + showContextMenu({ id, ...props }); + } +} + export function hideContextMenu(id: string) { jotaiStore.set(contextMenusAtom, (m) => m.filter((c) => c.id !== id)); } diff --git a/apps/yaak-client/lib/largeValue.ts b/apps/yaak-client/lib/largeValue.ts index 1bd663fe..3e4a0cf4 100644 --- a/apps/yaak-client/lib/largeValue.ts +++ b/apps/yaak-client/lib/largeValue.ts @@ -60,10 +60,23 @@ export function largeValueActions({ }); } + // Two ways to copy a picture, because either can be the one you wanted: the image to paste + // somewhere that takes one, or the text to paste back into a request + const image = isCopyableImage(sniffed); + if (image) { + items.push({ + label: "Copy Image", + leftSlot: createElement(Icon, { icon: "copy" }), + onSelect: () => copyImage(value(), sniffed, copyText()), + }); + } + items.push({ - label: sniffed?.mime.startsWith("image/") ? "Copy Image" : "Copy", - leftSlot: createElement(Icon, { icon: "copy" }), - onSelect: () => copyValue(value(), sniffed, copyText()), + label: sniffed?.encoding === "base64" ? "Copy Base64" : "Copy", + // Second of a pair reads as one thing with two options, so it keeps the indent without + // repeating the icon above it + leftSlot: createElement(Icon, { icon: image ? "empty" : "copy" }), + onSelect: () => copyToClipboard(copyText()), }); items.push({ @@ -167,15 +180,15 @@ async function toPngBlob(text: string, sniffed: SniffedValue): Promise { } /** - * Copies the value: the picture itself when it is one, and the text it stands for otherwise. + * Puts the picture on the clipboard, so it can be pasted anywhere that takes an image. * * Not awaited, and deliberately so. The webview only allows a clipboard write that starts inside * the click that asked for it, and decoding a few megabytes takes longer than that lasts — so * the item is handed the still-pending promise rather than a finished blob. */ -export function copyValue(text: string, sniffed: SniffedValue | null, hidden: string) { - if (!isCopyableImage(sniffed) || typeof ClipboardItem === "undefined") { - copyToClipboard(hidden); +export function copyImage(text: string, sniffed: SniffedValue, fallback: string) { + if (typeof ClipboardItem === "undefined") { + copyToClipboard(fallback); return; } @@ -195,7 +208,7 @@ export function copyValue(text: string, sniffed: SniffedValue | null, hidden: st // 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); + copyToClipboard(fallback); }); }