@opendata-ai/openchart-vanilla 6.3.0 → 6.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +44 -3
- package/dist/index.js +580 -4
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/selection-events.test.ts +869 -0
- package/src/index.ts +3 -1
- package/src/mount.ts +607 -3
- package/src/svg-renderer.ts +3 -0
- package/src/text-edit-overlay.ts +255 -0
package/src/mount.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
CompileOptions,
|
|
18
18
|
DarkMode,
|
|
19
19
|
ElementEdit,
|
|
20
|
+
ElementRef,
|
|
20
21
|
GraphSpec,
|
|
21
22
|
LayerSpec,
|
|
22
23
|
MeasureTextFn,
|
|
@@ -26,7 +27,7 @@ import type {
|
|
|
26
27
|
ThemeConfig,
|
|
27
28
|
TooltipContent,
|
|
28
29
|
} from '@opendata-ai/openchart-core';
|
|
29
|
-
import { isLayerSpec } from '@opendata-ai/openchart-core';
|
|
30
|
+
import { elementRef, isLayerSpec } from '@opendata-ai/openchart-core';
|
|
30
31
|
import { compileChart, compileLayer } from '@opendata-ai/openchart-engine';
|
|
31
32
|
import {
|
|
32
33
|
exportCSV,
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
} from './export';
|
|
40
41
|
import { observeResize } from './resize-observer';
|
|
41
42
|
import { renderChartSVG } from './svg-renderer';
|
|
43
|
+
import { createTextEditOverlay } from './text-edit-overlay';
|
|
42
44
|
import { createTooltipManager, type TooltipManager } from './tooltip';
|
|
43
45
|
|
|
44
46
|
// ---------------------------------------------------------------------------
|
|
@@ -54,6 +56,13 @@ export interface MountOptions extends ChartEventHandlers {
|
|
|
54
56
|
onDataPointClick?: (data: Record<string, unknown>) => void;
|
|
55
57
|
/** Enable responsive resizing. Defaults to true. */
|
|
56
58
|
responsive?: boolean;
|
|
59
|
+
/** Initial selected element. */
|
|
60
|
+
selectedElement?: ElementRef;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface UpdateOptions {
|
|
64
|
+
/** Override the selected element after update. When omitted, preserves current selection. */
|
|
65
|
+
selectedElement?: ElementRef;
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
export interface ExportOptions extends JPGExportOptions {
|
|
@@ -62,7 +71,7 @@ export interface ExportOptions extends JPGExportOptions {
|
|
|
62
71
|
|
|
63
72
|
export interface ChartInstance {
|
|
64
73
|
/** Re-compile and re-render with a new spec. */
|
|
65
|
-
update(spec: ChartSpec | LayerSpec | GraphSpec): void;
|
|
74
|
+
update(spec: ChartSpec | LayerSpec | GraphSpec, options?: UpdateOptions): void;
|
|
66
75
|
/** Re-compile at current container dimensions. */
|
|
67
76
|
resize(): void;
|
|
68
77
|
/** Export the chart. */
|
|
@@ -79,6 +88,14 @@ export interface ChartInstance {
|
|
|
79
88
|
destroy(): void;
|
|
80
89
|
/** The current compiled layout (for hooks / debugging). */
|
|
81
90
|
readonly layout: ChartLayout;
|
|
91
|
+
/** Get the currently selected element, or null if none. */
|
|
92
|
+
getSelectedElement(): ElementRef | null;
|
|
93
|
+
/** Programmatically select an element. Silent no-op if element not found. */
|
|
94
|
+
select(ref: ElementRef): void;
|
|
95
|
+
/** Deselect the current element. */
|
|
96
|
+
deselect(): void;
|
|
97
|
+
/** Whether inline text editing is active. */
|
|
98
|
+
readonly isEditing: boolean;
|
|
82
99
|
}
|
|
83
100
|
|
|
84
101
|
// ---------------------------------------------------------------------------
|
|
@@ -1497,6 +1514,252 @@ function createScreenReaderTable(
|
|
|
1497
1514
|
return table;
|
|
1498
1515
|
}
|
|
1499
1516
|
|
|
1517
|
+
// ---------------------------------------------------------------------------
|
|
1518
|
+
// Editable element helpers
|
|
1519
|
+
// ---------------------------------------------------------------------------
|
|
1520
|
+
|
|
1521
|
+
/** CSS for editable hover feedback, injected into the SVG as a <style> element. */
|
|
1522
|
+
const EDITABLE_HOVER_CSS = `
|
|
1523
|
+
.viz-editable-hover {
|
|
1524
|
+
outline: 1.5px solid rgba(79, 70, 229, 0.35);
|
|
1525
|
+
outline-offset: 2px;
|
|
1526
|
+
border-radius: 2px;
|
|
1527
|
+
}
|
|
1528
|
+
`;
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* Inject editable styles into an SVG element and make it focusable.
|
|
1532
|
+
* Called when any editing callback is provided.
|
|
1533
|
+
*/
|
|
1534
|
+
function makeEditable(svg: SVGElement): void {
|
|
1535
|
+
svg.setAttribute('tabindex', '0');
|
|
1536
|
+
svg.style.outline = 'none';
|
|
1537
|
+
|
|
1538
|
+
// Inject hover style into SVG defs
|
|
1539
|
+
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
|
|
1540
|
+
style.textContent = EDITABLE_HOVER_CSS;
|
|
1541
|
+
svg.insertBefore(style, svg.firstChild);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Check whether any editing-related callback is provided in the options.
|
|
1546
|
+
*/
|
|
1547
|
+
function hasEditingCallbacks(opts?: MountOptions): boolean {
|
|
1548
|
+
return !!(opts?.onEdit || opts?.onSelect || opts?.onDeselect || opts?.onTextEdit);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/**
|
|
1552
|
+
* Find a DOM element inside the SVG that matches the given ElementRef.
|
|
1553
|
+
*/
|
|
1554
|
+
function findElementByRef(svg: SVGElement, ref: ElementRef): SVGElement | null {
|
|
1555
|
+
switch (ref.type) {
|
|
1556
|
+
case 'annotation': {
|
|
1557
|
+
// Prefer id-based lookup when available
|
|
1558
|
+
if (ref.id) {
|
|
1559
|
+
const byId = svg.querySelector(`[data-annotation-id="${ref.id}"]`);
|
|
1560
|
+
if (byId) return byId as SVGElement;
|
|
1561
|
+
}
|
|
1562
|
+
return svg.querySelector(`[data-annotation-index="${ref.index}"]`) as SVGElement | null;
|
|
1563
|
+
}
|
|
1564
|
+
case 'chrome':
|
|
1565
|
+
return svg.querySelector(`[data-chrome-key="${ref.key}"]`) as SVGElement | null;
|
|
1566
|
+
case 'series-label':
|
|
1567
|
+
return svg.querySelector(`.viz-mark-label[data-series="${ref.series}"]`) as SVGElement | null;
|
|
1568
|
+
case 'legend':
|
|
1569
|
+
return svg.querySelector('.viz-legend') as SVGElement | null;
|
|
1570
|
+
case 'legend-entry':
|
|
1571
|
+
return svg.querySelector(`[data-legend-index="${ref.index}"]`) as SVGElement | null;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* Build an ElementRef from a DOM element's data attributes.
|
|
1577
|
+
* Walks up the tree to find the closest editable ancestor if needed.
|
|
1578
|
+
*/
|
|
1579
|
+
function buildElementRef(element: Element, _specAnnotations: Annotation[]): ElementRef | null {
|
|
1580
|
+
// Check for annotation
|
|
1581
|
+
const annotationEl = element.closest('[data-annotation-index]');
|
|
1582
|
+
if (annotationEl) {
|
|
1583
|
+
const index = Number(annotationEl.getAttribute('data-annotation-index'));
|
|
1584
|
+
const id = annotationEl.getAttribute('data-annotation-id') ?? undefined;
|
|
1585
|
+
return elementRef.annotation(index, id);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// Check for chrome
|
|
1589
|
+
const chromeEl = element.closest('[data-chrome-key]');
|
|
1590
|
+
if (chromeEl) {
|
|
1591
|
+
const key = chromeEl.getAttribute('data-chrome-key') as ChromeKey;
|
|
1592
|
+
if (key) return elementRef.chrome(key);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// Check for series label
|
|
1596
|
+
const seriesLabelEl = element.closest('.viz-mark-label[data-series]');
|
|
1597
|
+
if (seriesLabelEl) {
|
|
1598
|
+
const series = seriesLabelEl.getAttribute('data-series');
|
|
1599
|
+
if (series) return elementRef.seriesLabel(series);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// Check for legend entry
|
|
1603
|
+
const legendEntryEl = element.closest('[data-legend-index]');
|
|
1604
|
+
if (legendEntryEl) {
|
|
1605
|
+
const index = Number(legendEntryEl.getAttribute('data-legend-index'));
|
|
1606
|
+
const series = legendEntryEl.getAttribute('data-legend-label') ?? '';
|
|
1607
|
+
return elementRef.legendEntry(series, index);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// Check for legend group
|
|
1611
|
+
const legendEl = element.closest('.viz-legend');
|
|
1612
|
+
if (legendEl) return elementRef.legend();
|
|
1613
|
+
|
|
1614
|
+
return null;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* Get an ordered list of all editable ElementRefs from the current spec and layout.
|
|
1619
|
+
* Order: chrome (title, subtitle, source, byline, footer), annotations by index,
|
|
1620
|
+
* series labels alphabetical, legend.
|
|
1621
|
+
*/
|
|
1622
|
+
function getEditableElements(
|
|
1623
|
+
spec: ChartSpec | LayerSpec | GraphSpec,
|
|
1624
|
+
layout: ChartLayout,
|
|
1625
|
+
): ElementRef[] {
|
|
1626
|
+
const refs: ElementRef[] = [];
|
|
1627
|
+
|
|
1628
|
+
// Chrome keys in display order
|
|
1629
|
+
const chromeKeys: ChromeKey[] = ['title', 'subtitle', 'source', 'byline', 'footer'];
|
|
1630
|
+
for (const key of chromeKeys) {
|
|
1631
|
+
if (layout.chrome[key]) {
|
|
1632
|
+
refs.push(elementRef.chrome(key));
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// Annotations by index
|
|
1637
|
+
const annotations: Annotation[] =
|
|
1638
|
+
'annotations' in spec && Array.isArray(spec.annotations) ? spec.annotations : [];
|
|
1639
|
+
for (let i = 0; i < annotations.length; i++) {
|
|
1640
|
+
refs.push(elementRef.annotation(i, annotations[i].id));
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// Series labels (alphabetical)
|
|
1644
|
+
const seriesLabels: string[] = [];
|
|
1645
|
+
for (const mark of layout.marks) {
|
|
1646
|
+
if (mark.type === 'line' && mark.label?.visible && mark.seriesKey) {
|
|
1647
|
+
seriesLabels.push(mark.seriesKey);
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
seriesLabels.sort();
|
|
1651
|
+
for (const series of seriesLabels) {
|
|
1652
|
+
refs.push(elementRef.seriesLabel(series));
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// Legend
|
|
1656
|
+
if (layout.legend.entries.length > 0) {
|
|
1657
|
+
refs.push(elementRef.legend());
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
return refs;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
/**
|
|
1664
|
+
* Check if an ElementRef points to a text-editable element (chrome text or text annotation).
|
|
1665
|
+
*/
|
|
1666
|
+
function isTextEditable(ref: ElementRef, specAnnotations: Annotation[]): boolean {
|
|
1667
|
+
if (ref.type === 'chrome') return true;
|
|
1668
|
+
if (ref.type === 'annotation') {
|
|
1669
|
+
const annotation = specAnnotations[ref.index];
|
|
1670
|
+
return annotation?.type === 'text';
|
|
1671
|
+
}
|
|
1672
|
+
return false;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
/**
|
|
1676
|
+
* Get the current text content for an element ref.
|
|
1677
|
+
*/
|
|
1678
|
+
function getElementText(ref: ElementRef, spec: ChartSpec | LayerSpec | GraphSpec): string | null {
|
|
1679
|
+
if (ref.type === 'chrome') {
|
|
1680
|
+
const chromeConfig = 'chrome' in spec ? spec.chrome : undefined;
|
|
1681
|
+
if (!chromeConfig) return null;
|
|
1682
|
+
const entry = chromeConfig[ref.key];
|
|
1683
|
+
if (typeof entry === 'string') return entry;
|
|
1684
|
+
if (typeof entry === 'object' && entry !== null && 'text' in entry) {
|
|
1685
|
+
return (entry as { text: string }).text;
|
|
1686
|
+
}
|
|
1687
|
+
return null;
|
|
1688
|
+
}
|
|
1689
|
+
if (ref.type === 'annotation') {
|
|
1690
|
+
const annotations: Annotation[] =
|
|
1691
|
+
'annotations' in spec && Array.isArray(spec.annotations) ? spec.annotations : [];
|
|
1692
|
+
const annotation = annotations[ref.index];
|
|
1693
|
+
if (annotation?.type === 'text') return (annotation as TextAnnotation).text ?? null;
|
|
1694
|
+
if (annotation?.label) return annotation.label;
|
|
1695
|
+
return null;
|
|
1696
|
+
}
|
|
1697
|
+
return null;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* Compare two ElementRefs for equality.
|
|
1702
|
+
*/
|
|
1703
|
+
function refsEqual(a: ElementRef | null, b: ElementRef | null): boolean {
|
|
1704
|
+
if (a === null || b === null) return a === b;
|
|
1705
|
+
if (a.type !== b.type) return false;
|
|
1706
|
+
switch (a.type) {
|
|
1707
|
+
case 'annotation': {
|
|
1708
|
+
const bAnno = b as typeof a;
|
|
1709
|
+
if (a.id && bAnno.id) return a.id === bAnno.id;
|
|
1710
|
+
return a.index === bAnno.index;
|
|
1711
|
+
}
|
|
1712
|
+
case 'chrome':
|
|
1713
|
+
return a.key === (b as typeof a).key;
|
|
1714
|
+
case 'series-label':
|
|
1715
|
+
return a.series === (b as typeof a).series;
|
|
1716
|
+
case 'legend':
|
|
1717
|
+
return true;
|
|
1718
|
+
case 'legend-entry': {
|
|
1719
|
+
const bEntry = b as typeof a;
|
|
1720
|
+
return a.index === bEntry.index && a.series === bEntry.series;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
/**
|
|
1726
|
+
* Render a selection overlay rectangle around a target element.
|
|
1727
|
+
* Returns the overlay group element.
|
|
1728
|
+
*/
|
|
1729
|
+
function renderSelectionOverlay(
|
|
1730
|
+
svg: SVGElement,
|
|
1731
|
+
ref: ElementRef,
|
|
1732
|
+
layout: ChartLayout,
|
|
1733
|
+
): SVGGElement | null {
|
|
1734
|
+
const target = findElementByRef(svg, ref);
|
|
1735
|
+
if (!target) return null;
|
|
1736
|
+
|
|
1737
|
+
const bbox = (target as SVGGraphicsElement).getBBox();
|
|
1738
|
+
const padding = 4;
|
|
1739
|
+
|
|
1740
|
+
// Resolve accent color from theme
|
|
1741
|
+
const accentColor = layout.theme.colors.categorical?.[0] ?? '#4f46e5';
|
|
1742
|
+
|
|
1743
|
+
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
1744
|
+
g.setAttribute('class', 'viz-selection-overlay');
|
|
1745
|
+
|
|
1746
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
1747
|
+
rect.setAttribute('x', String(bbox.x - padding));
|
|
1748
|
+
rect.setAttribute('y', String(bbox.y - padding));
|
|
1749
|
+
rect.setAttribute('width', String(bbox.width + padding * 2));
|
|
1750
|
+
rect.setAttribute('height', String(bbox.height + padding * 2));
|
|
1751
|
+
rect.setAttribute('rx', '3');
|
|
1752
|
+
rect.setAttribute('fill', 'transparent');
|
|
1753
|
+
rect.setAttribute('stroke', accentColor);
|
|
1754
|
+
rect.setAttribute('stroke-width', '1.5');
|
|
1755
|
+
rect.setAttribute('pointer-events', 'none');
|
|
1756
|
+
|
|
1757
|
+
g.appendChild(rect);
|
|
1758
|
+
svg.appendChild(g);
|
|
1759
|
+
|
|
1760
|
+
return g;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1500
1763
|
// ---------------------------------------------------------------------------
|
|
1501
1764
|
// Main API
|
|
1502
1765
|
// ---------------------------------------------------------------------------
|
|
@@ -1526,12 +1789,20 @@ export function createChart(
|
|
|
1526
1789
|
let cleanupChartEvents: (() => void) | null = null;
|
|
1527
1790
|
let cleanupAnnotationDrag: (() => void) | null = null;
|
|
1528
1791
|
let cleanupEditDrags: (() => void) | null = null;
|
|
1792
|
+
let cleanupSelection: (() => void) | null = null;
|
|
1793
|
+
let cleanupKeyboardEdit: (() => void) | null = null;
|
|
1529
1794
|
let srTable: HTMLTableElement | null = null;
|
|
1530
1795
|
let destroyed = false;
|
|
1531
1796
|
let isDragging = false;
|
|
1532
1797
|
let pendingRender = false;
|
|
1533
1798
|
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1534
1799
|
|
|
1800
|
+
// Selection and text editing state
|
|
1801
|
+
let selectedElement: ElementRef | null = options?.selectedElement ?? null;
|
|
1802
|
+
let overlayElement: SVGGElement | null = null;
|
|
1803
|
+
let isTextEditingActive = false;
|
|
1804
|
+
let textEditCleanup: (() => void) | null = null;
|
|
1805
|
+
|
|
1535
1806
|
const measureText = createMeasureText();
|
|
1536
1807
|
|
|
1537
1808
|
function compile(): ChartLayout {
|
|
@@ -1560,6 +1831,274 @@ export function createChart(
|
|
|
1560
1831
|
};
|
|
1561
1832
|
}
|
|
1562
1833
|
|
|
1834
|
+
/** Get the current spec's annotations array. */
|
|
1835
|
+
function getSpecAnnotations(): Annotation[] {
|
|
1836
|
+
return 'annotations' in currentSpec && Array.isArray(currentSpec.annotations)
|
|
1837
|
+
? currentSpec.annotations
|
|
1838
|
+
: [];
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
/** Select an element: render overlay, fire onSelect, update state. */
|
|
1842
|
+
function selectElement(ref: ElementRef): void {
|
|
1843
|
+
if (!svgElement) return;
|
|
1844
|
+
|
|
1845
|
+
// Confirm the target element exists before deselecting the previous one
|
|
1846
|
+
const target = findElementByRef(svgElement, ref);
|
|
1847
|
+
if (!target) return;
|
|
1848
|
+
|
|
1849
|
+
// Deselect previous if different
|
|
1850
|
+
if (selectedElement && !refsEqual(selectedElement, ref)) {
|
|
1851
|
+
deselectElement();
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
selectedElement = ref;
|
|
1855
|
+
overlayElement = renderSelectionOverlay(svgElement, ref, currentLayout);
|
|
1856
|
+
options?.onSelect?.(ref);
|
|
1857
|
+
|
|
1858
|
+
// Focus SVG for keyboard events
|
|
1859
|
+
(svgElement as SVGSVGElement).focus();
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/** Deselect the current element: remove overlay, fire onDeselect, clear state. */
|
|
1863
|
+
function deselectElement(): void {
|
|
1864
|
+
if (!selectedElement) return;
|
|
1865
|
+
|
|
1866
|
+
// Cancel text editing if active
|
|
1867
|
+
if (isTextEditingActive && textEditCleanup) {
|
|
1868
|
+
textEditCleanup();
|
|
1869
|
+
textEditCleanup = null;
|
|
1870
|
+
isTextEditingActive = false;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
const prev = selectedElement;
|
|
1874
|
+
selectedElement = null;
|
|
1875
|
+
|
|
1876
|
+
if (overlayElement?.parentNode) {
|
|
1877
|
+
overlayElement.parentNode.removeChild(overlayElement);
|
|
1878
|
+
}
|
|
1879
|
+
overlayElement = null;
|
|
1880
|
+
|
|
1881
|
+
options?.onDeselect?.(prev);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
/** Enter text editing mode for the currently selected element. */
|
|
1885
|
+
function enterTextEditing(): void {
|
|
1886
|
+
if (!svgElement || !selectedElement || isTextEditingActive) return;
|
|
1887
|
+
|
|
1888
|
+
const specAnnotations = getSpecAnnotations();
|
|
1889
|
+
if (!isTextEditable(selectedElement, specAnnotations)) return;
|
|
1890
|
+
|
|
1891
|
+
const currentText = getElementText(selectedElement, currentSpec);
|
|
1892
|
+
if (currentText === null) return;
|
|
1893
|
+
|
|
1894
|
+
// Find the text element within the selected element
|
|
1895
|
+
const target = findElementByRef(svgElement, selectedElement);
|
|
1896
|
+
if (!target) return;
|
|
1897
|
+
|
|
1898
|
+
// The target might be a group; find the actual text element
|
|
1899
|
+
const textEl = target.tagName === 'text' ? target : target.querySelector('text');
|
|
1900
|
+
if (!textEl) return;
|
|
1901
|
+
|
|
1902
|
+
isTextEditingActive = true;
|
|
1903
|
+
const editRef = selectedElement;
|
|
1904
|
+
|
|
1905
|
+
const overlay = createTextEditOverlay({
|
|
1906
|
+
container,
|
|
1907
|
+
svg: svgElement as SVGSVGElement,
|
|
1908
|
+
targetElement: textEl as SVGElement,
|
|
1909
|
+
currentText,
|
|
1910
|
+
onCommit: (newText: string) => {
|
|
1911
|
+
isTextEditingActive = false;
|
|
1912
|
+
textEditCleanup = null;
|
|
1913
|
+
|
|
1914
|
+
if (newText !== currentText) {
|
|
1915
|
+
// Fire text edit callbacks
|
|
1916
|
+
options?.onTextEdit?.(editRef, currentText, newText);
|
|
1917
|
+
options?.onEdit?.({
|
|
1918
|
+
type: 'text-edit',
|
|
1919
|
+
element: editRef,
|
|
1920
|
+
oldText: currentText,
|
|
1921
|
+
newText,
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
},
|
|
1925
|
+
onCancel: () => {
|
|
1926
|
+
isTextEditingActive = false;
|
|
1927
|
+
textEditCleanup = null;
|
|
1928
|
+
},
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1931
|
+
textEditCleanup = overlay.destroy;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
/**
|
|
1935
|
+
* Wire click-based selection events on the SVG.
|
|
1936
|
+
* Uses event delegation for efficiency.
|
|
1937
|
+
*/
|
|
1938
|
+
function wireSelectionEvents(): () => void {
|
|
1939
|
+
if (!svgElement) return () => {};
|
|
1940
|
+
|
|
1941
|
+
const svg = svgElement;
|
|
1942
|
+
const cleanups: Array<() => void> = [];
|
|
1943
|
+
|
|
1944
|
+
// Click handler for selection
|
|
1945
|
+
const handleClick = (e: Event) => {
|
|
1946
|
+
const mouseEvent = e as MouseEvent;
|
|
1947
|
+
const target = mouseEvent.target as Element;
|
|
1948
|
+
|
|
1949
|
+
// Don't interfere with text editing
|
|
1950
|
+
if (isTextEditingActive) return;
|
|
1951
|
+
|
|
1952
|
+
const specAnnotations = getSpecAnnotations();
|
|
1953
|
+
const ref = buildElementRef(target, specAnnotations);
|
|
1954
|
+
|
|
1955
|
+
if (ref) {
|
|
1956
|
+
// Clicked on an editable element
|
|
1957
|
+
selectElement(ref);
|
|
1958
|
+
} else {
|
|
1959
|
+
// Clicked on empty area / non-editable element, deselect
|
|
1960
|
+
deselectElement();
|
|
1961
|
+
}
|
|
1962
|
+
};
|
|
1963
|
+
|
|
1964
|
+
svg.addEventListener('click', handleClick);
|
|
1965
|
+
cleanups.push(() => svg.removeEventListener('click', handleClick));
|
|
1966
|
+
|
|
1967
|
+
// Hover feedback on editable elements
|
|
1968
|
+
const handleMouseEnter = (e: Event) => {
|
|
1969
|
+
const target = (e.target as Element).closest(
|
|
1970
|
+
'[data-annotation-index], [data-chrome-key], .viz-mark-label[data-series], .viz-legend, [data-legend-index]',
|
|
1971
|
+
);
|
|
1972
|
+
if (target) {
|
|
1973
|
+
(target as SVGElement).classList.add('viz-editable-hover');
|
|
1974
|
+
}
|
|
1975
|
+
};
|
|
1976
|
+
|
|
1977
|
+
const handleMouseLeave = (e: Event) => {
|
|
1978
|
+
const target = (e.target as Element).closest('.viz-editable-hover');
|
|
1979
|
+
if (target) {
|
|
1980
|
+
(target as SVGElement).classList.remove('viz-editable-hover');
|
|
1981
|
+
}
|
|
1982
|
+
};
|
|
1983
|
+
|
|
1984
|
+
svg.addEventListener('mouseenter', handleMouseEnter, true);
|
|
1985
|
+
svg.addEventListener('mouseleave', handleMouseLeave, true);
|
|
1986
|
+
cleanups.push(() => {
|
|
1987
|
+
svg.removeEventListener('mouseenter', handleMouseEnter, true);
|
|
1988
|
+
svg.removeEventListener('mouseleave', handleMouseLeave, true);
|
|
1989
|
+
});
|
|
1990
|
+
|
|
1991
|
+
// Double-click to enter text editing
|
|
1992
|
+
const handleDblClick = (e: Event) => {
|
|
1993
|
+
const mouseEvent = e as MouseEvent;
|
|
1994
|
+
const target = mouseEvent.target as Element;
|
|
1995
|
+
const specAnnotations = getSpecAnnotations();
|
|
1996
|
+
const ref = buildElementRef(target, specAnnotations);
|
|
1997
|
+
|
|
1998
|
+
if (ref && isTextEditable(ref, specAnnotations)) {
|
|
1999
|
+
// Select first if not already selected
|
|
2000
|
+
if (!refsEqual(selectedElement, ref)) {
|
|
2001
|
+
selectElement(ref);
|
|
2002
|
+
}
|
|
2003
|
+
enterTextEditing();
|
|
2004
|
+
}
|
|
2005
|
+
};
|
|
2006
|
+
|
|
2007
|
+
svg.addEventListener('dblclick', handleDblClick);
|
|
2008
|
+
cleanups.push(() => svg.removeEventListener('dblclick', handleDblClick));
|
|
2009
|
+
|
|
2010
|
+
return () => {
|
|
2011
|
+
for (const cleanup of cleanups) {
|
|
2012
|
+
cleanup();
|
|
2013
|
+
}
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
/**
|
|
2018
|
+
* Wire keyboard events for edit actions on the SVG.
|
|
2019
|
+
* Delete/Backspace -> delete, Escape -> cancel/deselect, Tab -> cycle, Enter -> text edit.
|
|
2020
|
+
*/
|
|
2021
|
+
function wireKeyboardEditEvents(): () => void {
|
|
2022
|
+
if (!svgElement) return () => {};
|
|
2023
|
+
|
|
2024
|
+
const svg = svgElement;
|
|
2025
|
+
|
|
2026
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
2027
|
+
const specAnnotations = getSpecAnnotations();
|
|
2028
|
+
|
|
2029
|
+
switch (e.key) {
|
|
2030
|
+
case 'Delete':
|
|
2031
|
+
case 'Backspace': {
|
|
2032
|
+
if (selectedElement && !isTextEditingActive) {
|
|
2033
|
+
e.preventDefault();
|
|
2034
|
+
options?.onEdit?.({ type: 'delete', element: selectedElement });
|
|
2035
|
+
// Stay selected (consumer decides whether to remove the element)
|
|
2036
|
+
}
|
|
2037
|
+
break;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
case 'Escape': {
|
|
2041
|
+
e.preventDefault();
|
|
2042
|
+
if (isTextEditingActive && textEditCleanup) {
|
|
2043
|
+
// Cancel text editing, remain selected
|
|
2044
|
+
textEditCleanup();
|
|
2045
|
+
textEditCleanup = null;
|
|
2046
|
+
isTextEditingActive = false;
|
|
2047
|
+
} else if (selectedElement) {
|
|
2048
|
+
deselectElement();
|
|
2049
|
+
}
|
|
2050
|
+
break;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
case 'ArrowDown':
|
|
2054
|
+
case 'ArrowRight': {
|
|
2055
|
+
if (!isTextEditingActive && selectedElement) {
|
|
2056
|
+
e.preventDefault();
|
|
2057
|
+
const editables = getEditableElements(currentSpec, currentLayout);
|
|
2058
|
+
if (editables.length === 0) break;
|
|
2059
|
+
|
|
2060
|
+
const currentIndex = editables.findIndex((r) => refsEqual(r, selectedElement));
|
|
2061
|
+
const nextIndex = currentIndex >= editables.length - 1 ? 0 : currentIndex + 1;
|
|
2062
|
+
|
|
2063
|
+
selectElement(editables[nextIndex]);
|
|
2064
|
+
}
|
|
2065
|
+
break;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
case 'ArrowUp':
|
|
2069
|
+
case 'ArrowLeft': {
|
|
2070
|
+
if (!isTextEditingActive && selectedElement) {
|
|
2071
|
+
e.preventDefault();
|
|
2072
|
+
const editables = getEditableElements(currentSpec, currentLayout);
|
|
2073
|
+
if (editables.length === 0) break;
|
|
2074
|
+
|
|
2075
|
+
const currentIndex = editables.findIndex((r) => refsEqual(r, selectedElement));
|
|
2076
|
+
const nextIndex = currentIndex <= 0 ? editables.length - 1 : currentIndex - 1;
|
|
2077
|
+
|
|
2078
|
+
selectElement(editables[nextIndex]);
|
|
2079
|
+
}
|
|
2080
|
+
break;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
case 'Enter': {
|
|
2084
|
+
if (selectedElement && !isTextEditingActive) {
|
|
2085
|
+
if (isTextEditable(selectedElement, specAnnotations)) {
|
|
2086
|
+
e.preventDefault();
|
|
2087
|
+
enterTextEditing();
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
break;
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
};
|
|
2094
|
+
|
|
2095
|
+
svg.addEventListener('keydown', handleKeyDown);
|
|
2096
|
+
|
|
2097
|
+
return () => {
|
|
2098
|
+
svg.removeEventListener('keydown', handleKeyDown);
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
|
|
1563
2102
|
function render(): void {
|
|
1564
2103
|
// Defer re-render if a drag is in progress to avoid destroying the dragged element
|
|
1565
2104
|
if (isDragging) {
|
|
@@ -1596,6 +2135,20 @@ export function createChart(
|
|
|
1596
2135
|
cleanupEditDrags();
|
|
1597
2136
|
cleanupEditDrags = null;
|
|
1598
2137
|
}
|
|
2138
|
+
if (cleanupSelection) {
|
|
2139
|
+
cleanupSelection();
|
|
2140
|
+
cleanupSelection = null;
|
|
2141
|
+
}
|
|
2142
|
+
if (cleanupKeyboardEdit) {
|
|
2143
|
+
cleanupKeyboardEdit();
|
|
2144
|
+
cleanupKeyboardEdit = null;
|
|
2145
|
+
}
|
|
2146
|
+
if (textEditCleanup) {
|
|
2147
|
+
textEditCleanup();
|
|
2148
|
+
textEditCleanup = null;
|
|
2149
|
+
isTextEditingActive = false;
|
|
2150
|
+
}
|
|
2151
|
+
overlayElement = null;
|
|
1599
2152
|
if (svgElement?.parentNode) {
|
|
1600
2153
|
svgElement.parentNode.removeChild(svgElement);
|
|
1601
2154
|
}
|
|
@@ -1709,6 +2262,25 @@ export function createChart(
|
|
|
1709
2262
|
};
|
|
1710
2263
|
}
|
|
1711
2264
|
|
|
2265
|
+
// Wire selection and keyboard edit events when editing callbacks are provided
|
|
2266
|
+
if (hasEditingCallbacks(options)) {
|
|
2267
|
+
makeEditable(svgElement);
|
|
2268
|
+
cleanupSelection = wireSelectionEvents();
|
|
2269
|
+
cleanupKeyboardEdit = wireKeyboardEditEvents();
|
|
2270
|
+
|
|
2271
|
+
// Restore selection overlay after re-render
|
|
2272
|
+
if (selectedElement) {
|
|
2273
|
+
const target = findElementByRef(svgElement, selectedElement);
|
|
2274
|
+
if (target) {
|
|
2275
|
+
overlayElement = renderSelectionOverlay(svgElement, selectedElement, currentLayout);
|
|
2276
|
+
} else {
|
|
2277
|
+
// Element no longer exists in DOM, clear selection silently
|
|
2278
|
+
selectedElement = null;
|
|
2279
|
+
overlayElement = null;
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
|
|
1712
2284
|
// Create hidden data table for screen readers
|
|
1713
2285
|
srTable = createScreenReaderTable(currentLayout, container);
|
|
1714
2286
|
|
|
@@ -1722,9 +2294,12 @@ export function createChart(
|
|
|
1722
2294
|
}
|
|
1723
2295
|
}
|
|
1724
2296
|
|
|
1725
|
-
function update(newSpec: ChartSpec | GraphSpec): void {
|
|
2297
|
+
function update(newSpec: ChartSpec | GraphSpec, updateOpts?: UpdateOptions): void {
|
|
1726
2298
|
if (destroyed) return;
|
|
1727
2299
|
currentSpec = newSpec;
|
|
2300
|
+
if (updateOpts && 'selectedElement' in updateOpts) {
|
|
2301
|
+
selectedElement = updateOpts.selectedElement ?? null;
|
|
2302
|
+
}
|
|
1728
2303
|
render();
|
|
1729
2304
|
}
|
|
1730
2305
|
|
|
@@ -1800,6 +2375,21 @@ export function createChart(
|
|
|
1800
2375
|
cleanupEditDrags();
|
|
1801
2376
|
cleanupEditDrags = null;
|
|
1802
2377
|
}
|
|
2378
|
+
if (cleanupSelection) {
|
|
2379
|
+
cleanupSelection();
|
|
2380
|
+
cleanupSelection = null;
|
|
2381
|
+
}
|
|
2382
|
+
if (cleanupKeyboardEdit) {
|
|
2383
|
+
cleanupKeyboardEdit();
|
|
2384
|
+
cleanupKeyboardEdit = null;
|
|
2385
|
+
}
|
|
2386
|
+
if (textEditCleanup) {
|
|
2387
|
+
textEditCleanup();
|
|
2388
|
+
textEditCleanup = null;
|
|
2389
|
+
isTextEditingActive = false;
|
|
2390
|
+
}
|
|
2391
|
+
selectedElement = null;
|
|
2392
|
+
overlayElement = null;
|
|
1803
2393
|
if (disconnectResize) {
|
|
1804
2394
|
disconnectResize();
|
|
1805
2395
|
disconnectResize = null;
|
|
@@ -1842,5 +2432,19 @@ export function createChart(
|
|
|
1842
2432
|
get layout() {
|
|
1843
2433
|
return currentLayout;
|
|
1844
2434
|
},
|
|
2435
|
+
getSelectedElement(): ElementRef | null {
|
|
2436
|
+
return selectedElement;
|
|
2437
|
+
},
|
|
2438
|
+
select(ref: ElementRef): void {
|
|
2439
|
+
if (destroyed) return;
|
|
2440
|
+
selectElement(ref);
|
|
2441
|
+
},
|
|
2442
|
+
deselect(): void {
|
|
2443
|
+
if (destroyed) return;
|
|
2444
|
+
deselectElement();
|
|
2445
|
+
},
|
|
2446
|
+
get isEditing(): boolean {
|
|
2447
|
+
return isTextEditingActive;
|
|
2448
|
+
},
|
|
1845
2449
|
};
|
|
1846
2450
|
}
|