@schnsrw/casual-sheets 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +200 -0
- package/dist/embed.cjs +211 -0
- package/dist/embed.cjs.map +1 -0
- package/dist/embed.d.cts +193 -0
- package/dist/embed.d.ts +193 -0
- package/dist/embed.js +183 -0
- package/dist/embed.js.map +1 -0
- package/dist/index.cjs +899 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +864 -0
- package/dist/index.js.map +1 -0
- package/dist/signing.cjs +716 -0
- package/dist/signing.cjs.map +1 -0
- package/dist/signing.d.cts +141 -0
- package/dist/signing.d.ts +141 -0
- package/dist/signing.js +683 -0
- package/dist/signing.js.map +1 -0
- package/dist/types-s_O0u6Cg.d.cts +90 -0
- package/dist/types-s_O0u6Cg.d.ts +90 -0
- package/package.json +77 -0
- package/src/embed/EmbedTransport.ts +295 -0
- package/src/embed/EmbedTransport.unit.test.ts +161 -0
- package/src/embed/index.ts +40 -0
- package/src/embed/protocol.ts +161 -0
- package/src/index.ts +13 -0
- package/src/signing/SigningPane.tsx +374 -0
- package/src/signing/SigningProvider.tsx +126 -0
- package/src/signing/captures.tsx +316 -0
- package/src/signing/controller.ts +151 -0
- package/src/signing/controller.unit.test.ts +133 -0
- package/src/signing/index.ts +44 -0
- package/src/signing/types.ts +89 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SigningPane — the floating sidebar that walks the signer through
|
|
3
|
+
* fields. Lives inside <SigningProvider>; uses the controller via
|
|
4
|
+
* `useSigning()`.
|
|
5
|
+
*
|
|
6
|
+
* Layout: a right-anchored panel showing
|
|
7
|
+
* - Banner (optional, from session config)
|
|
8
|
+
* - Field list with state markers (active / signed / pending)
|
|
9
|
+
* - Method picker for the active field
|
|
10
|
+
* - Capture surface (Drawn / Typed / Uploaded) depending on method
|
|
11
|
+
* - Footer: Cancel + Complete
|
|
12
|
+
*
|
|
13
|
+
* The pane is presentation-only — every action routes through the
|
|
14
|
+
* SigningProvider's helpers (signField, completeIfReady, cancel)
|
|
15
|
+
* so the controller stays the single source of truth.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useEffect, useState, type CSSProperties } from 'react';
|
|
19
|
+
|
|
20
|
+
import { useSigning } from './SigningProvider';
|
|
21
|
+
import type { SignatureField, SignatureMethod, SignedFieldPayload } from './types';
|
|
22
|
+
import {
|
|
23
|
+
DrawnSignaturePad,
|
|
24
|
+
TypedSignatureField,
|
|
25
|
+
UploadedSignatureField,
|
|
26
|
+
type CapturedSignature,
|
|
27
|
+
} from './captures';
|
|
28
|
+
|
|
29
|
+
export interface SigningPaneProps {
|
|
30
|
+
/** Optional banner override; falls back to session.banner. */
|
|
31
|
+
banner?: string;
|
|
32
|
+
/** Optional data-testid root. */
|
|
33
|
+
testId?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function SigningPane({ banner, testId = 'signing-pane' }: SigningPaneProps) {
|
|
37
|
+
const ctx = useSigning();
|
|
38
|
+
if (!ctx) return null;
|
|
39
|
+
|
|
40
|
+
const { snapshot, signField, completeIfReady, cancel } = ctx;
|
|
41
|
+
if (snapshot.isComplete || snapshot.isCancelled) return null;
|
|
42
|
+
|
|
43
|
+
const active: SignatureField | null =
|
|
44
|
+
snapshot.activeFieldIndex >= 0 ? snapshot.fields[snapshot.activeFieldIndex] : null;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<aside style={paneStyle} role="region" aria-label="Signing pane" data-testid={testId}>
|
|
48
|
+
{banner && (
|
|
49
|
+
<div style={bannerStyle} data-testid={`${testId}-banner`}>
|
|
50
|
+
{banner}
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
<div style={listStyle} data-testid={`${testId}-fields`}>
|
|
54
|
+
{snapshot.fields.map((f, i) => {
|
|
55
|
+
const isSigned = !!snapshot.signed[f.fieldId];
|
|
56
|
+
const isActive = i === snapshot.activeFieldIndex;
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
key={f.fieldId}
|
|
60
|
+
style={listItemStyle(isActive, isSigned)}
|
|
61
|
+
data-testid={`${testId}-field-${f.fieldId}`}
|
|
62
|
+
data-state={isSigned ? 'signed' : isActive ? 'active' : 'pending'}
|
|
63
|
+
>
|
|
64
|
+
<span style={listIconStyle(isSigned)} aria-hidden="true">
|
|
65
|
+
{isSigned ? '✓' : i + 1}
|
|
66
|
+
</span>
|
|
67
|
+
<span style={listLabelStyle}>{f.label}</span>
|
|
68
|
+
{!f.required && (
|
|
69
|
+
<span style={optionalChipStyle} aria-label="Optional">
|
|
70
|
+
optional
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
})}
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{active && (
|
|
79
|
+
<ActiveFieldEditor
|
|
80
|
+
field={active}
|
|
81
|
+
testId={testId}
|
|
82
|
+
onCapture={async (cap, method) => {
|
|
83
|
+
const payload: SignedFieldPayload = {
|
|
84
|
+
fieldId: active.fieldId,
|
|
85
|
+
method,
|
|
86
|
+
bytes: cap.bytes,
|
|
87
|
+
mime: cap.mime,
|
|
88
|
+
signedAt: new Date().toISOString(),
|
|
89
|
+
};
|
|
90
|
+
await signField(payload);
|
|
91
|
+
}}
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
{!active && snapshot.canComplete && (
|
|
96
|
+
<div style={completeBlockStyle} data-testid={`${testId}-complete-block`}>
|
|
97
|
+
All required signatures collected. Ready to finalise.
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
<footer style={footerStyle}>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
onClick={() => cancel('signer_cancelled')}
|
|
105
|
+
style={secondaryBtnStyle()}
|
|
106
|
+
data-testid={`${testId}-cancel`}
|
|
107
|
+
>
|
|
108
|
+
Cancel
|
|
109
|
+
</button>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
onClick={() => void completeIfReady()}
|
|
113
|
+
disabled={!snapshot.canComplete}
|
|
114
|
+
style={primaryBtnStyle(!snapshot.canComplete)}
|
|
115
|
+
data-testid={`${testId}-complete`}
|
|
116
|
+
>
|
|
117
|
+
Complete
|
|
118
|
+
</button>
|
|
119
|
+
</footer>
|
|
120
|
+
</aside>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------
|
|
125
|
+
// Active field editor — method picker + capture surface
|
|
126
|
+
// ---------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
function ActiveFieldEditor({
|
|
129
|
+
field,
|
|
130
|
+
testId,
|
|
131
|
+
onCapture,
|
|
132
|
+
}: {
|
|
133
|
+
field: SignatureField;
|
|
134
|
+
testId: string;
|
|
135
|
+
onCapture: (cap: CapturedSignature, method: SignatureMethod) => void | Promise<void>;
|
|
136
|
+
}) {
|
|
137
|
+
const [method, setMethod] = useState<SignatureMethod>(field.methods[0]);
|
|
138
|
+
|
|
139
|
+
// Reset the method picker whenever the active field changes — a
|
|
140
|
+
// method that was valid for the previous field may not be in this
|
|
141
|
+
// field's list.
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
setMethod(field.methods[0]);
|
|
144
|
+
}, [field]);
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div style={editorStyle} data-testid={`${testId}-editor`}>
|
|
148
|
+
<div style={editorHeaderStyle}>
|
|
149
|
+
<div style={editorLabelStyle}>{field.label}</div>
|
|
150
|
+
{field.signer?.name && <div style={editorSignerStyle}>{field.signer.name}</div>}
|
|
151
|
+
</div>
|
|
152
|
+
{field.methods.length > 1 && (
|
|
153
|
+
<div style={methodTabsStyle} role="tablist" data-testid={`${testId}-methods`}>
|
|
154
|
+
{field.methods.map((m) => (
|
|
155
|
+
<button
|
|
156
|
+
key={m}
|
|
157
|
+
type="button"
|
|
158
|
+
role="tab"
|
|
159
|
+
aria-selected={method === m}
|
|
160
|
+
onClick={() => setMethod(m)}
|
|
161
|
+
style={methodTabStyle(method === m)}
|
|
162
|
+
data-testid={`${testId}-method-${m}`}
|
|
163
|
+
>
|
|
164
|
+
{methodLabel(m)}
|
|
165
|
+
</button>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
<div style={captureWrapStyle}>
|
|
170
|
+
{method === 'drawn' && <DrawnSignaturePad onCapture={(c) => onCapture(c, 'drawn')} />}
|
|
171
|
+
{method === 'typed' && (
|
|
172
|
+
<TypedSignatureField
|
|
173
|
+
defaultText={field.signer?.name ?? ''}
|
|
174
|
+
onCapture={(c) => onCapture(c, 'typed')}
|
|
175
|
+
/>
|
|
176
|
+
)}
|
|
177
|
+
{method === 'uploaded' && (
|
|
178
|
+
<UploadedSignatureField onCapture={(c) => onCapture(c, 'uploaded')} />
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function methodLabel(m: SignatureMethod): string {
|
|
186
|
+
switch (m) {
|
|
187
|
+
case 'drawn':
|
|
188
|
+
return 'Draw';
|
|
189
|
+
case 'typed':
|
|
190
|
+
return 'Type';
|
|
191
|
+
case 'uploaded':
|
|
192
|
+
return 'Upload';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------
|
|
197
|
+
// Styles
|
|
198
|
+
// ---------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
const paneStyle: CSSProperties = {
|
|
201
|
+
position: 'fixed',
|
|
202
|
+
top: 16,
|
|
203
|
+
right: 16,
|
|
204
|
+
bottom: 16,
|
|
205
|
+
width: 360,
|
|
206
|
+
maxWidth: '100vw',
|
|
207
|
+
display: 'flex',
|
|
208
|
+
flexDirection: 'column',
|
|
209
|
+
gap: 14,
|
|
210
|
+
padding: 16,
|
|
211
|
+
background: 'var(--doc-surface, #fff)',
|
|
212
|
+
border: '1px solid var(--doc-border, #cbd5e1)',
|
|
213
|
+
borderRadius: 12,
|
|
214
|
+
boxShadow: '0 1px 1px rgba(0, 0, 0, 0.04), 0 6px 24px rgba(15, 23, 42, 0.12)',
|
|
215
|
+
fontFamily: 'inherit',
|
|
216
|
+
zIndex: 9000,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const bannerStyle: CSSProperties = {
|
|
220
|
+
padding: '8px 10px',
|
|
221
|
+
background: 'var(--doc-surface-2, #f1f5f9)',
|
|
222
|
+
border: '1px solid var(--doc-border-light, #e2e8f0)',
|
|
223
|
+
borderRadius: 6,
|
|
224
|
+
fontSize: 12,
|
|
225
|
+
color: 'var(--doc-text-muted, #475569)',
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const listStyle: CSSProperties = {
|
|
229
|
+
display: 'flex',
|
|
230
|
+
flexDirection: 'column',
|
|
231
|
+
gap: 4,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
function listItemStyle(active: boolean, signed: boolean): CSSProperties {
|
|
235
|
+
return {
|
|
236
|
+
display: 'flex',
|
|
237
|
+
alignItems: 'center',
|
|
238
|
+
gap: 10,
|
|
239
|
+
padding: '8px 10px',
|
|
240
|
+
borderRadius: 6,
|
|
241
|
+
background: active ? 'var(--doc-surface-2, #f1f5f9)' : signed ? 'transparent' : 'transparent',
|
|
242
|
+
border: active ? '1px solid var(--doc-border, #cbd5e1)' : '1px solid transparent',
|
|
243
|
+
opacity: signed && !active ? 0.7 : 1,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function listIconStyle(signed: boolean): CSSProperties {
|
|
248
|
+
return {
|
|
249
|
+
display: 'inline-flex',
|
|
250
|
+
alignItems: 'center',
|
|
251
|
+
justifyContent: 'center',
|
|
252
|
+
width: 22,
|
|
253
|
+
height: 22,
|
|
254
|
+
borderRadius: '50%',
|
|
255
|
+
background: signed ? 'var(--doc-accent, #2563eb)' : 'var(--doc-surface-2, #f1f5f9)',
|
|
256
|
+
color: signed ? '#fff' : 'var(--doc-text-muted, #475569)',
|
|
257
|
+
fontSize: 12,
|
|
258
|
+
fontWeight: 600,
|
|
259
|
+
flexShrink: 0,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const listLabelStyle: CSSProperties = {
|
|
264
|
+
flex: 1,
|
|
265
|
+
fontSize: 13,
|
|
266
|
+
color: 'var(--doc-text, #0f172a)',
|
|
267
|
+
fontWeight: 500,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const optionalChipStyle: CSSProperties = {
|
|
271
|
+
fontSize: 11,
|
|
272
|
+
color: 'var(--doc-text-muted, #64748b)',
|
|
273
|
+
padding: '2px 6px',
|
|
274
|
+
background: 'var(--doc-surface-2, #f1f5f9)',
|
|
275
|
+
borderRadius: 4,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const editorStyle: CSSProperties = {
|
|
279
|
+
display: 'flex',
|
|
280
|
+
flexDirection: 'column',
|
|
281
|
+
gap: 12,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const editorHeaderStyle: CSSProperties = {
|
|
285
|
+
display: 'flex',
|
|
286
|
+
flexDirection: 'column',
|
|
287
|
+
gap: 2,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const editorLabelStyle: CSSProperties = {
|
|
291
|
+
fontSize: 13,
|
|
292
|
+
fontWeight: 600,
|
|
293
|
+
color: 'var(--doc-text, #0f172a)',
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const editorSignerStyle: CSSProperties = {
|
|
297
|
+
fontSize: 12,
|
|
298
|
+
color: 'var(--doc-text-muted, #64748b)',
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const methodTabsStyle: CSSProperties = {
|
|
302
|
+
display: 'flex',
|
|
303
|
+
gap: 4,
|
|
304
|
+
padding: 2,
|
|
305
|
+
background: 'var(--doc-surface-2, #f1f5f9)',
|
|
306
|
+
borderRadius: 6,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
function methodTabStyle(selected: boolean): CSSProperties {
|
|
310
|
+
return {
|
|
311
|
+
flex: 1,
|
|
312
|
+
padding: '6px 10px',
|
|
313
|
+
background: selected ? 'var(--doc-surface, #fff)' : 'transparent',
|
|
314
|
+
border: 'none',
|
|
315
|
+
borderRadius: 4,
|
|
316
|
+
fontSize: 12,
|
|
317
|
+
fontWeight: 500,
|
|
318
|
+
color: selected ? 'var(--doc-text, #0f172a)' : 'var(--doc-text-muted, #475569)',
|
|
319
|
+
cursor: 'pointer',
|
|
320
|
+
fontFamily: 'inherit',
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const captureWrapStyle: CSSProperties = {
|
|
325
|
+
display: 'flex',
|
|
326
|
+
flexDirection: 'column',
|
|
327
|
+
gap: 8,
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const completeBlockStyle: CSSProperties = {
|
|
331
|
+
padding: '10px 12px',
|
|
332
|
+
background: 'rgba(34, 197, 94, 0.08)',
|
|
333
|
+
border: '1px solid rgba(34, 197, 94, 0.28)',
|
|
334
|
+
borderRadius: 6,
|
|
335
|
+
fontSize: 12,
|
|
336
|
+
color: 'rgb(20, 83, 45)',
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const footerStyle: CSSProperties = {
|
|
340
|
+
display: 'flex',
|
|
341
|
+
justifyContent: 'flex-end',
|
|
342
|
+
gap: 8,
|
|
343
|
+
marginTop: 'auto',
|
|
344
|
+
paddingTop: 12,
|
|
345
|
+
borderTop: '1px solid var(--doc-border-light, #e2e8f0)',
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
function primaryBtnStyle(disabled: boolean): CSSProperties {
|
|
349
|
+
return {
|
|
350
|
+
padding: '8px 16px',
|
|
351
|
+
borderRadius: 6,
|
|
352
|
+
border: '1px solid transparent',
|
|
353
|
+
background: disabled ? 'var(--doc-border, #cbd5e1)' : 'var(--doc-accent, #2563eb)',
|
|
354
|
+
color: disabled ? 'var(--doc-text-muted, #64748b)' : '#fff',
|
|
355
|
+
fontSize: 13,
|
|
356
|
+
fontWeight: 600,
|
|
357
|
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
|
358
|
+
fontFamily: 'inherit',
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function secondaryBtnStyle(): CSSProperties {
|
|
363
|
+
return {
|
|
364
|
+
padding: '8px 16px',
|
|
365
|
+
borderRadius: 6,
|
|
366
|
+
border: '1px solid var(--doc-border, #cbd5e1)',
|
|
367
|
+
background: 'transparent',
|
|
368
|
+
color: 'var(--doc-text, #0f172a)',
|
|
369
|
+
fontSize: 13,
|
|
370
|
+
fontWeight: 500,
|
|
371
|
+
cursor: 'pointer',
|
|
372
|
+
fontFamily: 'inherit',
|
|
373
|
+
};
|
|
374
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SigningProvider — wraps a signing session in React context so
|
|
3
|
+
* descendant components (SigningPane, capture surfaces, and the
|
|
4
|
+
* editor's field-highlight decorations) all see the same snapshot.
|
|
5
|
+
*
|
|
6
|
+
* The pure state machine lives in `./controller.ts`; this file
|
|
7
|
+
* just bridges it to React.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
createSigningController,
|
|
14
|
+
type SigningController,
|
|
15
|
+
type SigningSnapshot,
|
|
16
|
+
} from './controller';
|
|
17
|
+
import type {
|
|
18
|
+
CancelReason,
|
|
19
|
+
SignatureCompletePayload,
|
|
20
|
+
SignedFieldPayload,
|
|
21
|
+
SigningSessionConfig,
|
|
22
|
+
} from './types';
|
|
23
|
+
|
|
24
|
+
interface SigningContextValue {
|
|
25
|
+
controller: SigningController;
|
|
26
|
+
snapshot: SigningSnapshot;
|
|
27
|
+
/** Convenience wrapper around controller.signField that also fires the host's onFieldSigned. */
|
|
28
|
+
signField: (payload: SignedFieldPayload) => Promise<void>;
|
|
29
|
+
/** Convenience wrapper around controller.complete + host's onComplete. */
|
|
30
|
+
completeIfReady: () => Promise<void>;
|
|
31
|
+
/** Convenience wrapper around controller.cancel + host's onCancel. */
|
|
32
|
+
cancel: (reason: CancelReason) => void;
|
|
33
|
+
/** Source-of-truth document bytes the editor renders. Captured at
|
|
34
|
+
* signing-session open; persists for the lifetime of the session. */
|
|
35
|
+
baseDocumentBytes: ArrayBuffer | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SigningContext = createContext<SigningContextValue | null>(null);
|
|
39
|
+
|
|
40
|
+
export interface SigningProviderProps {
|
|
41
|
+
/** Active signing session config. When null, signing is off and
|
|
42
|
+
* children render unchanged. */
|
|
43
|
+
session: SigningSessionConfig | null;
|
|
44
|
+
/** Current document bytes the editor is rendering. Captured into
|
|
45
|
+
* the context so the eventual `complete` payload carries the
|
|
46
|
+
* right base buffer. */
|
|
47
|
+
documentBytes: ArrayBuffer | null;
|
|
48
|
+
children: ReactNode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function SigningProvider({ session, documentBytes, children }: SigningProviderProps) {
|
|
52
|
+
if (!session) {
|
|
53
|
+
return <>{children}</>;
|
|
54
|
+
}
|
|
55
|
+
return (
|
|
56
|
+
<SigningProviderInner session={session} documentBytes={documentBytes}>
|
|
57
|
+
{children}
|
|
58
|
+
</SigningProviderInner>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function SigningProviderInner({
|
|
63
|
+
session,
|
|
64
|
+
documentBytes,
|
|
65
|
+
children,
|
|
66
|
+
}: {
|
|
67
|
+
session: SigningSessionConfig;
|
|
68
|
+
documentBytes: ArrayBuffer | null;
|
|
69
|
+
children: ReactNode;
|
|
70
|
+
}) {
|
|
71
|
+
// Controller is constructed once per session-config identity.
|
|
72
|
+
// Hosts that swap sessions mid-tree must change the React `key`
|
|
73
|
+
// on the provider — otherwise stale state would leak.
|
|
74
|
+
const controller = useMemo(
|
|
75
|
+
() => createSigningController(session.fields, session.mode),
|
|
76
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
77
|
+
[session.fields, session.mode],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const [snapshot, setSnapshot] = useState<SigningSnapshot>(() => controller.snapshot());
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const unsub = controller.subscribe(setSnapshot);
|
|
84
|
+
return unsub;
|
|
85
|
+
}, [controller]);
|
|
86
|
+
|
|
87
|
+
const value = useMemo<SigningContextValue>(
|
|
88
|
+
() => ({
|
|
89
|
+
controller,
|
|
90
|
+
snapshot,
|
|
91
|
+
signField: async (payload) => {
|
|
92
|
+
controller.signField(payload);
|
|
93
|
+
await session.onFieldSigned?.(payload);
|
|
94
|
+
},
|
|
95
|
+
completeIfReady: async () => {
|
|
96
|
+
if (!controller.snapshot().canComplete) return;
|
|
97
|
+
const final = controller.complete();
|
|
98
|
+
const completePayload: SignatureCompletePayload = {
|
|
99
|
+
fieldIds: final.fields
|
|
100
|
+
.map((f) => f.fieldId)
|
|
101
|
+
.filter((id) => final.signed[id] !== undefined),
|
|
102
|
+
bytes: documentBytes ?? new ArrayBuffer(0),
|
|
103
|
+
fields: final.signed,
|
|
104
|
+
};
|
|
105
|
+
await session.onComplete?.(completePayload);
|
|
106
|
+
},
|
|
107
|
+
cancel: (reason) => {
|
|
108
|
+
controller.cancel();
|
|
109
|
+
session.onCancel?.({ reason });
|
|
110
|
+
},
|
|
111
|
+
baseDocumentBytes: documentBytes,
|
|
112
|
+
}),
|
|
113
|
+
[controller, snapshot, session, documentBytes],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return <SigningContext.Provider value={value}>{children}</SigningContext.Provider>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Hook for descendants of <SigningProvider>. Returns null when
|
|
121
|
+
* no signing session is active — caller renders its non-signing
|
|
122
|
+
* shape.
|
|
123
|
+
*/
|
|
124
|
+
export function useSigning(): SigningContextValue | null {
|
|
125
|
+
return useContext(SigningContext);
|
|
126
|
+
}
|