@yewdiel21/motion-kit 0.16.0 → 0.17.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 (60) hide show
  1. package/README.md +21 -0
  2. package/dist/auto/index.d.ts +8 -3
  3. package/dist/auto/index.js +13 -3
  4. package/dist/auto/index.js.map +1 -1
  5. package/dist/{chunk-SDQ776LG.js → chunk-7IQTMF7Y.js} +1414 -38
  6. package/dist/chunk-7IQTMF7Y.js.map +1 -0
  7. package/dist/{chunk-NFZIX3IA.js → chunk-BZLMCHUD.js} +2 -2
  8. package/dist/{chunk-RBEKR5UZ.js → chunk-VJFPTLAK.js} +2 -2
  9. package/dist/css/components/drilldown-menu.css +130 -0
  10. package/dist/css/components/signature-pad.css +104 -0
  11. package/dist/css/components/voice-message-bubble.css +50 -0
  12. package/dist/css/components/waveform-player.css +36 -0
  13. package/dist/css/components/wheel-picker.css +17 -0
  14. package/dist/elements/index.d.ts +90 -4
  15. package/dist/elements/index.js +281 -4
  16. package/dist/elements/index.js.map +1 -1
  17. package/dist/{reveal-DRpNO4KP.d.ts → engine-Bn6hC87X.d.ts} +194 -1
  18. package/dist/index.css +305 -0
  19. package/dist/index.d.ts +3 -3
  20. package/dist/index.js +13 -3
  21. package/dist/index.js.map +1 -1
  22. package/dist/react/index.d.ts +45 -7
  23. package/dist/react/index.js +227 -3
  24. package/dist/react/index.js.map +1 -1
  25. package/dist/{reduced-DQwVXQYb.d.ts → reduced-YS6TZjpa.d.ts} +1 -1
  26. package/dist/{store-Da3zSh8Z.d.ts → store-CZ6vdtIE.d.ts} +1 -1
  27. package/package.json +110 -110
  28. package/registry.json +64 -0
  29. package/src/auto/auto-init.ts +308 -293
  30. package/src/components/drilldown-menu/README.md +163 -0
  31. package/src/components/drilldown-menu/drilldown-menu.css +129 -0
  32. package/src/components/drilldown-menu/element.ts +59 -0
  33. package/src/components/drilldown-menu/engine.ts +338 -0
  34. package/src/components/drilldown-menu/react.tsx +76 -0
  35. package/src/components/signature-pad/README.md +97 -0
  36. package/src/components/signature-pad/element.ts +57 -0
  37. package/src/components/signature-pad/engine.ts +437 -0
  38. package/src/components/signature-pad/react.tsx +56 -0
  39. package/src/components/signature-pad/signature-pad.css +103 -0
  40. package/src/components/voice-message-bubble/README.md +31 -0
  41. package/src/components/voice-message-bubble/element.ts +20 -0
  42. package/src/components/voice-message-bubble/engine.ts +68 -0
  43. package/src/components/voice-message-bubble/react.tsx +27 -0
  44. package/src/components/voice-message-bubble/voice-message-bubble.css +12 -0
  45. package/src/components/waveform-player/README.md +30 -0
  46. package/src/components/waveform-player/element.ts +21 -0
  47. package/src/components/waveform-player/engine.ts +261 -0
  48. package/src/components/waveform-player/react.tsx +27 -0
  49. package/src/components/waveform-player/waveform-player.css +35 -0
  50. package/src/components/wheel-picker/README.md +33 -0
  51. package/src/components/wheel-picker/element.ts +18 -0
  52. package/src/components/wheel-picker/engine.ts +210 -0
  53. package/src/components/wheel-picker/react.tsx +32 -0
  54. package/src/components/wheel-picker/wheel-picker.css +16 -0
  55. package/src/elements/index.ts +273 -258
  56. package/src/index.ts +295 -290
  57. package/src/react/index.ts +176 -171
  58. package/dist/chunk-SDQ776LG.js.map +0 -1
  59. /package/dist/{chunk-NFZIX3IA.js.map → chunk-BZLMCHUD.js.map} +0 -0
  60. /package/dist/{chunk-RBEKR5UZ.js.map → chunk-VJFPTLAK.js.map} +0 -0
@@ -0,0 +1,437 @@
1
+ import { gsap, EASE, registerMotion } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
3
+ import { createScope } from '../../core/scope';
4
+ import { inert, type Instance } from '../../core/types';
5
+
6
+ /** Coordinates are fractions of the drawing area, independent of its size. */
7
+ export interface SignaturePoint { x: number; y: number }
8
+ export interface SignatureStroke { points: SignaturePoint[]; color: string; width: number }
9
+ export interface SignatureData { version: 1; strokes: SignatureStroke[] }
10
+ export type SignaturePadChangeReason = 'draw' | 'undo' | 'redo' | 'clear' | 'import' | 'reset';
11
+ export interface SignaturePadChangeDetail {
12
+ data: SignatureData;
13
+ reason: SignaturePadChangeReason;
14
+ isEmpty: boolean;
15
+ canUndo: boolean;
16
+ canRedo: boolean;
17
+ }
18
+ export interface SignatureExportOptions {
19
+ /** CSS pixels; default is the current drawing area, or 640 × 240 before layout. */
20
+ width?: number;
21
+ height?: number;
22
+ /** Omit for transparency. Accepts a literal CSS color, without variables or URLs. */
23
+ background?: string;
24
+ }
25
+ export interface SignaturePNGOptions extends SignatureExportOptions {
26
+ /** Raster resolution multiplier, clamped to 1–4 (default 2). */
27
+ scale?: number;
28
+ }
29
+ export interface SignaturePadOptions {
30
+ data?: SignatureData;
31
+ label?: string;
32
+ hint?: string;
33
+ placeholder?: string;
34
+ /** Literal CSS ink color; defaults to the theme's text color. */
35
+ color?: string;
36
+ /** Ink width in CSS pixels, default 2.4. */
37
+ width?: number;
38
+ disabled?: boolean;
39
+ readOnly?: boolean;
40
+ /** Include native undo, redo and clear buttons (default true). */
41
+ toolbar?: boolean;
42
+ /** Adds a hidden input containing the versioned JSON data. */
43
+ name?: string;
44
+ /** Maximum undo steps, clamped to 1–100 (default 50). */
45
+ historyLimit?: number;
46
+ onChange?: (data: SignatureData, detail: SignaturePadChangeDetail) => void;
47
+ }
48
+ export interface SignaturePadInstance extends Instance {
49
+ readonly data: SignatureData;
50
+ readonly isEmpty: boolean;
51
+ readonly canUndo: boolean;
52
+ readonly canRedo: boolean;
53
+ undo(): void;
54
+ redo(): void;
55
+ clear(): void;
56
+ /** Invalid input returns false and leaves the signature untouched. Import resets history. */
57
+ importData(data: unknown, notify?: boolean): boolean;
58
+ exportData(): SignatureData;
59
+ exportSVG(options?: SignatureExportOptions): string;
60
+ /** Returns null when rasterization is unavailable or fails. */
61
+ exportPNG(options?: SignaturePNGOptions): Promise<Blob | null>;
62
+ setDisabled(disabled: boolean): void;
63
+ setReadOnly(readOnly: boolean): void;
64
+ }
65
+
66
+ const SVG_NS = 'http://www.w3.org/2000/svg';
67
+ const MAX_STROKES = 500;
68
+ const MAX_POINTS = 50_000;
69
+ const emptyData = (): SignatureData => ({ version: 1, strokes: [] });
70
+ const cloneStrokes = (strokes: SignatureStroke[]) => strokes.map((stroke) => ({ ...stroke, points: stroke.points.map((point) => ({ ...point })) }));
71
+ const sameStrokes = (left: SignatureStroke[], right: SignatureStroke[]) => left.length === right.length && left.every((stroke, index) => {
72
+ const other = right[index]!;
73
+ return stroke.color === other.color && stroke.width === other.width && stroke.points.length === other.points.length && stroke.points.every((point, index) => point.x === other.points[index]!.x && point.y === other.points[index]!.y);
74
+ });
75
+ const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
76
+ const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
77
+
78
+ /** Reject paint servers, CSS variables and markup so exports stay self-contained. */
79
+ function validColor(value: unknown): value is string {
80
+ if (typeof value !== 'string' || value.length > 100) return false;
81
+ if (/^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(value)) return true;
82
+ if (!/^(?:rgba?|hsla?)\([\d\s.,%/+\-degratun]+\)$/i.test(value) && !/^[a-z]+$/i.test(value)) return false;
83
+ if (/^(?:inherit|initial|unset|revert|revertlayer|currentcolor)$/i.test(value)) return false;
84
+ if (typeof document === 'undefined') return /^(?:black|white|transparent)$/i.test(value);
85
+ const probe = document.createElement('span');
86
+ probe.style.color = value;
87
+ return probe.style.color !== '';
88
+ }
89
+
90
+ function validatedData(value: unknown): SignatureData | null {
91
+ if (!value || typeof value !== 'object') return null;
92
+ const data = value as Record<string, unknown>;
93
+ if (data.version !== 1 || !Array.isArray(data.strokes) || data.strokes.length > MAX_STROKES) return null;
94
+ const strokes: SignatureStroke[] = [];
95
+ let count = 0;
96
+ for (const candidate of data.strokes) {
97
+ if (!candidate || typeof candidate !== 'object') return null;
98
+ const stroke = candidate as Record<string, unknown>;
99
+ if (!validColor(stroke.color) || !finite(stroke.width) || stroke.width < 0.1 || stroke.width > 64 || !Array.isArray(stroke.points) || !stroke.points.length) return null;
100
+ count += stroke.points.length;
101
+ if (count > MAX_POINTS) return null;
102
+ const points: SignaturePoint[] = [];
103
+ for (const candidate of stroke.points) {
104
+ if (!candidate || typeof candidate !== 'object') return null;
105
+ const point = candidate as Record<string, unknown>;
106
+ if (!finite(point.x) || !finite(point.y) || point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1) return null;
107
+ points.push({ x: point.x, y: point.y });
108
+ }
109
+ strokes.push({ color: stroke.color, width: stroke.width, points });
110
+ }
111
+ return { version: 1, strokes };
112
+ }
113
+
114
+ const rounded = (value: number) => Number(value.toFixed(3));
115
+ function strokePath(points: SignaturePoint[], width: number, height: number) {
116
+ const coordinates = points.map((point) => [rounded(point.x * width), rounded(point.y * height)] as const);
117
+ const first = coordinates[0]!;
118
+ let path = `M ${first[0]} ${first[1]}`;
119
+ if (coordinates.length === 1) return `${path} l 0.001 0`;
120
+ for (let i = 1; i < coordinates.length - 1; i++) {
121
+ const current = coordinates[i]!;
122
+ const next = coordinates[i + 1]!;
123
+ path += ` Q ${current[0]} ${current[1]} ${rounded((current[0] + next[0]) / 2)} ${rounded((current[1] + next[1]) / 2)}`;
124
+ }
125
+ const last = coordinates[coordinates.length - 1]!;
126
+ return `${path} L ${last[0]} ${last[1]}`;
127
+ }
128
+
129
+ function makePath(stroke: SignatureStroke, width: number, height: number) {
130
+ const path = document.createElementNS(SVG_NS, 'path');
131
+ path.setAttribute('d', strokePath(stroke.points, width, height));
132
+ path.setAttribute('fill', 'none');
133
+ path.setAttribute('stroke', stroke.color);
134
+ path.setAttribute('stroke-width', String(stroke.width));
135
+ path.setAttribute('stroke-linecap', 'round');
136
+ path.setAttribute('stroke-linejoin', 'round');
137
+ return path;
138
+ }
139
+
140
+ let nextId = 0;
141
+
142
+ /** A responsive SVG drawing pad. Drawing is immediate; only toolbar feedback uses motion. */
143
+ export function createSignaturePad(root: HTMLElement | null, options: SignaturePadOptions = {}): SignaturePadInstance {
144
+ if (!root) return inert<SignaturePadInstance>({
145
+ data: emptyData(), isEmpty: true, canUndo: false, canRedo: false,
146
+ undo() {}, redo() {}, clear() {}, importData: () => false, exportData: emptyData,
147
+ exportSVG: () => '', exportPNG: async () => null, setDisabled() {}, setReadOnly() {},
148
+ });
149
+ registerMotion();
150
+ const scope = createScope();
151
+ let strokes = validatedData(options.data)?.strokes ?? [];
152
+ const initialStrokes = cloneStrokes(strokes);
153
+ let disabled = Boolean(options.disabled);
154
+ let readOnly = Boolean(options.readOnly);
155
+ let undoStack: SignatureStroke[][] = [];
156
+ let redoStack: SignatureStroke[][] = [];
157
+ const historyLimit = finite(options.historyLimit) ? clamp(Math.floor(options.historyLimit), 1, 100) : 50;
158
+ const inkWidth = finite(options.width) ? clamp(options.width, 0.1, 64) : 2.4;
159
+ const exportData = (): SignatureData => ({ version: 1, strokes: cloneStrokes(strokes) });
160
+ const wrapper = document.createElement('div');
161
+ wrapper.dataset.mkSignatureBody = '';
162
+ const surface = document.createElement('div');
163
+ surface.dataset.mkSignatureSurface = '';
164
+ const svg = document.createElementNS(SVG_NS, 'svg');
165
+ svg.dataset.mkSignatureDrawing = '';
166
+ svg.setAttribute('role', 'img');
167
+ svg.setAttribute('aria-label', options.label ?? 'Área de firma');
168
+ svg.setAttribute('preserveAspectRatio', 'none');
169
+ const placeholder = document.createElement('span');
170
+ placeholder.dataset.mkSignaturePlaceholder = '';
171
+ placeholder.setAttribute('aria-hidden', 'true');
172
+ placeholder.textContent = options.placeholder ?? 'Firma aquí';
173
+ surface.append(svg, placeholder);
174
+ const footer = document.createElement('div');
175
+ footer.dataset.mkSignatureFooter = '';
176
+ const toolbar = document.createElement('div');
177
+ toolbar.dataset.mkSignatureToolbar = '';
178
+ toolbar.setAttribute('role', 'group');
179
+ toolbar.setAttribute('aria-label', 'Herramientas de firma');
180
+ const makeButton = (action: string, text: string) => {
181
+ const button = document.createElement('button');
182
+ button.type = 'button';
183
+ button.dataset.mkSignatureAction = action;
184
+ button.textContent = text;
185
+ toolbar.append(button);
186
+ return button;
187
+ };
188
+ const undoButton = makeButton('undo', 'Deshacer');
189
+ const redoButton = makeButton('redo', 'Rehacer');
190
+ const clearButton = makeButton('clear', 'Limpiar');
191
+ if (options.toolbar !== false) footer.append(toolbar);
192
+ const status = document.createElement('span');
193
+ status.dataset.mkSignatureStatus = '';
194
+ status.setAttribute('role', 'status');
195
+ status.setAttribute('aria-live', 'polite');
196
+ footer.append(status);
197
+ const hint = document.createElement('p');
198
+ hint.dataset.mkSignatureHint = '';
199
+ hint.id = `mk-signature-hint-${++nextId}`;
200
+ hint.textContent = options.hint ?? 'Dibuja con el ratón, el dedo o un lápiz. Puedes deshacer cada trazo.';
201
+ svg.setAttribute('aria-describedby', hint.id);
202
+ wrapper.append(surface, footer, hint);
203
+ let hidden: HTMLInputElement | null = null;
204
+ if (options.name) {
205
+ hidden = document.createElement('input');
206
+ hidden.type = 'hidden';
207
+ hidden.name = options.name;
208
+ wrapper.append(hidden);
209
+ }
210
+ root.append(wrapper);
211
+ scope.add(() => wrapper.remove());
212
+ for (const name of ['data-mk-disabled', 'data-mk-readonly', 'data-mk-drawing', 'data-mk-empty']) scope.attr(root, name, null);
213
+ scope.attr(root, 'data-mk-ready', '');
214
+ let dimensions = { width: 640, height: 240 };
215
+ let active: { pointerId: number; stroke: SignatureStroke; path: SVGPathElement } | null = null;
216
+ let feedback: gsap.core.Tween | null = null;
217
+ const locked = () => disabled || readOnly;
218
+
219
+ const update = () => {
220
+ root.toggleAttribute('data-mk-empty', !strokes.length && !active);
221
+ root.toggleAttribute('data-mk-disabled', disabled);
222
+ root.toggleAttribute('data-mk-readonly', readOnly);
223
+ svg.setAttribute('aria-disabled', String(disabled));
224
+ placeholder.hidden = strokes.length > 0 || active !== null;
225
+ undoButton.disabled = locked() || !undoStack.length;
226
+ redoButton.disabled = locked() || !redoStack.length;
227
+ clearButton.disabled = locked() || !strokes.length;
228
+ status.textContent = strokes.length ? `${strokes.length} ${strokes.length === 1 ? 'trazo' : 'trazos'} · ${readOnly ? 'Solo lectura' : 'Firma capturada'}` : 'Sin firma';
229
+ if (hidden) {
230
+ hidden.disabled = disabled;
231
+ hidden.value = JSON.stringify(exportData());
232
+ }
233
+ };
234
+ const render = () => {
235
+ const rect = surface.getBoundingClientRect();
236
+ if (rect.width > 0 && rect.height > 0) dimensions = { width: rect.width, height: rect.height };
237
+ svg.setAttribute('viewBox', `0 0 ${dimensions.width} ${dimensions.height}`);
238
+ svg.replaceChildren(...strokes.map((stroke) => makePath(stroke, dimensions.width, dimensions.height)));
239
+ if (active) {
240
+ active.path = makePath(active.stroke, dimensions.width, dimensions.height);
241
+ svg.append(active.path);
242
+ }
243
+ };
244
+ const emit = (reason: SignaturePadChangeReason) => {
245
+ const detail: SignaturePadChangeDetail = { data: exportData(), reason, isEmpty: !strokes.length, canUndo: !!undoStack.length, canRedo: !!redoStack.length };
246
+ options.onChange?.(exportData(), { ...detail, data: exportData() });
247
+ if (scope.disposed) return;
248
+ root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail }));
249
+ };
250
+ const release = (pointerId: number) => {
251
+ try { surface.releasePointerCapture?.(pointerId); } catch { /* The pointer may already be released. */ }
252
+ };
253
+ const cancel = () => {
254
+ if (!active) return;
255
+ const pointerId = active.pointerId;
256
+ active = null;
257
+ root.removeAttribute('data-mk-drawing');
258
+ release(pointerId);
259
+ render();
260
+ update();
261
+ };
262
+ const remember = () => {
263
+ undoStack.push(strokes);
264
+ if (undoStack.length > historyLimit) undoStack.shift();
265
+ redoStack = [];
266
+ };
267
+ const pulse = (button: HTMLButtonElement) => {
268
+ feedback?.progress(1).kill();
269
+ if (reduced()) return;
270
+ feedback = gsap.fromTo(button, { scale: 0.95 }, { scale: 1, duration: 0.24, ease: EASE.click });
271
+ };
272
+ const undo = () => {
273
+ if (scope.disposed) return;
274
+ cancel();
275
+ if (!undoStack.length) return;
276
+ redoStack.push(strokes);
277
+ strokes = undoStack.pop()!;
278
+ render(); update(); emit('undo');
279
+ };
280
+ const redo = () => {
281
+ if (scope.disposed) return;
282
+ cancel();
283
+ if (!redoStack.length) return;
284
+ undoStack.push(strokes);
285
+ strokes = redoStack.pop()!;
286
+ render(); update(); emit('redo');
287
+ };
288
+ const clear = () => {
289
+ if (scope.disposed) return;
290
+ cancel();
291
+ if (!strokes.length) return;
292
+ remember(); strokes = [];
293
+ render(); update(); emit('clear');
294
+ };
295
+ const form = hidden?.form;
296
+ if (form) scope.on(form, 'reset', (event) => {
297
+ // Native reset runs after dispatch. Waiting also observes preventDefault from later listeners.
298
+ scope.timeout(() => {
299
+ if (scope.disposed || event.defaultPrevented || hidden?.form !== form) return;
300
+ const changed = !sameStrokes(strokes, initialStrokes);
301
+ cancel(); strokes = cloneStrokes(initialStrokes); undoStack = []; redoStack = [];
302
+ render(); update();
303
+ if (changed) emit('reset');
304
+ }, 0);
305
+ });
306
+ const pointFrom = (event: PointerEvent): SignaturePoint | null => {
307
+ const rect = surface.getBoundingClientRect();
308
+ if (!rect.width || !rect.height || !finite(event.clientX) || !finite(event.clientY)) return null;
309
+ return { x: clamp((event.clientX - rect.left) / rect.width, 0, 1), y: clamp((event.clientY - rect.top) / rect.height, 0, 1) };
310
+ };
311
+ const addPoint = (event: PointerEvent) => {
312
+ if (!active) return;
313
+ const point = pointFrom(event);
314
+ if (!point || active.stroke.points.length >= MAX_POINTS - strokes.reduce((sum, stroke) => sum + stroke.points.length, 0)) return;
315
+ const last = active.stroke.points[active.stroke.points.length - 1]!;
316
+ if (point.x === last.x && point.y === last.y) return;
317
+ active.stroke.points.push(point);
318
+ active.path.setAttribute('d', strokePath(active.stroke.points, dimensions.width, dimensions.height));
319
+ };
320
+ scope.on(surface, 'pointerdown', (event) => {
321
+ const pointer = event as PointerEvent;
322
+ if (locked() || active || pointer.button !== 0 || pointer.isPrimary === false || strokes.length >= MAX_STROKES || strokes.reduce((sum, stroke) => sum + stroke.points.length, 0) >= MAX_POINTS) return;
323
+ const point = pointFrom(pointer);
324
+ if (!point) return;
325
+ pointer.preventDefault();
326
+ const themed = getComputedStyle(root).getPropertyValue('--mk-signature-ink').trim() || getComputedStyle(root).color;
327
+ const color = validColor(options.color) ? options.color : validColor(themed) ? themed : '#252735';
328
+ const stroke: SignatureStroke = { points: [point], color, width: inkWidth };
329
+ active = { pointerId: pointer.pointerId, stroke, path: makePath(stroke, dimensions.width, dimensions.height) };
330
+ render(); update();
331
+ root.setAttribute('data-mk-drawing', '');
332
+ try { surface.setPointerCapture?.(pointer.pointerId); } catch { /* Synthetic events have no active pointer. */ }
333
+ });
334
+ // Window listeners also finish safely when capture is unavailable in an embedded browser.
335
+ scope.on(window, 'pointermove', (event) => {
336
+ const pointer = event as PointerEvent;
337
+ if (!active || pointer.pointerId !== active.pointerId) return;
338
+ pointer.preventDefault();
339
+ const samples = typeof pointer.getCoalescedEvents === 'function' ? pointer.getCoalescedEvents() : [];
340
+ for (const sample of samples) addPoint(sample);
341
+ addPoint(pointer);
342
+ }, { passive: false });
343
+ scope.on(window, 'pointerup', (event) => {
344
+ const pointer = event as PointerEvent;
345
+ if (!active || pointer.pointerId !== active.pointerId) return;
346
+ addPoint(pointer);
347
+ const completed = active;
348
+ active = null;
349
+ root.removeAttribute('data-mk-drawing');
350
+ release(completed.pointerId);
351
+ remember(); strokes = [...strokes, completed.stroke];
352
+ update(); emit('draw');
353
+ });
354
+ for (const type of ['pointercancel', 'lostpointercapture']) scope.on(type === 'pointercancel' ? window : surface, type, (event) => {
355
+ if (active?.pointerId === (event as PointerEvent).pointerId) cancel();
356
+ });
357
+ scope.on(window, 'blur', cancel);
358
+ scope.on(document, 'visibilitychange', () => { if (document.hidden) cancel(); });
359
+ for (const [button, action] of [[undoButton, undo], [redoButton, redo], [clearButton, clear]] as const) scope.on(button, 'click', () => {
360
+ if (locked() || button.disabled) return;
361
+ pulse(button); action();
362
+ });
363
+ if (typeof ResizeObserver === 'function') scope.observe(new ResizeObserver(render)).observe(surface);
364
+ scope.on(window, 'resize', render);
365
+ scope.add(() => { cancel(); feedback?.kill(); });
366
+ render(); update();
367
+
368
+ const exportSVG = (settings: SignatureExportOptions = {}) => {
369
+ const width = finite(settings.width) ? clamp(settings.width, 1, 4096) : dimensions.width;
370
+ const height = finite(settings.height) ? clamp(settings.height, 1, 4096) : dimensions.height;
371
+ const output = document.createElementNS(SVG_NS, 'svg');
372
+ output.setAttribute('width', String(rounded(width)));
373
+ output.setAttribute('height', String(rounded(height)));
374
+ output.setAttribute('viewBox', `0 0 ${width} ${height}`);
375
+ if (validColor(settings.background)) {
376
+ const background = document.createElementNS(SVG_NS, 'rect');
377
+ background.setAttribute('width', '100%'); background.setAttribute('height', '100%'); background.setAttribute('fill', settings.background);
378
+ output.append(background);
379
+ }
380
+ output.append(...strokes.map((stroke) => makePath(stroke, width, height)));
381
+ return new XMLSerializer().serializeToString(output);
382
+ };
383
+ const pending = new Set<() => void>();
384
+ scope.add(() => { for (const cancelExport of pending) cancelExport(); pending.clear(); });
385
+ return {
386
+ get data() { return exportData(); },
387
+ get isEmpty() { return !strokes.length; },
388
+ get canUndo() { return !!undoStack.length; },
389
+ get canRedo() { return !!redoStack.length; },
390
+ undo, redo, clear, exportData,
391
+ importData(value, notify = true) {
392
+ if (scope.disposed) return false;
393
+ const next = validatedData(value);
394
+ if (!next) return false;
395
+ if (sameStrokes(next.strokes, strokes)) return true;
396
+ cancel(); strokes = next.strokes; undoStack = []; redoStack = [];
397
+ render(); update();
398
+ if (notify) emit('import');
399
+ return true;
400
+ },
401
+ exportSVG: (settings) => scope.disposed ? '' : exportSVG(settings),
402
+ exportPNG(settings = {}) {
403
+ if (scope.disposed) return Promise.resolve(null);
404
+ const canvas = document.createElement('canvas');
405
+ let context: CanvasRenderingContext2D | null;
406
+ try { context = canvas.getContext('2d'); }
407
+ catch { return Promise.resolve(null); }
408
+ if (!context || typeof canvas.toBlob !== 'function') return Promise.resolve(null);
409
+ const scale = finite(settings.scale) ? clamp(settings.scale, 1, 4) : 2;
410
+ canvas.width = Math.round((finite(settings.width) ? clamp(settings.width, 1, 4096) : dimensions.width) * scale);
411
+ canvas.height = Math.round((finite(settings.height) ? clamp(settings.height, 1, 4096) : dimensions.height) * scale);
412
+ return new Promise<Blob | null>((resolve) => {
413
+ const image = new Image();
414
+ let finished = false;
415
+ const done = (blob: Blob | null) => {
416
+ if (finished) return;
417
+ finished = true; image.onload = null; image.onerror = null;
418
+ pending.delete(cancelExport); clearTimeout(timer); resolve(scope.disposed ? null : blob);
419
+ };
420
+ const cancelExport = () => { done(null); image.src = ''; };
421
+ const timer = setTimeout(cancelExport, 10_000);
422
+ pending.add(cancelExport);
423
+ image.onerror = () => done(null);
424
+ image.onload = () => {
425
+ try {
426
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
427
+ canvas.toBlob(done, 'image/png');
428
+ } catch { done(null); }
429
+ };
430
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(exportSVG(settings))}`;
431
+ });
432
+ },
433
+ setDisabled(value) { if (!scope.disposed) { cancel(); disabled = Boolean(value); update(); } },
434
+ setReadOnly(value) { if (!scope.disposed) { cancel(); readOnly = Boolean(value); update(); } },
435
+ destroy: () => scope.dispose(),
436
+ };
437
+ }
@@ -0,0 +1,56 @@
1
+ 'use client';
2
+ import { useReducer, useRef, type HTMLAttributes, type MutableRefObject } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createSignaturePad, type SignatureData, type SignaturePadInstance, type SignaturePadOptions } from './engine';
5
+
6
+ export interface SignaturePadProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'children' | 'dangerouslySetInnerHTML' | 'defaultValue'>, Omit<SignaturePadOptions, 'data'> {
7
+ /** Controlled data. Accept edits through onChange; rejected edits are restored after rendering. */
8
+ value?: SignatureData;
9
+ defaultValue?: SignatureData;
10
+ instanceRef?: MutableRefObject<SignaturePadInstance | null>;
11
+ }
12
+
13
+ /** <SignaturePad value={signature} onChange={setSignature} name="signature" />. */
14
+ export function SignaturePad({ value, defaultValue, label, hint, placeholder, color, width, disabled, readOnly, toolbar, name, historyLimit, onChange, instanceRef, className, ...props }: SignaturePadProps) {
15
+ // JavaScript callers can still supply these props; the engine owns all child nodes.
16
+ const { children: _children, dangerouslySetInnerHTML: _html, ...rootProps } = props as HTMLAttributes<HTMLDivElement>;
17
+ const ref = useRef<HTMLDivElement>(null);
18
+ const instance = useRef<SignaturePadInstance | null>(null);
19
+ const edited = useRef(value ?? defaultValue);
20
+ const current = useLatest(value);
21
+ const change = useLatest(onChange);
22
+ const [, refresh] = useReducer((revision: number) => revision + 1, 0);
23
+ useIsomorphicLayoutEffect(() => {
24
+ const created = createSignaturePad(ref.current, {
25
+ data: current.current ?? edited.current,
26
+ label, hint, placeholder, color, width, disabled, readOnly, toolbar, name, historyLimit,
27
+ onChange: (data, detail) => {
28
+ edited.current = data;
29
+ change.current?.(data, detail);
30
+ // A render also reconciles an unchanged controlled prop when the owner rejects an edit.
31
+ if (current.current !== undefined) refresh();
32
+ },
33
+ });
34
+ instance.current = created;
35
+ return () => {
36
+ edited.current = created.exportData();
37
+ created.destroy();
38
+ instance.current = null;
39
+ };
40
+ // eslint-disable-next-line react-hooks/exhaustive-deps
41
+ }, [label, hint, placeholder, color, width, toolbar, name, historyLimit]);
42
+ useIsomorphicLayoutEffect(() => {
43
+ const created = instance.current;
44
+ if (!created) return;
45
+ created.setDisabled(Boolean(disabled));
46
+ created.setReadOnly(Boolean(readOnly));
47
+ }, [disabled, readOnly]);
48
+ useIsomorphicLayoutEffect(() => {
49
+ if (value !== undefined) instance.current?.importData(value, false);
50
+ });
51
+ useIsomorphicLayoutEffect(() => {
52
+ if (instanceRef) instanceRef.current = instance.current;
53
+ return () => { if (instanceRef) instanceRef.current = null; };
54
+ }, [instanceRef, label, hint, placeholder, color, width, toolbar, name, historyLimit]);
55
+ return <div {...rootProps} ref={ref} className={['mk-signature-pad', className].filter(Boolean).join(' ')} />;
56
+ }
@@ -0,0 +1,103 @@
1
+ /* Normalized SVG ink survives layout changes. Only the drawing area captures touch. */
2
+ .mk-signature-pad,
3
+ mk-signature-pad {
4
+ --mk-signature-height: clamp(12rem, 32vw, 17rem);
5
+ display: block;
6
+ min-width: 0;
7
+ overflow: hidden;
8
+ border: 1px solid var(--mk-outline-soft);
9
+ border-radius: 24px;
10
+ background: var(--mk-surface-1);
11
+ color: var(--mk-text);
12
+ font-family: var(--mk-font);
13
+ }
14
+
15
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-surface] {
16
+ position: relative;
17
+ height: var(--mk-signature-height);
18
+ min-height: 8rem;
19
+ overflow: hidden;
20
+ background: var(--mk-signature-paper, var(--mk-surface-2));
21
+ cursor: crosshair;
22
+ touch-action: none;
23
+ user-select: none;
24
+ -webkit-user-select: none;
25
+ }
26
+
27
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-surface]::after {
28
+ position: absolute;
29
+ right: 1.5rem;
30
+ bottom: 2rem;
31
+ left: 1.5rem;
32
+ border-bottom: 1px dashed var(--mk-outline-soft);
33
+ content: '';
34
+ pointer-events: none;
35
+ }
36
+
37
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-drawing] {
38
+ position: absolute;
39
+ inset: 0;
40
+ display: block;
41
+ width: 100%;
42
+ height: 100%;
43
+ overflow: hidden;
44
+ }
45
+
46
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-placeholder] {
47
+ position: absolute;
48
+ inset: 0;
49
+ display: grid;
50
+ place-items: center;
51
+ color: var(--mk-muted);
52
+ font-size: 1.5rem;
53
+ font-style: italic;
54
+ font-weight: 400;
55
+ pointer-events: none;
56
+ }
57
+
58
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-placeholder][hidden] { display: none; }
59
+
60
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-footer] {
61
+ display: flex;
62
+ flex-wrap: wrap;
63
+ align-items: center;
64
+ justify-content: space-between;
65
+ gap: 0.75rem;
66
+ padding: 0.875rem 1rem 0;
67
+ }
68
+
69
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-toolbar] {
70
+ display: flex;
71
+ flex-wrap: wrap;
72
+ gap: 0.375rem;
73
+ }
74
+
75
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-action] {
76
+ min-height: 2.75rem;
77
+ padding: 0.5rem 0.875rem;
78
+ border: 1px solid var(--mk-outline-soft);
79
+ border-radius: 64px;
80
+ background: var(--mk-surface-3);
81
+ color: var(--mk-text);
82
+ font: inherit;
83
+ font-size: 0.8125rem;
84
+ font-weight: 500;
85
+ cursor: pointer;
86
+ }
87
+
88
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-action]:hover:enabled { background: var(--mk-surface-4); }
89
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-action]:focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 2px; }
90
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-action]:disabled { opacity: 0.4; cursor: default; }
91
+
92
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-status],
93
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-hint] {
94
+ color: var(--mk-muted);
95
+ font-size: 0.75rem;
96
+ line-height: 1.5;
97
+ }
98
+
99
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-status] { font-variant-numeric: tabular-nums; }
100
+ :is(.mk-signature-pad, mk-signature-pad) [data-mk-signature-hint] { margin: 0; padding: 0.75rem 1rem 1rem; }
101
+ :is(.mk-signature-pad, mk-signature-pad)[data-mk-readonly] [data-mk-signature-surface],
102
+ :is(.mk-signature-pad, mk-signature-pad)[data-mk-disabled] [data-mk-signature-surface] { cursor: default; touch-action: auto; }
103
+ :is(.mk-signature-pad, mk-signature-pad)[data-mk-disabled] [data-mk-signature-surface] { opacity: 0.55; }
@@ -0,0 +1,31 @@
1
+ # Voice Message Bubble
2
+
3
+ Burbuja entrante/saliente con autor, hora y estado de entrega. Compone el reproductor real [Waveform Player](../waveform-player/README.md); no graba, no solicita micrófono y no inicia audio automáticamente.
4
+
5
+ ```tsx
6
+ import { VoiceMessageBubble } from '@yewdiel21/motion-kit/react';
7
+ import '@yewdiel21/motion-kit/css/components/waveform-player';
8
+ import '@yewdiel21/motion-kit/css/components/voice-message-bubble';
9
+
10
+ <VoiceMessageBubble src="/mensaje.wav" peaks={[.2, .6, .9, .4]} author="Ana"
11
+ timestamp="10:42" direction="incoming" />
12
+ <VoiceMessageBubble src="/respuesta.wav" peaks={peaks} author="Tú"
13
+ timestamp="10:43" direction="outgoing" status="read" />
14
+ ```
15
+
16
+ ```html
17
+ <mk-voice-message-bubble src="/mensaje.wav" peaks="[0.2,0.6,0.9,0.4]"
18
+ author="Ana" timestamp="10:42" direction="incoming"></mk-voice-message-bubble>
19
+ <script type="module">
20
+ import { defineVoiceMessageBubble } from '@yewdiel21/motion-kit/elements';
21
+ defineVoiceMessageBubble();
22
+ </script>
23
+ ```
24
+
25
+ `createVoiceMessageBubble(root, options)` acepta todas las opciones del reproductor y añade `direction` (`incoming` por defecto u `outgoing`), `author`, `timestamp` (texto visible), `dateTime` (fecha legible por máquinas, opcional) y `status` (`sending`, `sent`, `delivered`, `read`, `failed`). El estado de entrega lo controla tu aplicación; no implica envío de mensajes. Autor/hora/estado vacíos se ocultan. El nombre accesible del reproductor incluye el autor, o usa `label` si lo especificas.
26
+
27
+ La instancia ofrece `play(): Promise<boolean>`, `pause()`, `seek(seconds)`, `update(partialOptions)`, `state`, `audio`, `destroy()`. `VoiceMessageBubble` admite `instanceRef`. `MkVoiceMessageBubbleElement` expone los mismos métodos y actualiza atributos sin recrear el audio. `peaks` y `playback-rates` son JSON; el atributo de fecha es `date-time`. Los cambios de autor, dirección y hora conservan la reproducción. Eventos `mk:audio-*` del reproductor burbujean desde la burbuja.
28
+
29
+ Necesita **waveform-player.css y voice-message-bubble.css**. El motor también depende de `waveform-player/engine.ts`; copiar la receta debe incluir esa dependencia. Usa los tokens del tema y respeta movimiento reducido a través del reproductor. Los picos se suministran de antemano; no realiza análisis automático de audio. Agrega una transcripción cerca del mensaje para contenido hablado. Los demos usan un sonido sintetizado original local de seis segundos, sin voz humana.
30
+
31
+ Tipos: `VoiceMessageBubbleOptions`, `VoiceMessageBubbleInstance`, `VoiceMessageStatus`, `VoiceMessageBubbleProps`.
@@ -0,0 +1,20 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createVoiceMessageBubble, type VoiceMessageBubbleInstance, type VoiceMessageBubbleOptions } from './engine';
3
+
4
+ export class MkVoiceMessageBubbleElement extends MkElement<VoiceMessageBubbleInstance> {
5
+ static options: Record<string, OptionType> = { src: 'string', peaks: 'json', label: 'string', disabled: 'boolean', playbackRates: 'json', direction: 'string', author: 'string', timestamp: 'string', dateTime: 'string', status: 'string' };
6
+ protected create(options: Record<string, unknown>) { return createVoiceMessageBubble(this, options as VoiceMessageBubbleOptions); }
7
+ play() { return this.instance?.play() ?? Promise.resolve(false); }
8
+ pause() { this.instance?.pause(); }
9
+ seek(seconds: number) { this.instance?.seek(seconds); }
10
+ update(options: Partial<VoiceMessageBubbleOptions>) { this.instance?.update(options); }
11
+ get state() { return this.instance?.state; }
12
+ get audio() { return this.instance?.audio ?? null; }
13
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
14
+ if (previous === next || !this.instance) return;
15
+ const option = name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
16
+ if (!Object.hasOwn(MkVoiceMessageBubbleElement.options, option)) return;
17
+ this.instance.update({ [option]: this.options[option] });
18
+ }
19
+ }
20
+ export const defineVoiceMessageBubble = () => defineElement('mk-voice-message-bubble', MkVoiceMessageBubbleElement);