@nucleux/md3-handwriting 0.0.0-stage → 0.6.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.
- package/LICENSE +21 -0
- package/README.md +34 -2
- package/dist/index.cjs +598 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +21 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +576 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nucleux
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nucleux/md3-handwriting
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Material 3 Handwriting Input — write the request by hand, settle uncertain words, add it yourself — Nucleux agentic UI for React.
|
|
4
|
+
|
|
5
|
+
Part of **[Nucleux](https://github.com/intelligaia-open-labs/nucleux)** — agentic UI components for React. Install a single component, or the full kit via [`@nucleux/react`](https://www.npmjs.com/package/@nucleux/react).
|
|
6
|
+
|
|
7
|
+
This is the Material 3 twin of [`@nucleux/handwriting`](https://www.npmjs.com/package/@nucleux/handwriting) (`HandwritingInput`). It shares the twin's API exactly — the props types are the twin's own — so you can switch families by changing only the import. Only the look differs: MD3 colour roles, shape, elevation, state layers and motion (stilled under reduced motion).
|
|
8
|
+
|
|
9
|
+
## Exports
|
|
10
|
+
|
|
11
|
+
- **`Md3HandwritingInput`** — draw-pad / live-ocr / annotation-stroke; the reading sits under the ink, uncertain words are “?” buttons with an MD3 menu of alternatives, and Add to message waits until they are settled
|
|
12
|
+
- Types: `Md3HandwritingInputProps`, `Md3HandwritingInputVariant`, `Md3HandwritingInputConvert`, `Md3HandwritingInputMode`, `Md3HandwritingInputStatus`, `HandwritingPoint`, `HandwritingStroke`, `HandwritingWord`, `HandwritingRecognition`, `HandwritingRecognizeRequest`, `HandwritingAcceptDetail`
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm add @nucleux/md3-handwriting @nucleux/tokens
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`react` / `react-dom` are peer dependencies.
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import { Md3HandwritingInput } from "@nucleux/md3-handwriting";
|
|
26
|
+
|
|
27
|
+
<Md3HandwritingInput onRecognize={myRecognizer} onAccept={(text) => append(text)} />
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires the theme once in your app: `import "@nucleux/tokens/styles.css";`
|
|
31
|
+
and the Tailwind preset from `@nucleux/tokens/preset`.
|
|
32
|
+
|
|
33
|
+
## License
|
|
34
|
+
|
|
35
|
+
MIT © Nucleux
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var React = require('react');
|
|
5
|
+
var utils = require('@nucleux/utils');
|
|
6
|
+
var md3Button = require('@nucleux/md3-button');
|
|
7
|
+
var md3Chip = require('@nucleux/md3-chip');
|
|
8
|
+
var md3LoadingIndicator = require('@nucleux/md3-loading-indicator');
|
|
9
|
+
var md3Menu = require('@nucleux/md3-menu');
|
|
10
|
+
var md3TextField = require('@nucleux/md3-text-field');
|
|
11
|
+
var lucideReact = require('lucide-react');
|
|
12
|
+
var jsxRuntime = require('react/jsx-runtime');
|
|
13
|
+
|
|
14
|
+
function _interopNamespace(e) {
|
|
15
|
+
if (e && e.__esModule) return e;
|
|
16
|
+
var n = Object.create(null);
|
|
17
|
+
if (e) {
|
|
18
|
+
Object.keys(e).forEach(function (k) {
|
|
19
|
+
if (k !== 'default') {
|
|
20
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
21
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
22
|
+
enumerable: true,
|
|
23
|
+
get: function () { return e[k]; }
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
n.default = e;
|
|
29
|
+
return Object.freeze(n);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
var React__namespace = /*#__PURE__*/_interopNamespace(React);
|
|
33
|
+
|
|
34
|
+
// src/index.tsx
|
|
35
|
+
var useIsomorphicLayoutEffect = typeof window !== "undefined" ? React__namespace.useLayoutEffect : React__namespace.useEffect;
|
|
36
|
+
var wordsText = (words) => words.map((w) => w.text).join(" ");
|
|
37
|
+
function UncertainWord({
|
|
38
|
+
word,
|
|
39
|
+
onChoose
|
|
40
|
+
}) {
|
|
41
|
+
const [open, setOpen] = React__namespace.useState(false);
|
|
42
|
+
const wrapRef = React__namespace.useRef(null);
|
|
43
|
+
const triggerRef = React__namespace.useRef(null);
|
|
44
|
+
const menuRef = React__namespace.useRef(null);
|
|
45
|
+
const menuId = React__namespace.useId();
|
|
46
|
+
const choices = [word.text, ...word.alternatives ?? []].filter((t, j, all) => all.indexOf(t) === j);
|
|
47
|
+
React__namespace.useEffect(() => {
|
|
48
|
+
if (!open) return;
|
|
49
|
+
requestAnimationFrame(() => menuRef.current?.querySelector('[role="menuitem"]')?.focus());
|
|
50
|
+
const onDown = (e) => {
|
|
51
|
+
if (!wrapRef.current?.contains(e.target)) setOpen(false);
|
|
52
|
+
};
|
|
53
|
+
document.addEventListener("pointerdown", onDown);
|
|
54
|
+
return () => document.removeEventListener("pointerdown", onDown);
|
|
55
|
+
}, [open]);
|
|
56
|
+
const close = () => {
|
|
57
|
+
setOpen(false);
|
|
58
|
+
triggerRef.current?.focus();
|
|
59
|
+
};
|
|
60
|
+
const onMenuKeyDown = (e) => {
|
|
61
|
+
const items = Array.from(menuRef.current?.querySelectorAll('[role="menuitem"]') ?? []);
|
|
62
|
+
const i = items.indexOf(document.activeElement);
|
|
63
|
+
if (e.key === "Escape" || e.key === "Tab") {
|
|
64
|
+
if (e.key === "Escape") e.preventDefault();
|
|
65
|
+
close();
|
|
66
|
+
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
const step = e.key === "ArrowDown" ? 1 : -1;
|
|
69
|
+
items[(i + step + items.length) % items.length]?.focus();
|
|
70
|
+
} else if (e.key === "Home" || e.key === "End") {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
items[e.key === "Home" ? 0 : items.length - 1]?.focus();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
return /* @__PURE__ */ jsxRuntime.jsxs("span", { ref: wrapRef, className: "relative inline-flex", children: [
|
|
76
|
+
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
77
|
+
"button",
|
|
78
|
+
{
|
|
79
|
+
ref: triggerRef,
|
|
80
|
+
type: "button",
|
|
81
|
+
"aria-label": `${word.text} \u2014 uncertain, choose the right word`,
|
|
82
|
+
"aria-haspopup": "menu",
|
|
83
|
+
"aria-expanded": open,
|
|
84
|
+
"aria-controls": open ? menuId : void 0,
|
|
85
|
+
onClick: () => setOpen((o) => !o),
|
|
86
|
+
onKeyDown: (e) => {
|
|
87
|
+
if (e.key === "ArrowDown") {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
setOpen(true);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
className: utils.cn(
|
|
93
|
+
"relative inline-flex h-8 items-center gap-0.5 overflow-hidden rounded-md-sm px-1.5 text-sm text-md-on-surface",
|
|
94
|
+
"underline decoration-md-error decoration-dotted decoration-2 underline-offset-4",
|
|
95
|
+
"before:absolute before:inset-0 before:bg-md-primary before:opacity-0 before:transition-opacity before:content-['']",
|
|
96
|
+
"hover:before:opacity-[0.08] focus-visible:before:opacity-[0.12] active:before:opacity-[0.12]",
|
|
97
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-md-primary",
|
|
98
|
+
"transition-transform duration-200 active:scale-95 motion-reduce:transition-none"
|
|
99
|
+
),
|
|
100
|
+
children: [
|
|
101
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "relative", children: word.text }),
|
|
102
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", className: "relative font-semibold text-md-error no-underline", children: "?" })
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
),
|
|
106
|
+
open && /* @__PURE__ */ jsxRuntime.jsxs(
|
|
107
|
+
md3Menu.Md3Menu,
|
|
108
|
+
{
|
|
109
|
+
ref: menuRef,
|
|
110
|
+
id: menuId,
|
|
111
|
+
"aria-label": "Did you write\u2026",
|
|
112
|
+
onKeyDown: onMenuKeyDown,
|
|
113
|
+
className: "absolute left-0 top-full z-50 mt-1 animate-nx-fade-in motion-reduce:animate-none",
|
|
114
|
+
children: [
|
|
115
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { "aria-hidden": "true", className: "block px-3 pb-1 pt-1 text-xs font-medium text-md-on-surface-variant", children: "Did you write\u2026" }),
|
|
116
|
+
choices.map((alt) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
117
|
+
md3Menu.Md3MenuItem,
|
|
118
|
+
{
|
|
119
|
+
tabIndex: -1,
|
|
120
|
+
onClick: () => {
|
|
121
|
+
onChoose(alt);
|
|
122
|
+
setOpen(false);
|
|
123
|
+
},
|
|
124
|
+
children: alt
|
|
125
|
+
},
|
|
126
|
+
alt
|
|
127
|
+
))
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
] });
|
|
132
|
+
}
|
|
133
|
+
var Md3HandwritingInput = React__namespace.forwardRef(
|
|
134
|
+
({
|
|
135
|
+
variant = "draw-pad",
|
|
136
|
+
onRecognize,
|
|
137
|
+
convert,
|
|
138
|
+
autoDelay = 900,
|
|
139
|
+
onAccept,
|
|
140
|
+
strokes: strokesProp,
|
|
141
|
+
defaultStrokes,
|
|
142
|
+
onStrokesChange,
|
|
143
|
+
defaultRecognition,
|
|
144
|
+
defaultMode = "draw",
|
|
145
|
+
strokeWidth = variant === "annotation-stroke" ? 3 : 2.5,
|
|
146
|
+
label = "Handwriting pad",
|
|
147
|
+
placeholder,
|
|
148
|
+
statusText = "Recognising your writing\u2026",
|
|
149
|
+
failureText = "Couldn\u2019t recognise this writing.",
|
|
150
|
+
acceptLabel = "Add to message",
|
|
151
|
+
typingLabel = "Type your request",
|
|
152
|
+
disabled = false,
|
|
153
|
+
className,
|
|
154
|
+
children,
|
|
155
|
+
...props
|
|
156
|
+
}, ref) => {
|
|
157
|
+
const mode = convert ?? (variant === "live-ocr" ? "auto" : "review");
|
|
158
|
+
const isAnnotation = variant === "annotation-stroke";
|
|
159
|
+
const [internalStrokes, setInternalStrokes] = React__namespace.useState(defaultStrokes ?? []);
|
|
160
|
+
const strokes = strokesProp ?? internalStrokes;
|
|
161
|
+
const [inputMode, setInputMode] = React__namespace.useState(defaultMode);
|
|
162
|
+
const [status, setStatus] = React__namespace.useState(defaultRecognition ? "recognized" : "idle");
|
|
163
|
+
const [recognition, setRecognition] = React__namespace.useState(defaultRecognition ?? null);
|
|
164
|
+
const [words, setWords] = React__namespace.useState(defaultRecognition?.words ?? null);
|
|
165
|
+
const [editedText, setEditedText] = React__namespace.useState(null);
|
|
166
|
+
const [typed, setTyped] = React__namespace.useState("");
|
|
167
|
+
const [announcement, setAnnouncement] = React__namespace.useState("");
|
|
168
|
+
const canvasRef = React__namespace.useRef(null);
|
|
169
|
+
const padRef = React__namespace.useRef(null);
|
|
170
|
+
const drawing = React__namespace.useRef(null);
|
|
171
|
+
const requestId = React__namespace.useRef(0);
|
|
172
|
+
const strokesRef = React__namespace.useRef(strokes);
|
|
173
|
+
strokesRef.current = strokes;
|
|
174
|
+
const resultId = React__namespace.useId();
|
|
175
|
+
const resultInputRef = React__namespace.useRef(null);
|
|
176
|
+
const setStrokes = (next) => {
|
|
177
|
+
if (strokesProp === void 0) setInternalStrokes(next);
|
|
178
|
+
onStrokesChange?.(next);
|
|
179
|
+
};
|
|
180
|
+
const widthFor = (stroke, p) => stroke.pointerType === "pen" && p.pressure > 0 ? strokeWidth * (0.4 + p.pressure * 1.2) : strokeWidth;
|
|
181
|
+
const noCanvas = React__namespace.useRef(false);
|
|
182
|
+
const getContext = () => {
|
|
183
|
+
const canvas = canvasRef.current;
|
|
184
|
+
if (!canvas || noCanvas.current) return null;
|
|
185
|
+
let ctx = null;
|
|
186
|
+
try {
|
|
187
|
+
ctx = canvas.getContext("2d");
|
|
188
|
+
} catch {
|
|
189
|
+
ctx = null;
|
|
190
|
+
}
|
|
191
|
+
if (!ctx) {
|
|
192
|
+
noCanvas.current = true;
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
|
|
196
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
197
|
+
ctx.lineCap = "round";
|
|
198
|
+
ctx.lineJoin = "round";
|
|
199
|
+
const color = typeof getComputedStyle === "function" ? getComputedStyle(canvas).color : "";
|
|
200
|
+
ctx.strokeStyle = color || "currentColor";
|
|
201
|
+
ctx.fillStyle = ctx.strokeStyle;
|
|
202
|
+
return ctx;
|
|
203
|
+
};
|
|
204
|
+
const drawSegment = (ctx, stroke, a, b) => {
|
|
205
|
+
ctx.lineWidth = widthFor(stroke, b);
|
|
206
|
+
ctx.beginPath();
|
|
207
|
+
ctx.moveTo(a.x, a.y);
|
|
208
|
+
ctx.lineTo(b.x, b.y);
|
|
209
|
+
ctx.stroke();
|
|
210
|
+
};
|
|
211
|
+
const drawDot = (ctx, stroke, p) => {
|
|
212
|
+
ctx.beginPath();
|
|
213
|
+
ctx.arc(p.x, p.y, widthFor(stroke, p) / 2, 0, Math.PI * 2);
|
|
214
|
+
ctx.fill();
|
|
215
|
+
};
|
|
216
|
+
const redraw = React__namespace.useCallback(() => {
|
|
217
|
+
const canvas = canvasRef.current;
|
|
218
|
+
if (!canvas) return;
|
|
219
|
+
const ctx = getContext();
|
|
220
|
+
if (!ctx) return;
|
|
221
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
222
|
+
for (const stroke of strokesRef.current) {
|
|
223
|
+
const pts = stroke.points;
|
|
224
|
+
if (pts.length === 1) drawDot(ctx, stroke, pts[0]);
|
|
225
|
+
for (let i = 1; i < pts.length; i++) drawSegment(ctx, stroke, pts[i - 1], pts[i]);
|
|
226
|
+
}
|
|
227
|
+
}, [strokeWidth]);
|
|
228
|
+
React__namespace.useEffect(() => {
|
|
229
|
+
const pad = padRef.current;
|
|
230
|
+
const canvas = canvasRef.current;
|
|
231
|
+
if (!pad || !canvas || typeof ResizeObserver === "undefined") return;
|
|
232
|
+
const ro = new ResizeObserver(() => {
|
|
233
|
+
const rect = pad.getBoundingClientRect();
|
|
234
|
+
if (!rect.width || !rect.height) return;
|
|
235
|
+
const dpr = window.devicePixelRatio || 1;
|
|
236
|
+
canvas.width = Math.round(rect.width * dpr);
|
|
237
|
+
canvas.height = Math.round(rect.height * dpr);
|
|
238
|
+
redraw();
|
|
239
|
+
});
|
|
240
|
+
ro.observe(pad);
|
|
241
|
+
return () => ro.disconnect();
|
|
242
|
+
}, [redraw, inputMode]);
|
|
243
|
+
const hasDrawn = React__namespace.useRef(false);
|
|
244
|
+
useIsomorphicLayoutEffect(() => {
|
|
245
|
+
if (strokes.length === 0 && !hasDrawn.current) return;
|
|
246
|
+
hasDrawn.current = true;
|
|
247
|
+
redraw();
|
|
248
|
+
}, [strokes, redraw]);
|
|
249
|
+
const pointFrom = (e) => {
|
|
250
|
+
const rect = canvasRef.current.getBoundingClientRect();
|
|
251
|
+
return {
|
|
252
|
+
x: (e.clientX ?? 0) - rect.left,
|
|
253
|
+
y: (e.clientY ?? 0) - rect.top,
|
|
254
|
+
pressure: typeof e.pressure === "number" && e.pressure > 0 ? e.pressure : 0.5,
|
|
255
|
+
t: e.timeStamp ?? Date.now()
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
const onPointerDown = (e) => {
|
|
259
|
+
if (disabled || status === "recognizing") return;
|
|
260
|
+
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
e.currentTarget.setPointerCapture?.(e.pointerId);
|
|
263
|
+
const stroke = { pointerType: e.pointerType || "mouse", points: [pointFrom(e)] };
|
|
264
|
+
drawing.current = stroke;
|
|
265
|
+
const ctx = getContext();
|
|
266
|
+
if (ctx) drawDot(ctx, stroke, stroke.points[0]);
|
|
267
|
+
};
|
|
268
|
+
const onPointerMove = (e) => {
|
|
269
|
+
const stroke = drawing.current;
|
|
270
|
+
if (!stroke) return;
|
|
271
|
+
const native = e.nativeEvent;
|
|
272
|
+
const events = native.getCoalescedEvents?.() ?? [];
|
|
273
|
+
const ctx = getContext();
|
|
274
|
+
for (const ev of events.length ? events : [native]) {
|
|
275
|
+
const p = pointFrom(ev);
|
|
276
|
+
const prev = stroke.points[stroke.points.length - 1];
|
|
277
|
+
stroke.points.push(p);
|
|
278
|
+
if (ctx) drawSegment(ctx, stroke, prev, p);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
const endStroke = () => {
|
|
282
|
+
const stroke = drawing.current;
|
|
283
|
+
if (!stroke) return;
|
|
284
|
+
drawing.current = null;
|
|
285
|
+
setStrokes([...strokesRef.current, stroke]);
|
|
286
|
+
};
|
|
287
|
+
const toImage = (type = "image/png") => new Promise((resolve) => {
|
|
288
|
+
const canvas = canvasRef.current;
|
|
289
|
+
if (!canvas || typeof canvas.toBlob !== "function") return resolve(null);
|
|
290
|
+
try {
|
|
291
|
+
canvas.toBlob((blob) => resolve(blob), type);
|
|
292
|
+
} catch {
|
|
293
|
+
resolve(null);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
const applyRecognition = (result) => {
|
|
297
|
+
setRecognition(result);
|
|
298
|
+
setWords(result.words ?? null);
|
|
299
|
+
setEditedText(null);
|
|
300
|
+
setStatus("recognized");
|
|
301
|
+
const unsure = result.words?.filter((w) => w.uncertain).length ?? 0;
|
|
302
|
+
setAnnouncement(
|
|
303
|
+
`Recognised: ${result.words ? wordsText(result.words) : result.text}.` + (unsure ? ` ${unsure} ${unsure === 1 ? "word needs" : "words need"} checking.` : "")
|
|
304
|
+
);
|
|
305
|
+
};
|
|
306
|
+
const recognize = async () => {
|
|
307
|
+
if (!onRecognize || strokesRef.current.length === 0) return;
|
|
308
|
+
const id = ++requestId.current;
|
|
309
|
+
setStatus("recognizing");
|
|
310
|
+
setAnnouncement(typeof statusText === "string" ? statusText : "Recognising your writing\u2026");
|
|
311
|
+
const rect = padRef.current?.getBoundingClientRect();
|
|
312
|
+
try {
|
|
313
|
+
const result = await onRecognize({
|
|
314
|
+
strokes: strokesRef.current,
|
|
315
|
+
width: rect?.width ?? 0,
|
|
316
|
+
height: rect?.height ?? 0,
|
|
317
|
+
toImage
|
|
318
|
+
});
|
|
319
|
+
if (id !== requestId.current) return;
|
|
320
|
+
applyRecognition(result);
|
|
321
|
+
} catch {
|
|
322
|
+
if (id !== requestId.current) return;
|
|
323
|
+
setStatus("error");
|
|
324
|
+
setAnnouncement(typeof failureText === "string" ? failureText : "Couldn\u2019t recognise this writing.");
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
const prevStrokes = React__namespace.useRef(strokes);
|
|
328
|
+
React__namespace.useEffect(() => {
|
|
329
|
+
if (prevStrokes.current === strokes) return;
|
|
330
|
+
prevStrokes.current = strokes;
|
|
331
|
+
requestId.current += 1;
|
|
332
|
+
if (strokes.length === 0) {
|
|
333
|
+
setStatus("idle");
|
|
334
|
+
setRecognition(null);
|
|
335
|
+
setWords(null);
|
|
336
|
+
setEditedText(null);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (mode === "auto" && onRecognize) {
|
|
340
|
+
const timer = setTimeout(() => void recognize(), autoDelay);
|
|
341
|
+
return () => clearTimeout(timer);
|
|
342
|
+
}
|
|
343
|
+
setStatus("idle");
|
|
344
|
+
setRecognition(null);
|
|
345
|
+
setWords(null);
|
|
346
|
+
setEditedText(null);
|
|
347
|
+
}, [strokes]);
|
|
348
|
+
const unsettled = editedText === null && !!words?.some((w) => w.uncertain);
|
|
349
|
+
const resultText = editedText ?? (words ? wordsText(words) : recognition?.text ?? "");
|
|
350
|
+
const settleWord = (index, text) => {
|
|
351
|
+
if (!words) return;
|
|
352
|
+
const next = words.map((w, i) => i === index ? { text, uncertain: false } : w);
|
|
353
|
+
setWords(next);
|
|
354
|
+
const left = next.filter((w) => w.uncertain).length;
|
|
355
|
+
if (!left) requestAnimationFrame(() => resultInputRef.current?.focus());
|
|
356
|
+
setAnnouncement(`\u201C${text}\u201D chosen.${left ? ` ${left} still to check.` : " Ready to add."}`);
|
|
357
|
+
};
|
|
358
|
+
const reset = () => {
|
|
359
|
+
requestId.current += 1;
|
|
360
|
+
setStrokes([]);
|
|
361
|
+
setStatus("idle");
|
|
362
|
+
setRecognition(null);
|
|
363
|
+
setWords(null);
|
|
364
|
+
setEditedText(null);
|
|
365
|
+
};
|
|
366
|
+
const accept = () => {
|
|
367
|
+
if (inputMode === "type") {
|
|
368
|
+
const text = typed.trim();
|
|
369
|
+
if (!text) return;
|
|
370
|
+
onAccept?.(text, { source: "typed", strokes: [] });
|
|
371
|
+
setTyped("");
|
|
372
|
+
} else {
|
|
373
|
+
const text = resultText.trim();
|
|
374
|
+
if (!text || unsettled) return;
|
|
375
|
+
onAccept?.(text, { source: "handwriting", strokes, recognition: recognition ?? void 0 });
|
|
376
|
+
reset();
|
|
377
|
+
}
|
|
378
|
+
setAnnouncement("Added to message.");
|
|
379
|
+
};
|
|
380
|
+
const undoStroke = () => setStrokes(strokes.slice(0, -1));
|
|
381
|
+
const setRef = (node) => {
|
|
382
|
+
if (typeof ref === "function") ref(node);
|
|
383
|
+
else if (ref) ref.current = node;
|
|
384
|
+
};
|
|
385
|
+
const emptyHint = placeholder ?? (isAnnotation ? "Draw on the content to mark what you mean" : "Draw or write\u2026");
|
|
386
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
387
|
+
"div",
|
|
388
|
+
{
|
|
389
|
+
ref: setRef,
|
|
390
|
+
role: "group",
|
|
391
|
+
"aria-label": isAnnotation ? "Annotation input" : "Handwriting input",
|
|
392
|
+
"data-variant": variant,
|
|
393
|
+
"data-status": status,
|
|
394
|
+
className: utils.cn("flex flex-col gap-3 text-md-on-surface", className),
|
|
395
|
+
...props,
|
|
396
|
+
children: [
|
|
397
|
+
inputMode === "type" ? /* @__PURE__ */ jsxRuntime.jsx(
|
|
398
|
+
"textarea",
|
|
399
|
+
{
|
|
400
|
+
"aria-label": typingLabel,
|
|
401
|
+
placeholder: typingLabel,
|
|
402
|
+
value: typed,
|
|
403
|
+
onChange: (e) => setTyped(e.target.value),
|
|
404
|
+
disabled,
|
|
405
|
+
rows: 3,
|
|
406
|
+
className: utils.cn(
|
|
407
|
+
"w-full resize-none rounded-md-xs border border-md-outline bg-transparent px-4 py-3 text-base text-md-on-surface outline-none transition-colors",
|
|
408
|
+
"placeholder:text-md-on-surface-variant focus:border-2 focus:border-md-primary focus:px-[15px] focus:py-[11px]",
|
|
409
|
+
"disabled:cursor-not-allowed disabled:opacity-[0.38]"
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
) : /* @__PURE__ */ jsxRuntime.jsxs(
|
|
413
|
+
"div",
|
|
414
|
+
{
|
|
415
|
+
ref: padRef,
|
|
416
|
+
className: utils.cn(
|
|
417
|
+
"relative overflow-hidden rounded-md-lg",
|
|
418
|
+
isAnnotation ? "min-h-40 border border-md-outline-variant" : "h-44 border-[1.5px] border-dashed border-md-outline bg-md-surface-container-low",
|
|
419
|
+
variant === "live-ocr" && "h-36 border border-solid border-md-outline-variant",
|
|
420
|
+
disabled && "opacity-[0.38]"
|
|
421
|
+
),
|
|
422
|
+
"aria-busy": status === "recognizing" || void 0,
|
|
423
|
+
children: [
|
|
424
|
+
isAnnotation && children,
|
|
425
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
426
|
+
"canvas",
|
|
427
|
+
{
|
|
428
|
+
ref: canvasRef,
|
|
429
|
+
role: "img",
|
|
430
|
+
"aria-label": `${label}${strokes.length ? ` \u2014 ${strokes.length} ${strokes.length === 1 ? "stroke" : "strokes"}` : " \u2014 empty"}`,
|
|
431
|
+
onPointerDown,
|
|
432
|
+
onPointerMove,
|
|
433
|
+
onPointerUp: endStroke,
|
|
434
|
+
onPointerCancel: endStroke,
|
|
435
|
+
className: utils.cn(
|
|
436
|
+
"absolute inset-0 size-full touch-none",
|
|
437
|
+
disabled ? "cursor-not-allowed" : "cursor-crosshair",
|
|
438
|
+
isAnnotation ? "text-md-error" : "text-md-on-surface"
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
),
|
|
442
|
+
strokes.length === 0 && /* @__PURE__ */ jsxRuntime.jsx(
|
|
443
|
+
"p",
|
|
444
|
+
{
|
|
445
|
+
"aria-hidden": "true",
|
|
446
|
+
className: utils.cn(
|
|
447
|
+
"pointer-events-none absolute text-xs text-md-on-surface-variant",
|
|
448
|
+
isAnnotation ? "bottom-2 right-2 rounded-full bg-md-surface-container-high px-2.5 py-1 font-medium" : "inset-x-0 bottom-3 text-center"
|
|
449
|
+
),
|
|
450
|
+
children: emptyHint
|
|
451
|
+
}
|
|
452
|
+
),
|
|
453
|
+
isAnnotation && status === "recognized" && resultText && /* @__PURE__ */ jsxRuntime.jsxs(
|
|
454
|
+
"span",
|
|
455
|
+
{
|
|
456
|
+
"aria-hidden": "true",
|
|
457
|
+
className: "pointer-events-none absolute bottom-2 right-2 rounded-full bg-md-surface-container-high px-2.5 py-1 text-xs font-medium text-md-on-surface shadow-md-1",
|
|
458
|
+
children: [
|
|
459
|
+
"\u201C",
|
|
460
|
+
resultText,
|
|
461
|
+
"\u201D"
|
|
462
|
+
]
|
|
463
|
+
}
|
|
464
|
+
),
|
|
465
|
+
status === "recognizing" && // A slow, low-contrast sheen while the ink is read; the ink stays visible beneath it.
|
|
466
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
467
|
+
"span",
|
|
468
|
+
{
|
|
469
|
+
"aria-hidden": "true",
|
|
470
|
+
className: "pointer-events-none absolute inset-0 animate-pulse bg-md-primary/[0.08] motion-reduce:animate-none"
|
|
471
|
+
}
|
|
472
|
+
)
|
|
473
|
+
]
|
|
474
|
+
}
|
|
475
|
+
),
|
|
476
|
+
inputMode === "draw" && status === "recognizing" && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2 text-sm text-md-on-surface-variant", children: [
|
|
477
|
+
/* @__PURE__ */ jsxRuntime.jsx(md3LoadingIndicator.Md3LoadingIndicator, { size: 24, "aria-hidden": "true" }),
|
|
478
|
+
statusText
|
|
479
|
+
] }),
|
|
480
|
+
inputMode === "draw" && status === "error" && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-2 rounded-md-md bg-md-error-container px-3 py-2.5 text-md-on-error-container", children: [
|
|
481
|
+
/* @__PURE__ */ jsxRuntime.jsxs("p", { className: "flex items-center gap-2 text-sm", children: [
|
|
482
|
+
/* @__PURE__ */ jsxRuntime.jsx(lucideReact.AlertCircle, { className: "size-[18px] shrink-0", "aria-hidden": "true" }),
|
|
483
|
+
failureText,
|
|
484
|
+
" Your writing is kept."
|
|
485
|
+
] }),
|
|
486
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap gap-2", children: [
|
|
487
|
+
/* @__PURE__ */ jsxRuntime.jsx(md3Button.Md3Button, { variant: "filled", icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.RotateCcw, { "aria-hidden": "true" }), onClick: () => void recognize(), children: "Retry" }),
|
|
488
|
+
/* @__PURE__ */ jsxRuntime.jsx(md3Button.Md3Button, { variant: "text", onClick: reset, className: "text-md-on-error-container", children: "Rewrite" })
|
|
489
|
+
] })
|
|
490
|
+
] }),
|
|
491
|
+
inputMode === "draw" && status === "recognized" && recognition && /* @__PURE__ */ jsxRuntime.jsxs(
|
|
492
|
+
"div",
|
|
493
|
+
{
|
|
494
|
+
id: resultId,
|
|
495
|
+
className: "flex flex-col gap-2 border-t border-md-outline-variant pt-3",
|
|
496
|
+
"data-slot": "handwriting-result",
|
|
497
|
+
children: [
|
|
498
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs font-medium tracking-[0.5px] text-md-on-surface-variant", children: isAnnotation ? "Read as an instruction" : "Recognised" }),
|
|
499
|
+
unsettled && words ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
|
|
500
|
+
/* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-wrap items-center gap-1 text-base text-md-on-surface", children: words.map(
|
|
501
|
+
(w, i) => w.uncertain ? /* @__PURE__ */ jsxRuntime.jsx(UncertainWord, { word: w, onChoose: (text) => settleWord(i, text) }, i) : /* @__PURE__ */ jsxRuntime.jsx("span", { className: "animate-nx-fade-in motion-reduce:animate-none", children: w.text }, i)
|
|
502
|
+
) }),
|
|
503
|
+
/* @__PURE__ */ jsxRuntime.jsxs("p", { className: "text-xs text-md-on-surface-variant", children: [
|
|
504
|
+
"Check the words marked \u201C?\u201D to add this to your message.",
|
|
505
|
+
" ",
|
|
506
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
507
|
+
"button",
|
|
508
|
+
{
|
|
509
|
+
type: "button",
|
|
510
|
+
onClick: () => setEditedText(resultText),
|
|
511
|
+
className: "rounded-md-xs font-medium text-md-primary underline-offset-2 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-md-primary",
|
|
512
|
+
children: "Edit text instead"
|
|
513
|
+
}
|
|
514
|
+
)
|
|
515
|
+
] })
|
|
516
|
+
] }) : /* @__PURE__ */ jsxRuntime.jsx(
|
|
517
|
+
md3TextField.Md3TextField,
|
|
518
|
+
{
|
|
519
|
+
ref: resultInputRef,
|
|
520
|
+
label: "Recognised text",
|
|
521
|
+
value: resultText,
|
|
522
|
+
onChange: (e) => setEditedText(e.target.value),
|
|
523
|
+
disabled
|
|
524
|
+
}
|
|
525
|
+
),
|
|
526
|
+
!unsettled && recognition.alternatives && recognition.alternatives.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap items-center gap-2", role: "group", "aria-label": "Other readings", children: [
|
|
527
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-xs text-md-on-surface-variant", children: "Other readings:" }),
|
|
528
|
+
recognition.alternatives.map((alt) => /* @__PURE__ */ jsxRuntime.jsx(md3Chip.Md3Chip, { variant: "filter", selected: resultText === alt, onClick: () => setEditedText(alt), children: alt }, alt))
|
|
529
|
+
] })
|
|
530
|
+
]
|
|
531
|
+
}
|
|
532
|
+
),
|
|
533
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap items-center gap-1", children: [
|
|
534
|
+
inputMode === "draw" && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
|
|
535
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
536
|
+
md3Button.Md3Button,
|
|
537
|
+
{
|
|
538
|
+
variant: "text",
|
|
539
|
+
icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Undo2, { "aria-hidden": "true" }),
|
|
540
|
+
onClick: undoStroke,
|
|
541
|
+
disabled: disabled || strokes.length === 0 || status === "recognizing",
|
|
542
|
+
children: "Undo stroke"
|
|
543
|
+
}
|
|
544
|
+
),
|
|
545
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
546
|
+
md3Button.Md3Button,
|
|
547
|
+
{
|
|
548
|
+
variant: "text",
|
|
549
|
+
icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Eraser, { "aria-hidden": "true" }),
|
|
550
|
+
onClick: reset,
|
|
551
|
+
disabled: disabled || strokes.length === 0,
|
|
552
|
+
children: "Clear"
|
|
553
|
+
}
|
|
554
|
+
)
|
|
555
|
+
] }),
|
|
556
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
557
|
+
md3Button.Md3Button,
|
|
558
|
+
{
|
|
559
|
+
variant: "text",
|
|
560
|
+
icon: inputMode === "draw" ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Keyboard, { "aria-hidden": "true" }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.PenLine, { "aria-hidden": "true" }),
|
|
561
|
+
onClick: () => setInputMode(inputMode === "draw" ? "type" : "draw"),
|
|
562
|
+
disabled,
|
|
563
|
+
children: inputMode === "draw" ? "Type instead" : "Write instead"
|
|
564
|
+
}
|
|
565
|
+
),
|
|
566
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "flex-1" }),
|
|
567
|
+
inputMode === "draw" && onRecognize && (status !== "recognized" || mode === "review") && /* @__PURE__ */ jsxRuntime.jsx(
|
|
568
|
+
md3Button.Md3Button,
|
|
569
|
+
{
|
|
570
|
+
variant: "tonal",
|
|
571
|
+
icon: /* @__PURE__ */ jsxRuntime.jsx(lucideReact.ScanText, { "aria-hidden": "true" }),
|
|
572
|
+
onClick: () => void recognize(),
|
|
573
|
+
disabled: disabled || strokes.length === 0 || status === "recognizing",
|
|
574
|
+
children: "Recognise"
|
|
575
|
+
}
|
|
576
|
+
),
|
|
577
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
578
|
+
md3Button.Md3Button,
|
|
579
|
+
{
|
|
580
|
+
variant: "filled",
|
|
581
|
+
onClick: accept,
|
|
582
|
+
disabled: disabled || (inputMode === "type" ? !typed.trim() : status !== "recognized" || unsettled || !resultText.trim()),
|
|
583
|
+
"aria-describedby": inputMode === "draw" && status === "recognized" ? resultId : void 0,
|
|
584
|
+
children: acceptLabel
|
|
585
|
+
}
|
|
586
|
+
)
|
|
587
|
+
] }),
|
|
588
|
+
/* @__PURE__ */ jsxRuntime.jsx("p", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement })
|
|
589
|
+
]
|
|
590
|
+
}
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
);
|
|
594
|
+
Md3HandwritingInput.displayName = "Md3HandwritingInput";
|
|
595
|
+
|
|
596
|
+
exports.Md3HandwritingInput = Md3HandwritingInput;
|
|
597
|
+
//# sourceMappingURL=index.cjs.map
|
|
598
|
+
//# sourceMappingURL=index.cjs.map
|