@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.
@@ -793,6 +793,9 @@ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, in
793
793
  const g = createSVGElement('g');
794
794
  g.setAttribute('class', `viz-annotation viz-annotation-${annotation.type}`);
795
795
  g.setAttribute('data-annotation-index', String(index));
796
+ if (annotation.id) {
797
+ g.setAttribute('data-annotation-id', annotation.id);
798
+ }
796
799
 
797
800
  // Range rect
798
801
  if (annotation.rect) {
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Text edit overlay: creates a positioned textarea over an SVG text element
3
+ * for inline editing. Handles commit (Enter), cancel (Escape), and
4
+ * click-outside-to-commit behavior.
5
+ */
6
+
7
+ export interface TextEditOverlayConfig {
8
+ /** The container div that holds the SVG. */
9
+ container: HTMLElement;
10
+ /** The root SVG element. */
11
+ svg: SVGSVGElement;
12
+ /** The SVG text element being edited. */
13
+ targetElement: SVGElement;
14
+ /** Current text content to populate the textarea. */
15
+ currentText: string;
16
+ /** Called when the user commits the edit (Enter or click outside). */
17
+ onCommit: (newText: string) => void;
18
+ /** Called when the user cancels the edit (Escape). */
19
+ onCancel: () => void;
20
+ }
21
+
22
+ /**
23
+ * Get the viewBox-to-pixel scale factors for an SVG element.
24
+ * Same approach used by the drag handlers in mount.ts.
25
+ */
26
+ function getScale(svg: SVGSVGElement): { scaleX: number; scaleY: number } {
27
+ const viewBox = svg.viewBox?.baseVal;
28
+ const svgRect = svg.getBoundingClientRect();
29
+ return {
30
+ scaleX: viewBox?.width && svgRect.width ? svgRect.width / viewBox.width : 1,
31
+ scaleY: viewBox?.height && svgRect.height ? svgRect.height / viewBox.height : 1,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Read computed font styles from an SVG text element and map them to CSS.
37
+ */
38
+ function getTextStyles(
39
+ targetElement: SVGElement,
40
+ scale: { scaleX: number; scaleY: number },
41
+ ): {
42
+ fontFamily: string;
43
+ fontSize: string;
44
+ fontWeight: string;
45
+ color: string;
46
+ textAlign: string;
47
+ lineHeight: string;
48
+ } {
49
+ const computed = window.getComputedStyle(targetElement);
50
+
51
+ // Font size needs to be scaled from SVG viewBox units to pixel units
52
+ const svgFontSize = parseFloat(
53
+ targetElement.getAttribute('font-size') ?? computed.fontSize ?? '14',
54
+ );
55
+ const pixelFontSize = svgFontSize * scale.scaleY;
56
+
57
+ const fontFamily = targetElement.getAttribute('font-family') ?? computed.fontFamily ?? 'inherit';
58
+ const fontWeight = targetElement.getAttribute('font-weight') ?? computed.fontWeight ?? '400';
59
+
60
+ // Get fill color from inline style or attribute
61
+ const fill =
62
+ (targetElement as SVGElement & ElementCSSInlineStyle).style.getPropertyValue('fill') ||
63
+ targetElement.getAttribute('fill') ||
64
+ computed.color ||
65
+ '#000';
66
+
67
+ // Map SVG text-anchor to CSS text-align
68
+ const textAnchor = targetElement.getAttribute('text-anchor') ?? 'start';
69
+ let textAlign = 'left';
70
+ if (textAnchor === 'middle') textAlign = 'center';
71
+ else if (textAnchor === 'end') textAlign = 'right';
72
+
73
+ return {
74
+ fontFamily,
75
+ fontSize: `${pixelFontSize}px`,
76
+ fontWeight,
77
+ color: fill,
78
+ textAlign,
79
+ lineHeight: '1.3',
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Compute the pixel position and size for the textarea based on the
85
+ * target SVG element's bounding box.
86
+ */
87
+ function computePosition(
88
+ targetElement: SVGElement,
89
+ svg: SVGSVGElement,
90
+ container: HTMLElement,
91
+ ): { top: number; left: number; width: number; height: number } {
92
+ const bbox = (targetElement as SVGGraphicsElement).getBBox();
93
+ const scale = getScale(svg);
94
+ const svgRect = svg.getBoundingClientRect();
95
+ const containerRect = container.getBoundingClientRect();
96
+
97
+ // SVG viewBox coords -> pixel coords relative to the container
98
+ const padding = 4;
99
+ const left = bbox.x * scale.scaleX + (svgRect.left - containerRect.left) - padding;
100
+ const top = bbox.y * scale.scaleY + (svgRect.top - containerRect.top) - padding;
101
+ const width = bbox.width * scale.scaleX + padding * 2;
102
+ const height = bbox.height * scale.scaleY + padding * 2;
103
+
104
+ return {
105
+ top: Math.max(0, top),
106
+ left: Math.max(0, left),
107
+ width: Math.max(width, 60),
108
+ height: Math.max(height, 24),
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Create an inline text editing overlay positioned over an SVG text element.
114
+ * Returns an object with a `destroy()` method to clean up.
115
+ */
116
+ export function createTextEditOverlay(config: TextEditOverlayConfig): { destroy: () => void } {
117
+ const { container, svg, targetElement, currentText, onCommit, onCancel } = config;
118
+
119
+ let destroyed = false;
120
+
121
+ // Hide the underlying SVG text element (not display:none to preserve layout)
122
+ const originalOpacity = targetElement.getAttribute('opacity');
123
+ targetElement.setAttribute('opacity', '0');
124
+
125
+ // Compute position and styles
126
+ const scale = getScale(svg);
127
+ const styles = getTextStyles(targetElement, scale);
128
+ const position = computePosition(targetElement, svg, container);
129
+
130
+ // Create the textarea
131
+ const textarea = document.createElement('textarea');
132
+ textarea.value = currentText;
133
+
134
+ // Ensure container is positioned so absolute children work
135
+ const containerPosition = window.getComputedStyle(container).position;
136
+ const containerWasStatic = containerPosition === 'static';
137
+ if (containerWasStatic) {
138
+ container.style.position = 'relative';
139
+ }
140
+
141
+ // Style the textarea to match the SVG text
142
+ Object.assign(textarea.style, {
143
+ position: 'absolute',
144
+ top: `${position.top}px`,
145
+ left: `${position.left}px`,
146
+ width: `${position.width}px`,
147
+ minHeight: `${position.height}px`,
148
+ fontFamily: styles.fontFamily,
149
+ fontSize: styles.fontSize,
150
+ fontWeight: styles.fontWeight,
151
+ color: styles.color,
152
+ textAlign: styles.textAlign,
153
+ lineHeight: styles.lineHeight,
154
+ padding: '2px 4px',
155
+ margin: '0',
156
+ border: '1px solid rgba(79, 70, 229, 0.4)',
157
+ borderRadius: '3px',
158
+ background: 'rgba(255, 255, 255, 0.95)',
159
+ outline: 'none',
160
+ resize: 'none',
161
+ overflow: 'hidden',
162
+ boxSizing: 'border-box',
163
+ zIndex: '10000',
164
+ // Remove default textarea appearance
165
+ WebkitAppearance: 'none',
166
+ appearance: 'none',
167
+ } as Record<string, string>);
168
+
169
+ container.appendChild(textarea);
170
+
171
+ // Auto-select all text
172
+ textarea.focus();
173
+ textarea.select();
174
+
175
+ // Cleanup function
176
+ function destroy(): void {
177
+ if (destroyed) return;
178
+ destroyed = true;
179
+
180
+ // Restore SVG text visibility
181
+ if (originalOpacity !== null) {
182
+ targetElement.setAttribute('opacity', originalOpacity);
183
+ } else {
184
+ targetElement.removeAttribute('opacity');
185
+ }
186
+
187
+ // Remove event listeners
188
+ textarea.removeEventListener('keydown', handleKeyDown);
189
+ document.removeEventListener('mousedown', handleClickOutside);
190
+ resizeObserver.disconnect();
191
+
192
+ // Restore container position if we changed it
193
+ if (containerWasStatic) {
194
+ container.style.position = '';
195
+ }
196
+
197
+ // Remove the textarea
198
+ if (textarea.parentNode) {
199
+ textarea.parentNode.removeChild(textarea);
200
+ }
201
+ }
202
+
203
+ function commit(): void {
204
+ if (destroyed) return;
205
+ const newText = textarea.value;
206
+ destroy();
207
+ onCommit(newText);
208
+ }
209
+
210
+ function cancel(): void {
211
+ if (destroyed) return;
212
+ destroy();
213
+ onCancel();
214
+ }
215
+
216
+ // Keyboard handling
217
+ const handleKeyDown = (e: KeyboardEvent) => {
218
+ if (e.key === 'Enter' && !e.shiftKey) {
219
+ e.preventDefault();
220
+ commit();
221
+ } else if (e.key === 'Escape') {
222
+ e.preventDefault();
223
+ cancel();
224
+ }
225
+ };
226
+
227
+ textarea.addEventListener('keydown', handleKeyDown);
228
+
229
+ // Click outside to commit
230
+ const handleClickOutside = (e: MouseEvent) => {
231
+ if (!textarea.contains(e.target as Node)) {
232
+ commit();
233
+ }
234
+ };
235
+
236
+ // Delay attaching click-outside so the current click event doesn't trigger it
237
+ requestAnimationFrame(() => {
238
+ if (!destroyed) {
239
+ document.addEventListener('mousedown', handleClickOutside);
240
+ }
241
+ });
242
+
243
+ // Reposition on container resize
244
+ const resizeObserver = new ResizeObserver(() => {
245
+ if (destroyed) return;
246
+ const newPosition = computePosition(targetElement, svg, container);
247
+ textarea.style.top = `${newPosition.top}px`;
248
+ textarea.style.left = `${newPosition.left}px`;
249
+ textarea.style.width = `${newPosition.width}px`;
250
+ textarea.style.minHeight = `${newPosition.height}px`;
251
+ });
252
+ resizeObserver.observe(container);
253
+
254
+ return { destroy };
255
+ }