Skip to content

feat: useEmbed #17

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<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"
/>
</>
);
```

### <a id="available-props"></a>Available props

<table>
Expand All @@ -129,6 +158,12 @@ import { EmbedPDF } from '@simplepdf/react-embed-pdf';
<th>Required</th>
<th>Description</th>
</tr>
<tr>
<td>ref</td>
<td>EmbedRefHandlers</td>
<td>No</td>
<td>Used for programmatic control of the editor</td>
</tr>
<tr>
<td>mode</td>
<td>"inline" | "modal"</td>
Expand Down
4 changes: 2 additions & 2 deletions react/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion react/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
147 changes: 147 additions & 0 deletions react/src/hook.tsx
Original file line number Diff line number Diff line change
@@ -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<Result['data']['result']>;
selectTool: (
toolType: 'TEXT' | 'BOXED_TEXT' | 'CHECKBOX' | 'PICTURE' | 'SIGNATURE' | null,
) => Promise<Result['data']['result']>;
};

export type EventPayload = {
type: string;
data: unknown;
};

export function sendEvent(iframe: HTMLIFrameElement, payload: EventPayload) {
const requestId = generateRandomID();
return new Promise<Result['data']['result']>((resolve) => {
try {
const handleMessage = (event: MessageEvent<string>) => {
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<EmbedRefHandlers | null>; actions: EmbedActions } => {
const embedRef = React.useRef<EmbedRefHandlers>(null);

const handleSubmit: EmbedRefHandlers['submit'] = React.useCallback(
async ({ downloadCopyOnDevice }): Promise<Result['data']['result']> => {
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 <Embed /> component',
},
});
}

const result = await embedRef.current.submit({ downloadCopyOnDevice });

return result;
},
[],
);

const handleSelectTool: EmbedRefHandlers['selectTool'] = React.useCallback(
async (toolType): Promise<Result['data']['result']> => {
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 <Embed /> component',
},
});
}

const result = await embedRef.current.selectTool(toolType);

return result;
},
[],
);

return {
embedRef,
actions: {
submit: handleSubmit,
selectTool: handleSelectTool,
},
};
};

export type EmbedRefHandlers = EmbedActions;
77 changes: 67 additions & 10 deletions react/src/index.tsx
Original file line number Diff line number Diff line change
@@ -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 } };
Expand Down Expand Up @@ -128,15 +131,58 @@ type DocumentToLoadState =
isEditorReady: boolean;
};

export const EmbedPDF: React.FC<Props> = (props) => {
export const EmbedPDF = React.forwardRef<EmbedRefHandlers, Props>((props, ref) => {
const { context, companyIdentifier, locale } = props;
const editorActionsReadyRef = React.useRef<Promise<void>>(null);
const editorActionsReadyResolveRef = React.useRef<() => void>(null);
const [documentState, setDocumentState] = React.useState<DocumentToLoadState>({
type: null,
value: null,
isEditorReady: false,
});
const iframeRef = React.useRef<HTMLIFrameElement>(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);
Expand Down Expand Up @@ -235,19 +281,30 @@ export const EmbedPDF: React.FC<Props> = (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;
Expand Down Expand Up @@ -315,4 +372,4 @@ export const EmbedPDF: React.FC<Props> = (props) => {
}

return <ModalComponent children={props.children} editorURL={editorURL} ref={iframeRef} />;
};
});