From 13de28a0f65e6d9faaa80e0cc1ef5b4af43d294a Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 11 Feb 2025 09:21:37 +0200 Subject: [PATCH 1/3] feat: useEmbed --- react/src/hook.tsx | 147 ++++++++++++++++++++++++++++++++++++++++++++ react/src/index.tsx | 77 ++++++++++++++++++++--- 2 files changed, 214 insertions(+), 10 deletions(-) create mode 100644 react/src/hook.tsx 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 ; -}; +}); From 2add4e935c7b62b91b06567857627a181c2f79ed Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Feb 2025 08:19:42 +0200 Subject: [PATCH 2/3] docs: add documentation --- react/README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) 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 ( + <> + + + + +); +``` + ### Available props @@ -129,6 +158,12 @@ import { EmbedPDF } from '@simplepdf/react-embed-pdf'; + + + + + + From 3f496c9fe2131c732ec5fa19165ac47ca30f13b7 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 12 Feb 2025 08:36:49 +0200 Subject: [PATCH 3/3] chore: publish @simplepdf/react-embed-pdf@1.9.0 --- react/package-lock.json | 4 ++-- react/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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",
Required Description
refEmbedRefHandlersNoUsed for programmatic control of the editor
mode "inline" | "modal"