Skip to content

Commit 13de28a

Browse files
committed
feat: useEmbed
1 parent 8e3c8e3 commit 13de28a

File tree

2 files changed

+214
-10
lines changed

2 files changed

+214
-10
lines changed

react/src/hook.tsx

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import * as React from 'react';
2+
3+
const DEFAULT_REQUEST_TIMEOUT_IN_MS = 5000;
4+
5+
const generateRandomID = () => {
6+
return Math.random().toString(36).substring(2, 15);
7+
};
8+
9+
export type EmbedActions = {
10+
submit: (options: { downloadCopyOnDevice: boolean }) => Promise<Result['data']['result']>;
11+
selectTool: (
12+
toolType: 'TEXT' | 'BOXED_TEXT' | 'CHECKBOX' | 'PICTURE' | 'SIGNATURE' | null,
13+
) => Promise<Result['data']['result']>;
14+
};
15+
16+
export type EventPayload = {
17+
type: string;
18+
data: unknown;
19+
};
20+
21+
export function sendEvent(iframe: HTMLIFrameElement, payload: EventPayload) {
22+
const requestId = generateRandomID();
23+
return new Promise<Result['data']['result']>((resolve) => {
24+
try {
25+
const handleMessage = (event: MessageEvent<string>) => {
26+
const parsedEvent: Result = (() => {
27+
try {
28+
const parsedEvent = JSON.parse(event.data);
29+
30+
if (parsedEvent.type !== 'REQUEST_RESULT') {
31+
return {
32+
data: {
33+
request_id: null,
34+
},
35+
};
36+
}
37+
38+
return parsedEvent;
39+
} catch (e) {
40+
return null;
41+
}
42+
})();
43+
const isTargetIframe = event.source === iframe.contentWindow;
44+
const isMatchingResponse = parsedEvent.data.request_id === requestId;
45+
46+
if (isTargetIframe && isMatchingResponse) {
47+
resolve(parsedEvent.data.result);
48+
window.removeEventListener('message', handleMessage);
49+
}
50+
};
51+
52+
window.addEventListener('message', handleMessage);
53+
54+
iframe.contentWindow?.postMessage(JSON.stringify({ ...payload, request_id: requestId }), '*');
55+
56+
const timeoutId = setTimeout(() => {
57+
resolve({
58+
success: false,
59+
error: {
60+
code: 'unexpected:request_timed_out',
61+
message: 'The request timed out: try again',
62+
},
63+
} satisfies Result['data']['result']);
64+
window.removeEventListener('message', handleMessage);
65+
}, DEFAULT_REQUEST_TIMEOUT_IN_MS);
66+
67+
const cleanup = () => clearTimeout(timeoutId);
68+
window.addEventListener('message', cleanup);
69+
} catch (e) {
70+
const error = e as Error;
71+
resolve({
72+
success: false,
73+
error: {
74+
code: 'unexpected:failed_processing_request',
75+
message: `The following error happened: ${error.name}:${error.message}`,
76+
},
77+
});
78+
}
79+
});
80+
}
81+
82+
type ErrorCodePrefix = 'bad_request' | 'unexpected';
83+
84+
type Result = {
85+
type: 'REQUEST_RESULT';
86+
data: {
87+
request_id: string;
88+
result:
89+
| { success: true }
90+
| {
91+
success: false;
92+
error: { code: `${ErrorCodePrefix}:${string}`; message: string };
93+
};
94+
};
95+
};
96+
97+
export const useEmbed = (): { embedRef: React.RefObject<EmbedRefHandlers | null>; actions: EmbedActions } => {
98+
const embedRef = React.useRef<EmbedRefHandlers>(null);
99+
100+
const handleSubmit: EmbedRefHandlers['submit'] = React.useCallback(
101+
async ({ downloadCopyOnDevice }): Promise<Result['data']['result']> => {
102+
if (embedRef.current === null) {
103+
return Promise.resolve({
104+
success: false as const,
105+
error: {
106+
code: 'bad_request:embed_ref_not_available' as const,
107+
message: 'embedRef is not available: make sure to pass embedRef to the <Embed /> component',
108+
},
109+
});
110+
}
111+
112+
const result = await embedRef.current.submit({ downloadCopyOnDevice });
113+
114+
return result;
115+
},
116+
[],
117+
);
118+
119+
const handleSelectTool: EmbedRefHandlers['selectTool'] = React.useCallback(
120+
async (toolType): Promise<Result['data']['result']> => {
121+
if (embedRef.current === null) {
122+
return Promise.resolve({
123+
success: false as const,
124+
error: {
125+
code: 'bad_request:embed_ref_not_available' as const,
126+
message: 'embedRef is not available: make sure to pass embedRef to the <Embed /> component',
127+
},
128+
});
129+
}
130+
131+
const result = await embedRef.current.selectTool(toolType);
132+
133+
return result;
134+
},
135+
[],
136+
);
137+
138+
return {
139+
embedRef,
140+
actions: {
141+
submit: handleSubmit,
142+
selectTool: handleSelectTool,
143+
},
144+
};
145+
};
146+
147+
export type EmbedRefHandlers = EmbedActions;

react/src/index.tsx

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import * as React from 'react';
22
import { createPortal } from 'react-dom';
3+
import { sendEvent, EmbedRefHandlers, useEmbed } from './hook';
34

45
import './styles.scss';
56

7+
export { useEmbed };
8+
69
export type EmbedEvent =
710
| { type: 'DOCUMENT_LOADED'; data: { document_id: string } }
811
| { type: 'SUBMISSION_SENT'; data: { submission_id: string } };
@@ -128,15 +131,58 @@ type DocumentToLoadState =
128131
isEditorReady: boolean;
129132
};
130133

131-
export const EmbedPDF: React.FC<Props> = (props) => {
134+
export const EmbedPDF = React.forwardRef<EmbedRefHandlers, Props>((props, ref) => {
132135
const { context, companyIdentifier, locale } = props;
136+
const editorActionsReadyRef = React.useRef<Promise<void>>(null);
137+
const editorActionsReadyResolveRef = React.useRef<() => void>(null);
133138
const [documentState, setDocumentState] = React.useState<DocumentToLoadState>({
134139
type: null,
135140
value: null,
136141
isEditorReady: false,
137142
});
138143
const iframeRef = React.useRef<HTMLIFrameElement>(null);
139144

145+
const submit: EmbedRefHandlers['submit'] = React.useCallback(async ({ downloadCopyOnDevice }) => {
146+
if (!iframeRef.current) {
147+
throw Error('Unexpected');
148+
}
149+
150+
await editorActionsReadyRef.current;
151+
152+
const eventResponse = await sendEvent(iframeRef.current, {
153+
type: 'SUBMIT',
154+
data: { download_copy: downloadCopyOnDevice },
155+
});
156+
157+
return eventResponse;
158+
}, []);
159+
160+
const selectTool: EmbedRefHandlers['selectTool'] = React.useCallback(async (toolType) => {
161+
if (!iframeRef.current) {
162+
throw Error('Unexpected');
163+
}
164+
165+
await editorActionsReadyRef.current;
166+
167+
const eventResponse = await sendEvent(iframeRef.current, {
168+
type: 'SELECT_TOOL',
169+
data: { tool: toolType },
170+
});
171+
172+
return eventResponse;
173+
}, []);
174+
175+
React.useImperativeHandle(ref, () => ({
176+
submit,
177+
selectTool,
178+
}));
179+
180+
React.useEffect(() => {
181+
editorActionsReadyRef.current = new Promise((resolve) => {
182+
editorActionsReadyResolveRef.current = resolve;
183+
});
184+
}, []);
185+
140186
const url: string | null = isInlineComponent(props)
141187
? (props.documentURL ?? null)
142188
: ((props.children as { props?: { href: string } })?.props?.href ?? null);
@@ -235,19 +281,30 @@ export const EmbedPDF: React.FC<Props> = (props) => {
235281
}
236282
})();
237283

284+
const handleEmbedEvent = async (payload: EmbedEvent) => {
285+
try {
286+
await props.onEmbedEvent?.(payload);
287+
} catch (e) {
288+
console.error(`onEmbedEvent failed to execute: ${JSON.stringify(e)}`);
289+
}
290+
};
291+
238292
switch (payload?.type) {
239293
case 'EDITOR_READY':
240294
setDocumentState((prev) => ({ ...prev, isEditorReady: true }));
241295
return;
242-
case 'DOCUMENT_LOADED':
243-
case 'SUBMISSION_SENT':
244-
try {
245-
await props.onEmbedEvent?.(payload);
246-
} catch (e) {
247-
console.error(`onEmbedEvent failed to execute: ${JSON.stringify(e)}`);
248-
}
249-
296+
case 'DOCUMENT_LOADED': {
297+
// EDGE-CASE handling
298+
// Timeout necessary for now due to a race condition on SimplePDF's end
299+
// Without it actions.submit prior to the editor being loaded resolves to "document not found"
300+
await setTimeout(() => editorActionsReadyResolveRef.current?.(), 200);
301+
await handleEmbedEvent(payload);
302+
return;
303+
}
304+
case 'SUBMISSION_SENT': {
305+
await handleEmbedEvent(payload);
250306
return;
307+
}
251308

252309
default:
253310
return;
@@ -315,4 +372,4 @@ export const EmbedPDF: React.FC<Props> = (props) => {
315372
}
316373

317374
return <ModalComponent children={props.children} editorURL={editorURL} ref={iframeRef} />;
318-
};
375+
});

0 commit comments

Comments
 (0)