diff --git a/.changeset/long-paths-tell.md b/.changeset/long-paths-tell.md new file mode 100644 index 0000000..7c10344 --- /dev/null +++ b/.changeset/long-paths-tell.md @@ -0,0 +1,5 @@ +--- +"@simplepdf/react-embed-pdf": minor +--- + +Adds new programmatic actions to the React embed component for advanced integrations: goTo, createField, clearFields, getDocumentContent diff --git a/.github/workflows/react.yaml b/.github/workflows/react.yaml index de172a2..2d75438 100644 --- a/.github/workflows/react.yaml +++ b/.github/workflows/react.yaml @@ -38,3 +38,6 @@ jobs: - name: Types run: npm run test:types + + - name: Tests + run: npm test diff --git a/.gitignore b/.gitignore index 09cc88b..3bed324 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,6 @@ chrome-extension/release/ chrome-extension/release.zip .claude/ -.wrangler/ \ No newline at end of file +.wrangler/ + +coverage \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..25fa621 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/package-lock.json b/package-lock.json index 2611d7f..4c41f54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,13 @@ "@changesets/cli": "^2.29.8" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -27,6 +34,64 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -37,6 +102,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@changesets/apply-release-plan": { "version": "7.0.14", "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.0.14.tgz", @@ -1439,6 +1528,110 @@ "resolved": "web", "link": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1482,6 +1675,7 @@ "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1661,6 +1855,19 @@ "node": ">=8" } }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1668,6 +1875,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -1688,6 +1905,28 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", + "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1827,6 +2066,13 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssstyle": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", @@ -1890,6 +2136,16 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-indent": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", @@ -1913,6 +2169,13 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -2188,6 +2451,16 @@ "dev": true, "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -2201,6 +2474,13 @@ "node": ">=18" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -2266,6 +2546,16 @@ "node": ">= 4" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2336,6 +2626,61 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -2427,6 +2772,26 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loose-envify/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2441,6 +2806,16 @@ "dev": true, "license": "ISC" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2451,6 +2826,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -2501,6 +2888,16 @@ "node": ">=8.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", @@ -2570,6 +2967,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/outdent": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", @@ -2832,6 +3240,21 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2890,10 +3313,46 @@ "safe-buffer": "^5.1.0" } }, - "node_modules/read-yaml-file": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", - "integrity": "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==", + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/read-yaml-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", + "integrity": "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==", "dev": true, "license": "MIT", "dependencies": { @@ -2930,6 +3389,20 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -3145,6 +3618,16 @@ "node": ">=v12.22.7" } }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -3314,6 +3797,19 @@ "node": ">=4" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -3327,6 +3823,19 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -3926,9 +4435,15 @@ "license": "MIT", "devDependencies": { "@rollup/plugin-terser": "^0.4.4", + "@testing-library/dom": "10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "16.3.1", + "@testing-library/user-event": "^14.6.1", "@types/react": "*", "@types/react-dom": "*", + "@vitest/coverage-v8": "4.0.17", "autoprefixer": "^10.4.18", + "jsdom": "26.1.0", "postcss": "^8.4.35", "prettier": "^3.4.2", "react": "^18.2.0", @@ -3938,7 +4453,8 @@ "rollup-plugin-scss": "^4.0.0", "rollup-plugin-typescript2": "^0.36.0", "sass": "^1.71.1", - "typescript": "^5.7.3" + "typescript": "^5.9.3", + "vitest": "4.0.17" }, "engines": { "node": ">=18" @@ -4044,6 +4560,148 @@ "@types/react": "^19.0.0" } }, + "react/node_modules/@vitest/coverage-v8": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.17.tgz", + "integrity": "sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.17", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.17", + "vitest": "4.0.17" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "react/node_modules/@vitest/expect": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", + "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.17", + "@vitest/utils": "4.0.17", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "react/node_modules/@vitest/mocker": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", + "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.17", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "react/node_modules/@vitest/pretty-format": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", + "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "react/node_modules/@vitest/runner": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", + "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "react/node_modules/@vitest/snapshot": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", + "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.17", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "react/node_modules/@vitest/spy": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", + "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "react/node_modules/@vitest/utils": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", + "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.17", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "react/node_modules/ansi-regex": { "version": "6.1.0", "dev": true, @@ -4153,6 +4811,16 @@ ], "license": "CC-BY-4.0" }, + "react/node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "react/node_modules/chokidar": { "version": "4.0.3", "dev": true, @@ -4223,6 +4891,16 @@ "node": ">=6" } }, + "react/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "react/node_modules/foreground-child": { "version": "3.3.0", "dev": true, @@ -4298,22 +4976,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "react/node_modules/js-tokens": { - "version": "4.0.0", - "dev": true, - "license": "MIT" - }, - "react/node_modules/loose-envify": { - "version": "1.4.0", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "react/node_modules/minimatch": { "version": "9.0.5", "dev": true, @@ -4362,6 +5024,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "react/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "react/node_modules/postcss-value-parser": { "version": "4.2.0", "dev": true, @@ -4381,30 +5056,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "react/node_modules/react": { - "version": "18.3.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "react/node_modules/react-dom": { - "version": "18.3.1", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, "react/node_modules/readdirp": { "version": "4.1.0", "dev": true, @@ -4471,14 +5122,6 @@ "@parcel/watcher": "^2.4.1" } }, - "react/node_modules/scheduler": { - "version": "0.23.2", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, "react/node_modules/string-width": { "version": "5.1.2", "dev": true, @@ -4567,6 +5210,26 @@ "node": ">=8" } }, + "react/node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "react/node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "react/node_modules/update-browserslist-db": { "version": "1.1.2", "dev": true, @@ -4596,6 +5259,85 @@ "browserslist": ">= 4.21.0" } }, + "react/node_modules/vitest": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", + "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.0.17", + "@vitest/mocker": "4.0.17", + "@vitest/pretty-format": "4.0.17", + "@vitest/runner": "4.0.17", + "@vitest/snapshot": "4.0.17", + "@vitest/spy": "4.0.17", + "@vitest/utils": "4.0.17", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.17", + "@vitest/browser-preview": "4.0.17", + "@vitest/browser-webdriverio": "4.0.17", + "@vitest/ui": "4.0.17", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "react/node_modules/wrap-ansi": { "version": "8.1.0", "dev": true, diff --git a/react/.prettierignore b/react/.prettierignore index 5b18f42..8c6ec28 100644 --- a/react/.prettierignore +++ b/react/.prettierignore @@ -1,5 +1,6 @@ dist/ dev/ +coverage/ package-lock.json diff --git a/react/README.md b/react/README.md index b9268e0..d6b45a2 100644 --- a/react/README.md +++ b/react/README.md @@ -122,33 +122,89 @@ import { EmbedPDF } from '@simplepdf/react-embed-pdf'; ### Programmatic Control -_Requires a SimplePDF account_ +_Some actions require 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 +| Action | Description | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | +| `actions.goTo({ page })` | Navigate to a specific page | +| `actions.selectTool(toolType)` | Select a tool: `'TEXT'`, `'BOXED_TEXT'`, `'CHECKBOX'`, `'PICTURE'`, `'SIGNATURE'`, or `null` to deselect (`CURSOR`) | +| `actions.createField(options)` | Create a field at specified position (see below) | +| `actions.clearFields(options?)` | Clear fields by `fieldIds` or `page`, or all fields if no options | +| `actions.getDocumentContent({ extractionMode })` | Extract document content (`extractionMode: 'auto'` or `'ocr'`) | +| `actions.submit({ downloadCopyOnDevice })` | Submit the document | -```jsx -import { EmbedPDF, useEmbed } from "@simplepdf/react-embed-pdf"; - -const { embedRef, actions } = useEmbed(); +All actions return a `Promise` with a result object: `{ success: true, data: ... }` or `{ success: false, error: { code, message } }`. -return ( - <> - - +```jsx +import { EmbedPDF, useEmbed } from '@simplepdf/react-embed-pdf'; + +const Editor = () => { + const { embedRef, actions } = useEmbed(); + + const handleSubmit = async () => { + const result = await actions.submit({ downloadCopyOnDevice: false }); + if (result.success) { + console.log('Submitted!'); + } + }; + + const handleExtract = async () => { + const result = await actions.getDocumentContent({ extractionMode: 'auto' }); + if (result.success) { + console.log('Document name:', result.data.name); + console.log('Pages:', result.data.pages); + } + }; + + const handleCreateTextField = async () => { + const result = await actions.createField({ + type: 'TEXT', + page: 1, + x: 100, + y: 200, + width: 150, + height: 30, + value: 'Hello World', + }); + if (result.success) { + console.log('Created field:', result.data.field_id); + } + }; + + return ( + <> + + + + + - -); + + ); +}; ``` +#### `createField` options + +The `createField` action uses a discriminated union based on field type: + +| Type | `value` format | +| --------------------- | ----------------------------------------------------------- | +| `TEXT` / `BOXED_TEXT` | Plain text content | +| `CHECKBOX` | `'checked'` or `'unchecked'` | +| `PICTURE` | Data URL (base64) | +| `SIGNATURE` | Data URL (base64) or plain text (generates typed signature) | + +All field types share these base options: `page`, `x`, `y`, `width`, `height` (coordinates in PDF points, origin at bottom-left). + ### Available props @@ -160,9 +216,9 @@ return ( - + - + @@ -170,9 +226,9 @@ return ( - + - + @@ -188,6 +244,12 @@ return ( + + + + + + @@ -225,12 +287,12 @@ return ( 1. Link the widget ```sh -yarn link -yarn start +npm link +npm start ``` 2. Use it in the target application ```sh -yarn link @simplepdf/react-embed-pdf +npm link @simplepdf/react-embed-pdf ``` diff --git a/react/package.json b/react/package.json index 9e32d01..41bce3e 100644 --- a/react/package.json +++ b/react/package.json @@ -13,12 +13,19 @@ "author": "bendersej", "license": "MIT", "private": false, + "engines": { + "node": ">=18" + }, "scripts": { + "pretest": "tsc --noEmit -p tsconfig.test.json", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "test:types": "tsc --noEmit", "test:format": "npm run prettier -- --check", "prettier": "prettier .", "format": "npm run prettier -- --write", - "prepublishOnly": "rimraf dist && yarn build", + "prepublishOnly": "rimraf dist && npm run build", "build": "rollup -c", "start": "rollup -c -w" }, @@ -28,9 +35,15 @@ }, "devDependencies": { "@rollup/plugin-terser": "^0.4.4", + "@testing-library/dom": "10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "16.3.1", + "@testing-library/user-event": "^14.6.1", "@types/react": "*", "@types/react-dom": "*", + "@vitest/coverage-v8": "4.0.17", "autoprefixer": "^10.4.18", + "jsdom": "26.1.0", "postcss": "^8.4.35", "prettier": "^3.4.2", "react": "^18.2.0", @@ -40,7 +53,8 @@ "rollup-plugin-scss": "^4.0.0", "rollup-plugin-typescript2": "^0.36.0", "sass": "^1.71.1", - "typescript": "^5.7.3" + "typescript": "^5.9.3", + "vitest": "4.0.17" }, "files": ["dist"], "keywords": ["react", "typescript", "npm", "pdf"] diff --git a/react/rollup.config.js b/react/rollup.config.js index 6088509..ffa5666 100644 --- a/react/rollup.config.js +++ b/react/rollup.config.js @@ -3,8 +3,10 @@ import postcss from 'postcss'; import autoprefixer from 'autoprefixer'; import typescript from 'rollup-plugin-typescript2'; import terser from '@rollup/plugin-terser'; +import { createRequire } from 'module'; -import pkg from './package.json' assert { type: 'json' }; +const require = createRequire(import.meta.url); +const pkg = require('./package.json'); export default { input: 'src/index.tsx', diff --git a/react/src/hook.test.ts b/react/src/hook.test.ts new file mode 100644 index 0000000..8aac7df --- /dev/null +++ b/react/src/hook.test.ts @@ -0,0 +1,551 @@ +import { describe, it, expect, vi, beforeEach, afterEach, expectTypeOf } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { sendEvent, useEmbed, EmbedActions } from './hook'; + +type MessageEventHandler = (event: MessageEvent) => void; + +interface MockContentWindow { + postMessage: ReturnType; +} + +interface MockIframe { + contentWindow: MockContentWindow; +} + +const createMockIframe = (): { iframe: MockIframe; postMessageSpy: ReturnType } => { + const postMessageSpy = vi.fn(); + return { + iframe: { contentWindow: { postMessage: postMessageSpy } }, + postMessageSpy, + }; +}; + +const extractRequestId = (postMessageSpy: ReturnType): string => { + const rawMessage = postMessageSpy.mock.calls[0]?.[0]; + if (typeof rawMessage !== 'string') { + throw new Error('Expected postMessage to be called with a string'); + } + const parsed = JSON.parse(rawMessage); + return parsed.request_id; +}; + +const createMessageEvent = ({ + source, + data, +}: { + source: MockContentWindow | Record; + data: string; +}): MessageEvent => ({ source, data }) as unknown as MessageEvent; + +describe('sendEvent', () => { + let removeEventListenerSpy: ReturnType; + let messageHandler: MessageEventHandler | null = null; + + beforeEach(() => { + vi.useFakeTimers(); + + vi.spyOn(window, 'addEventListener').mockImplementation((type, handler) => { + if (type === 'message') { + messageHandler = handler as MessageEventHandler; + } + }); + + removeEventListenerSpy = vi.spyOn(window, 'removeEventListener').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + messageHandler = null; + }); + + it('resolves with result when matching REQUEST_RESULT received', async () => { + const { iframe } = createMockIframe(); + + const resultPromise = sendEvent(iframe as unknown as HTMLIFrameElement, { type: 'SUBMIT', data: {} }); + + const requestId = extractRequestId(iframe.contentWindow.postMessage); + + messageHandler?.( + createMessageEvent({ + source: iframe.contentWindow, + data: JSON.stringify({ + type: 'REQUEST_RESULT', + data: { request_id: requestId, result: { success: true } }, + }), + }), + ); + + const result = await resultPromise; + expect(result).toEqual({ success: true }); + }); + + it('ignores messages from different source', async () => { + const { iframe } = createMockIframe(); + + const resultPromise = sendEvent(iframe as unknown as HTMLIFrameElement, { type: 'SUBMIT', data: {} }); + + const requestId = extractRequestId(iframe.contentWindow.postMessage); + + messageHandler?.( + createMessageEvent({ + source: { postMessage: vi.fn() }, + data: JSON.stringify({ + type: 'REQUEST_RESULT', + data: { request_id: requestId, result: { success: true } }, + }), + }), + ); + + vi.advanceTimersByTime(30000); + const result = await resultPromise; + expect(result).toEqual({ + success: false, + error: { code: 'unexpected:request_timed_out', message: 'The request timed out: try again' }, + }); + }); + + it('ignores messages with different request_id', async () => { + const { iframe } = createMockIframe(); + + const resultPromise = sendEvent(iframe as unknown as HTMLIFrameElement, { type: 'SUBMIT', data: {} }); + + messageHandler?.( + createMessageEvent({ + source: iframe.contentWindow, + data: JSON.stringify({ + type: 'REQUEST_RESULT', + data: { request_id: 'wrong_id', result: { success: true } }, + }), + }), + ); + + vi.advanceTimersByTime(30000); + const result = await resultPromise; + expect(result).toEqual({ + success: false, + error: { code: 'unexpected:request_timed_out', message: 'The request timed out: try again' }, + }); + }); + + it('ignores non-REQUEST_RESULT messages', async () => { + const { iframe } = createMockIframe(); + + const resultPromise = sendEvent(iframe as unknown as HTMLIFrameElement, { type: 'SUBMIT', data: {} }); + + messageHandler?.( + createMessageEvent({ + source: iframe.contentWindow, + data: JSON.stringify({ type: 'EDITOR_READY', data: {} }), + }), + ); + + vi.advanceTimersByTime(30000); + const result = await resultPromise; + expect(result).toEqual({ + success: false, + error: { code: 'unexpected:request_timed_out', message: 'The request timed out: try again' }, + }); + }); + + it('times out after 30 seconds with error result', async () => { + const { iframe } = createMockIframe(); + + const resultPromise = sendEvent(iframe as unknown as HTMLIFrameElement, { type: 'SUBMIT', data: {} }); + + vi.advanceTimersByTime(30000); + + const result = await resultPromise; + expect(result).toEqual({ + success: false, + error: { code: 'unexpected:request_timed_out', message: 'The request timed out: try again' }, + }); + }); + + it('removes event listener after successful response', async () => { + const { iframe } = createMockIframe(); + + const resultPromise = sendEvent(iframe as unknown as HTMLIFrameElement, { type: 'SUBMIT', data: {} }); + + const requestId = extractRequestId(iframe.contentWindow.postMessage); + + messageHandler?.( + createMessageEvent({ + source: iframe.contentWindow, + data: JSON.stringify({ + type: 'REQUEST_RESULT', + data: { request_id: requestId, result: { success: true } }, + }), + }), + ); + + await resultPromise; + expect(removeEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function)); + }); + + it('removes event listener after timeout', async () => { + const { iframe } = createMockIframe(); + + const resultPromise = sendEvent(iframe as unknown as HTMLIFrameElement, { type: 'SUBMIT', data: {} }); + + vi.advanceTimersByTime(30000); + + await resultPromise; + expect(removeEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function)); + }); + + it('handles invalid JSON in message gracefully', async () => { + const { iframe } = createMockIframe(); + + const resultPromise = sendEvent(iframe as unknown as HTMLIFrameElement, { type: 'SUBMIT', data: {} }); + + messageHandler?.( + createMessageEvent({ + source: iframe.contentWindow, + data: 'not valid json', + }), + ); + + vi.advanceTimersByTime(30000); + const result = await resultPromise; + expect(result).toEqual({ + success: false, + error: { code: 'unexpected:request_timed_out', message: 'The request timed out: try again' }, + }); + }); +}); + +describe('useEmbed', () => { + const expectedError = { + success: false, + error: { + code: 'bad_request:embed_ref_not_available', + message: 'embedRef is not available: make sure to pass embedRef to the component', + }, + }; + + describe('initial state', () => { + it('returns embedRef and actions object', () => { + const { result } = renderHook(() => useEmbed()); + + expect(result.current.embedRef).toBeDefined(); + expect(result.current.embedRef.current).toBeNull(); + expect(result.current.actions).toBeDefined(); + }); + }); + + describe('actions without ref attached', () => { + it('goTo returns error when embedRef not attached', async () => { + const { result } = renderHook(() => useEmbed()); + const actionResult = await result.current.actions.goTo({ page: 1 }); + expect(actionResult).toEqual(expectedError); + }); + + it('selectTool returns error when embedRef not attached', async () => { + const { result } = renderHook(() => useEmbed()); + const actionResult = await result.current.actions.selectTool('TEXT'); + expect(actionResult).toEqual(expectedError); + }); + + it('createField returns error when embedRef not attached', async () => { + const { result } = renderHook(() => useEmbed()); + const actionResult = await result.current.actions.createField({ + type: 'TEXT', + page: 1, + x: 0, + y: 0, + width: 100, + height: 20, + }); + expect(actionResult).toEqual(expectedError); + }); + + it('clearFields returns error when embedRef not attached', async () => { + const { result } = renderHook(() => useEmbed()); + const actionResult = await result.current.actions.clearFields({}); + expect(actionResult).toEqual(expectedError); + }); + + it('getDocumentContent returns error when embedRef not attached', async () => { + const { result } = renderHook(() => useEmbed()); + const actionResult = await result.current.actions.getDocumentContent({ extractionMode: 'auto' }); + expect(actionResult).toEqual(expectedError); + }); + + it('submit returns error when embedRef not attached', async () => { + const { result } = renderHook(() => useEmbed()); + const actionResult = await result.current.actions.submit({ downloadCopyOnDevice: false }); + expect(actionResult).toEqual(expectedError); + }); + }); + + describe('actions with ref attached', () => { + const createMockEmbedRef = (): { + ref: EmbedActions; + spies: Record>; + } => { + const spies = { + goTo: vi.fn().mockResolvedValue({ success: true }), + selectTool: vi.fn().mockResolvedValue({ success: true }), + createField: vi.fn().mockResolvedValue({ success: true }), + clearFields: vi.fn().mockResolvedValue({ success: true }), + getDocumentContent: vi.fn().mockResolvedValue({ success: true }), + submit: vi.fn().mockResolvedValue({ success: true }), + }; + + return { + ref: spies, + spies, + }; + }; + + it('goTo delegates to ref.goTo', async () => { + const { result } = renderHook(() => useEmbed()); + const { ref, spies } = createMockEmbedRef(); + (result.current.embedRef as React.MutableRefObject).current = ref; + + const actionResult = await result.current.actions.goTo({ page: 1 }); + + expect(spies.goTo).toHaveBeenCalledWith({ page: 1 }); + expect(actionResult).toEqual({ success: true }); + }); + + it('selectTool delegates to ref.selectTool', async () => { + const { result } = renderHook(() => useEmbed()); + const { ref, spies } = createMockEmbedRef(); + (result.current.embedRef as React.MutableRefObject).current = ref; + + const actionResult = await result.current.actions.selectTool('TEXT'); + + expect(spies.selectTool).toHaveBeenCalledWith('TEXT'); + expect(actionResult).toEqual({ success: true }); + }); + + it('createField delegates to ref.createField', async () => { + const { result } = renderHook(() => useEmbed()); + const { ref, spies } = createMockEmbedRef(); + (result.current.embedRef as React.MutableRefObject).current = ref; + + const fieldOptions = { type: 'TEXT' as const, page: 1, x: 0, y: 0, width: 100, height: 20 }; + const actionResult = await result.current.actions.createField(fieldOptions); + + expect(spies.createField).toHaveBeenCalledWith(fieldOptions); + expect(actionResult).toEqual({ success: true }); + }); + + it('clearFields delegates to ref.clearFields', async () => { + const { result } = renderHook(() => useEmbed()); + const { ref, spies } = createMockEmbedRef(); + (result.current.embedRef as React.MutableRefObject).current = ref; + + const actionResult = await result.current.actions.clearFields({}); + + expect(spies.clearFields).toHaveBeenCalledWith({}); + expect(actionResult).toEqual({ success: true }); + }); + + it('getDocumentContent delegates to ref.getDocumentContent', async () => { + const { result } = renderHook(() => useEmbed()); + const { ref, spies } = createMockEmbedRef(); + (result.current.embedRef as React.MutableRefObject).current = ref; + + const actionResult = await result.current.actions.getDocumentContent({ extractionMode: 'auto' }); + + expect(spies.getDocumentContent).toHaveBeenCalledWith({ extractionMode: 'auto' }); + expect(actionResult).toEqual({ success: true }); + }); + + it('submit delegates to ref.submit', async () => { + const { result } = renderHook(() => useEmbed()); + const { ref, spies } = createMockEmbedRef(); + (result.current.embedRef as React.MutableRefObject).current = ref; + + const actionResult = await result.current.actions.submit({ downloadCopyOnDevice: false }); + + expect(spies.submit).toHaveBeenCalledWith({ downloadCopyOnDevice: false }); + expect(actionResult).toEqual({ success: true }); + }); + }); + + it('maintains stable action references across renders', () => { + const { result, rerender } = renderHook(() => useEmbed()); + + const initialActions = result.current.actions; + rerender(); + + expect(result.current.actions.goTo).toBe(initialActions.goTo); + expect(result.current.actions.submit).toBe(initialActions.submit); + }); +}); + +describe('Type assertions', () => { + // These types are intentionally inlined to act as a "frozen" contract. + // If the actual types change, these tests will fail at compile time. + + type ExpectedToolType = 'TEXT' | 'BOXED_TEXT' | 'CHECKBOX' | 'PICTURE' | 'SIGNATURE'; + + type ExpectedBaseFieldOptions = { + page: number; + x: number; + y: number; + width: number; + height: number; + }; + + type ExpectedTextFieldOptions = ExpectedBaseFieldOptions & { + type: 'TEXT' | 'BOXED_TEXT'; + value?: string; + }; + + type ExpectedCheckboxFieldOptions = ExpectedBaseFieldOptions & { + type: 'CHECKBOX'; + value?: 'checked' | 'unchecked'; + }; + + type ExpectedPictureFieldOptions = ExpectedBaseFieldOptions & { + type: 'PICTURE'; + value?: string; + }; + + type ExpectedSignatureFieldOptions = ExpectedBaseFieldOptions & { + type: 'SIGNATURE'; + value?: string; + }; + + type ExpectedCreateFieldOptions = + | ExpectedTextFieldOptions + | ExpectedCheckboxFieldOptions + | ExpectedPictureFieldOptions + | ExpectedSignatureFieldOptions; + + type ExpectedErrorResult = { + success: false; + error: { code: string; message: string }; + }; + + type ExpectedSuccessResult = TData extends undefined + ? { success: true } + : { success: true; data: TData }; + + type ExpectedActionResult = ExpectedSuccessResult | ExpectedErrorResult; + + describe('EmbedActions', () => { + it('goTo accepts { page: number } and returns ActionResult', () => { + expectTypeOf().parameter(0).toEqualTypeOf<{ page: number }>(); + expectTypeOf().returns.resolves.toExtend(); + }); + + it('selectTool accepts ToolType | null and returns ActionResult', () => { + expectTypeOf().parameter(0).toEqualTypeOf(); + expectTypeOf().returns.resolves.toExtend(); + }); + + it('createField accepts CreateFieldOptions and returns ActionResult with field_id', () => { + expectTypeOf().parameter(0).toEqualTypeOf(); + expectTypeOf().returns.resolves.toExtend< + ExpectedActionResult<{ field_id: string }> + >(); + }); + + it('clearFields accepts optional { fieldIds?, page? } and returns ActionResult with cleared_count', () => { + expectTypeOf() + .parameter(0) + .toEqualTypeOf<{ fieldIds?: string[]; page?: number } | undefined>(); + expectTypeOf().returns.resolves.toExtend< + ExpectedActionResult<{ cleared_count: number }> + >(); + }); + + it('getDocumentContent requires { extractionMode } and returns ActionResult with document content', () => { + expectTypeOf().parameter(0).toEqualTypeOf<{ + extractionMode: 'auto' | 'ocr'; + }>(); + expectTypeOf().returns.resolves.toExtend< + ExpectedActionResult<{ name: string; pages: { page: number; content: string }[] }> + >(); + }); + + it('submit requires { downloadCopyOnDevice } and returns ActionResult', () => { + expectTypeOf().parameter(0).toEqualTypeOf<{ downloadCopyOnDevice: boolean }>(); + expectTypeOf().returns.resolves.toExtend(); + }); + }); + + describe('createField discriminated union', () => { + it('TEXT field options are accepted', () => { + const textField = { + type: 'TEXT' as const, + page: 1, + x: 0, + y: 0, + width: 100, + height: 20, + value: 'hello', + }; + expectTypeOf(textField).toExtend(); + }); + + it('BOXED_TEXT field options are accepted', () => { + const boxedTextField = { + type: 'BOXED_TEXT' as const, + page: 1, + x: 0, + y: 0, + width: 100, + height: 20, + value: 'hello', + }; + expectTypeOf(boxedTextField).toExtend(); + }); + + it('CHECKBOX field accepts only checked/unchecked values', () => { + const checkedField = { + type: 'CHECKBOX' as const, + page: 1, + x: 0, + y: 0, + width: 20, + height: 20, + value: 'checked' as const, + }; + expectTypeOf(checkedField).toExtend(); + + const uncheckedField = { + type: 'CHECKBOX' as const, + page: 1, + x: 0, + y: 0, + width: 20, + height: 20, + value: 'unchecked' as const, + }; + expectTypeOf(uncheckedField).toExtend(); + }); + + it('PICTURE field options are accepted', () => { + const pictureField = { + type: 'PICTURE' as const, + page: 1, + x: 0, + y: 0, + width: 100, + height: 100, + value: 'data:image/png;base64,...', + }; + expectTypeOf(pictureField).toExtend(); + }); + + it('SIGNATURE field options are accepted', () => { + const signatureField = { + type: 'SIGNATURE' as const, + page: 1, + x: 0, + y: 0, + width: 150, + height: 50, + value: 'John Doe', + }; + expectTypeOf(signatureField).toExtend(); + }); + }); +}); diff --git a/react/src/hook.tsx b/react/src/hook.tsx index 00cf7c8..2a70b0d 100644 --- a/react/src/hook.tsx +++ b/react/src/hook.tsx @@ -1,16 +1,83 @@ import * as React from 'react'; +import { generateRandomID } from './utils'; -const DEFAULT_REQUEST_TIMEOUT_IN_MS = 5000; +const DEFAULT_REQUEST_TIMEOUT_IN_MS = 30000; -const generateRandomID = () => { - return Math.random().toString(36).substring(2, 15); +type ExtractionMode = 'auto' | 'ocr'; + +type ToolType = 'TEXT' | 'BOXED_TEXT' | 'CHECKBOX' | 'PICTURE' | 'SIGNATURE'; + +type BaseFieldOptions = { + page: number; + x: number; + y: number; + width: number; + height: number; +}; + +type TextFieldOptions = BaseFieldOptions & { + type: 'TEXT' | 'BOXED_TEXT'; + value?: string; +}; + +type CheckboxFieldOptions = BaseFieldOptions & { + type: 'CHECKBOX'; + value?: 'checked' | 'unchecked'; +}; + +type PictureFieldOptions = BaseFieldOptions & { + type: 'PICTURE'; + value?: string; // Data URL (base64) +}; + +type SignatureFieldOptions = BaseFieldOptions & { + type: 'SIGNATURE'; + value?: string; // Data URL (base64) or plain text (generates typed signature) +}; + +export type CreateFieldOptions = TextFieldOptions | CheckboxFieldOptions | PictureFieldOptions | SignatureFieldOptions; + +type ErrorCodePrefix = 'bad_request' | 'unexpected' | 'forbidden'; + +type ErrorResult = { + success: false; + error: { code: `${ErrorCodePrefix}:${string}`; message: string }; +}; + +type SuccessResult = TData extends undefined ? { success: true } : { success: true; data: TData }; + +type ActionResult = SuccessResult | ErrorResult; + +type DocumentContentPage = { + page: number; + content: string; +}; + +type DocumentContentResult = { + name: string; + pages: DocumentContentPage[]; +}; + +type ClearFieldsResult = { + cleared_count: number; +}; + +type CreateFieldResult = { + field_id: string; }; export type EmbedActions = { - submit: (options: { downloadCopyOnDevice: boolean }) => Promise; - selectTool: ( - toolType: 'TEXT' | 'BOXED_TEXT' | 'CHECKBOX' | 'PICTURE' | 'SIGNATURE' | null, - ) => Promise; + goTo: (options: { page: number }) => Promise; + + selectTool: (toolType: ToolType | null) => Promise; + + createField: (options: CreateFieldOptions) => Promise>; + + clearFields: (options?: { fieldIds?: string[]; page?: number }) => Promise>; + + getDocumentContent: (options: { extractionMode: ExtractionMode }) => Promise>; + + submit: (options: { downloadCopyOnDevice: boolean }) => Promise; }; export type EventPayload = { @@ -18,130 +85,135 @@ export type EventPayload = { data: unknown; }; -export function sendEvent(iframe: HTMLIFrameElement, payload: EventPayload) { +type RequestResultEvent = { + type: 'REQUEST_RESULT'; + data: { + request_id: string; + result: ActionResult; + }; +}; + +export const sendEvent = ( + iframe: HTMLIFrameElement, + payload: EventPayload, +): Promise> => { 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 new Promise>((resolve) => { + const handleMessage = (event: MessageEvent) => { + const parsedEvent: RequestResultEvent | null = (() => { + try { + const parsed = JSON.parse(event.data); + if (parsed.type !== 'REQUEST_RESULT') { 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); + return parsed; + } catch { + return null; } - }; + })(); - window.addEventListener('message', handleMessage); + if (parsedEvent === null) { + return; + } - iframe.contentWindow?.postMessage(JSON.stringify({ ...payload, request_id: requestId }), '*'); + const isTargetIframe = event.source === iframe.contentWindow; + const isMatchingResponse = parsedEvent.data.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']); + if (isTargetIframe && isMatchingResponse) { + resolve(parsedEvent.data.result); window.removeEventListener('message', handleMessage); - }, DEFAULT_REQUEST_TIMEOUT_IN_MS); + clearTimeout(timeoutId); + } + }; - const cleanup = () => clearTimeout(timeoutId); - window.addEventListener('message', cleanup); - } catch (e) { - const error = e as Error; + window.addEventListener('message', handleMessage); + + iframe.contentWindow?.postMessage(JSON.stringify({ ...payload, request_id: requestId }), '*'); + + const timeoutId = setTimeout(() => { resolve({ success: false, error: { - code: 'unexpected:failed_processing_request', - message: `The following error happened: ${error.name}:${error.message}`, + code: 'unexpected:request_timed_out', + message: 'The request timed out: try again', }, }); - } + window.removeEventListener('message', handleMessage); + }, DEFAULT_REQUEST_TIMEOUT_IN_MS); }); -} - -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); +export const useEmbed = (): { embedRef: React.RefObject; actions: EmbedActions } => { + const embedRef = React.useRef(null); - const handleSubmit: EmbedRefHandlers['submit'] = React.useCallback( - async ({ downloadCopyOnDevice }): Promise => { + const createAction = ( + actionFn: (ref: EmbedActions, ...args: TArgs) => Promise>, + ): ((...args: TArgs) => Promise>) => { + return async (...args: TArgs): Promise> => { if (embedRef.current === null) { - return Promise.resolve({ - success: false as const, + return { + success: false, error: { - code: 'bad_request:embed_ref_not_available' as const, + code: 'bad_request:embed_ref_not_available', message: 'embedRef is not available: make sure to pass embedRef to the component', }, - }); + }; } + return actionFn(embedRef.current, ...args); + }; + }; + + const handleGoTo = React.useCallback( + createAction<[{ page: number }]>(async (ref, options) => { + return ref.goTo(options); + }), + [], + ); - const result = await embedRef.current.submit({ downloadCopyOnDevice }); + const handleSelectTool = React.useCallback( + createAction<[ToolType | null]>(async (ref, toolType) => { + return ref.selectTool(toolType); + }), + [], + ); - return result; - }, + const handleCreateField = React.useCallback( + createAction<[CreateFieldOptions], CreateFieldResult>(async (ref, options) => { + return ref.createField(options); + }), [], ); - 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 handleClearFields = React.useCallback( + createAction<[{ fieldIds?: string[]; page?: number }?], ClearFieldsResult>(async (ref, options) => { + return ref.clearFields(options); + }), + [], + ); - const result = await embedRef.current.selectTool(toolType); + const handleGetDocumentContent = React.useCallback( + createAction<[{ extractionMode: ExtractionMode }], DocumentContentResult>(async (ref, options) => { + return ref.getDocumentContent(options); + }), + [], + ); - return result; - }, + const handleSubmit = React.useCallback( + createAction<[{ downloadCopyOnDevice: boolean }]>(async (ref, options) => { + return ref.submit(options); + }), [], ); return { embedRef, actions: { - submit: handleSubmit, + goTo: handleGoTo, selectTool: handleSelectTool, + createField: handleCreateField, + clearFields: handleClearFields, + getDocumentContent: handleGetDocumentContent, + submit: handleSubmit, }, }; }; - -export type EmbedRefHandlers = EmbedActions; diff --git a/react/src/index.test.tsx b/react/src/index.test.tsx new file mode 100644 index 0000000..5732a40 --- /dev/null +++ b/react/src/index.test.tsx @@ -0,0 +1,829 @@ +/// + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { EmbedPDF } from './index'; +import type { EmbedActions } from './hook'; + +vi.mock('./styles.scss', () => ({})); + +/** + * These tests focus on user-facing behavior: + * - What users see (iframe, modal, styling) + * - How users interact (clicking triggers, calling ref methods) + * - What users receive (events via callbacks) + * + * Tests intentionally avoid implementation details like: + * - Internal postMessage protocol format + * - Internal state machine transitions + * - Internal timing workarounds + */ + +type MessageEventHandler = (event: MessageEvent) => void; + +const createMessageEvent = ({ + origin, + source, + data, +}: { + origin: string; + source: Window | null; + data: string; +}): MessageEvent => ({ origin, source, data }) as MessageEvent; + +const getIframe = (): HTMLIFrameElement => { + const iframe = screen.getByTitle('SimplePDF'); + return iframe as HTMLIFrameElement; +}; + +const getIframeSrcUrl = (): URL => { + const iframe = getIframe(); + const src = iframe.getAttribute('src'); + if (src === null) { + throw new Error('Expected iframe to have src attribute'); + } + return new URL(src); +}; + +class MockFileReader { + result: string | null = 'data:application/pdf;base64,dGVzdA=='; + onload: ((e: ProgressEvent) => void) | null = null; + onerror: ((e: ProgressEvent) => void) | null = null; + + readAsDataURL(): void { + queueMicrotask(() => { + this.onload?.({} as ProgressEvent); + }); + } +} + +class MockFileReaderWithError { + result: string | null = null; + onload: ((e: ProgressEvent) => void) | null = null; + onerror: ((e: ProgressEvent) => void) | null = null; + + readAsDataURL(): void { + queueMicrotask(() => { + this.onerror?.({} as ProgressEvent); + }); + } +} + +describe('EmbedPDF', () => { + let messageHandler: MessageEventHandler | null = null; + + beforeEach(() => { + vi.spyOn(window, 'addEventListener').mockImplementation((type, handler) => { + if (type === 'message') { + messageHandler = handler as MessageEventHandler; + } + }); + vi.spyOn(window, 'removeEventListener').mockImplementation(() => {}); + }); + + afterEach(() => { + messageHandler = null; + }); + + describe('inline mode', () => { + it('renders an iframe with title', () => { + render(); + + expect(screen.getByTitle('SimplePDF')).toBeInTheDocument(); + }); + + it('applies className prop to iframe', () => { + render(); + + expect(screen.getByTitle('SimplePDF')).toHaveClass('custom-class'); + }); + + it('applies style prop to iframe with border: 0', () => { + render(); + + const iframe = screen.getByTitle('SimplePDF'); + expect(iframe).toHaveStyle({ width: '100%', height: '500px', border: '0px' }); + }); + + it.each([ + { baseDomain: undefined, companyIdentifier: undefined, expectedOrigin: 'https://react-editor.simplepdf.com' }, + { baseDomain: 'custom.com', companyIdentifier: 'myco', expectedOrigin: 'https://myco.custom.com' }, + { baseDomain: 'simplepdf.nil:3000', companyIdentifier: 'e2e', expectedOrigin: 'http://e2e.simplepdf.nil:3000' }, + ])( + 'sets iframe src with correct domain ($expectedOrigin)', + async ({ baseDomain, companyIdentifier, expectedOrigin }) => { + render(); + + await waitFor(() => { + const url = getIframeSrcUrl(); + expect(url.origin).toBe(expectedOrigin); + expect(url.pathname).toBe('/en/editor'); + }); + }, + ); + + it.each([ + { locale: undefined, expectedPath: '/en/editor' }, + { locale: 'fr' as const, expectedPath: '/fr/editor' }, + { locale: 'de' as const, expectedPath: '/de/editor' }, + ])('sets correct locale in URL path ($expectedPath)', async ({ locale, expectedPath }) => { + render(); + + await waitFor(() => { + const url = getIframeSrcUrl(); + expect(url.pathname).toBe(expectedPath); + }); + }); + + it('adds loadingPlaceholder param when documentURL provided', async () => { + render(); + + await waitFor(() => { + const url = getIframeSrcUrl(); + expect(url.searchParams.get('loadingPlaceholder')).toBe('true'); + }); + }); + + it('adds context param when context provided', async () => { + render(); + + await waitFor(() => { + const url = getIframeSrcUrl(); + const encodedContext = url.searchParams.get('context'); + if (encodedContext === null) { + throw new Error('Expected context param to be present'); + } + const decodedContext = JSON.parse(atob(decodeURIComponent(encodedContext))); + expect(decodedContext).toEqual({ key: 'value' }); + }); + }); + + it('sets up message event listener on mount', () => { + render(); + + expect(window.addEventListener).toHaveBeenCalledWith('message', expect.any(Function), false); + }); + + it('removes message event listener on unmount', () => { + const { unmount } = render(); + + unmount(); + + expect(window.removeEventListener).toHaveBeenCalledWith('message', expect.any(Function)); + }); + }); + + describe('modal mode', () => { + it('renders children trigger element', () => { + render( + + + , + ); + + expect(screen.getByRole('button', { name: 'Open PDF Editor' })).toBeInTheDocument(); + }); + + it('does not render modal initially', () => { + render( + + + , + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('opens modal when trigger clicked', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Open PDF Editor' })); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('renders modal with aria-modal attribute', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Open PDF Editor' })); + + expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true'); + }); + + it('renders close button with aria-label', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Open PDF Editor' })); + + expect(screen.getByRole('button', { name: 'Close PDF editor modal' })).toBeInTheDocument(); + }); + + it('closes modal when close button clicked', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Open PDF Editor' })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Close PDF editor modal' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders iframe inside modal', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Open PDF Editor' })); + + expect(screen.getByTitle('SimplePDF')).toBeInTheDocument(); + }); + + it('sets iframe src with editor URL when modal opens', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Open PDF Editor' })); + + const url = getIframeSrcUrl(); + expect(url.origin).toBe('https://testco.simplepdf.com'); + expect(url.pathname).toBe('/en/editor'); + }); + + it('extracts href from anchor child for document loading', async () => { + const user = userEvent.setup(); + render( + + Edit PDF + , + ); + + await user.click(screen.getByRole('link', { name: 'Edit PDF' })); + + const url = getIframeSrcUrl(); + expect(url.searchParams.get('loadingPlaceholder')).toBe('true'); + }); + + it('renders null for non-element children', () => { + render({'plain text'}); + + expect(screen.queryByText('plain text')).not.toBeInTheDocument(); + }); + }); + + describe('ref handlers', () => { + it('exposes action methods via ref', async () => { + const ref = React.createRef(); + + render(); + + await waitFor(() => { + expect(ref.current).not.toBeNull(); + }); + + expect(typeof ref.current?.goTo).toBe('function'); + expect(typeof ref.current?.selectTool).toBe('function'); + expect(typeof ref.current?.createField).toBe('function'); + expect(typeof ref.current?.clearFields).toBe('function'); + expect(typeof ref.current?.getDocumentContent).toBe('function'); + expect(typeof ref.current?.submit).toBe('function'); + }); + + describe('action methods before modal opens (iframe not available)', () => { + it.each([ + { action: 'goTo' as const, args: { page: 1 } }, + { action: 'selectTool' as const, args: 'TEXT' as const }, + { + action: 'createField' as const, + args: { type: 'TEXT' as const, page: 1, x: 0, y: 0, width: 100, height: 20 }, + }, + { action: 'clearFields' as const, args: {} }, + { action: 'getDocumentContent' as const, args: {} }, + { action: 'submit' as const, args: { downloadCopyOnDevice: false } }, + ])('$action returns error when iframe not available (modal not opened)', async ({ action, args }) => { + const ref = React.createRef(); + + render( + + + , + ); + + await waitFor(() => { + expect(ref.current).not.toBeNull(); + }); + + if (ref.current === null) { + throw new Error('Expected ref to be available'); + } + + const result = await (ref.current[action] as (arg: never) => Promise)(args as never); + + expect(result).toEqual({ + success: false, + error: { + code: 'unexpected:iframe_not_available', + message: 'Iframe not available', + }, + }); + }); + }); + + describe('action methods when editor is ready (inline mode)', () => { + interface MockContentWindow { + postMessage: ReturnType; + } + + const setupMockContentWindow = (iframe: HTMLIFrameElement): MockContentWindow => { + const mockContentWindow: MockContentWindow = { + postMessage: vi.fn(), + }; + Object.defineProperty(iframe, 'contentWindow', { + value: mockContentWindow, + writable: true, + }); + return mockContentWindow; + }; + + const simulateEditorReady = async ( + iframe: HTMLIFrameElement, + mockContentWindow: MockContentWindow, + ): Promise => { + await act(async () => { + messageHandler?.( + createMessageEvent({ + origin: 'https://react-editor.simplepdf.com', + source: mockContentWindow as unknown as Window, + data: JSON.stringify({ type: 'EDITOR_READY', data: {} }), + }), + ); + }); + + await act(async () => { + messageHandler?.( + createMessageEvent({ + origin: 'https://react-editor.simplepdf.com', + source: mockContentWindow as unknown as Window, + data: JSON.stringify({ type: 'DOCUMENT_LOADED', data: { document_id: 'doc123' } }), + }), + ); + }); + + // Allow editor ready promise to resolve + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + }); + }; + + const extractRequestId = (mockContentWindow: MockContentWindow): string => { + const calls = mockContentWindow.postMessage.mock.calls; + const lastCall = calls[calls.length - 1]; + if (!lastCall || typeof lastCall[0] !== 'string') { + throw new Error('Expected postMessage to be called with a string'); + } + const parsed = JSON.parse(lastCall[0]); + return parsed.request_id; + }; + + const simulateActionResponse = async ({ + mockContentWindow, + result, + }: { + mockContentWindow: MockContentWindow; + result: { success: true } | { success: true; data: unknown }; + }): Promise => { + const requestId = extractRequestId(mockContentWindow); + + await act(async () => { + messageHandler?.( + createMessageEvent({ + origin: 'https://react-editor.simplepdf.com', + source: mockContentWindow as unknown as Window, + data: JSON.stringify({ + type: 'REQUEST_RESULT', + data: { request_id: requestId, result }, + }), + }), + ); + }); + }; + + it('goTo resolves with success when iframe responds', async () => { + const ref = React.createRef(); + + render(); + + const iframe = getIframe(); + const mockContentWindow = setupMockContentWindow(iframe); + + await simulateEditorReady(iframe, mockContentWindow); + + if (ref.current === null) { + throw new Error('Expected ref to be available'); + } + + const resultPromise = ref.current.goTo({ page: 2 }); + + await waitFor(() => { + expect(mockContentWindow.postMessage).toHaveBeenCalled(); + }); + + await simulateActionResponse({ + mockContentWindow, + result: { success: true }, + }); + + const result = await resultPromise; + expect(result).toEqual({ success: true }); + }); + + it('submit resolves with success when iframe responds', async () => { + const ref = React.createRef(); + + render(); + + const iframe = getIframe(); + const mockContentWindow = setupMockContentWindow(iframe); + + await simulateEditorReady(iframe, mockContentWindow); + + if (ref.current === null) { + throw new Error('Expected ref to be available'); + } + + const resultPromise = ref.current.submit({ downloadCopyOnDevice: false }); + + await waitFor(() => { + expect(mockContentWindow.postMessage).toHaveBeenCalled(); + }); + + await simulateActionResponse({ + mockContentWindow, + result: { success: true }, + }); + + const result = await resultPromise; + expect(result).toEqual({ success: true }); + }); + + it('getDocumentContent resolves with data when iframe responds', async () => { + const ref = React.createRef(); + + render(); + + const iframe = getIframe(); + const mockContentWindow = setupMockContentWindow(iframe); + + await simulateEditorReady(iframe, mockContentWindow); + + if (ref.current === null) { + throw new Error('Expected ref to be available'); + } + + const resultPromise = ref.current.getDocumentContent({ extractionMode: 'auto' }); + + await waitFor(() => { + expect(mockContentWindow.postMessage).toHaveBeenCalled(); + }); + + await simulateActionResponse({ + mockContentWindow, + result: { + success: true, + data: { + name: 'document.pdf', + pages: [{ page: 1, content: 'Hello world' }], + }, + }, + }); + + const result = await resultPromise; + expect(result).toEqual({ + success: true, + data: { + name: 'document.pdf', + pages: [{ page: 1, content: 'Hello world' }], + }, + }); + }); + }); + }); + + describe('event handling', () => { + it('calls onEmbedEvent when EDITOR_READY received', async () => { + const onEmbedEvent = vi.fn(); + + render(); + + const iframe = getIframe(); + + await act(async () => { + messageHandler?.( + createMessageEvent({ + origin: 'https://react-editor.simplepdf.com', + source: iframe.contentWindow, + data: JSON.stringify({ type: 'EDITOR_READY', data: {} }), + }), + ); + }); + + expect(onEmbedEvent).toHaveBeenCalledWith({ type: 'EDITOR_READY', data: {} }); + }); + + it.each([ + { type: 'DOCUMENT_LOADED', data: { document_id: 'doc123' } }, + { type: 'PAGE_FOCUSED', data: { previous_page: 1, current_page: 2, total_pages: 5 } }, + { type: 'SUBMISSION_SENT', data: { document_id: 'doc123', submission_id: 'sub456' } }, + ])('calls onEmbedEvent for $type', async ({ type, data }) => { + const onEmbedEvent = vi.fn(); + + render(); + + const iframe = getIframe(); + + await act(async () => { + messageHandler?.( + createMessageEvent({ + origin: 'https://react-editor.simplepdf.com', + source: iframe.contentWindow, + data: JSON.stringify({ type, data }), + }), + ); + }); + + expect(onEmbedEvent).toHaveBeenCalledWith({ type, data }); + }); + + it('ignores events from different origins', async () => { + const onEmbedEvent = vi.fn(); + + render(); + + await act(async () => { + messageHandler?.( + createMessageEvent({ + origin: 'https://malicious.com', + source: null, + data: JSON.stringify({ type: 'EDITOR_READY', data: {} }), + }), + ); + }); + + expect(onEmbedEvent).not.toHaveBeenCalled(); + }); + + it('ignores events from untrusted iframe source', async () => { + const onEmbedEvent = vi.fn(); + + render(); + + await act(async () => { + messageHandler?.( + createMessageEvent({ + origin: 'https://react-editor.simplepdf.com', + source: {} as Window, + data: JSON.stringify({ type: 'EDITOR_READY', data: {} }), + }), + ); + }); + + expect(onEmbedEvent).not.toHaveBeenCalled(); + }); + + it('handles invalid JSON in message gracefully', async () => { + const onEmbedEvent = vi.fn(); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render(); + + const iframe = getIframe(); + + await act(async () => { + messageHandler?.( + createMessageEvent({ + origin: 'https://react-editor.simplepdf.com', + source: iframe.contentWindow, + data: 'not valid json', + }), + ); + }); + + expect(onEmbedEvent).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to parse iFrame event payload'); + }); + + it('handles error when onEmbedEvent throws', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const onEmbedEvent = vi.fn().mockRejectedValue(new Error('Handler error')); + + render(); + + const iframe = getIframe(); + + await act(async () => { + messageHandler?.( + createMessageEvent({ + origin: 'https://react-editor.simplepdf.com', + source: iframe.contentWindow, + data: JSON.stringify({ type: 'EDITOR_READY', data: {} }), + }), + ); + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('onEmbedEvent failed to execute')); + }); + }); + + describe('document loading', () => { + let originalFetch: typeof globalThis.fetch; + let originalFileReader: typeof globalThis.FileReader; + + beforeEach(() => { + originalFetch = globalThis.fetch; + originalFileReader = globalThis.FileReader; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + globalThis.FileReader = originalFileReader; + }); + + it('calls fetch with documentURL when provided', async () => { + const fetchMock = vi.fn().mockImplementation( + () => + new Promise(() => { + // Never resolve to prevent state updates + }), + ); + globalThis.fetch = fetchMock; + + render(); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith('https://example.com/document.pdf', { + method: 'GET', + credentials: 'same-origin', + }); + }); + }); + + it('adds open param to URL when CORS fallback is triggered', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new Error('CORS error')); + + render(); + + await waitFor(() => { + const url = getIframeSrcUrl(); + expect(url.searchParams.get('open')).toBe('https://example.com/doc.pdf'); + }); + }); + + it('falls back to CORS proxy when fetch returns non-ok response', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 403 }); + + render(); + + await waitFor(() => { + const url = getIframeSrcUrl(); + expect(url.searchParams.get('open')).toBe('https://example.com/forbidden.pdf'); + }); + }); + + it('loads document via fetch and FileReader when CORS allows', async () => { + const mockBlob = new Blob(['pdf content'], { type: 'application/pdf' }); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(mockBlob), + }); + + globalThis.FileReader = MockFileReader as unknown as typeof FileReader; + + render(); + + await waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalledWith('https://example.com/doc.pdf', { + method: 'GET', + credentials: 'same-origin', + }); + }); + }); + + it('falls back to CORS proxy when FileReader fails', async () => { + const mockBlob = new Blob(['pdf content'], { type: 'application/pdf' }); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(mockBlob), + }); + + globalThis.FileReader = MockFileReaderWithError as unknown as typeof FileReader; + + render(); + + await waitFor(() => { + const url = getIframeSrcUrl(); + expect(url.searchParams.get('open')).toBe('https://example.com/doc.pdf'); + }); + }); + + it('handles unmount during fetch without errors', async () => { + let resolveFetch: (value: Response) => void; + globalThis.fetch = vi.fn().mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { unmount } = render(); + + await waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalled(); + }); + + unmount(); + + await act(async () => { + resolveFetch!({ + ok: true, + blob: () => Promise.resolve(new Blob(['pdf'], { type: 'application/pdf' })), + } as Response); + }); + + expect(consoleErrorSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Can't perform a React state update on an unmounted component"), + ); + }); + }); + + describe('context changes', () => { + it('re-encodes context when it changes', () => { + const decodeContext = (url: URL): unknown => { + const encoded = url.searchParams.get('context'); + if (encoded === null) { + throw new Error('Expected context param to be present'); + } + return JSON.parse(atob(decodeURIComponent(encoded))); + }; + + const { rerender } = render(); + + const initialUrl = getIframeSrcUrl(); + expect(decodeContext(initialUrl)).toEqual({ v: 1 }); + + rerender(); + + const newUrl = getIframeSrcUrl(); + expect(decodeContext(newUrl)).toEqual({ v: 2 }); + }); + }); + + describe('unknown event types', () => { + it('ignores unknown event types', async () => { + const onEmbedEvent = vi.fn(); + + render(); + + const iframe = getIframe(); + + await act(async () => { + messageHandler?.( + createMessageEvent({ + origin: 'https://react-editor.simplepdf.com', + source: iframe.contentWindow, + data: JSON.stringify({ type: 'UNKNOWN_EVENT', data: {} }), + }), + ); + }); + + expect(onEmbedEvent).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/react/src/index.tsx b/react/src/index.tsx index 21adb5f..e14ddb5 100644 --- a/react/src/index.tsx +++ b/react/src/index.tsx @@ -1,14 +1,17 @@ import * as React from 'react'; import { createPortal } from 'react-dom'; -import { sendEvent, EmbedRefHandlers, useEmbed } from './hook'; +import { sendEvent, EmbedActions, useEmbed } from './hook'; +import { buildEditorDomain, encodeContext, buildEditorURL, extractDocumentName, type Locale } from './utils'; import './styles.scss'; export { useEmbed }; export type EmbedEvent = + | { type: 'EDITOR_READY'; data: Record } | { type: 'DOCUMENT_LOADED'; data: { document_id: string } } - | { type: 'SUBMISSION_SENT'; data: { submission_id: string } }; + | { type: 'PAGE_FOCUSED'; data: { previous_page: number | null; current_page: number; total_pages: number } } + | { type: 'SUBMISSION_SENT'; data: { document_id: string; submission_id: string } }; type Props = InlineProps | ModalProps; @@ -16,7 +19,12 @@ interface CommonProps { companyIdentifier?: string; context?: Record; onEmbedEvent?: (event: EmbedEvent) => Promise | void; - locale?: 'en' | 'de' | 'es' | 'fr' | 'it' | 'pt'; + locale?: Locale; + /** + * Override the base domain for self-hosted deployments (e.g., "yourdomain.com"). + * Interested in enterprise self-hosting? Contact sales@simplepdf.com + */ + baseDomain?: string; } interface InlineProps extends CommonProps { @@ -41,34 +49,11 @@ const CloseIcon: React.FC = () => ( ); -const loadDocument = async ({ - iframeRef, - documentDataURL, - documentName, - editorDomain, -}: { - iframeRef: React.RefObject; - documentDataURL: string; - documentName: string; - editorDomain: string; -}) => { - const editorDomainURL = new URL(editorDomain); - iframeRef.current?.contentWindow?.postMessage( - JSON.stringify({ - type: 'LOAD_DOCUMENT', - data: { data_url: documentDataURL, name: documentName }, - }), - editorDomainURL.origin, - ); -}; - -const DEFAULT_LOCALE = 'en'; - const isInlineComponent = (props: Props): props is InlineProps => (props as InlineProps).mode === 'inline'; const InlineComponent = React.forwardRef>( ({ className, style }, iframeRef) => { - return
refEmbedRefHandlersEmbedActions NoUsed for programmatic control of the editorUsed for programmatic control of the editor (see Programmatic Control section)
modeNo (defaults to "modal") Inline the editor or display it inside a modal
locale"en" | "de" | "es" | "fr" | "it" | "pt""en" | "de" | "es" | "fr" | "it" | "nl" | "pt" No (defaults to "en") Language to display the editor in (ISO locale)
No Allows collecting customers submissions
baseDomainstringNoOverride the base domain for self-hosted deployments (e.g., "yourdomain.com"). Contact sales@simplepdf.com for enterprise self-hosting
context Record<string, unknown>