@inkra/react 0.1.0 → 0.3.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 (177) hide show
  1. package/README.md +186 -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 +111 -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 +45 -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 +241 -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 +88 -0
  35. package/dist/dropdown-menu/index.d.ts +1 -1
  36. package/dist/dropdown-menu/index.js +3 -2
  37. package/dist/editable/index.d.ts +53 -0
  38. package/dist/editable/index.js +162 -0
  39. package/dist/empty/index.d.ts +21 -0
  40. package/dist/empty/index.js +23 -0
  41. package/dist/flip/index.d.ts +49 -0
  42. package/dist/flip/index.js +138 -0
  43. package/dist/form/EditablePencilText.js +188 -143
  44. package/dist/form/Input.js +1 -1
  45. package/dist/form/Textarea.d.ts +12 -0
  46. package/dist/form/Textarea.js +83 -4
  47. package/dist/form/index.d.ts +1 -1
  48. package/dist/heatmap/index.d.ts +59 -0
  49. package/dist/heatmap/index.js +248 -0
  50. package/dist/hotkey/index.d.ts +37 -0
  51. package/dist/hotkey/index.js +130 -0
  52. package/dist/hotkey/model.d.ts +27 -0
  53. package/dist/hotkey/model.js +97 -0
  54. package/dist/hover-card/index.d.ts +33 -0
  55. package/dist/hover-card/index.js +141 -0
  56. package/dist/image-cropper/index.d.ts +82 -0
  57. package/dist/image-cropper/index.js +261 -0
  58. package/dist/image-cropper/model.d.ts +42 -0
  59. package/dist/image-cropper/model.js +94 -0
  60. package/dist/image-viewer/index.d.ts +79 -0
  61. package/dist/image-viewer/index.js +220 -0
  62. package/dist/image-viewer/model.d.ts +9 -0
  63. package/dist/image-viewer/model.js +18 -0
  64. package/dist/index.d.ts +47 -0
  65. package/dist/index.js +47 -0
  66. package/dist/input-number/index.d.ts +6 -0
  67. package/dist/input-number/index.js +126 -15
  68. package/dist/input-number/model.d.ts +13 -0
  69. package/dist/input-number/model.js +50 -0
  70. package/dist/internal/cache.d.ts +1 -1
  71. package/dist/internal/cache.js +1 -1
  72. package/dist/internal/renderTokens.d.ts +10 -0
  73. package/dist/internal/renderTokens.js +13 -0
  74. package/dist/internal/surfaceRenderer.d.ts +14 -6
  75. package/dist/internal/surfaceRenderer.js +15 -12
  76. package/dist/internal/textRenderer.d.ts +1 -0
  77. package/dist/internal/textRenderer.js +33 -7
  78. package/dist/internal/usePencilSurface.js +16 -8
  79. package/dist/internal/useSelectionDrawing.js +15 -11
  80. package/dist/journey-map/MapScale.js +3 -1
  81. package/dist/journey-map/index.js +3 -1
  82. package/dist/keypad/index.d.ts +23 -0
  83. package/dist/keypad/index.js +63 -0
  84. package/dist/keypad/model.d.ts +20 -0
  85. package/dist/keypad/model.js +33 -0
  86. package/dist/layout/index.d.ts +63 -0
  87. package/dist/layout/index.js +62 -0
  88. package/dist/log/index.d.ts +45 -0
  89. package/dist/log/index.js +159 -0
  90. package/dist/log/model.d.ts +16 -0
  91. package/dist/log/model.js +13 -0
  92. package/dist/node-graph/index.d.ts +123 -0
  93. package/dist/node-graph/index.js +304 -0
  94. package/dist/number-ticker/index.d.ts +31 -0
  95. package/dist/number-ticker/index.js +135 -0
  96. package/dist/number-ticker/model.d.ts +41 -0
  97. package/dist/number-ticker/model.js +163 -0
  98. package/dist/number-ticker/precision.d.ts +7 -0
  99. package/dist/number-ticker/precision.js +134 -0
  100. package/dist/overflow-list/index.d.ts +39 -0
  101. package/dist/overflow-list/index.js +230 -0
  102. package/dist/overflow-list/layout.d.ts +19 -0
  103. package/dist/overflow-list/layout.js +44 -0
  104. package/dist/overlay/Overlay.d.ts +1 -1
  105. package/dist/overlay/Overlay.js +4 -2
  106. package/dist/overlay/Popover.d.ts +1 -1
  107. package/dist/overlay/Popover.js +3 -2
  108. package/dist/overlay/themeBridge.d.ts +12 -1
  109. package/dist/overlay/themeBridge.js +27 -1
  110. package/dist/pagination/index.d.ts +23 -0
  111. package/dist/pagination/index.js +41 -0
  112. package/dist/pagination/model.d.ts +4 -0
  113. package/dist/pagination/model.js +42 -0
  114. package/dist/pan-zoom/index.d.ts +71 -0
  115. package/dist/pan-zoom/index.js +175 -0
  116. package/dist/radar/index.d.ts +56 -0
  117. package/dist/radar/index.js +220 -0
  118. package/dist/range-slider/index.d.ts +16 -0
  119. package/dist/range-slider/index.js +22 -0
  120. package/dist/rating/index.d.ts +28 -0
  121. package/dist/rating/index.js +127 -0
  122. package/dist/resizable/index.d.ts +32 -0
  123. package/dist/resizable/index.js +50 -0
  124. package/dist/scroll-area/index.d.ts +36 -0
  125. package/dist/scroll-area/index.js +44 -0
  126. package/dist/segmented-progress/SegmentedProgress.d.ts +17 -0
  127. package/dist/segmented-progress/SegmentedProgress.js +42 -0
  128. package/dist/slider/index.js +1 -1
  129. package/dist/slot/index.d.ts +24 -0
  130. package/dist/slot/index.js +29 -0
  131. package/dist/sortable/index.d.ts +50 -0
  132. package/dist/sortable/index.js +283 -0
  133. package/dist/sortable/model.d.ts +30 -0
  134. package/dist/sortable/model.js +64 -0
  135. package/dist/sparkline/index.d.ts +19 -0
  136. package/dist/sparkline/index.js +240 -0
  137. package/dist/sparkline/model.d.ts +16 -0
  138. package/dist/sparkline/model.js +28 -0
  139. package/dist/steps/index.d.ts +16 -0
  140. package/dist/steps/index.js +37 -0
  141. package/dist/styles.css +4957 -269
  142. package/dist/tag-input/index.d.ts +69 -0
  143. package/dist/tag-input/index.js +325 -0
  144. package/dist/tag-input/model.d.ts +14 -0
  145. package/dist/tag-input/model.js +38 -0
  146. package/dist/time-picker/index.d.ts +15 -0
  147. package/dist/time-picker/index.js +168 -0
  148. package/dist/timeline/index.d.ts +14 -0
  149. package/dist/timeline/index.js +16 -0
  150. package/dist/toast/index.d.ts +27 -0
  151. package/dist/toast/index.js +36 -0
  152. package/dist/toggle-group/index.d.ts +17 -0
  153. package/dist/toggle-group/index.js +26 -0
  154. package/dist/tokens.css +10 -9
  155. package/dist/tooltip/Tooltip.d.ts +1 -1
  156. package/dist/tooltip/Tooltip.js +3 -2
  157. package/dist/transfer/index.d.ts +64 -0
  158. package/dist/transfer/index.js +174 -0
  159. package/dist/transfer/model.d.ts +12 -0
  160. package/dist/transfer/model.js +33 -0
  161. package/dist/tree/index.d.ts +39 -0
  162. package/dist/tree/index.js +36 -0
  163. package/dist/tree-select/index.d.ts +59 -0
  164. package/dist/tree-select/index.js +142 -0
  165. package/dist/tree-select/model.d.ts +31 -0
  166. package/dist/tree-select/model.js +135 -0
  167. package/dist/typewriter/index.d.ts +21 -0
  168. package/dist/typewriter/index.js +101 -0
  169. package/dist/typewriter/model.d.ts +7 -0
  170. package/dist/typewriter/model.js +45 -0
  171. package/dist/upload/index.d.ts +59 -0
  172. package/dist/upload/index.js +230 -0
  173. package/dist/virtual-list/index.d.ts +44 -0
  174. package/dist/virtual-list/index.js +129 -0
  175. package/dist/virtual-list/model.d.ts +7 -0
  176. package/dist/virtual-list/model.js +27 -0
  177. package/package.json +23 -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,243 @@ 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)
139
+ if (!multiline && content.text.length > MAX_EDITABLE_LINE)
160
140
  return false;
161
- measure.font = `400 ${fontSize}px ${family}`;
162
- measure.fontKerning = 'none';
163
- if (!multiline && Array.from(content.text).length > MAX_EDITABLE_LINE)
164
- 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 numericControl = control instanceof HTMLInputElement && control.type === 'number';
217
+ let nativeNumberEditing = false;
218
+ const appearanceKey = () => {
219
+ const style = getComputedStyle(control);
220
+ return JSON.stringify([
221
+ control.clientWidth, control.clientHeight, control.offsetLeft, control.offsetTop,
222
+ control.scrollLeft, control.scrollTop, control.value, control.placeholder, effectiveDpr(2),
223
+ composingRef.current?.start, composingRef.current?.end, composingRef.current?.value.length,
224
+ ...['font-family', 'font-size', 'line-height', 'text-align', 'text-indent',
225
+ 'letter-spacing', 'white-space', 'word-break', 'overflow-wrap', 'direction', 'tab-size',
226
+ 'padding-top', 'padding-left', 'padding-right', 'padding-bottom',
227
+ '--inkra-letter-variation', '--inkra-letter-breaks', '--inkra-letter-outline',
228
+ '--inkra-letter-fill', '--inkra-letter-grain',
229
+ '--inkra-editable-text-color', '--inkra-editable-placeholder-color'].map(name => style.getPropertyValue(name)),
230
+ ]);
231
+ };
220
232
  const paint = () => {
221
- pending = null;
222
- if (disposed || composingRef.current)
233
+ if (disposed)
234
+ return;
235
+ // Number inputs expose a sanitized value, not incomplete drafts such as "-" or "1.".
236
+ if (numericControl && (nativeNumberEditing || control.validity.badInput || composingRef.current)) {
237
+ root?.removeAttribute('data-pencil-editable-ready');
238
+ lastAppearance = '';
239
+ return;
240
+ }
241
+ const key = appearanceKey();
242
+ if (key === lastAppearance && root?.hasAttribute('data-pencil-editable-ready'))
223
243
  return;
224
244
  try {
225
- const ready = paintEditableText(canvas, control, seed, multiline);
226
- if (ready)
245
+ if (paintEditableText(canvas, buffer, glyphs, control, seed, multiline, composingRef.current)) {
227
246
  root?.setAttribute('data-pencil-editable-ready', 'true');
228
- else
247
+ lastAppearance = key;
248
+ }
249
+ else {
229
250
  root?.removeAttribute('data-pencil-editable-ready');
251
+ lastAppearance = '';
252
+ }
230
253
  }
231
254
  catch {
232
255
  root?.removeAttribute('data-pencil-editable-ready');
256
+ lastAppearance = '';
233
257
  }
234
258
  };
235
- const schedule = () => {
236
- if (disposed || composingRef.current || frame || pending)
259
+ scheduleRef.current = paint;
260
+ const beforeInput = (event) => {
261
+ if (!numericControl || event.defaultPrevented)
237
262
  return;
238
- frame = requestAnimationFrame(() => {
239
- frame = 0;
240
- pending = queueTextPaint(paint);
241
- });
263
+ nativeNumberEditing = true;
264
+ root?.setAttribute('data-pencil-number-editing', 'true');
265
+ paint();
242
266
  };
243
- scheduleRef.current = schedule;
244
- const invalidate = () => {
245
- root?.removeAttribute('data-pencil-editable-ready');
246
- schedule();
267
+ const finishNumberEditing = () => {
268
+ if (!numericControl)
269
+ return;
270
+ nativeNumberEditing = false;
271
+ root?.removeAttribute('data-pencil-number-editing');
272
+ paint();
247
273
  };
248
- const showNativeComposition = () => {
249
- composingRef.current = true;
250
- pending?.();
251
- pending = null;
274
+ const startComposition = () => {
275
+ if (numericControl) {
276
+ nativeNumberEditing = true;
277
+ root?.setAttribute('data-pencil-number-editing', 'true');
278
+ }
279
+ composingRef.current = {
280
+ value: control.value,
281
+ start: control.selectionStart ?? control.value.length,
282
+ end: control.selectionEnd ?? control.value.length,
283
+ };
252
284
  root?.setAttribute('data-composing', 'true');
253
- root?.removeAttribute('data-pencil-editable-ready');
285
+ paint();
254
286
  };
255
287
  const finishComposition = () => {
256
- composingRef.current = false;
288
+ composingRef.current = null;
257
289
  root?.removeAttribute('data-composing');
258
- schedule();
290
+ paint();
259
291
  };
260
- const reset = () => window.setTimeout(invalidate, 0);
292
+ const refreshFonts = () => {
293
+ glyphs = new BoundedCache(128, 4 * 1024 * 1024);
294
+ lastAppearance = '';
295
+ paint();
296
+ };
297
+ const reset = () => queueMicrotask(() => {
298
+ if (disposed)
299
+ return;
300
+ composingRef.current = null;
301
+ root?.removeAttribute('data-composing');
302
+ nativeNumberEditing = false;
303
+ root?.removeAttribute('data-pencil-number-editing');
304
+ refreshFonts();
305
+ });
261
306
  const form = control.form;
262
- const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(invalidate);
307
+ const resize = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(paint);
263
308
  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
- });
309
+ const appearance = new MutationObserver(paint);
310
+ for (let element = control; element; element = element.parentElement) {
311
+ appearance.observe(element, { attributes: true, attributeFilter: ['style', 'class', 'dir', 'data-theme', 'data-inkra-theme'] });
312
+ }
270
313
  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);
314
+ colorScheme.addEventListener('change', paint);
315
+ control.addEventListener('beforeinput', beforeInput);
316
+ control.addEventListener('blur', finishNumberEditing);
317
+ control.addEventListener('input', paint);
318
+ control.addEventListener('scroll', paint, { passive: true });
319
+ control.addEventListener('compositionstart', startComposition);
275
320
  control.addEventListener('compositionend', finishComposition);
276
321
  form?.addEventListener('reset', reset);
277
- document.fonts?.addEventListener('loadingdone', invalidate);
278
- document.fonts?.addEventListener('loadingerror', invalidate);
322
+ document.fonts?.addEventListener('loadingdone', refreshFonts);
323
+ document.fonts?.addEventListener('loadingerror', refreshFonts);
279
324
  document.fonts?.ready.then(() => { if (!disposed)
280
- invalidate(); });
281
- const stopRecovery = observeCanvasRecovery(invalidate);
282
- invalidate();
325
+ refreshFonts(); });
326
+ const stopRecovery = observeCanvasRecovery(refreshFonts);
327
+ paint();
283
328
  return () => {
284
329
  disposed = true;
285
330
  stopRecovery();
286
- pending?.();
287
331
  resize?.disconnect();
288
332
  appearance.disconnect();
289
- colorScheme.removeEventListener('change', invalidate);
290
- control.removeEventListener('input', invalidate);
291
- control.removeEventListener('scroll', invalidate);
292
- control.removeEventListener('compositionstart', showNativeComposition);
333
+ colorScheme.removeEventListener('change', paint);
334
+ control.removeEventListener('beforeinput', beforeInput);
335
+ control.removeEventListener('blur', finishNumberEditing);
336
+ control.removeEventListener('input', paint);
337
+ control.removeEventListener('scroll', paint);
338
+ control.removeEventListener('compositionstart', startComposition);
293
339
  control.removeEventListener('compositionend', finishComposition);
294
340
  form?.removeEventListener('reset', reset);
295
- document.fonts?.removeEventListener('loadingdone', invalidate);
296
- document.fonts?.removeEventListener('loadingerror', invalidate);
297
- cancelAnimationFrame(frame);
341
+ document.fonts?.removeEventListener('loadingdone', refreshFonts);
342
+ document.fonts?.removeEventListener('loadingerror', refreshFonts);
298
343
  root?.removeAttribute('data-pencil-editable-ready');
299
344
  root?.removeAttribute('data-composing');
300
- composingRef.current = false;
345
+ root?.removeAttribute('data-pencil-number-editing');
346
+ composingRef.current = null;
347
+ glyphs = new BoundedCache(0, 0);
348
+ buffer.width = buffer.height = 0;
349
+ canvas.width = canvas.height = 0;
301
350
  scheduleRef.current = () => { };
302
351
  };
303
352
  }, [controlRef, multiline, seed]);
304
353
  useBrowserLayoutEffect(() => {
305
- if (composingRef.current)
306
- return;
307
- controlRef.current?.closest('.inkra-field-control-hit')
308
- ?.removeAttribute('data-pencil-editable-ready');
309
354
  scheduleRef.current();
310
355
  }, [controlRef, defaultValue, placeholder, value]);
311
356
  return _jsx("canvas", { "aria-hidden": "true", className: "inkra-editable-text", "data-renderer": "inkra-editable-lettering", ref: canvasRef });
@@ -7,7 +7,7 @@ import { PencilText } from '../PencilText.js';
7
7
  import { InputAction } from './InputAction.js';
8
8
  import { useResolvedControlProps } from './controlProps.js';
9
9
  import { EditablePencilText } from './EditablePencilText.js';
10
- const editablePencilInputTypes = new Set(['text', 'search', 'email', 'url', 'tel']);
10
+ const editablePencilInputTypes = new Set(['text', 'search', 'email', 'url', 'tel', 'number']);
11
11
  const clearableInputTypes = new Set(['text', 'search', 'email', 'url', 'tel', 'password', 'number']);
12
12
  // Lucide X / LoaderCircle ISC geometry, rendered by the shared pencil engine.
13
13
  const clearPaths = [{ d: 'M18 6 6 18' }, { d: 'm6 6 12 12' }];
@@ -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>>;