mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Per-chart export: copy/download spec and PNG/SVG from the preview header
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
/* ChartExport — per-chart export disclosure in the Live Preview header (spec §08). */
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Trigger — a compact ghost button (icon + label), matching the header utilities. */
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
height: 28px;
|
||||
padding: 0 var(--space-3);
|
||||
border: var(--border-width) solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease),
|
||||
color var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.trigger:hover:not(:disabled) {
|
||||
background: var(--layer-01);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.trigger[aria-expanded='true'] {
|
||||
background: var(--layer-02);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.trigger:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.trigger:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.triggerIcon {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
/* The disclosed panel — portaled to <body>, positioned `fixed` (top/right set
|
||||
inline) so it escapes the panes' overflow clipping. Mirrors SettingsPopover. */
|
||||
.pop {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 264px;
|
||||
max-width: min(320px, 92vw);
|
||||
padding: var(--space-3);
|
||||
background: var(--layer-01);
|
||||
border: var(--border-width) solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.groupTitle {
|
||||
margin: var(--space-3) 0 var(--space-1);
|
||||
padding: 0 var(--space-2);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* The first group heading sits flush with the panel top. */
|
||||
.groupTitle:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Action rows — full-width, left-aligned ghost buttons. */
|
||||
.action {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-2);
|
||||
border: var(--border-width) solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.action:hover:not(:disabled) {
|
||||
background: var(--layer-02);
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.action:disabled {
|
||||
color: var(--text-placeholder);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* The monospace extension hint, trailing the JSON action. */
|
||||
.ext {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.action:disabled .ext {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: var(--space-2) 0 0;
|
||||
padding: 0 var(--space-2);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Chart export — the per-chart "Export" affordance in the Live Preview header
|
||||
* (spec §08 → Per-chart export). Distinct from the header's *workspace* Export
|
||||
* (whole-library backup): this gets *one* chart out — its spec to the clipboard or
|
||||
* a `.vl.json` file, or its rendered image as PNG/SVG.
|
||||
*
|
||||
* It lives in the preview header on purpose: the image formats need the live Vega
|
||||
* `view` (held by LivePreview), and "export this chart" reads most naturally beside
|
||||
* the chart you're looking at. The spec actions export the *shown* text (draft or
|
||||
* published), so every format matches what's on screen.
|
||||
*
|
||||
* A few options ride along, because export has real choices to make:
|
||||
* - **Resolution** (PNG) — a device-pixel-ratio-aware multiplier, so the default
|
||||
* "1×" is already Retina-crisp (a naive 1× export looks soft on a 2× display).
|
||||
* - **Background** — the chart config is transparent (to show the pane colour),
|
||||
* which would make a naive export transparent; default to the theme colour, with
|
||||
* White and Transparent on offer.
|
||||
* - **Referenced data** (shown only when the spec references saved datasets) —
|
||||
* inline the data so the exported spec renders standalone, or keep the reference.
|
||||
*
|
||||
* Widget: a **disclosure**, not an ARIA menu — like `SettingsPopover`. The container
|
||||
* is a labelled `group` of option controls + action `<button>`s (Tab/Shift+Tab move
|
||||
* between them); the trigger carries `aria-expanded` + `aria-controls`; Esc closes
|
||||
* and restores focus; an outside click closes. It shares the single-open popover
|
||||
* registry, and is portaled to `<body>` (the panes clip their overflow).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import {
|
||||
chartExportFilename,
|
||||
inlineReferencedDatasets,
|
||||
referencedDatasetNames,
|
||||
} from '@core/chart-export';
|
||||
import { DatasetNotFoundError } from '@core/rendering';
|
||||
import { copyText, downloadJson, downloadUrl } from '../infrastructure/file-transfer';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore';
|
||||
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { Icon } from './Icon';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
import { SettingRow } from './SettingsPopover';
|
||||
import styles from './ChartExport.module.css';
|
||||
|
||||
/** Shared registry id (single popover open at a time across the panes). */
|
||||
const POP_ID = 'chart-export';
|
||||
/** Gap (px) between the trigger and the disclosed panel (matches SettingsPopover). */
|
||||
const GAP = 6;
|
||||
|
||||
type ScaleChoice = '1' | '2' | '3';
|
||||
type BackgroundChoice = 'theme' | 'white' | 'transparent';
|
||||
|
||||
/** PNG resolution multipliers (× device pixel ratio — see ImageExportOptions). */
|
||||
const SCALE_OPTIONS: ReadonlyArray<SegmentedOption<ScaleChoice>> = [
|
||||
{ value: '1', label: '1×', title: '1× — matches your screen (Retina-aware)' },
|
||||
{ value: '2', label: '2×', title: '2× — double resolution, for print or zoom' },
|
||||
{ value: '3', label: '3×', title: '3× — triple resolution' },
|
||||
];
|
||||
|
||||
const BACKGROUND_OPTIONS: ReadonlyArray<SegmentedOption<BackgroundChoice>> = [
|
||||
{ value: 'theme', label: 'Theme', title: 'Match the current theme’s background' },
|
||||
{ value: 'white', label: 'White', title: 'White background' },
|
||||
{ value: 'transparent', label: 'None', title: 'Transparent background' },
|
||||
];
|
||||
|
||||
const REFDATA_OPTIONS: ReadonlyArray<SegmentedOption<'inline' | 'ref'>> = [
|
||||
{ value: 'inline', label: 'Inline', title: 'Embed the data so the file renders standalone' },
|
||||
{
|
||||
value: 'ref',
|
||||
label: 'Keep refs',
|
||||
title: 'Keep the dataset reference (renders only in Astrolabe)',
|
||||
},
|
||||
];
|
||||
|
||||
/** Resolve a background choice to a CSS colour, or null for transparent. The
|
||||
* "theme" colour is read live from the page so it always tracks the active theme. */
|
||||
function resolveBackground(choice: BackgroundChoice): string | null {
|
||||
if (choice === 'transparent') return null;
|
||||
if (choice === 'white') return '#ffffff';
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
|
||||
return bg || '#ffffff';
|
||||
}
|
||||
|
||||
export interface ChartExportProps {
|
||||
/** Whether a chart is currently rendered — gates the image (PNG/SVG) actions.
|
||||
* The spec actions only need text, so they ignore this. */
|
||||
chartReady: boolean;
|
||||
/** Rasterize/serialize the live view to a downloadable URL, or null if the view
|
||||
* isn't available (caller owns the Vega view). */
|
||||
getImageUrl: (
|
||||
format: 'png' | 'svg',
|
||||
options: { scale: number; background: string | null },
|
||||
) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export function ChartExport({ chartReady, getImageUrl }: ChartExportProps) {
|
||||
const open = useSettingsPopoverStore((s) => s.openId === POP_ID);
|
||||
const toggle = useSettingsPopoverStore((s) => s.toggle);
|
||||
const close = useSettingsPopoverStore((s) => s.close);
|
||||
// Primitive reads only (no fresh objects) so the header doesn't re-render needlessly.
|
||||
const name = useSnippetStore((s) => selectActiveSnippet(s)?.name ?? 'chart');
|
||||
const shownText = useSnippetStore(selectShownText);
|
||||
const datasets = useDatasetStore(useShallow((s) => s.datasets));
|
||||
const hasSpec = shownText.trim() !== '';
|
||||
|
||||
// Export options, persisted while the component is mounted (across opens).
|
||||
const [scale, setScale] = useState<ScaleChoice>('1');
|
||||
const [background, setBackground] = useState<BackgroundChoice>('theme');
|
||||
const [inline, setInline] = useState(true);
|
||||
|
||||
// Which saved datasets the shown spec references — drives the inline-data option.
|
||||
const refs = useMemo(() => referencedDatasetNames(shownText), [shownText]);
|
||||
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Position the fixed panel from the trigger's rect (panes clip overflow → body
|
||||
// portal + fixed). Imperative, like SettingsPopover — no state, no scroll re-render.
|
||||
const place = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
const pop = popRef.current;
|
||||
if (!trigger || !pop) return;
|
||||
const r = trigger.getBoundingClientRect();
|
||||
pop.style.top = `${r.bottom + GAP}px`;
|
||||
pop.style.right = `${window.innerWidth - r.right}px`;
|
||||
pop.style.left = 'auto';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
window.addEventListener('resize', place);
|
||||
window.addEventListener('scroll', place, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', place);
|
||||
window.removeEventListener('scroll', place, true);
|
||||
};
|
||||
}, [open, place]);
|
||||
|
||||
// Esc closes + restores focus; an outside pointer click closes (APG disclosure).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
const onPointer = (e: PointerEvent) => {
|
||||
const t = e.target as Node;
|
||||
if (!popRef.current?.contains(t) && !triggerRef.current?.contains(t)) close();
|
||||
};
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
document.addEventListener('pointerdown', onPointer, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
document.removeEventListener('pointerdown', onPointer, true);
|
||||
};
|
||||
}, [open, close]);
|
||||
|
||||
// On open: place before paint and move focus to the first control.
|
||||
const setPopNode = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
popRef.current = node;
|
||||
if (node) {
|
||||
place();
|
||||
node.querySelector<HTMLElement>('button, [role="radio"]')?.focus();
|
||||
}
|
||||
},
|
||||
[place],
|
||||
);
|
||||
|
||||
/** The spec text to export — inlined for portability when chosen and refs exist.
|
||||
* Returns null after surfacing an error (a referenced dataset is missing). */
|
||||
const buildSpecText = (): string | null => {
|
||||
if (!inline || refs.length === 0) return shownText;
|
||||
try {
|
||||
return inlineReferencedDatasets(shownText, datasets);
|
||||
} catch (e) {
|
||||
const missing = e instanceof DatasetNotFoundError ? ` "${e.datasetName}"` : '';
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: 'Couldn’t inline data',
|
||||
message: `A referenced dataset${missing} isn’t in your library. Add it, or choose “Keep refs”.`,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const copySpec = async () => {
|
||||
close();
|
||||
const text = buildSpecText();
|
||||
if (text == null) return;
|
||||
try {
|
||||
await copyText(text);
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Spec copied',
|
||||
message: 'The chart’s Vega-Lite spec is on your clipboard as JSON.',
|
||||
});
|
||||
} catch {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: 'Couldn’t copy',
|
||||
message: 'Your browser blocked clipboard access. Select and copy the spec from the editor.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const downloadSpec = () => {
|
||||
close();
|
||||
const text = buildSpecText();
|
||||
if (text == null) return;
|
||||
const filename = chartExportFilename(name, 'vl.json');
|
||||
downloadJson(filename, text);
|
||||
notify({ kind: 'success', title: 'Spec downloaded', message: `Saved ${filename}.` });
|
||||
};
|
||||
|
||||
const downloadImage = async (format: 'png' | 'svg') => {
|
||||
close();
|
||||
const url = await getImageUrl(format, {
|
||||
scale: Number(scale),
|
||||
background: resolveBackground(background),
|
||||
});
|
||||
if (!url) {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: 'Couldn’t export image',
|
||||
message: 'The chart isn’t ready yet. Wait for it to finish rendering, then try again.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const filename = chartExportFilename(name, format);
|
||||
downloadUrl(filename, url);
|
||||
notify({ kind: 'success', title: 'Chart exported', message: `Saved ${filename}.` });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
aria-expanded={open}
|
||||
aria-controls={POP_ID}
|
||||
disabled={!hasSpec}
|
||||
title={hasSpec ? 'Export this chart' : 'Select a snippet to export'}
|
||||
onClick={() => toggle(POP_ID)}
|
||||
>
|
||||
<Icon name="export" className={styles.triggerIcon} />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={setPopNode}
|
||||
id={POP_ID}
|
||||
className={styles.pop}
|
||||
role="group"
|
||||
aria-label="Export chart"
|
||||
>
|
||||
<h4 className={styles.groupTitle}>Spec</h4>
|
||||
{refs.length > 0 && (
|
||||
<SettingRow label="Referenced data">
|
||||
<SegmentedControl
|
||||
label="Referenced data"
|
||||
options={REFDATA_OPTIONS}
|
||||
value={inline ? 'inline' : 'ref'}
|
||||
onChange={(v) => setInline(v === 'inline')}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
<button type="button" className={styles.action} onClick={() => void copySpec()}>
|
||||
Copy spec
|
||||
</button>
|
||||
<button type="button" className={styles.action} onClick={downloadSpec}>
|
||||
Download JSON <span className={styles.ext}>.vl.json</span>
|
||||
</button>
|
||||
|
||||
<h4 className={styles.groupTitle}>Image</h4>
|
||||
<SettingRow label="Resolution">
|
||||
<SegmentedControl
|
||||
label="PNG resolution"
|
||||
options={SCALE_OPTIONS}
|
||||
value={scale}
|
||||
onChange={setScale}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Background">
|
||||
<SegmentedControl
|
||||
label="Background"
|
||||
options={BACKGROUND_OPTIONS}
|
||||
value={background}
|
||||
onChange={setBackground}
|
||||
/>
|
||||
</SettingRow>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
disabled={!chartReady}
|
||||
onClick={() => void downloadImage('png')}
|
||||
>
|
||||
Download PNG
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
disabled={!chartReady}
|
||||
onClick={() => void downloadImage('svg')}
|
||||
>
|
||||
Download SVG
|
||||
</button>
|
||||
{!chartReady && <p className={styles.hint}>Render the chart to export an image.</p>}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,9 +21,14 @@
|
||||
border-bottom: var(--border-width) solid var(--border);
|
||||
}
|
||||
|
||||
/* Push the settings gear (the last child) to the far right. */
|
||||
.header > :last-child {
|
||||
/* Push the right cluster (export + settings gear) to the far right; Fit hugs
|
||||
the left. The cluster itself is the last child, so any header overflow trims
|
||||
from its right edge, never clipping "Original" on the left. */
|
||||
.headerEnd {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.body {
|
||||
|
||||
@@ -50,6 +50,10 @@ vi.mock('./SettingsPopover', () => ({
|
||||
SettingRow: () => null,
|
||||
RangeControl: () => null,
|
||||
}));
|
||||
// Isolate the busy-overlay assertions (which locate the overlay by its
|
||||
// `aria-hidden="true"`) from the export control's own decorative icon: the
|
||||
// per-chart export is a sibling header control, mocked out like SettingsPopover.
|
||||
vi.mock('./ChartExport', () => ({ ChartExport: () => null }));
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* (M3) plugs into prepareSpecForRender without changing this component.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import type { VisualizationSpec } from 'vega-embed';
|
||||
import type { FitMode } from '@core/rendering';
|
||||
@@ -27,6 +27,7 @@ import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||
import { ChartExport } from './ChartExport';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
|
||||
import styles from './LivePreview.module.css';
|
||||
@@ -116,6 +117,11 @@ export function LivePreview() {
|
||||
const setError = usePreviewStore((s) => s.setError);
|
||||
const busy = usePreviewStore((s) => s.busy);
|
||||
const setBusy = usePreviewStore((s) => s.setBusy);
|
||||
// Mirrors whether `handleRef` currently holds a live view, so the per-chart
|
||||
// export's image actions (which need the view) can enable/disable reactively —
|
||||
// a ref change alone wouldn't re-render. Set true on a successful render, false
|
||||
// on clear/error/unmount.
|
||||
const [chartReady, setChartReady] = useState(false);
|
||||
|
||||
// Busy-indication timer ref: if a render exceeds ~1s we surface a non-blocking
|
||||
// overlay (arch §10.2 NN/g: >1s owes a busy indication; <1s shows nothing to
|
||||
@@ -198,6 +204,7 @@ export function LivePreview() {
|
||||
if (isEmpty) {
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
setChartReady(false);
|
||||
setError(null);
|
||||
clearBusy();
|
||||
return;
|
||||
@@ -218,10 +225,12 @@ export function LivePreview() {
|
||||
return;
|
||||
}
|
||||
handleRef.current = handle;
|
||||
setChartReady(true);
|
||||
setError(null);
|
||||
clearBusy();
|
||||
} catch (e) {
|
||||
if (mine === generationRef.current) {
|
||||
setChartReady(false);
|
||||
clearBusy();
|
||||
// A missing dataset reference is not a JSON/spec problem, so it gets a
|
||||
// tailored, fixable message instead of the generic syntax hint (council:
|
||||
@@ -253,11 +262,31 @@ export function LivePreview() {
|
||||
datasets,
|
||||
setError,
|
||||
setBusy,
|
||||
setChartReady,
|
||||
bufferEpoch,
|
||||
editorView,
|
||||
renderDebounce,
|
||||
]);
|
||||
|
||||
// Rasterize/serialize the live view for the per-chart export (spec §08). Reads
|
||||
// `handleRef` (the view LivePreview owns); returns null when no view is live or
|
||||
// the export fails, so the export UI can report it. Stable identity (no deps).
|
||||
const getImageUrl = useCallback(
|
||||
async (
|
||||
format: 'png' | 'svg',
|
||||
options: { scale: number; background: string | null },
|
||||
): Promise<string | null> => {
|
||||
const handle = handleRef.current;
|
||||
if (!handle) return null;
|
||||
try {
|
||||
return await handle.toImageURL(format, options);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
||||
// observe the element, so we do: one observer on the stable host node for the
|
||||
// component's life. Only responsive fit modes depend on container size;
|
||||
@@ -280,6 +309,7 @@ export function LivePreview() {
|
||||
() => () => {
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
setChartReady(false);
|
||||
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
|
||||
usePreviewStore.getState().setError(null);
|
||||
usePreviewStore.getState().setBusy(false);
|
||||
@@ -291,7 +321,11 @@ export function LivePreview() {
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<FitControl />
|
||||
<PreviewSettings />
|
||||
{/* Right cluster: export this chart, then the preview settings gear. */}
|
||||
<div className={styles.headerEnd}>
|
||||
<ChartExport chartReady={chartReady} getImageUrl={getImageUrl} />
|
||||
<PreviewSettings />
|
||||
</div>
|
||||
</div>
|
||||
{/*
|
||||
* aria-busy on the chart region tells AT the area is being updated (arch §10.2;
|
||||
|
||||
Reference in New Issue
Block a user