@opendata-ai/openchart-vanilla 6.2.1 → 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/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
  }