@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21

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.
Files changed (145) hide show
  1. package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/chunk-DCODCLWI.js +234 -0
  3. package/dist/chunk-DCODCLWI.js.map +1 -0
  4. package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
  5. package/dist/chunk-EKAUQAAX.js.map +1 -0
  6. package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
  7. package/dist/chunk-KNT5AZ2O.js.map +1 -0
  8. package/dist/chunk-RM5XS6NR.js +113 -0
  9. package/dist/chunk-RM5XS6NR.js.map +1 -0
  10. package/dist/chunk-T32UHMZA.js +282 -0
  11. package/dist/chunk-T32UHMZA.js.map +1 -0
  12. package/dist/export-gif.d.ts +113 -0
  13. package/dist/export-gif.js +21 -0
  14. package/dist/export-gif.js.map +1 -0
  15. package/dist/index.d.ts +283 -24
  16. package/dist/index.js +2012 -763
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.js +7 -69
  21. package/dist/static.js.map +1 -1
  22. package/dist/story/index.d.ts +3 -2
  23. package/dist/story/index.js +5 -3
  24. package/dist/story/index.js.map +1 -1
  25. package/dist/styles.css +1 -1
  26. package/dist/styles.css.map +1 -1
  27. package/package.json +14 -6
  28. package/dist/chunk-5EOV5JQZ.js.map +0 -1
  29. package/dist/chunk-ENXSUXC5.js.map +0 -1
  30. package/src/__test-fixtures__/dom.ts +0 -70
  31. package/src/__test-fixtures__/specs.ts +0 -214
  32. package/src/__tests__/animation.test.ts +0 -358
  33. package/src/__tests__/annotations-render.test.ts +0 -201
  34. package/src/__tests__/compound-labels.test.ts +0 -122
  35. package/src/__tests__/continuous-legend.test.ts +0 -91
  36. package/src/__tests__/crosshair.test.ts +0 -130
  37. package/src/__tests__/edit-events.test.ts +0 -902
  38. package/src/__tests__/events.test.ts +0 -387
  39. package/src/__tests__/export.test.ts +0 -241
  40. package/src/__tests__/font-race.test.ts +0 -199
  41. package/src/__tests__/gradient-ids.test.ts +0 -159
  42. package/src/__tests__/gradient-utils.test.ts +0 -125
  43. package/src/__tests__/known-bugs-render.test.ts +0 -183
  44. package/src/__tests__/map.test.ts +0 -293
  45. package/src/__tests__/mount.test.ts +0 -357
  46. package/src/__tests__/pattern-fills.test.ts +0 -105
  47. package/src/__tests__/resize-timing.test.ts +0 -218
  48. package/src/__tests__/sankey.test.ts +0 -133
  49. package/src/__tests__/selection-events.test.ts +0 -869
  50. package/src/__tests__/series-search.test.ts +0 -311
  51. package/src/__tests__/static.test.ts +0 -141
  52. package/src/__tests__/svg-renderer.test.ts +0 -895
  53. package/src/__tests__/table-keyboard.test.ts +0 -361
  54. package/src/__tests__/table-mount.test.ts +0 -639
  55. package/src/__tests__/tilemap.test.ts +0 -158
  56. package/src/__tests__/tooltip.test.ts +0 -337
  57. package/src/__tests__/transition.test.ts +0 -1777
  58. package/src/__tests__/you-draw-it.test.ts +0 -273
  59. package/src/animation-vars.ts +0 -42
  60. package/src/animation.ts +0 -76
  61. package/src/barlist-mount.ts +0 -383
  62. package/src/barlist-renderer.ts +0 -267
  63. package/src/dom-helpers.ts +0 -17
  64. package/src/export.ts +0 -482
  65. package/src/gradient-utils.ts +0 -138
  66. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  67. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  68. package/src/graph/__tests__/interaction.test.ts +0 -205
  69. package/src/graph/__tests__/keyboard.test.ts +0 -653
  70. package/src/graph/__tests__/search.test.ts +0 -88
  71. package/src/graph/__tests__/simulation.test.ts +0 -233
  72. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  73. package/src/graph/__tests__/zoom.test.ts +0 -203
  74. package/src/graph/canvas-renderer.ts +0 -747
  75. package/src/graph/interaction.ts +0 -367
  76. package/src/graph/keyboard.ts +0 -208
  77. package/src/graph/search.ts +0 -50
  78. package/src/graph/simulation-worker-url.ts +0 -12
  79. package/src/graph/simulation-worker.ts +0 -278
  80. package/src/graph/simulation.ts +0 -422
  81. package/src/graph/spatial-index.ts +0 -121
  82. package/src/graph/types.ts +0 -47
  83. package/src/graph/worker-protocol.ts +0 -70
  84. package/src/graph/zoom.ts +0 -134
  85. package/src/graph-mount.ts +0 -888
  86. package/src/index.ts +0 -76
  87. package/src/interactions/chart-events.ts +0 -139
  88. package/src/interactions/crosshair.ts +0 -233
  89. package/src/interactions/drag-handler.ts +0 -175
  90. package/src/interactions/editing-drags.ts +0 -515
  91. package/src/interactions/index.ts +0 -25
  92. package/src/interactions/keyboard-nav.ts +0 -111
  93. package/src/interactions/legend-interaction.ts +0 -46
  94. package/src/interactions/selection.ts +0 -280
  95. package/src/interactions/tooltip-events.ts +0 -72
  96. package/src/map-camera.ts +0 -88
  97. package/src/map-mount.ts +0 -701
  98. package/src/map-renderer.ts +0 -338
  99. package/src/map-transition.ts +0 -83
  100. package/src/measure-text.ts +0 -134
  101. package/src/mount.ts +0 -1382
  102. package/src/pattern-utils.ts +0 -110
  103. package/src/renderers/annotations.ts +0 -388
  104. package/src/renderers/axes.ts +0 -351
  105. package/src/renderers/brand.ts +0 -87
  106. package/src/renderers/chrome.ts +0 -196
  107. package/src/renderers/endpoint-labels.ts +0 -150
  108. package/src/renderers/legend.ts +0 -311
  109. package/src/renderers/marks.ts +0 -561
  110. package/src/renderers/metrics.ts +0 -63
  111. package/src/renderers/svg-dom.ts +0 -40
  112. package/src/renderers/table-cells.ts +0 -446
  113. package/src/resize-observer.ts +0 -46
  114. package/src/sankey-mount.ts +0 -622
  115. package/src/sankey-renderer.ts +0 -614
  116. package/src/series-search.ts +0 -392
  117. package/src/static.ts +0 -251
  118. package/src/story/__tests__/camera-math.test.ts +0 -145
  119. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  120. package/src/story/__tests__/progress-math.test.ts +0 -95
  121. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  122. package/src/story/__tests__/tween.test.ts +0 -159
  123. package/src/story/camera-math.ts +0 -125
  124. package/src/story/create-chart-story.ts +0 -353
  125. package/src/story/crossfade.ts +0 -70
  126. package/src/story/index.ts +0 -39
  127. package/src/story/progress-math.ts +0 -85
  128. package/src/story/resolve-camera-target.ts +0 -119
  129. package/src/story/scroll-driver.ts +0 -206
  130. package/src/story/story-camera.ts +0 -49
  131. package/src/story/tween.ts +0 -112
  132. package/src/story/types.ts +0 -76
  133. package/src/svg-ids.ts +0 -23
  134. package/src/svg-renderer.ts +0 -383
  135. package/src/table-keyboard.ts +0 -266
  136. package/src/table-mount.ts +0 -567
  137. package/src/table-renderer.ts +0 -414
  138. package/src/text-edit-overlay.ts +0 -255
  139. package/src/theme-tokens.ts +0 -51
  140. package/src/tilemap-mount.ts +0 -511
  141. package/src/tilemap-renderer.ts +0 -396
  142. package/src/tooltip.ts +0 -177
  143. package/src/transition.ts +0 -2816
  144. package/src/you-draw-it.ts +0 -480
  145. /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
@@ -1,414 +0,0 @@
1
- /**
2
- * Table renderer: produces semantic HTML from a TableLayout.
3
- *
4
- * renderTable() creates the full DOM structure: chrome, search bar,
5
- * scrollable table with sticky column support, pagination, and footer.
6
- * The returned element replaces or appends to the given container.
7
- */
8
-
9
- import type { ResolvedColumn, TableLayout, TableRow } from '@opendata-ai/openchart-core';
10
- import { BRAND_FONT_SIZE } from '@opendata-ai/openchart-core';
11
- import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
12
- import { stampAnimationVars } from './animation-vars';
13
- import { applySrOnlyStyles } from './dom-helpers';
14
- import { renderCell } from './renderers/table-cells';
15
-
16
- /** Options for renderTable(). */
17
- export interface TableRenderOptions {
18
- /** Whether to apply entrance animation on this render. */
19
- animate?: boolean;
20
- }
21
-
22
- // ---------------------------------------------------------------------------
23
- // Constants
24
- // ---------------------------------------------------------------------------
25
-
26
- const BRAND_URL = 'https://tryopendata.ai';
27
-
28
- // ---------------------------------------------------------------------------
29
- // Chrome rendering
30
- // ---------------------------------------------------------------------------
31
-
32
- /** Create chrome (title/subtitle or source/footer) block. */
33
- function renderChromeBlock(
34
- layout: TableLayout,
35
- position: 'header' | 'footer',
36
- ): HTMLDivElement | null {
37
- const chrome = layout.chrome;
38
-
39
- if (position === 'header') {
40
- if (!chrome.title && !chrome.subtitle) return null;
41
-
42
- const div = document.createElement('div');
43
- div.className = 'oc-chrome';
44
-
45
- if (chrome.title) {
46
- const h = document.createElement('div');
47
- h.className = 'oc-table-title';
48
- h.textContent = chrome.title.text;
49
- div.appendChild(h);
50
- }
51
- if (chrome.subtitle) {
52
- const sub = document.createElement('div');
53
- sub.className = 'oc-table-subtitle';
54
- sub.textContent = chrome.subtitle.text;
55
- div.appendChild(sub);
56
- }
57
-
58
- return div;
59
- }
60
-
61
- // Footer position
62
- if (!chrome.source && !chrome.footer) return null;
63
-
64
- const div = document.createElement('div');
65
- div.className = 'oc-chrome oc-chrome-footer';
66
-
67
- if (chrome.source) {
68
- const src = document.createElement('div');
69
- src.className = 'oc-table-source';
70
- src.textContent = chrome.source.text;
71
- div.appendChild(src);
72
- }
73
- if (chrome.footer) {
74
- const foot = document.createElement('div');
75
- foot.className = 'oc-table-footer-text';
76
- foot.textContent = chrome.footer.text;
77
- div.appendChild(foot);
78
- }
79
-
80
- return div;
81
- }
82
-
83
- // ---------------------------------------------------------------------------
84
- // Table head
85
- // ---------------------------------------------------------------------------
86
-
87
- function renderThead(
88
- columns: ResolvedColumn[],
89
- sort: TableLayout['sort'],
90
- ): HTMLTableSectionElement {
91
- const thead = document.createElement('thead');
92
- const tr = document.createElement('tr');
93
- tr.setAttribute('role', 'row');
94
-
95
- for (const col of columns) {
96
- const th = document.createElement('th');
97
- th.setAttribute('scope', 'col');
98
- th.setAttribute('role', 'columnheader');
99
- th.style.textAlign = col.align;
100
- th.style.width = `${col.width}px`;
101
-
102
- // Sort state: use 'ascending'/'descending' per WAI-ARIA spec
103
- let ariaSortValue: string = 'none';
104
- if (sort && sort.column === col.key) {
105
- ariaSortValue = sort.direction === 'asc' ? 'ascending' : 'descending';
106
- }
107
- th.setAttribute('aria-sort', ariaSortValue);
108
- th.setAttribute('data-column', col.key);
109
-
110
- // Label
111
- const labelSpan = document.createTextNode(col.label);
112
- th.appendChild(labelSpan);
113
-
114
- // Sort button
115
- if (col.sortable) {
116
- const btn = document.createElement('button');
117
- btn.className = 'oc-table-sort-btn';
118
- btn.setAttribute('aria-label', `Sort by ${col.label}`);
119
- btn.setAttribute('data-sort-column', col.key);
120
- btn.type = 'button';
121
- th.appendChild(btn);
122
- }
123
-
124
- tr.appendChild(th);
125
- }
126
-
127
- thead.appendChild(tr);
128
- return thead;
129
- }
130
-
131
- // ---------------------------------------------------------------------------
132
- // Table body
133
- // ---------------------------------------------------------------------------
134
-
135
- function renderTbody(rows: TableRow[], columns: ResolvedColumn[]): HTMLTableSectionElement {
136
- const tbody = document.createElement('tbody');
137
-
138
- for (let r = 0; r < rows.length; r++) {
139
- const row = rows[r];
140
- const tr = document.createElement('tr');
141
- tr.setAttribute('role', 'row');
142
- tr.setAttribute('data-row-id', row.id);
143
- tr.style.setProperty('--oc-row-index', String(r));
144
-
145
- for (let c = 0; c < columns.length; c++) {
146
- const cell = row.cells[c];
147
- if (!cell) continue;
148
-
149
- const td = renderCell(cell);
150
- td.setAttribute('role', 'gridcell');
151
- td.style.textAlign = columns[c].align;
152
- tr.appendChild(td);
153
- }
154
-
155
- tbody.appendChild(tr);
156
- }
157
-
158
- return tbody;
159
- }
160
-
161
- // ---------------------------------------------------------------------------
162
- // Search bar
163
- // ---------------------------------------------------------------------------
164
-
165
- function renderSearchBar(layout: TableLayout): HTMLDivElement | null {
166
- if (!layout.search.enabled) return null;
167
-
168
- const div = document.createElement('div');
169
- div.className = 'oc-table-search';
170
-
171
- const input = document.createElement('input');
172
- input.type = 'search';
173
- input.placeholder = layout.search.placeholder;
174
- input.setAttribute('aria-label', 'Search table');
175
- input.value = layout.search.query;
176
-
177
- div.appendChild(input);
178
- return div;
179
- }
180
-
181
- // ---------------------------------------------------------------------------
182
- // Pagination
183
- // ---------------------------------------------------------------------------
184
-
185
- function renderPagination(layout: TableLayout): HTMLDivElement | null {
186
- if (!layout.pagination) return null;
187
-
188
- const { page, pageSize, totalRows, totalPages } = layout.pagination;
189
-
190
- const div = document.createElement('div');
191
- div.className = 'oc-table-pagination';
192
-
193
- const info = document.createElement('span');
194
- info.className = 'oc-table-pagination-info';
195
-
196
- if (totalRows === 0) {
197
- info.textContent = 'No results';
198
- } else {
199
- const start = page * pageSize + 1;
200
- const end = Math.min((page + 1) * pageSize, totalRows);
201
- info.textContent = `Showing ${start}-${end} of ${totalRows}`;
202
- }
203
-
204
- div.appendChild(info);
205
-
206
- const btnGroup = document.createElement('span');
207
- btnGroup.className = 'oc-table-pagination-btns';
208
-
209
- const prevBtn = document.createElement('button');
210
- prevBtn.setAttribute('aria-label', 'Previous page');
211
- prevBtn.setAttribute('data-page-action', 'prev');
212
- prevBtn.textContent = 'Prev';
213
- prevBtn.disabled = page <= 0;
214
- btnGroup.appendChild(prevBtn);
215
-
216
- const nextBtn = document.createElement('button');
217
- nextBtn.setAttribute('aria-label', 'Next page');
218
- nextBtn.setAttribute('data-page-action', 'next');
219
- nextBtn.textContent = 'Next';
220
- nextBtn.disabled = page >= totalPages - 1;
221
- btnGroup.appendChild(nextBtn);
222
-
223
- div.appendChild(btnGroup);
224
- return div;
225
- }
226
-
227
- // ---------------------------------------------------------------------------
228
- // Empty state
229
- // ---------------------------------------------------------------------------
230
-
231
- function renderEmptyState(message: string): HTMLDivElement {
232
- const div = document.createElement('div');
233
- div.className = 'oc-table-empty';
234
- div.setAttribute('aria-live', 'polite');
235
- div.textContent = message;
236
- return div;
237
- }
238
-
239
- // ---------------------------------------------------------------------------
240
- // Main render
241
- // ---------------------------------------------------------------------------
242
-
243
- /**
244
- * Render a TableLayout into a full DOM structure.
245
- *
246
- * @param layout - The compiled table layout.
247
- * @param container - The container element to render into.
248
- * @returns The wrapper element that was created.
249
- */
250
- export function renderTable(
251
- layout: TableLayout,
252
- container: HTMLElement,
253
- opts?: TableRenderOptions,
254
- ): HTMLElement {
255
- const wrapper = document.createElement('div');
256
- wrapper.className = 'oc-table-wrapper';
257
-
258
- // Apply theme colors as CSS custom properties so table CSS picks them up.
259
- // Without this, dark-background themes show invisible text since the
260
- // CSS defaults (--oc-text etc.) are light-mode values.
261
- const { theme, chrome } = layout;
262
- if (theme) {
263
- const s = wrapper.style;
264
- s.setProperty('--oc-bg', theme.colors.background);
265
- s.setProperty('--oc-text', theme.colors.text);
266
- s.setProperty('--oc-text-secondary', theme.colors.axis ?? theme.colors.text);
267
- s.setProperty('--oc-text-muted', theme.colors.axis ?? theme.colors.text);
268
- s.setProperty('--oc-gridline', theme.colors.gridline);
269
- s.setProperty('--oc-border', theme.colors.gridline);
270
- s.setProperty('--oc-font-family', theme.fonts.family);
271
- s.fontFamily = theme.fonts.family;
272
- }
273
-
274
- // Set computed chrome CSS custom properties so chrome elements pick up
275
- // theme-resolved values via CSS fallbacks (e.g. --oc-title-computed-size).
276
- {
277
- const s = wrapper.style;
278
- if (chrome.title) {
279
- s.setProperty('--oc-title-computed-size', `${chrome.title.style.fontSize}px`);
280
- s.setProperty('--oc-title-computed-weight', String(chrome.title.style.fontWeight));
281
- s.setProperty('--oc-title-computed-color', chrome.title.style.fill);
282
- }
283
- if (chrome.subtitle) {
284
- s.setProperty('--oc-subtitle-computed-size', `${chrome.subtitle.style.fontSize}px`);
285
- s.setProperty('--oc-subtitle-computed-weight', String(chrome.subtitle.style.fontWeight));
286
- s.setProperty('--oc-subtitle-computed-color', chrome.subtitle.style.fill);
287
- }
288
- if (chrome.source) {
289
- s.setProperty('--oc-source-computed-size', `${chrome.source.style.fontSize}px`);
290
- s.setProperty('--oc-source-computed-color', chrome.source.style.fill);
291
- }
292
- if (chrome.footer) {
293
- s.setProperty('--oc-footer-computed-size', `${chrome.footer.style.fontSize}px`);
294
- s.setProperty('--oc-footer-computed-color', chrome.footer.style.fill);
295
- }
296
- }
297
-
298
- // Apply class modifiers
299
- if (layout.compact) {
300
- wrapper.classList.add('oc-table--compact');
301
- }
302
-
303
- // Header chrome
304
- const headerChrome = renderChromeBlock(layout, 'header');
305
- if (headerChrome) {
306
- wrapper.appendChild(headerChrome);
307
- }
308
-
309
- // Search bar
310
- const searchBar = renderSearchBar(layout);
311
- if (searchBar) {
312
- wrapper.appendChild(searchBar);
313
- }
314
-
315
- // Handle empty data
316
- if (layout.rows.length === 0) {
317
- const message = layout.search.query ? 'No results found' : 'No data';
318
- wrapper.appendChild(renderEmptyState(message));
319
- } else {
320
- // Scroll container
321
- const scroll = document.createElement('div');
322
- scroll.className = 'oc-table-scroll';
323
-
324
- // Table
325
- const table = document.createElement('table');
326
- table.setAttribute('role', 'grid');
327
- table.setAttribute('aria-label', layout.a11y.caption);
328
-
329
- if (layout.stickyFirstColumn) {
330
- table.classList.add('oc-table--sticky');
331
- }
332
-
333
- // Caption (screen reader only – inline styles ensure hiding even without
334
- // the external stylesheet, e.g. CDN / esm.sh usage)
335
- const caption = document.createElement('caption');
336
- caption.className = 'oc-sr-only';
337
- applySrOnlyStyles(caption);
338
- caption.textContent = layout.a11y.summary;
339
- table.appendChild(caption);
340
-
341
- // Thead
342
- table.appendChild(renderThead(layout.columns, layout.sort));
343
-
344
- // Tbody
345
- table.appendChild(renderTbody(layout.rows, layout.columns));
346
-
347
- scroll.appendChild(table);
348
- wrapper.appendChild(scroll);
349
- }
350
-
351
- // Pagination
352
- const pagination = renderPagination(layout);
353
- if (pagination) {
354
- wrapper.appendChild(pagination);
355
- }
356
-
357
- // Footer row: source/footer chrome on the left, brand watermark on the
358
- // right, laid out on a single line via flex. Built up here and appended
359
- // after the live region so source text and watermark share a baseline.
360
- const footerRow = document.createElement('div');
361
- footerRow.className = 'oc-table-footer-row';
362
-
363
- const footerChrome = renderChromeBlock(layout, 'footer');
364
- if (footerChrome) {
365
- footerRow.appendChild(footerChrome);
366
- }
367
-
368
- // Live region for screen reader announcements (sort changes, search results)
369
- const liveRegion = document.createElement('div');
370
- liveRegion.className = 'oc-table-live-region oc-sr-only';
371
- applySrOnlyStyles(liveRegion);
372
- liveRegion.setAttribute('aria-live', 'polite');
373
- liveRegion.setAttribute('aria-atomic', 'true');
374
- liveRegion.setAttribute('role', 'status');
375
- wrapper.appendChild(liveRegion);
376
-
377
- // Brand watermark — joins the footer row, pushed to the right by the
378
- // flex container so it sits on the same line as the source text.
379
- if (layout.watermark) {
380
- const brandColor = theme ? theme.colors.axis : '#999999';
381
- const brand = document.createElement('div');
382
- brand.className = 'oc-table-ref';
383
- const brandLink = document.createElement('a');
384
- brandLink.href = BRAND_URL;
385
- brandLink.target = '_blank';
386
- brandLink.rel = 'noopener';
387
- brandLink.style.cssText = `font-size: ${BRAND_FONT_SIZE}px; font-weight: 600; color: ${brandColor}; opacity: 0.55; text-decoration: none; font-family: ${theme ? theme.fonts.family : 'sans-serif'};`;
388
- brandLink.textContent = 'tryOpenData.ai';
389
- brand.appendChild(brandLink);
390
- footerRow.appendChild(brand);
391
- }
392
-
393
- // Append the footer row only when it has content (source/footer/watermark).
394
- if (footerRow.childElementCount > 0) {
395
- wrapper.appendChild(footerRow);
396
- }
397
-
398
- // Animation: stamp CSS custom properties and add oc-animate class BEFORE
399
- // DOM insertion to avoid a flash of final state.
400
- if (opts?.animate && layout.animation?.enter) {
401
- const enterPhase = layout.animation.enter;
402
- const rowCount = layout.rows.length;
403
- const stagger = clampStaggerDelay(enterPhase.staggerDelay, rowCount);
404
- stampAnimationVars(wrapper, {
405
- duration: enterPhase.duration,
406
- stagger,
407
- ease: enterPhase.ease,
408
- });
409
- wrapper.classList.add('oc-animate');
410
- }
411
-
412
- container.appendChild(wrapper);
413
- return wrapper;
414
- }
@@ -1,255 +0,0 @@
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)', // intentionally 0.4, not --oc-editable-hover (0.35)
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
- }