@inkra/react 0.1.0 → 0.2.0

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 (160) hide show
  1. package/README.md +104 -1
  2. package/dist/PencilIcon.js +14 -16
  3. package/dist/PencilLine.js +18 -12
  4. package/dist/PencilText.js +192 -59
  5. package/dist/alert-dialog/index.d.ts +64 -0
  6. package/dist/alert-dialog/index.js +109 -0
  7. package/dist/audio-player/index.d.ts +60 -0
  8. package/dist/audio-player/index.js +101 -0
  9. package/dist/carousel/index.d.ts +51 -0
  10. package/dist/carousel/index.js +290 -0
  11. package/dist/choice-grid/index.d.ts +38 -0
  12. package/dist/choice-grid/index.js +85 -0
  13. package/dist/color-picker/color.d.ts +15 -0
  14. package/dist/color-picker/color.js +55 -0
  15. package/dist/color-picker/index.d.ts +31 -0
  16. package/dist/color-picker/index.js +69 -0
  17. package/dist/command/index.d.ts +23 -0
  18. package/dist/command/index.js +39 -0
  19. package/dist/context-menu/index.d.ts +76 -0
  20. package/dist/context-menu/index.js +240 -0
  21. package/dist/data-table/index.d.ts +47 -0
  22. package/dist/data-table/index.js +91 -0
  23. package/dist/date-picker/index.d.ts +50 -0
  24. package/dist/date-picker/index.js +180 -0
  25. package/dist/display/ProgressRing.d.ts +6 -0
  26. package/dist/display/ProgressRing.js +146 -0
  27. package/dist/display/index.d.ts +27 -0
  28. package/dist/display/index.js +18 -2
  29. package/dist/dockable-panel/index.d.ts +41 -0
  30. package/dist/dockable-panel/index.js +253 -0
  31. package/dist/dockable-panel/model.d.ts +21 -0
  32. package/dist/dockable-panel/model.js +61 -0
  33. package/dist/drawer/index.d.ts +63 -0
  34. package/dist/drawer/index.js +86 -0
  35. package/dist/editable/index.d.ts +53 -0
  36. package/dist/editable/index.js +162 -0
  37. package/dist/empty/index.d.ts +21 -0
  38. package/dist/empty/index.js +23 -0
  39. package/dist/flip/index.d.ts +49 -0
  40. package/dist/flip/index.js +138 -0
  41. package/dist/form/EditablePencilText.js +157 -146
  42. package/dist/form/Textarea.d.ts +12 -0
  43. package/dist/form/Textarea.js +83 -4
  44. package/dist/form/index.d.ts +1 -1
  45. package/dist/heatmap/index.d.ts +59 -0
  46. package/dist/heatmap/index.js +248 -0
  47. package/dist/hotkey/index.d.ts +37 -0
  48. package/dist/hotkey/index.js +130 -0
  49. package/dist/hotkey/model.d.ts +27 -0
  50. package/dist/hotkey/model.js +97 -0
  51. package/dist/hover-card/index.d.ts +33 -0
  52. package/dist/hover-card/index.js +141 -0
  53. package/dist/image-cropper/index.d.ts +82 -0
  54. package/dist/image-cropper/index.js +261 -0
  55. package/dist/image-cropper/model.d.ts +42 -0
  56. package/dist/image-cropper/model.js +94 -0
  57. package/dist/image-viewer/index.d.ts +79 -0
  58. package/dist/image-viewer/index.js +220 -0
  59. package/dist/image-viewer/model.d.ts +9 -0
  60. package/dist/image-viewer/model.js +18 -0
  61. package/dist/index.d.ts +47 -0
  62. package/dist/index.js +47 -0
  63. package/dist/internal/cache.d.ts +1 -1
  64. package/dist/internal/cache.js +1 -1
  65. package/dist/internal/renderTokens.d.ts +10 -0
  66. package/dist/internal/renderTokens.js +13 -0
  67. package/dist/internal/surfaceRenderer.d.ts +14 -6
  68. package/dist/internal/surfaceRenderer.js +15 -12
  69. package/dist/internal/textRenderer.d.ts +1 -0
  70. package/dist/internal/textRenderer.js +33 -7
  71. package/dist/internal/usePencilSurface.js +16 -8
  72. package/dist/internal/useSelectionDrawing.js +15 -11
  73. package/dist/journey-map/MapScale.js +3 -1
  74. package/dist/journey-map/index.js +3 -1
  75. package/dist/keypad/index.d.ts +23 -0
  76. package/dist/keypad/index.js +63 -0
  77. package/dist/keypad/model.d.ts +20 -0
  78. package/dist/keypad/model.js +33 -0
  79. package/dist/layout/index.d.ts +63 -0
  80. package/dist/layout/index.js +62 -0
  81. package/dist/log/index.d.ts +45 -0
  82. package/dist/log/index.js +159 -0
  83. package/dist/log/model.d.ts +16 -0
  84. package/dist/log/model.js +13 -0
  85. package/dist/node-graph/index.d.ts +123 -0
  86. package/dist/node-graph/index.js +304 -0
  87. package/dist/number-ticker/index.d.ts +29 -0
  88. package/dist/number-ticker/index.js +132 -0
  89. package/dist/number-ticker/model.d.ts +8 -0
  90. package/dist/number-ticker/model.js +34 -0
  91. package/dist/overflow-list/index.d.ts +39 -0
  92. package/dist/overflow-list/index.js +230 -0
  93. package/dist/overflow-list/layout.d.ts +19 -0
  94. package/dist/overflow-list/layout.js +44 -0
  95. package/dist/pagination/index.d.ts +23 -0
  96. package/dist/pagination/index.js +41 -0
  97. package/dist/pagination/model.d.ts +4 -0
  98. package/dist/pagination/model.js +42 -0
  99. package/dist/pan-zoom/index.d.ts +71 -0
  100. package/dist/pan-zoom/index.js +175 -0
  101. package/dist/radar/index.d.ts +54 -0
  102. package/dist/radar/index.js +220 -0
  103. package/dist/range-slider/index.d.ts +16 -0
  104. package/dist/range-slider/index.js +22 -0
  105. package/dist/rating/index.d.ts +28 -0
  106. package/dist/rating/index.js +127 -0
  107. package/dist/resizable/index.d.ts +32 -0
  108. package/dist/resizable/index.js +50 -0
  109. package/dist/scroll-area/index.d.ts +36 -0
  110. package/dist/scroll-area/index.js +44 -0
  111. package/dist/segmented-progress/SegmentedProgress.d.ts +17 -0
  112. package/dist/segmented-progress/SegmentedProgress.js +42 -0
  113. package/dist/slider/index.js +1 -1
  114. package/dist/slot/index.d.ts +24 -0
  115. package/dist/slot/index.js +29 -0
  116. package/dist/sortable/index.d.ts +50 -0
  117. package/dist/sortable/index.js +278 -0
  118. package/dist/sortable/model.d.ts +30 -0
  119. package/dist/sortable/model.js +64 -0
  120. package/dist/sparkline/index.d.ts +19 -0
  121. package/dist/sparkline/index.js +240 -0
  122. package/dist/sparkline/model.d.ts +16 -0
  123. package/dist/sparkline/model.js +28 -0
  124. package/dist/steps/index.d.ts +16 -0
  125. package/dist/steps/index.js +37 -0
  126. package/dist/styles.css +4850 -287
  127. package/dist/tag-input/index.d.ts +69 -0
  128. package/dist/tag-input/index.js +325 -0
  129. package/dist/tag-input/model.d.ts +14 -0
  130. package/dist/tag-input/model.js +38 -0
  131. package/dist/time-picker/index.d.ts +15 -0
  132. package/dist/time-picker/index.js +168 -0
  133. package/dist/timeline/index.d.ts +14 -0
  134. package/dist/timeline/index.js +16 -0
  135. package/dist/toast/index.d.ts +27 -0
  136. package/dist/toast/index.js +36 -0
  137. package/dist/toggle-group/index.d.ts +17 -0
  138. package/dist/toggle-group/index.js +26 -0
  139. package/dist/tokens.css +10 -9
  140. package/dist/transfer/index.d.ts +64 -0
  141. package/dist/transfer/index.js +174 -0
  142. package/dist/transfer/model.d.ts +12 -0
  143. package/dist/transfer/model.js +33 -0
  144. package/dist/tree/index.d.ts +39 -0
  145. package/dist/tree/index.js +36 -0
  146. package/dist/tree-select/index.d.ts +59 -0
  147. package/dist/tree-select/index.js +142 -0
  148. package/dist/tree-select/model.d.ts +31 -0
  149. package/dist/tree-select/model.js +135 -0
  150. package/dist/typewriter/index.d.ts +21 -0
  151. package/dist/typewriter/index.js +101 -0
  152. package/dist/typewriter/model.d.ts +7 -0
  153. package/dist/typewriter/model.js +45 -0
  154. package/dist/upload/index.d.ts +59 -0
  155. package/dist/upload/index.js +230 -0
  156. package/dist/virtual-list/index.d.ts +44 -0
  157. package/dist/virtual-list/index.js +128 -0
  158. package/dist/virtual-list/model.d.ts +7 -0
  159. package/dist/virtual-list/model.js +27 -0
  160. package/package.json +22 -3
@@ -1,14 +1,14 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useRef } from 'react';
3
3
  import { useBrowserLayoutEffect } from '../internal/useBrowserLayoutEffect.js';
4
- import { effectiveDpr } from '../internal/cache.js';
4
+ import { BoundedCache, canvasByteCost, canvasIsUsable, effectiveDpr } from '../internal/cache.js';
5
5
  import { observeCanvasRecovery } from '../internal/recovery.js';
6
6
  import { seedToNumber } from '../internal/random.js';
7
- import { renderPencilText } from '../internal/textRenderer.js';
8
- import { queueTextPaint } from '../internal/textQueue.js';
7
+ import { pencilLettering, renderPencilText } from '../internal/textRenderer.js';
8
+ import { readRenderToken } from '../internal/renderTokens.js';
9
9
  const MAX_EDITABLE_TEXT = 4096;
10
10
  const MAX_EDITABLE_LINE = 256;
11
- const complexScript = /[\u0590-\u08ff\u0900-\u0dff\u0e00-\u0fff\u1780-\u18af\u200c\u200d]/u;
11
+ const complexScript = /[\u0300-\u036f\u0590-\u08ff\u0900-\u0dff\u0e00-\u0fff\u1780-\u18af\u200c\u200d\ufe00-\ufe0f]/u;
12
12
  function numeric(style, property) {
13
13
  const value = Number.parseFloat(style.getPropertyValue(property));
14
14
  return Number.isFinite(value) ? value : 0;
@@ -17,29 +17,6 @@ function editableText(control) {
17
17
  const placeholder = control.value.length === 0;
18
18
  return { text: placeholder ? control.placeholder : control.value, placeholder };
19
19
  }
20
- function visibleText(text, context, scrollLeft, viewportWidth) {
21
- const characters = Array.from(text);
22
- const overscan = Math.max(16, viewportWidth * 0.2);
23
- const startEdge = Math.max(0, scrollLeft - overscan);
24
- const endEdge = scrollLeft + viewportWidth + overscan;
25
- let offset = 0;
26
- let start = 0;
27
- let end = characters.length;
28
- for (let index = 0; index < characters.length; index += 1) {
29
- const width = context.measureText(characters[index]).width;
30
- if (offset + width < startEdge)
31
- start = index + 1;
32
- if (offset <= endEdge)
33
- end = index + 1;
34
- offset += width;
35
- if (offset > endEdge)
36
- break;
37
- }
38
- const visible = characters.slice(start, end).join('');
39
- if (Array.from(visible).length > MAX_EDITABLE_LINE)
40
- return null;
41
- return { text: visible, offset: context.measureText(characters.slice(0, start).join('')).width };
42
- }
43
20
  function copyMirrorStyle(mirror, control, style) {
44
21
  Object.assign(mirror.style, {
45
22
  boxSizing: 'border-box',
@@ -92,7 +69,7 @@ function measureTextareaLines(control, text, style) {
92
69
  const rectangle = range.getBoundingClientRect();
93
70
  let line = lines[lines.length - 1];
94
71
  if (!line || Math.abs(line.top - rectangle.top) > 1) {
95
- line = { text: '', left: rectangle.left - mirrorBox.left, top: rectangle.top - mirrorBox.top };
72
+ line = { text: '', left: rectangle.left - mirrorBox.left, top: rectangle.top - mirrorBox.top, start: offset - character.length };
96
73
  lines.push(line);
97
74
  }
98
75
  line.text += character;
@@ -119,22 +96,17 @@ function textStart(direction, textAlign, controlWidth, paddingStart, paddingEnd,
119
96
  return controlWidth - paddingEnd - advance;
120
97
  return paddingStart;
121
98
  }
122
- function paintEditableText(canvas, control, seed, multiline) {
99
+ function paintEditableText(canvas, buffer, glyphs, control, seed, multiline, composition) {
123
100
  const width = control.clientWidth;
124
101
  const height = control.clientHeight;
125
- const context = canvas.getContext('2d');
126
- if (!context || context.isContextLost?.() || width < 2 || height < 2)
102
+ const target = canvas.getContext('2d');
103
+ const context = buffer.getContext('2d');
104
+ const dpr = effectiveDpr(2);
105
+ if (!target || target.isContextLost?.() || !context || context.isContextLost?.()
106
+ || width < 2 || height < 2 || width * height * dpr ** 2 > 2 * 1024 * 1024)
127
107
  return false;
128
- Object.assign(canvas.style, {
129
- left: `${control.offsetLeft}px`,
130
- top: `${control.offsetTop}px`,
131
- right: 'auto',
132
- bottom: 'auto',
133
- width: `${width}px`,
134
- height: `${height}px`,
135
- });
136
108
  const content = editableText(control);
137
- if (Array.from(content.text).length > MAX_EDITABLE_TEXT)
109
+ if (content.text.length > MAX_EDITABLE_TEXT)
138
110
  return false;
139
111
  const style = getComputedStyle(control);
140
112
  if (style.direction === 'rtl' || complexScript.test(content.text))
@@ -142,170 +114,209 @@ function paintEditableText(canvas, control, seed, multiline) {
142
114
  const fontSize = Number.parseFloat(style.fontSize);
143
115
  if (!Number.isFinite(fontSize) || fontSize <= 0)
144
116
  return false;
145
- const dpr = effectiveDpr(2);
146
- canvas.width = Math.ceil(width * dpr);
147
- canvas.height = Math.ceil(height * dpr);
148
- context.setTransform(dpr, 0, 0, dpr, 0, 0);
149
- context.clearRect(0, 0, width, height);
150
- if (!content.text)
151
- return true;
152
117
  const family = style.fontFamily;
153
118
  const color = content.placeholder
154
119
  ? style.getPropertyValue('--inkra-editable-placeholder-color').trim() || style.color
155
120
  : style.getPropertyValue('--inkra-editable-text-color').trim() || style.color;
121
+ const settings = {
122
+ variation: readRenderToken(style, '--inkra-letter-variation', pencilLettering.variation, 0, 1),
123
+ breaks: readRenderToken(style, '--inkra-letter-breaks', pencilLettering.breaks, 0, 1),
124
+ outline: readRenderToken(style, '--inkra-letter-outline', pencilLettering.outline, 0, 1),
125
+ fill: readRenderToken(style, '--inkra-letter-fill', pencilLettering.fill, 0, 1),
126
+ grain: readRenderToken(style, '--inkra-letter-grain', pencilLettering.grain, 0, 1),
127
+ };
128
+ buffer.width = Math.ceil(width * dpr);
129
+ buffer.height = Math.ceil(height * dpr);
130
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
131
+ context.font = `400 ${fontSize}px ${family}`;
132
+ context.fontKerning = 'none';
133
+ context.fillStyle = color;
134
+ const metrics = context.measureText('Mg');
135
+ const ascent = metrics.fontBoundingBoxAscent ?? fontSize;
136
+ const descent = metrics.fontBoundingBoxDescent ?? fontSize * 0.25;
156
137
  const paddingStart = numeric(style, 'padding-inline-start') || numeric(style, 'padding-left');
157
138
  const paddingEnd = numeric(style, 'padding-inline-end') || numeric(style, 'padding-right');
158
- const measure = document.createElement('canvas').getContext('2d');
159
- if (!measure)
160
- return false;
161
- measure.font = `400 ${fontSize}px ${family}`;
162
- measure.fontKerning = 'none';
163
- if (!multiline && Array.from(content.text).length > MAX_EDITABLE_LINE)
139
+ if (!multiline && content.text.length > MAX_EDITABLE_LINE)
164
140
  return false;
165
- if (!multiline && style.direction === 'rtl'
166
- && measure.measureText(content.text).width > control.clientWidth - paddingStart - paddingEnd)
167
- return false;
168
- const measuredLines = multiline
141
+ const lines = multiline
169
142
  ? measureTextareaLines(control, content.text, style)
170
- : [{ text: content.text, left: 0, top: 0 }];
171
- if (!measuredLines)
143
+ : [{ text: content.text, left: 0, top: 0, start: 0 }];
144
+ if (!lines)
172
145
  return false;
173
146
  const baseSeed = seedToNumber(seed);
174
- context.save();
175
- context.beginPath();
176
- context.rect(0, 0, width, height);
177
- context.clip();
178
- for (let index = 0; index < measuredLines.length; index += 1) {
179
- const line = measuredLines[index];
180
- const visible = multiline && control.wrap !== 'off'
181
- ? { text: line.text, offset: 0 }
182
- : visibleText(line.text, measure, control.scrollLeft, width - paddingStart - paddingEnd);
183
- if (!visible?.text)
184
- continue;
185
- const sprite = renderPencilText(visible.text, {
186
- size: fontSize,
187
- family,
188
- color,
189
- seed: baseSeed + index * 131,
190
- dpr,
191
- cache: false,
192
- });
193
- const start = multiline
194
- ? line.left
195
- : textStart(style.direction, style.textAlign, control.clientWidth, paddingStart, paddingEnd, sprite.advance);
196
- const x = start + visible.offset - control.scrollLeft - sprite.padding;
197
- const y = multiline
198
- ? line.top - control.scrollTop - sprite.padding
199
- : (control.clientHeight - sprite.height) / 2;
200
- if (y > height || y + sprite.height < 0)
147
+ const glyphAppearance = JSON.stringify([fontSize, family, color, baseSeed, dpr, settings]);
148
+ const composingStart = composition?.start ?? -1;
149
+ const composingEnd = composition
150
+ ? Math.max(composition.start, control.value.length - (composition.value.length - composition.end))
151
+ : -1;
152
+ for (const line of lines) {
153
+ const baseline = multiline
154
+ ? line.top - control.scrollTop + ascent
155
+ : (height - ascent - descent) / 2 + ascent;
156
+ if (baseline - ascent > height || baseline + descent < 0)
201
157
  continue;
202
- context.drawImage(sprite.ink, x, y, sprite.width, sprite.height);
158
+ let left = (multiline ? line.left : textStart(style.direction, style.textAlign, width, paddingStart, paddingEnd, context.measureText(line.text).width)) - control.scrollLeft;
159
+ let offset = line.start;
160
+ for (const character of line.text) {
161
+ const advance = character === '\n' || character === '\r' ? 0
162
+ : character === '\t' ? numeric(style, 'tab-size') * context.measureText(' ').width
163
+ : context.measureText(character).width;
164
+ if (advance && left + advance >= 0 && left <= width) {
165
+ if (composition && !content.placeholder && offset >= composingStart && offset < composingEnd) {
166
+ // Keep preedit visibly underlined without changing the native IME session.
167
+ context.fillText(character, left, baseline);
168
+ context.fillRect(left, baseline + 2, advance, 1 / dpr);
169
+ }
170
+ else if (character.trim()) {
171
+ const key = glyphAppearance + character;
172
+ let glyph = glyphs.get(key);
173
+ if (!glyph || !canvasIsUsable(glyph.ink)) {
174
+ glyph = renderPencilText(character, {
175
+ size: fontSize, family, color, seed: baseSeed ^ seedToNumber(character),
176
+ dpr, settings, cache: false,
177
+ });
178
+ glyphs.set(key, glyph, canvasByteCost(glyph.ink));
179
+ }
180
+ context.drawImage(glyph.ink, left - glyph.padding, baseline - glyph.ascent - glyph.padding, glyph.width, glyph.height);
181
+ }
182
+ }
183
+ left += advance;
184
+ offset += character.length;
185
+ }
203
186
  }
204
- context.restore();
187
+ // Commit only a complete frame. The visible canvas never waits empty for idle work.
188
+ if (canvas.width !== buffer.width)
189
+ canvas.width = buffer.width;
190
+ if (canvas.height !== buffer.height)
191
+ canvas.height = buffer.height;
192
+ target.setTransform(1, 0, 0, 1, 0, 0);
193
+ target.clearRect(0, 0, canvas.width, canvas.height);
194
+ target.drawImage(buffer, 0, 0);
195
+ Object.assign(canvas.style, {
196
+ left: `${control.offsetLeft}px`, top: `${control.offsetTop}px`,
197
+ right: 'auto', bottom: 'auto', width: `${width}px`, height: `${height}px`,
198
+ });
205
199
  return true;
206
200
  }
207
201
  export function EditablePencilText({ controlRef, defaultValue, multiline = false, placeholder, seed, value, }) {
208
202
  const canvasRef = useRef(null);
209
203
  const scheduleRef = useRef(() => { });
210
- const composingRef = useRef(false);
204
+ const composingRef = useRef(null);
211
205
  useBrowserLayoutEffect(() => {
212
206
  const control = controlRef.current;
213
207
  const canvas = canvasRef.current;
214
208
  if (!control || !canvas)
215
209
  return undefined;
216
210
  const root = control.closest('.inkra-field-control-hit');
217
- let frame = 0;
218
- let pending = null;
211
+ const buffer = document.createElement('canvas');
212
+ // Private, bounded, per-editor glyphs. Never enter the shared text cache.
213
+ let glyphs = new BoundedCache(128, 4 * 1024 * 1024);
219
214
  let disposed = false;
215
+ let lastAppearance = '';
216
+ const appearanceKey = () => {
217
+ const style = getComputedStyle(control);
218
+ return JSON.stringify([
219
+ control.clientWidth, control.clientHeight, control.offsetLeft, control.offsetTop,
220
+ control.scrollLeft, control.scrollTop, control.value, control.placeholder, effectiveDpr(2),
221
+ composingRef.current?.start, composingRef.current?.end, composingRef.current?.value.length,
222
+ ...['font-family', 'font-size', 'line-height', 'text-align', 'text-indent',
223
+ 'letter-spacing', 'white-space', 'word-break', 'overflow-wrap', 'direction', 'tab-size',
224
+ 'padding-top', 'padding-left', 'padding-right', 'padding-bottom',
225
+ '--inkra-letter-variation', '--inkra-letter-breaks', '--inkra-letter-outline',
226
+ '--inkra-letter-fill', '--inkra-letter-grain',
227
+ '--inkra-editable-text-color', '--inkra-editable-placeholder-color'].map(name => style.getPropertyValue(name)),
228
+ ]);
229
+ };
220
230
  const paint = () => {
221
- pending = null;
222
- if (disposed || composingRef.current)
231
+ if (disposed)
232
+ return;
233
+ const key = appearanceKey();
234
+ if (key === lastAppearance && root?.hasAttribute('data-pencil-editable-ready'))
223
235
  return;
224
236
  try {
225
- const ready = paintEditableText(canvas, control, seed, multiline);
226
- if (ready)
237
+ if (paintEditableText(canvas, buffer, glyphs, control, seed, multiline, composingRef.current)) {
227
238
  root?.setAttribute('data-pencil-editable-ready', 'true');
228
- else
239
+ lastAppearance = key;
240
+ }
241
+ else {
229
242
  root?.removeAttribute('data-pencil-editable-ready');
243
+ lastAppearance = '';
244
+ }
230
245
  }
231
246
  catch {
232
247
  root?.removeAttribute('data-pencil-editable-ready');
248
+ lastAppearance = '';
233
249
  }
234
250
  };
235
- const schedule = () => {
236
- if (disposed || composingRef.current || frame || pending)
237
- return;
238
- frame = requestAnimationFrame(() => {
239
- frame = 0;
240
- pending = queueTextPaint(paint);
241
- });
242
- };
243
- scheduleRef.current = schedule;
244
- const invalidate = () => {
245
- root?.removeAttribute('data-pencil-editable-ready');
246
- schedule();
247
- };
248
- const showNativeComposition = () => {
249
- composingRef.current = true;
250
- pending?.();
251
- pending = null;
251
+ scheduleRef.current = paint;
252
+ const startComposition = () => {
253
+ composingRef.current = {
254
+ value: control.value,
255
+ start: control.selectionStart ?? control.value.length,
256
+ end: control.selectionEnd ?? control.value.length,
257
+ };
252
258
  root?.setAttribute('data-composing', 'true');
253
- root?.removeAttribute('data-pencil-editable-ready');
259
+ paint();
254
260
  };
255
261
  const finishComposition = () => {
256
- composingRef.current = false;
262
+ composingRef.current = null;
257
263
  root?.removeAttribute('data-composing');
258
- schedule();
264
+ paint();
265
+ };
266
+ const refreshFonts = () => {
267
+ glyphs = new BoundedCache(128, 4 * 1024 * 1024);
268
+ lastAppearance = '';
269
+ paint();
259
270
  };
260
- const reset = () => window.setTimeout(invalidate, 0);
271
+ const reset = () => queueMicrotask(() => {
272
+ if (disposed)
273
+ return;
274
+ composingRef.current = null;
275
+ root?.removeAttribute('data-composing');
276
+ refreshFonts();
277
+ });
261
278
  const form = control.form;
262
- const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(invalidate);
279
+ const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(paint);
263
280
  resize?.observe(control);
264
- const appearance = new MutationObserver(invalidate);
265
- appearance.observe(document.documentElement, {
266
- attributes: true,
267
- subtree: true,
268
- attributeFilter: ['data-theme', 'data-inkra-theme'],
269
- });
281
+ const appearance = new MutationObserver(paint);
282
+ for (let element = control; element; element = element.parentElement) {
283
+ appearance.observe(element, { attributes: true, attributeFilter: ['style', 'class', 'dir', 'data-theme', 'data-inkra-theme'] });
284
+ }
270
285
  const colorScheme = window.matchMedia('(prefers-color-scheme: dark)');
271
- colorScheme.addEventListener('change', invalidate);
272
- control.addEventListener('input', invalidate);
273
- control.addEventListener('scroll', invalidate, { passive: true });
274
- control.addEventListener('compositionstart', showNativeComposition);
286
+ colorScheme.addEventListener('change', paint);
287
+ control.addEventListener('input', paint);
288
+ control.addEventListener('scroll', paint, { passive: true });
289
+ control.addEventListener('compositionstart', startComposition);
275
290
  control.addEventListener('compositionend', finishComposition);
276
291
  form?.addEventListener('reset', reset);
277
- document.fonts?.addEventListener('loadingdone', invalidate);
278
- document.fonts?.addEventListener('loadingerror', invalidate);
292
+ document.fonts?.addEventListener('loadingdone', refreshFonts);
293
+ document.fonts?.addEventListener('loadingerror', refreshFonts);
279
294
  document.fonts?.ready.then(() => { if (!disposed)
280
- invalidate(); });
281
- const stopRecovery = observeCanvasRecovery(invalidate);
282
- invalidate();
295
+ refreshFonts(); });
296
+ const stopRecovery = observeCanvasRecovery(refreshFonts);
297
+ paint();
283
298
  return () => {
284
299
  disposed = true;
285
300
  stopRecovery();
286
- pending?.();
287
301
  resize?.disconnect();
288
302
  appearance.disconnect();
289
- colorScheme.removeEventListener('change', invalidate);
290
- control.removeEventListener('input', invalidate);
291
- control.removeEventListener('scroll', invalidate);
292
- control.removeEventListener('compositionstart', showNativeComposition);
303
+ colorScheme.removeEventListener('change', paint);
304
+ control.removeEventListener('input', paint);
305
+ control.removeEventListener('scroll', paint);
306
+ control.removeEventListener('compositionstart', startComposition);
293
307
  control.removeEventListener('compositionend', finishComposition);
294
308
  form?.removeEventListener('reset', reset);
295
- document.fonts?.removeEventListener('loadingdone', invalidate);
296
- document.fonts?.removeEventListener('loadingerror', invalidate);
297
- cancelAnimationFrame(frame);
309
+ document.fonts?.removeEventListener('loadingdone', refreshFonts);
310
+ document.fonts?.removeEventListener('loadingerror', refreshFonts);
298
311
  root?.removeAttribute('data-pencil-editable-ready');
299
312
  root?.removeAttribute('data-composing');
300
- composingRef.current = false;
313
+ composingRef.current = null;
314
+ glyphs = new BoundedCache(0, 0);
315
+ buffer.width = buffer.height = 0;
301
316
  scheduleRef.current = () => { };
302
317
  };
303
318
  }, [controlRef, multiline, seed]);
304
319
  useBrowserLayoutEffect(() => {
305
- if (composingRef.current)
306
- return;
307
- controlRef.current?.closest('.inkra-field-control-hit')
308
- ?.removeAttribute('data-pencil-editable-ready');
309
320
  scheduleRef.current();
310
321
  }, [controlRef, defaultValue, placeholder, value]);
311
322
  return _jsx("canvas", { "aria-hidden": "true", className: "inkra-editable-text", "data-renderer": "inkra-editable-lettering", ref: canvasRef });
@@ -1,5 +1,17 @@
1
1
  import type { TextareaHTMLAttributes } from 'react';
2
2
  import type { FieldControlStateProps } from './types.js';
3
+ export interface TextareaCountDetails {
4
+ count: number;
5
+ maxLength: number;
6
+ remaining: number;
7
+ }
3
8
  export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement>, FieldControlStateProps {
9
+ countMode?: 'remaining' | 'fraction' | 'progress' | 'none';
10
+ countDisplay?: 'auto' | 'always';
11
+ /** Ratio of maxLength at which the automatic counter appears. */
12
+ countThreshold?: number;
13
+ getCountLabel?: (details: TextareaCountDetails) => string;
14
+ resize?: 'none' | 'vertical';
15
+ resizeLabel?: string;
4
16
  }
5
17
  export declare const Textarea: import("react").ForwardRefExoticComponent<TextareaProps & import("react").RefAttributes<HTMLTextAreaElement>>;
@@ -1,14 +1,64 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useId, useImperativeHandle, useRef } from 'react';
2
+ import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState } from 'react';
3
3
  import { PencilSurface } from '../PencilSurface.js';
4
+ import { PencilText } from '../PencilText.js';
5
+ import { PencilIcon } from '../PencilIcon.js';
6
+ import { Button } from '../Button.js';
7
+ import { Progress } from '../display/index.js';
4
8
  import { useResolvedControlProps } from './controlProps.js';
5
9
  import { EditablePencilText } from './EditablePencilText.js';
6
- export const Textarea = forwardRef(function Textarea({ 'aria-describedby': ariaDescribedBy, 'aria-errormessage': ariaErrorMessage, 'aria-invalid': ariaInvalid, className, disabled, id, invalid = false, readOnly, required, seed, size = 'md', ...props }, ref) {
10
+ const resizePaths = [{ d: 'M12 3v18M8 7l4-4 4 4M8 17l4 4 4-4' }];
11
+ const defaultCountLabel = ({ remaining }) => remaining < 0 ? `${-remaining} over limit` : `${remaining} remaining`;
12
+ export const Textarea = forwardRef(function Textarea({ 'aria-describedby': ariaDescribedBy, 'aria-errormessage': ariaErrorMessage, 'aria-invalid': ariaInvalid, className, disabled, id, invalid = false, readOnly, required, seed, size = 'md', countMode = 'remaining', countDisplay = 'auto', countThreshold = 0.8, getCountLabel = defaultCountLabel, resize = 'none', resizeLabel = 'Resize text area', ...props }, ref) {
7
13
  const generatedId = useId();
8
14
  const textareaRef = useRef(null);
15
+ const [uncontrolledCount, setUncontrolledCount] = useState(() => String(props.defaultValue ?? '').length);
16
+ const [height, setHeight] = useState();
17
+ const [measuredHeight, setMeasuredHeight] = useState();
18
+ const drag = useRef(null);
19
+ const controlled = props.value != null;
20
+ const count = controlled ? String(props.value).length : uncontrolledCount;
21
+ const maximum = props.maxLength;
22
+ const hasCount = countMode !== 'none' && typeof maximum === 'number' && Number.isFinite(maximum) && maximum >= 0;
23
+ const threshold = Number.isFinite(countThreshold) ? Math.max(0, Math.min(1, countThreshold)) : 0.8;
24
+ const showCount = hasCount && (countDisplay === 'always' || count >= maximum * threshold);
25
+ const countId = `inkra-textarea-${generatedId}-count`;
26
+ const controlId = id ?? `inkra-textarea-${generatedId}`;
27
+ const remaining = hasCount ? maximum - count : 0;
28
+ const countLabel = hasCount ? getCountLabel({ count, maxLength: maximum, remaining }) : '';
29
+ const minimumHeight = size === 'sm' ? 80 : size === 'lg' ? 112 : 96;
30
+ const resizeTo = (next) => setHeight(Math.max(minimumHeight, Math.min(1200, next)));
31
+ useEffect(() => {
32
+ const control = textareaRef.current;
33
+ if (resize !== 'vertical' || !control)
34
+ return;
35
+ const measure = () => setMeasuredHeight(control.getBoundingClientRect().height);
36
+ measure();
37
+ if (typeof ResizeObserver === 'undefined')
38
+ return;
39
+ const observer = new ResizeObserver(measure);
40
+ observer.observe(control);
41
+ return () => observer.disconnect();
42
+ }, [resize]);
43
+ useEffect(() => {
44
+ const control = textareaRef.current;
45
+ const form = control?.form;
46
+ let frame = 0;
47
+ const reset = () => {
48
+ cancelAnimationFrame(frame);
49
+ frame = requestAnimationFrame(() => setUncontrolledCount(control?.value.length ?? 0));
50
+ };
51
+ form?.addEventListener('reset', reset);
52
+ if (!controlled)
53
+ setUncontrolledCount(control?.value.length ?? 0);
54
+ return () => {
55
+ cancelAnimationFrame(frame);
56
+ form?.removeEventListener('reset', reset);
57
+ };
58
+ }, [controlled, props.defaultValue, props.form]);
9
59
  useImperativeHandle(ref, () => textareaRef.current);
10
60
  const state = useResolvedControlProps({
11
- id,
61
+ id: controlId,
12
62
  required,
13
63
  disabled,
14
64
  readOnly,
@@ -19,5 +69,34 @@ export const Textarea = forwardRef(function Textarea({ 'aria-describedby': ariaD
19
69
  });
20
70
  const resolvedSeed = seed ?? `inkra-textarea-${state.id ?? generatedId}`;
21
71
  const classes = ['inkra-textarea', className].filter(Boolean).join(' ');
22
- return (_jsx("span", { className: "inkra-field-control-hit inkra-field-control-hit--textarea", "data-disabled": state.disabled || undefined, "data-invalid": state.invalid || undefined, "data-readonly": state.readOnly || undefined, "data-size": size, children: _jsxs(PencilSurface, { as: "span", className: "inkra-field-control inkra-field-control--textarea", seed: resolvedSeed, variant: state.invalid ? 'danger' : 'outline', children: [_jsx("textarea", { ...props, "aria-describedby": state.describedBy, "aria-errormessage": state.errorMessage, "aria-invalid": state.ariaInvalid, className: classes, disabled: state.disabled, id: state.id, readOnly: state.readOnly, ref: textareaRef, required: state.required }), _jsx(EditablePencilText, { controlRef: textareaRef, defaultValue: props.defaultValue, multiline: true, placeholder: props.placeholder, seed: `${resolvedSeed}-editable`, value: props.value })] }) }));
72
+ return (_jsx("div", { className: "inkra-field-control-hit inkra-field-control-hit--textarea", "data-disabled": state.disabled || undefined, "data-invalid": state.invalid || undefined, "data-readonly": state.readOnly || undefined, "data-size": size, children: _jsxs(PencilSurface, { className: "inkra-field-control inkra-field-control--textarea", seed: resolvedSeed, variant: state.invalid ? 'danger' : 'outline', children: [_jsx("textarea", { ...props, "aria-describedby": [state.describedBy, showCount && countId].filter(Boolean).join(' ') || undefined, "aria-errormessage": state.errorMessage, "aria-invalid": state.ariaInvalid, className: classes, disabled: state.disabled, id: state.id, readOnly: state.readOnly, ref: textareaRef, required: state.required, style: { ...props.style, ...(resize === 'vertical' && height !== undefined ? { height } : {}), resize: 'none' }, onInput: event => {
73
+ if (!controlled)
74
+ setUncontrolledCount(event.currentTarget.value.length);
75
+ props.onInput?.(event);
76
+ } }), _jsx(EditablePencilText, { controlRef: textareaRef, defaultValue: props.defaultValue, multiline: true, placeholder: props.placeholder, seed: `${resolvedSeed}-editable`, value: props.value }), (hasCount || resize === 'vertical') && _jsxs("div", { className: "inkra-textarea__footer", children: [hasCount && _jsxs("div", { className: "inkra-textarea__count", id: countId, "data-visible": showCount, "data-limit": remaining <= 0 ? 'reached' : 'near', "aria-hidden": !showCount || undefined, children: [countMode === 'progress' && _jsx(Progress, { size: "sm", value: maximum === 0 ? 1 : count, max: maximum === 0 ? 1 : maximum, "aria-label": countLabel, "aria-valuetext": countLabel }), _jsx(PencilText, { children: countMode === 'fraction' ? `${count} / ${maximum}` : countLabel })] }), resize === 'vertical' && _jsx(Button, { className: "inkra-textarea__resize", variant: "ghost", size: "icon", role: "slider", "aria-label": resizeLabel, title: resizeLabel, "aria-controls": state.id, "aria-orientation": "vertical", "aria-valuemin": minimumHeight, "aria-valuemax": 1200, "aria-valuenow": Math.round(measuredHeight ?? minimumHeight), disabled: state.disabled, onPointerDown: event => {
77
+ if (event.button !== 0)
78
+ return;
79
+ drag.current = { y: event.clientY, height: textareaRef.current?.getBoundingClientRect().height ?? minimumHeight };
80
+ event.currentTarget.setPointerCapture(event.pointerId);
81
+ }, onPointerMove: event => {
82
+ if (drag.current)
83
+ resizeTo(drag.current.height + event.clientY - drag.current.y);
84
+ }, onPointerUp: event => {
85
+ drag.current = null;
86
+ if (event.currentTarget.hasPointerCapture(event.pointerId))
87
+ event.currentTarget.releasePointerCapture(event.pointerId);
88
+ }, onPointerCancel: () => { drag.current = null; }, onLostPointerCapture: () => { drag.current = null; }, onKeyDown: event => {
89
+ const current = textareaRef.current?.getBoundingClientRect().height ?? minimumHeight;
90
+ if (event.key === 'ArrowUp')
91
+ resizeTo(current - 20);
92
+ else if (event.key === 'ArrowDown')
93
+ resizeTo(current + 20);
94
+ else if (event.key === 'Home')
95
+ resizeTo(minimumHeight);
96
+ else if (event.key === 'End')
97
+ resizeTo(1200);
98
+ else
99
+ return;
100
+ event.preventDefault();
101
+ }, children: _jsx(PencilIcon, { paths: resizePaths, size: 22 }) })] })] }) }));
23
102
  });
@@ -3,5 +3,5 @@ export type { FieldProps } from './Field.js';
3
3
  export { Input } from './Input.js';
4
4
  export type { InputProps, InputVariant, InputTextAlign, InputValueChangeDetails, InputClassNames } from './Input.js';
5
5
  export { Textarea } from './Textarea.js';
6
- export type { TextareaProps } from './Textarea.js';
6
+ export type { TextareaProps, TextareaCountDetails } from './Textarea.js';
7
7
  export type { FieldControlSize, FieldControlStateProps } from './types.js';
@@ -0,0 +1,59 @@
1
+ import type { HTMLAttributes, ReactNode } from 'react';
2
+ export type HeatmapKey = string | number;
3
+ export type HeatmapScaleType = 'continuous' | 'quantize';
4
+ export interface HeatmapAxisItem {
5
+ id: HeatmapKey;
6
+ label: ReactNode;
7
+ ariaLabel?: string;
8
+ }
9
+ export interface HeatmapCell {
10
+ rowId: HeatmapKey;
11
+ columnId: HeatmapKey;
12
+ value?: number | null;
13
+ disabled?: boolean;
14
+ data?: unknown;
15
+ }
16
+ export interface HeatmapSelection {
17
+ rowId: HeatmapKey;
18
+ columnId: HeatmapKey;
19
+ }
20
+ export interface HeatmapCellContext {
21
+ row: HeatmapAxisItem;
22
+ column: HeatmapAxisItem;
23
+ color: string | null;
24
+ missing: boolean;
25
+ selected: boolean;
26
+ active: boolean;
27
+ }
28
+ export interface HeatmapDetailsContext extends HeatmapCellContext {
29
+ valueLabel: string;
30
+ }
31
+ export interface HeatmapProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onChange'> {
32
+ rows: readonly HeatmapAxisItem[];
33
+ columns: readonly HeatmapAxisItem[];
34
+ cells: readonly HeatmapCell[];
35
+ label: ReactNode;
36
+ description?: ReactNode;
37
+ scaleType?: HeatmapScaleType;
38
+ domain?: readonly [number, number];
39
+ colorRange?: readonly string[];
40
+ binCount?: number;
41
+ showLegend?: boolean;
42
+ showValues?: boolean;
43
+ legendLabel?: ReactNode;
44
+ missingLabel?: ReactNode;
45
+ missingValueLabel?: string;
46
+ selectionMode?: 'single' | 'none';
47
+ selectedCell?: HeatmapSelection | null;
48
+ defaultSelectedCell?: HeatmapSelection | null;
49
+ onSelectedCellChange?: (selection: HeatmapSelection | null, cell: HeatmapCell | null) => void;
50
+ getValueLabel?: (value: number) => string;
51
+ getCellAriaLabel?: (cell: HeatmapCell, context: HeatmapDetailsContext) => string;
52
+ renderCell?: (cell: HeatmapCell, context: HeatmapCellContext) => ReactNode;
53
+ renderDetails?: (cell: HeatmapCell, context: HeatmapDetailsContext) => ReactNode;
54
+ cellSize?: number | string;
55
+ rowLabelWidth?: number | string;
56
+ rowHeaderLabel?: string;
57
+ emptyMessage?: ReactNode;
58
+ }
59
+ export declare const Heatmap: import("react").ForwardRefExoticComponent<HeatmapProps & import("react").RefAttributes<HTMLDivElement>>;