Skip to content

Commit 344b9d6

Browse files
authored
feat: useEmbed (#17)
## Background This PR introduces the hook `useEmbed` that allows to programmatically control the editor: ```tsx import { EmbedPDF, useEmbed } from "@simplepdf/react-embed-pdf"; const { embedRef, actions } = useEmbed(); return ( <> <button onClick={() => await actions.submit({ downloadCopyOnDevice: false })}>Submit</button> <button onClick={() => await actions.selectTool('TEXT')}>Select Text Tool</button> <EmbedPDF companyIdentifier="yourcompany" ref={embedRef} mode="inline" style={{ width: 900, height: 800 }} documentURL="https://cdn.simplepdf.com/simple-pdf/assets/sample.pdf" /> </> ); ``` Coupled with the upcoming `headless` SimplePDF feature, this allows to fully customise the look and feel of SimplePDF by removing the sidebar entirely and controlling the editor programmatically. **Currently the two following actions have been implemented:** - Select tool - Submit **Upcoming actions:** - Fill fields ## Changes - [x] Introduce `useEmbed` - [x] Add documentation - [x] Publish `@simplepdf/react-embed-pdf@1.9.0`
1 parent 8e3c8e3 commit 344b9d6

File tree

5 files changed

+252
-13
lines changed

5 files changed

+252
-13
lines changed

react/README.md

+35
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,35 @@ import { EmbedPDF } from '@simplepdf/react-embed-pdf';
120120
/>;
121121
```
122122

123+
### Programmatic Control
124+
125+
_Requires a SimplePDF account_
126+
127+
Use `const { embedRef, actions } = useEmbed();` to programmatically control the embed editor:
128+
129+
- `actions.submit`: Submit the document (specify or not whether to download a copy of the document on the device of the user)
130+
- `actions.selectTool`: Select a tool to use
131+
132+
```jsx
133+
import { EmbedPDF, useEmbed } from "@simplepdf/react-embed-pdf";
134+
135+
const { embedRef, actions } = useEmbed();
136+
137+
return (
138+
<>
139+
<button onClick={() => await actions.submit({ downloadCopyOnDevice: false })}>Submit</button>
140+
<button onClick={() => await actions.selectTool('TEXT')}>Select Text Tool</button>
141+
<EmbedPDF
142+
companyIdentifier="yourcompany"
143+
ref={embedRef}
144+
mode="inline"
145+
style={{ width: 900, height: 800 }}
146+
documentURL="https://cdn.simplepdf.com/simple-pdf/assets/sample.pdf"
147+
/>
148+
</>
149+
);
150+
```
151+
123152
### <a id="available-props"></a>Available props
124153

125154
<table>
@@ -129,6 +158,12 @@ import { EmbedPDF } from '@simplepdf/react-embed-pdf';
129158
<th>Required</th>
130159
<th>Description</th>
131160
</tr>
161+
<tr>
162+
<td>ref</td>
163+
<td>EmbedRefHandlers</td>
164+
<td>No</td>
165+
<td>Used for programmatic control of the editor</td>
166+
</tr>
132167
<tr>
133168
<td>mode</td>
134169
<td>"inline" | "modal"</td>

react/package-lock.json

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

react/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@simplepdf/react-embed-pdf",
3-
"version": "1.8.4",
3+
"version": "1.9.0",
44
"description": "SimplePDF straight into your React app",
55
"repository": {
66
"type": "git",

react/src/hook.tsx

+147
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

+67-10
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)