86 lines
3.2 KiB
TypeScript
86 lines
3.2 KiB
TypeScript
/**
|
|||
|
|
* The image the OS shows under the cursor during a drag-out.
|
||
|
|
*
|
||
|
|
* `startDrag` requires one — the plugin's `image` argument is not optional, and
|
||
|
|
* it only accepts a `data:image/png;base64,` URL — so this is drawn rather than
|
||
|
|
* shipped as an asset. Drawing it is also what keeps the colours honest: the
|
||
|
|
* palette lives in CSS custom properties, and reading them off the document is
|
||
|
|
* the only way a raw-pixel preview can still come from the design tokens rather
|
||
|
|
* than from hard-coded hexes.
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* A 1x1 transparent PNG, used when no 2D canvas is available — jsdom has none,
|
||
|
|
* and a webview can refuse a context under memory pressure. `startDrag` needs
|
||
|
|
* *an* image, and a drag with an invisible preview is much better than no drag.
|
||
|
|
*/
|
||
|
|
const TRANSPARENT_PNG =
|
||
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=";
|
||
|
|
|
||
|
|
/** Longest filename drawn in full; past this the middle is elided. */
|
||
|
|
const MAX_LABEL = 28;
|
||
|
|
|
||
|
|
function cssVar(name: string, fallback: string): string {
|
||
|
|
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||
|
|
return value || fallback;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Keep both ends of a long name — the extension is the informative half. */
|
||
|
|
function elide(label: string): string {
|
||
|
|
if (label.length <= MAX_LABEL) return label;
|
||
|
|
const head = label.slice(0, MAX_LABEL - 12);
|
||
|
|
const tail = label.slice(-9);
|
||
|
|
return `${head}…${tail}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function dragPreviewIcon(label: string): string {
|
||
|
|
try {
|
||
|
|
const text = elide(label);
|
||
|
|
// Cap the scale: the OS draws this at logical size, so a 3x buffer is only
|
||
|
|
// bytes over IPC.
|
||
|
|
const scale = Math.min(window.devicePixelRatio || 1, 2);
|
||
|
|
const height = 24;
|
||
|
|
const padding = 8;
|
||
|
|
const canvas = document.createElement("canvas");
|
||
|
|
|
||
|
|
// Measuring needs a context, and sizing the canvas resets it — so measure
|
||
|
|
// on a throwaway pass, then size, then draw.
|
||
|
|
const probe = canvas.getContext("2d");
|
||
|
|
if (!probe) return TRANSPARENT_PNG;
|
||
|
|
const font = "12px ui-monospace, SFMono-Regular, Menlo, monospace";
|
||
|
|
probe.font = font;
|
||
|
|
const width = Math.ceil(probe.measureText(text).width) + padding * 2;
|
||
|
|
|
||
|
|
canvas.width = Math.round(width * scale);
|
||
|
|
canvas.height = Math.round(height * scale);
|
||
|
|
const ctx = canvas.getContext("2d");
|
||
|
|
if (!ctx) return TRANSPARENT_PNG;
|
||
|
|
ctx.scale(scale, scale);
|
||
|
|
|
||
|
|
ctx.fillStyle = cssVar("--bg-tertiary", "#2a2a2a");
|
||
|
|
ctx.strokeStyle = cssVar("--accent", "#6aa8ff");
|
||
|
|
ctx.lineWidth = 1;
|
||
|
|
if (typeof ctx.roundRect === "function") {
|
||
|
|
ctx.beginPath();
|
||
|
|
ctx.roundRect(0.5, 0.5, width - 1, height - 1, 4);
|
||
|
|
ctx.fill();
|
||
|
|
ctx.stroke();
|
||
|
|
} else {
|
||
|
|
ctx.fillRect(0.5, 0.5, width - 1, height - 1);
|
||
|
|
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx.font = font;
|
||
|
|
ctx.fillStyle = cssVar("--text-primary", "#e6e6e6");
|
||
|
|
ctx.textBaseline = "middle";
|
||
|
|
ctx.fillText(text, padding, height / 2);
|
||
|
|
|
||
|
|
const url = canvas.toDataURL("image/png");
|
||
|
|
// jsdom (and a canvas that failed to encode) answers `data:,` — which the
|
||
|
|
// Rust side rejects outright, taking the whole drag with it.
|
||
|
|
return url.startsWith("data:image/png;base64,") ? url : TRANSPARENT_PNG;
|
||
|
|
} catch {
|
||
|
|
return TRANSPARENT_PNG;
|
||
|
|
}
|
||
|
|
}
|