mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-24 20:34:05 +02:00
Collapse encoded media in editable fields too (#534)
This commit is contained in:
@@ -8,12 +8,15 @@ export function ContextMenus() {
|
|||||||
const menus = useAtomValue(contextMenusAtom);
|
const menus = useAtomValue(contextMenusAtom);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{menus.map(({ id, items, triggerPosition, triggerRect }) => (
|
{menus.map(({ id, items, triggerPosition, triggerRect, triggerEl }) => (
|
||||||
<ErrorBoundary key={id} name={`ContextMenu ${id}`}>
|
<ErrorBoundary key={id} name={`ContextMenu ${id}`}>
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
items={items}
|
items={items}
|
||||||
triggerPosition={triggerPosition}
|
triggerPosition={triggerPosition}
|
||||||
triggerRect={triggerRect}
|
triggerRect={triggerRect}
|
||||||
|
// The trigger isn't a React component here, so it arrives as an element and gets
|
||||||
|
// wrapped to look like the ref the menu expects
|
||||||
|
triggerRef={{ current: triggerEl ?? null }}
|
||||||
onClose={() => hideContextMenu(id)}
|
onClose={() => hideContextMenu(id)}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
@@ -242,13 +242,20 @@ export interface ContextMenuProps {
|
|||||||
* from a button.
|
* from a button.
|
||||||
*/
|
*/
|
||||||
triggerRect?: Pick<DOMRect, "top" | "bottom" | "left" | "right">;
|
triggerRect?: Pick<DOMRect, "top" | "bottom" | "left" | "right">;
|
||||||
|
/**
|
||||||
|
* 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<HTMLElement | null>;
|
||||||
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, triggerRect, className, items, onClose },
|
{ triggerPosition, triggerRect, triggerRef, className, items, onClose },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const triggerShape = useMemo(
|
const triggerShape = useMemo(
|
||||||
@@ -275,6 +282,7 @@ export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function Co
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
items={items}
|
items={items}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
triggerRef={triggerRef}
|
||||||
triggerShape={triggerShape}
|
triggerShape={triggerShape}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -290,7 +298,9 @@ interface MenuProps {
|
|||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
items: DropdownItem[];
|
items: DropdownItem[];
|
||||||
triggerRef?: RefObject<HTMLButtonElement | null>;
|
// 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<HTMLElement | null>;
|
||||||
isSubmenu?: boolean;
|
isSubmenu?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { IconButton } from "../IconButton";
|
|||||||
import "./Editor.css";
|
import "./Editor.css";
|
||||||
import {
|
import {
|
||||||
baseExtensions,
|
baseExtensions,
|
||||||
|
editableExtensions,
|
||||||
getLanguageExtension,
|
getLanguageExtension,
|
||||||
multiLineExtensions,
|
multiLineExtensions,
|
||||||
readonlyExtensions,
|
readonlyExtensions,
|
||||||
@@ -289,7 +290,7 @@ function EditorInner({
|
|||||||
function configureReadOnly() {
|
function configureReadOnly() {
|
||||||
if (cm.current === null) return;
|
if (cm.current === null) return;
|
||||||
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
|
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
|
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
|
||||||
if (current === next) return;
|
if (current === next) return;
|
||||||
|
|
||||||
@@ -412,7 +413,7 @@ function EditorInner({
|
|||||||
keymapCompartment.current.of(
|
keymapCompartment.current.of(
|
||||||
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
|
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
|
||||||
),
|
),
|
||||||
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension),
|
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : editableExtensions),
|
||||||
...getExtensions({
|
...getExtensions({
|
||||||
container,
|
container,
|
||||||
singleLine,
|
singleLine,
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import { pluralizeCount } from "../../../lib/pluralize";
|
|||||||
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
|
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
|
||||||
import type { EditorProps } from "./Editor";
|
import type { EditorProps } from "./Editor";
|
||||||
import { jsonParseLinter } from "./json-lint";
|
import { jsonParseLinter } from "./json-lint";
|
||||||
import { largeValues } from "./largeValues";
|
import { largeValues, mediaValues } from "./largeValues";
|
||||||
import { pairs } from "./pairs/extension";
|
import { pairs } from "./pairs/extension";
|
||||||
import { searchMatchCount } from "./searchMatchCount";
|
import { searchMatchCount } from "./searchMatchCount";
|
||||||
import { text } from "./text/extension";
|
import { text } from "./text/extension";
|
||||||
@@ -259,10 +259,19 @@ export const baseExtensions = [
|
|||||||
export const readonlyExtensions = [
|
export const readonlyExtensions = [
|
||||||
EditorState.readOnly.of(true),
|
EditorState.readOnly.of(true),
|
||||||
EditorView.contentAttributes.of({ tabindex: "-1" }),
|
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,
|
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 }) => [
|
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
|
||||||
search({ top: true }),
|
search({ top: true }),
|
||||||
searchMatchCount(),
|
searchMatchCount(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { twig } from "./twig/extension";
|
||||||
import { describe, expect, test } from "vite-plus/test";
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
import {
|
import {
|
||||||
COLLAPSE_MEDIA_CHARS,
|
COLLAPSE_MEDIA_CHARS,
|
||||||
@@ -256,9 +257,15 @@ describe("sniffing the collapsed value", () => {
|
|||||||
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
|
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("names nothing when a column cut lands mid-line after other text", () => {
|
test("finds a run that starts partway through a line, and names it", () => {
|
||||||
const state = plainState(`some prefix text ${pngBase64()}`);
|
// No grammar here, and the value is not the whole line. The run is found by its alphabet,
|
||||||
expect(widgets(state)[0]?.sniffed).toBeNull();
|
// 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", () => {
|
describe("viewport scoping", () => {
|
||||||
const doc = () => {
|
const doc = () => {
|
||||||
const value = "A".repeat(COLLAPSE_TOKEN_CHARS * 2);
|
const value = "A".repeat(COLLAPSE_TOKEN_CHARS * 2);
|
||||||
|
|||||||
@@ -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
|
* 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}.
|
* 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
|
* An editable document gets rule 2 alone, as {@link mediaValues}. Encoded media is never typed
|
||||||
* text they can't see.
|
* 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.
|
* 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
|
* 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.
|
* 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.role = "button";
|
||||||
el.ariaHasPopup = "menu";
|
el.ariaHasPopup = "menu";
|
||||||
el.tabIndex = 0;
|
el.tabIndex = 0;
|
||||||
@@ -116,13 +128,13 @@ function makeTagButton(el: HTMLElement, title: string, onOpen: (at: DOMRect) =>
|
|||||||
el.addEventListener("click", (e) => {
|
el.addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpen(el.getBoundingClientRect());
|
onOpen();
|
||||||
});
|
});
|
||||||
el.addEventListener("keydown", (e) => {
|
el.addEventListener("keydown", (e) => {
|
||||||
if (e.key !== "Enter" && e.key !== " ") return;
|
if (e.key !== "Enter" && e.key !== " ") return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onOpen(el.getBoundingClientRect());
|
onOpen();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +189,7 @@ class LargeValueWidget extends WidgetType {
|
|||||||
makeTagButton(
|
makeTagButton(
|
||||||
el,
|
el,
|
||||||
this.sniffed == null ? "Value actions" : `${this.sniffed.label} actions`,
|
this.sniffed == null ? "Value actions" : `${this.sniffed.label} actions`,
|
||||||
(rect) => fireAndForget(this.openMenu(view, rect)),
|
() => fireAndForget(this.openMenu(view, el)),
|
||||||
);
|
);
|
||||||
|
|
||||||
return 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
|
* neither the React menu nor the viewers behind the dialog are worth carrying until someone
|
||||||
* asks for them.
|
* asks for them.
|
||||||
*/
|
*/
|
||||||
private async openMenu(
|
private async openMenu(view: EditorView, tag: HTMLElement) {
|
||||||
view: EditorView,
|
const [{ toggleContextMenu }, { showLargeValueDialog }, { encodingLabel, largeValueActions }] =
|
||||||
rect: Pick<DOMRect, "top" | "bottom" | "left" | "right">,
|
|
||||||
) {
|
|
||||||
const [{ showContextMenu }, { showLargeValueDialog }, { encodingLabel, largeValueActions }] =
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
import("../../../lib/contextMenu"),
|
import("../../../lib/contextMenu"),
|
||||||
import("../../LargeValueDialog"),
|
import("../../LargeValueDialog"),
|
||||||
@@ -209,11 +218,13 @@ class LargeValueWidget extends WidgetType {
|
|||||||
Math.min(this.valueFrom + SNIFF_HEAD_CHARS, this.to),
|
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",
|
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 },
|
triggerPosition: { x: rect.left, y: rect.bottom },
|
||||||
triggerRect: rect,
|
triggerRect: rect,
|
||||||
|
triggerEl: tag,
|
||||||
items: [
|
items: [
|
||||||
{ type: "separator", label: encodingLabel(head, sniffed, this.to - this.from) },
|
{ type: "separator", label: encodingLabel(head, sniffed, this.to - this.from) },
|
||||||
...largeValueActions({
|
...largeValueActions({
|
||||||
@@ -344,25 +355,68 @@ function treeForLines(state: EditorState, lines: Line[]): SyntaxTree {
|
|||||||
|
|
||||||
const QUOTES = ['"', "'", "`"];
|
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": "<tag>"` and the key
|
* Deliberately grammar-free. Every editable field in the app mixes its language with the twig
|
||||||
* still tells you what it is. Undelimited tokens are left to the column cut instead: replacing
|
* parser, which mounts the base language as an *overlay*, and overlays are not traversed by
|
||||||
* one whole would leave the line with nothing on it but a tag.
|
* `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[] = [];
|
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, and neither can its children
|
// 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
|
// 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;
|
||||||
|
|
||||||
@@ -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
|
// Everything inside the quotes. The whole value is hidden, so it is its own value range
|
||||||
const valueFrom = from + 1;
|
const valueFrom = from + 1;
|
||||||
const valueTo = to - 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 head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo));
|
||||||
const sniffed = sniffValue(head);
|
collapses.push({ from: valueFrom, to: valueTo, valueFrom, 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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -397,6 +440,23 @@ function findCollapsibleTokens(state: EditorState, tree: SyntaxTree, line: Line)
|
|||||||
return collapses;
|
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
|
* Where the line runs past the column limit, counting only what is still visible after the
|
||||||
* token collapses, or -1 if it fits.
|
* token collapses, or -1 if it fits.
|
||||||
@@ -421,10 +481,21 @@ function findColumnCut(line: Line, tokens: Collapse[]): number {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collapsesForLine(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] {
|
function collapsesForLine(
|
||||||
const tokens = findCollapsibleTokens(state, tree, line);
|
state: EditorState,
|
||||||
if (line.to - line.from <= MAX_VISIBLE_LINE_CHARS) {
|
tree: SyntaxTree,
|
||||||
return tokens; // Short enough to render, so there is nothing left to cut
|
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);
|
const cut = findColumnCut(line, tokens);
|
||||||
@@ -450,6 +521,7 @@ function collapsesForLine(state: EditorState, tree: SyntaxTree, line: Line): Col
|
|||||||
export function collapseDecorations(
|
export function collapseDecorations(
|
||||||
state: EditorState,
|
state: EditorState,
|
||||||
ranges: readonly { from: number; to: number }[],
|
ranges: readonly { from: number; to: number }[],
|
||||||
|
rules: CollapseRules = "all",
|
||||||
): DecorationSet {
|
): DecorationSet {
|
||||||
const lines = collapsibleLines(state, ranges);
|
const lines = collapsibleLines(state, ranges);
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0) {
|
||||||
@@ -459,7 +531,7 @@ export function collapseDecorations(
|
|||||||
const tree = treeForLines(state, lines);
|
const tree = treeForLines(state, lines);
|
||||||
const decorations: Range<Decoration>[] = [];
|
const decorations: Range<Decoration>[] = [];
|
||||||
for (const line of lines) {
|
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);
|
const widget = new LargeValueWidget(from, to, valueFrom, sniffed);
|
||||||
decorations.push(Decoration.replace({ widget }).range(from, to));
|
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
|
* 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.
|
* advances — a value can only be found once the grammar has reached it.
|
||||||
*/
|
*/
|
||||||
const largeValuePlugin = ViewPlugin.fromClass(
|
function collapsePlugin(rules: CollapseRules) {
|
||||||
class {
|
return ViewPlugin.fromClass(
|
||||||
decorations: DecorationSet;
|
class {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
|
||||||
constructor(view: EditorView) {
|
constructor(view: EditorView) {
|
||||||
this.decorations = collapseDecorations(view.state, view.visibleRanges);
|
this.decorations = collapseDecorations(view.state, view.visibleRanges, rules);
|
||||||
}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
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
|
|
||||||
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")];
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface ContextMenuInstance {
|
|||||||
triggerPosition: { x: number; y: number };
|
triggerPosition: { x: number; y: number };
|
||||||
/** The trigger's box, when it has one, so placement can align to its edges */
|
/** The trigger's box, when it has one, so placement can align to its edges */
|
||||||
triggerRect?: Pick<DOMRect, "top" | "bottom" | "left" | "right">;
|
triggerRect?: Pick<DOMRect, "top" | "bottom" | "left" | "right">;
|
||||||
|
/** The element it belongs to, so clicking that doesn't read as a click outside */
|
||||||
|
triggerEl?: HTMLElement | null;
|
||||||
items: DropdownItem[];
|
items: DropdownItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +27,20 @@ export function showContextMenu({ id, ...props }: ContextMenuInstance) {
|
|||||||
jotaiStore.set(contextMenusAtom, (m) => [...m.filter((c) => c.id !== id), { id, ...props }]);
|
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) {
|
export function hideContextMenu(id: string) {
|
||||||
jotaiStore.set(contextMenusAtom, (m) => m.filter((c) => c.id !== id));
|
jotaiStore.set(contextMenusAtom, (m) => m.filter((c) => c.id !== id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
items.push({
|
||||||
label: sniffed?.mime.startsWith("image/") ? "Copy Image" : "Copy",
|
label: sniffed?.encoding === "base64" ? "Copy Base64" : "Copy",
|
||||||
leftSlot: createElement(Icon, { icon: "copy" }),
|
// Second of a pair reads as one thing with two options, so it keeps the indent without
|
||||||
onSelect: () => copyValue(value(), sniffed, copyText()),
|
// repeating the icon above it
|
||||||
|
leftSlot: createElement(Icon, { icon: image ? "empty" : "copy" }),
|
||||||
|
onSelect: () => copyToClipboard(copyText()),
|
||||||
});
|
});
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
@@ -167,15 +180,15 @@ async function toPngBlob(text: string, sniffed: SniffedValue): Promise<Blob> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* 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 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.
|
* the item is handed the still-pending promise rather than a finished blob.
|
||||||
*/
|
*/
|
||||||
export function copyValue(text: string, sniffed: SniffedValue | null, hidden: string) {
|
export function copyImage(text: string, sniffed: SniffedValue, fallback: string) {
|
||||||
if (!isCopyableImage(sniffed) || typeof ClipboardItem === "undefined") {
|
if (typeof ClipboardItem === "undefined") {
|
||||||
copyToClipboard(hidden);
|
copyToClipboard(fallback);
|
||||||
return;
|
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
|
// 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.
|
// encoded text is always there to fall back on.
|
||||||
console.error("Failed to copy image, copying text instead", err);
|
console.error("Failed to copy image, copying text instead", err);
|
||||||
copyToClipboard(hidden);
|
copyToClipboard(fallback);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user