diff --git a/react/README.md b/react/README.md
index 292bb97..2865c81 100644
--- a/react/README.md
+++ b/react/README.md
@@ -120,6 +120,35 @@ import { EmbedPDF } from '@simplepdf/react-embed-pdf';
/>;
```
+### Programmatic Control
+
+_Requires a SimplePDF account_
+
+Use `const { embedRef, actions } = useEmbed();` to programmatically control the embed editor:
+
+- `actions.submit`: Submit the document (specify or not whether to download a copy of the document on the device of the user)
+- `actions.selectTool`: Select a tool to use
+
+```jsx
+import { EmbedPDF, useEmbed } from "@simplepdf/react-embed-pdf";
+
+const { embedRef, actions } = useEmbed();
+
+return (
+ <>
+ await actions.submit({ downloadCopyOnDevice: false })}>Submit
+ await actions.selectTool('TEXT')}>Select Text Tool
+
+ >
+);
+```
+
### Available props
@@ -129,6 +158,12 @@ import { EmbedPDF } from '@simplepdf/react-embed-pdf';
Required
Description
+
+ ref
+ EmbedRefHandlers
+ No
+ Used for programmatic control of the editor
+
mode
"inline" | "modal"
diff --git a/react/package-lock.json b/react/package-lock.json
index 54c8294..7a99847 100644
--- a/react/package-lock.json
+++ b/react/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@simplepdf/react-embed-pdf",
- "version": "1.8.4",
+ "version": "1.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@simplepdf/react-embed-pdf",
- "version": "1.8.4",
+ "version": "1.9.0",
"license": "MIT",
"devDependencies": {
"@rollup/plugin-terser": "^0.4.4",
diff --git a/react/package.json b/react/package.json
index 788b365..9e32d01 100644
--- a/react/package.json
+++ b/react/package.json
@@ -1,6 +1,6 @@
{
"name": "@simplepdf/react-embed-pdf",
- "version": "1.8.4",
+ "version": "1.9.0",
"description": "SimplePDF straight into your React app",
"repository": {
"type": "git",
diff --git a/react/src/hook.tsx b/react/src/hook.tsx
new file mode 100644
index 0000000..00cf7c8
--- /dev/null
+++ b/react/src/hook.tsx
@@ -0,0 +1,147 @@
+import * as React from 'react';
+
+const DEFAULT_REQUEST_TIMEOUT_IN_MS = 5000;
+
+const generateRandomID = () => {
+ return Math.random().toString(36).substring(2, 15);
+};
+
+export type EmbedActions = {
+ submit: (options: { downloadCopyOnDevice: boolean }) => Promise;
+ selectTool: (
+ toolType: 'TEXT' | 'BOXED_TEXT' | 'CHECKBOX' | 'PICTURE' | 'SIGNATURE' | null,
+ ) => Promise;
+};
+
+export type EventPayload = {
+ type: string;
+ data: unknown;
+};
+
+export function sendEvent(iframe: HTMLIFrameElement, payload: EventPayload) {
+ const requestId = generateRandomID();
+ return new Promise((resolve) => {
+ try {
+ const handleMessage = (event: MessageEvent) => {
+ const parsedEvent: Result = (() => {
+ try {
+ const parsedEvent = JSON.parse(event.data);
+
+ if (parsedEvent.type !== 'REQUEST_RESULT') {
+ return {
+ data: {
+ request_id: null,
+ },
+ };
+ }
+
+ return parsedEvent;
+ } catch (e) {
+ return null;
+ }
+ })();
+ const isTargetIframe = event.source === iframe.contentWindow;
+ const isMatchingResponse = parsedEvent.data.request_id === requestId;
+
+ if (isTargetIframe && isMatchingResponse) {
+ resolve(parsedEvent.data.result);
+ window.removeEventListener('message', handleMessage);
+ }
+ };
+
+ window.addEventListener('message', handleMessage);
+
+ iframe.contentWindow?.postMessage(JSON.stringify({ ...payload, request_id: requestId }), '*');
+
+ const timeoutId = setTimeout(() => {
+ resolve({
+ success: false,
+ error: {
+ code: 'unexpected:request_timed_out',
+ message: 'The request timed out: try again',
+ },
+ } satisfies Result['data']['result']);
+ window.removeEventListener('message', handleMessage);
+ }, DEFAULT_REQUEST_TIMEOUT_IN_MS);
+
+ const cleanup = () => clearTimeout(timeoutId);
+ window.addEventListener('message', cleanup);
+ } catch (e) {
+ const error = e as Error;
+ resolve({
+ success: false,
+ error: {
+ code: 'unexpected:failed_processing_request',
+ message: `The following error happened: ${error.name}:${error.message}`,
+ },
+ });
+ }
+ });
+}
+
+type ErrorCodePrefix = 'bad_request' | 'unexpected';
+
+type Result = {
+ type: 'REQUEST_RESULT';
+ data: {
+ request_id: string;
+ result:
+ | { success: true }
+ | {
+ success: false;
+ error: { code: `${ErrorCodePrefix}:${string}`; message: string };
+ };
+ };
+};
+
+export const useEmbed = (): { embedRef: React.RefObject; actions: EmbedActions } => {
+ const embedRef = React.useRef(null);
+
+ const handleSubmit: EmbedRefHandlers['submit'] = React.useCallback(
+ async ({ downloadCopyOnDevice }): Promise => {
+ if (embedRef.current === null) {
+ return Promise.resolve({
+ success: false as const,
+ error: {
+ code: 'bad_request:embed_ref_not_available' as const,
+ message: 'embedRef is not available: make sure to pass embedRef to the component',
+ },
+ });
+ }
+
+ const result = await embedRef.current.submit({ downloadCopyOnDevice });
+
+ return result;
+ },
+ [],
+ );
+
+ const handleSelectTool: EmbedRefHandlers['selectTool'] = React.useCallback(
+ async (toolType): Promise => {
+ if (embedRef.current === null) {
+ return Promise.resolve({
+ success: false as const,
+ error: {
+ code: 'bad_request:embed_ref_not_available' as const,
+ message: 'embedRef is not available: make sure to pass embedRef to the component',
+ },
+ });
+ }
+
+ const result = await embedRef.current.selectTool(toolType);
+
+ return result;
+ },
+ [],
+ );
+
+ return {
+ embedRef,
+ actions: {
+ submit: handleSubmit,
+ selectTool: handleSelectTool,
+ },
+ };
+};
+
+export type EmbedRefHandlers = EmbedActions;
diff --git a/react/src/index.tsx b/react/src/index.tsx
index 08e557c..21adb5f 100644
--- a/react/src/index.tsx
+++ b/react/src/index.tsx
@@ -1,8 +1,11 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
+import { sendEvent, EmbedRefHandlers, useEmbed } from './hook';
import './styles.scss';
+export { useEmbed };
+
export type EmbedEvent =
| { type: 'DOCUMENT_LOADED'; data: { document_id: string } }
| { type: 'SUBMISSION_SENT'; data: { submission_id: string } };
@@ -128,8 +131,10 @@ type DocumentToLoadState =
isEditorReady: boolean;
};
-export const EmbedPDF: React.FC = (props) => {
+export const EmbedPDF = React.forwardRef((props, ref) => {
const { context, companyIdentifier, locale } = props;
+ const editorActionsReadyRef = React.useRef>(null);
+ const editorActionsReadyResolveRef = React.useRef<() => void>(null);
const [documentState, setDocumentState] = React.useState({
type: null,
value: null,
@@ -137,6 +142,47 @@ export const EmbedPDF: React.FC = (props) => {
});
const iframeRef = React.useRef(null);
+ const submit: EmbedRefHandlers['submit'] = React.useCallback(async ({ downloadCopyOnDevice }) => {
+ if (!iframeRef.current) {
+ throw Error('Unexpected');
+ }
+
+ await editorActionsReadyRef.current;
+
+ const eventResponse = await sendEvent(iframeRef.current, {
+ type: 'SUBMIT',
+ data: { download_copy: downloadCopyOnDevice },
+ });
+
+ return eventResponse;
+ }, []);
+
+ const selectTool: EmbedRefHandlers['selectTool'] = React.useCallback(async (toolType) => {
+ if (!iframeRef.current) {
+ throw Error('Unexpected');
+ }
+
+ await editorActionsReadyRef.current;
+
+ const eventResponse = await sendEvent(iframeRef.current, {
+ type: 'SELECT_TOOL',
+ data: { tool: toolType },
+ });
+
+ return eventResponse;
+ }, []);
+
+ React.useImperativeHandle(ref, () => ({
+ submit,
+ selectTool,
+ }));
+
+ React.useEffect(() => {
+ editorActionsReadyRef.current = new Promise((resolve) => {
+ editorActionsReadyResolveRef.current = resolve;
+ });
+ }, []);
+
const url: string | null = isInlineComponent(props)
? (props.documentURL ?? null)
: ((props.children as { props?: { href: string } })?.props?.href ?? null);
@@ -235,19 +281,30 @@ export const EmbedPDF: React.FC = (props) => {
}
})();
+ const handleEmbedEvent = async (payload: EmbedEvent) => {
+ try {
+ await props.onEmbedEvent?.(payload);
+ } catch (e) {
+ console.error(`onEmbedEvent failed to execute: ${JSON.stringify(e)}`);
+ }
+ };
+
switch (payload?.type) {
case 'EDITOR_READY':
setDocumentState((prev) => ({ ...prev, isEditorReady: true }));
return;
- case 'DOCUMENT_LOADED':
- case 'SUBMISSION_SENT':
- try {
- await props.onEmbedEvent?.(payload);
- } catch (e) {
- console.error(`onEmbedEvent failed to execute: ${JSON.stringify(e)}`);
- }
-
+ case 'DOCUMENT_LOADED': {
+ // EDGE-CASE handling
+ // Timeout necessary for now due to a race condition on SimplePDF's end
+ // Without it actions.submit prior to the editor being loaded resolves to "document not found"
+ await setTimeout(() => editorActionsReadyResolveRef.current?.(), 200);
+ await handleEmbedEvent(payload);
+ return;
+ }
+ case 'SUBMISSION_SENT': {
+ await handleEmbedEvent(payload);
return;
+ }
default:
return;
@@ -315,4 +372,4 @@ export const EmbedPDF: React.FC = (props) => {
}
return ;
-};
+});