diff --git a/examples/react-render-target/README.md b/examples/react-render-target/README.md new file mode 100644 index 0000000..872f00e --- /dev/null +++ b/examples/react-render-target/README.md @@ -0,0 +1,21 @@ +# Proton Web SDK — `renderTarget` example (React) + +A minimal React app that mounts the Proton Web SDK dialog inside a specific +DOM element via the new `renderTarget` option, instead of the default +`document.body`. + +## Run + +```bash +pnpm install +pnpm --filter example-react-render-target dev +``` + +## What it demonstrates + +- `uiOptions.renderTarget` passed to `ProtonWebSDK()` — the login modal is + appended inside the referenced element. +- `renderer.setRenderTarget(el)` called before `session.transact(...)` — the + sign-request modal re-parents into the same container. + +The target can be either an `HTMLElement` or a CSS selector string. diff --git a/examples/react-render-target/index.html b/examples/react-render-target/index.html new file mode 100644 index 0000000..20e8305 --- /dev/null +++ b/examples/react-render-target/index.html @@ -0,0 +1,289 @@ + + + + + + Proton Web SDK - renderTarget example + + + +
+ + + diff --git a/examples/react-render-target/package.json b/examples/react-render-target/package.json new file mode 100644 index 0000000..c7c9af7 --- /dev/null +++ b/examples/react-render-target/package.json @@ -0,0 +1,23 @@ +{ + "name": "example-react-render-target", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@proton/web-sdk": "workspace:^", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "~5.8.3", + "vite": "^6.3.5" + } +} diff --git a/examples/react-render-target/src/App.tsx b/examples/react-render-target/src/App.tsx new file mode 100644 index 0000000..9e11f37 --- /dev/null +++ b/examples/react-render-target/src/App.tsx @@ -0,0 +1,269 @@ +import {useRef, useState} from "react"; +import ProtonWebSDK from "@proton/web-sdk"; +import type {Link, LinkSession, ProtonWebLink} from "@proton/web-sdk"; + +const REQUEST_ACCOUNT = "taskly"; +const BURN_ACCOUNT = "eosio.null"; +const CHAIN_ID = + "71ee83bcf52142d61019d95f9cc5427ba6a0d7ff8accd9e2088ae2abeaf3d3dd"; +const ENDPOINTS = ["https://testnet.rockerone.io"]; + +type Renderer = NonNullable< + Awaited>["renderer"] +>; + +type Mode = "connect" | "sign" | "done"; + +const nextFrame = () => + new Promise(resolve => requestAnimationFrame(() => resolve())); + +export const App = () => { + const targetRef = useRef(null); + const linkRef = useRef(undefined); + const rendererRef = useRef(undefined); + const [session, setSession] = useState(); + const [mode, setMode] = useState(null); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState("Not connected"); + const [burned, setBurned] = useState(false); + + const openWith = async (next: Mode) => { + setMode(next); + await nextFrame(); + }; + + const closeModal = () => setMode(null); + + const connect = async () => { + setBusy(true); + await openWith("connect"); + if (!targetRef.current) { + setBusy(false); + return; + } + try { + const result = await ProtonWebSDK({ + linkOptions: { + endpoints: ENDPOINTS, + chainId: CHAIN_ID, + restoreSession: false, + }, + transportOptions: { + requestAccount: REQUEST_ACCOUNT, + requestStatus: false, + }, + uiOptions: { + appInfo: {name: "renderTarget demo"}, + renderTarget: targetRef.current, + }, + }); + linkRef.current = result.link; + rendererRef.current = result.renderer; + if (result.session) { + setSession(result.session); + setStatus(`Connected as ${result.session.auth.actor}`); + setMode("done"); + } else { + setStatus("Login cancelled"); + closeModal(); + } + } catch (err) { + setStatus(`Error: ${(err as Error).message}`); + closeModal(); + } finally { + setBusy(false); + } + }; + + const transfer = async ( + to: string, + memo: string, + successLabel: string, + embedded: boolean, + ) => { + if (!session) return; + setBusy(true); + if (embedded) { + await openWith("sign"); + rendererRef.current?.setRenderTarget(targetRef.current!); + } else { + rendererRef.current?.setRenderTarget(null); + } + try { + await session.transact( + { + actions: [ + { + account: "eosio.token", + name: "transfer", + data: { + from: session.auth.actor, + to, + quantity: "0.0001 XPR", + memo, + }, + authorization: [session.auth], + }, + ], + }, + {broadcast: true}, + ); + setStatus(successLabel); + if (embedded) setMode("done"); + } catch (err) { + setStatus(`Error: ${(err as Error).message}`); + if (embedded) setMode("done"); + } finally { + setBusy(false); + } + }; + + const sendToTaskly = () => + transfer( + REQUEST_ACCOUNT, + "renderTarget example", + "Transfer signed and broadcast", + false, + ); + + const burn = async () => { + await transfer( + BURN_ACCOUNT, + "burn", + "Burned 0.0001 XPR to eosio.null", + true, + ); + setBurned(true); + }; + + const logout = async () => { + if (linkRef.current && session) { + await linkRef.current.removeSession( + REQUEST_ACCOUNT, + session.auth, + CHAIN_ID, + ); + } + linkRef.current = undefined; + rendererRef.current = undefined; + setSession(undefined); + setStatus("Not connected"); + setBurned(false); + }; + + return ( + <> +
+

Proton Web SDK — renderTarget example

+

+ Clicking Connect opens a two-pane modal: the left + side is your own UI, and the SDK dialog mounts into the right pane + using the new renderTarget option. +

+ +
{status}
+ + {!session ? ( + + ) : ( +
+ + +
+ )} +
+ + {mode && ( +
{ + if (e.target === e.currentTarget && !busy) closeModal(); + }} + > +
+ +
+ {mode === "done" && session && ( +
+
+ + + +
+

+ {status.startsWith("Transfer") + ? "Transaction signed" + : "Wallet connected"} +

+

{String(session.auth.actor)}

+

+ {status.startsWith("Transfer") || + status.startsWith("Burned") + ? "Your transaction has been broadcast to the network." + : "You can now sign transactions from this app."} +

+ +
+ )} +
+
+
+ )} + + ); +}; diff --git a/examples/react-render-target/src/main.tsx b/examples/react-render-target/src/main.tsx new file mode 100644 index 0000000..607cf4b --- /dev/null +++ b/examples/react-render-target/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/examples/react-render-target/tsconfig.app.json b/examples/react-render-target/tsconfig.app.json new file mode 100644 index 0000000..b464276 --- /dev/null +++ b/examples/react-render-target/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/examples/react-render-target/tsconfig.json b/examples/react-render-target/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/examples/react-render-target/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/examples/react-render-target/tsconfig.node.json b/examples/react-render-target/tsconfig.node.json new file mode 100644 index 0000000..c604786 --- /dev/null +++ b/examples/react-render-target/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/react-render-target/vite.config.ts b/examples/react-render-target/vite.config.ts new file mode 100644 index 0000000..9ffcc67 --- /dev/null +++ b/examples/react-render-target/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/proton-web-renderer/src/index.ts b/packages/proton-web-renderer/src/index.ts index c5f48ca..8c7a48c 100644 --- a/packages/proton-web-renderer/src/index.ts +++ b/packages/proton-web-renderer/src/index.ts @@ -15,6 +15,7 @@ import { backAction, closeAction, demoMode, + embeddedMode, enabledWallets, error, manualAction, @@ -62,11 +63,19 @@ export class WebRenderer implements UIRenderer { async selectWallet({ enabledWallets: wallets, - }: {enabledWallets?: UIWalletType[] | string[]} = {}): Promise { + renderTarget, + }: { + enabledWallets?: UIWalletType[] | string[] + renderTarget?: HTMLElement | string | null + } = {}): Promise { if (!wallets) { wallets = ENABLED_WALLETS } + if (renderTarget) { + this.setRenderTarget(renderTarget) + } + enabledWallets.set(new Set(wallets as UIWalletType[])) router.push(ROUTES.WEBAUTH_CONNECT) @@ -89,6 +98,9 @@ export class WebRenderer implements UIRenderer { if (payload.wallet_type === SUPPORTED_WALLETS.ANCHOR) { route = ROUTES.OTHER_ANCHOR_USE } + if (payload.renderTarget) { + this.setRenderTarget(payload.renderTarget) + } this.request(route, payload) } @@ -97,6 +109,9 @@ export class WebRenderer implements UIRenderer { if (payload.wallet_type === SUPPORTED_WALLETS.ANCHOR) { route = ROUTES.OTHER_ANCHOR_SIGN } + if (payload.renderTarget) { + this.setRenderTarget(payload.renderTarget) + } this.sign_request(route, payload) } @@ -105,10 +120,16 @@ export class WebRenderer implements UIRenderer { if (payload.wallet_type === SUPPORTED_WALLETS.ANCHOR) { route = ROUTES.OTHER_ANCHOR_SIGN_MANUAL } + if (payload.renderTarget) { + this.setRenderTarget(payload.renderTarget) + } this.request(route, payload) } showError(payload: UIErrorPayload): void { + if (payload.renderTarget) { + this.setRenderTarget(payload.renderTarget) + } error.set(payload.data) this.show() } @@ -119,6 +140,10 @@ export class WebRenderer implements UIRenderer { route = ROUTES.OTHER_ANCHOR_SIGN } + if (payload.renderTarget) { + this.setRenderTarget(payload.renderTarget) + } + recoverError.set(payload.data) if (payload.onManual) { manualAction.set(payload.onManual) @@ -266,13 +291,42 @@ export class WebRenderer implements UIRenderer { this.initialized = true } + private resolveRenderTarget( + target: HTMLElement | string | null | undefined + ): HTMLElement { + if (target instanceof HTMLElement) { + return target + } + if (typeof target === 'string') { + const found = document.querySelector(target) + if (found) { + return found + } + } + return document.body + } + + setRenderTarget(target: HTMLElement | string | null): void { + this.options.renderTarget = target + if (!this.initialized || !this.element) { + return + } + const parent = this.resolveRenderTarget(target) + if (this.element.parentNode !== parent) { + parent.append(this.element) + } + embeddedMode.set(parent !== document.body) + } + private appendDialogElement() { const existing = document.getElementById(this.elementId) if (!this.element || !this.shadow) { throw new Error('The WebRenderer is not initialized. Call the initialize method first.') } if (!existing) { - document.body.append(this.element) + const target = this.resolveRenderTarget(this.options.renderTarget) + target.append(this.element) + embeddedMode.set(target !== document.body) this.offDOMContentLoaded() this.app = mount(App, { diff --git a/packages/proton-web-renderer/src/types.ts b/packages/proton-web-renderer/src/types.ts index ae54aa1..a307c54 100644 --- a/packages/proton-web-renderer/src/types.ts +++ b/packages/proton-web-renderer/src/types.ts @@ -13,12 +13,14 @@ export type UISpace = export interface UIRendererOptions extends UIProps { id?: string + renderTarget?: HTMLElement | string | null } interface UIGenericPayload { wallet_type: UIWalletType | string onClose?: () => void onBack?: () => void + renderTarget?: HTMLElement | string | null } export interface UIRequestPayload extends UIGenericPayload { @@ -31,6 +33,7 @@ export type UISignManuallyPayload = UIRequestPayload export interface UIErrorPayload { wallet_type: UIWalletType | string data: UIError + renderTarget?: HTMLElement | string | null } export interface UISignPayload extends UIGenericPayload { @@ -51,4 +54,5 @@ export interface UIRenderer { close(): void destroy(): void showLoading(): void + setRenderTarget(_: HTMLElement | string | null): void } diff --git a/packages/proton-web-renderer/src/ui/components/Countdown.svelte b/packages/proton-web-renderer/src/ui/components/Countdown.svelte index 4c79db6..532844d 100644 --- a/packages/proton-web-renderer/src/ui/components/Countdown.svelte +++ b/packages/proton-web-renderer/src/ui/components/Countdown.svelte @@ -114,6 +114,7 @@ } .content { + position: relative; background: black; border-radius: inherit; box-sizing: border-box; @@ -124,7 +125,7 @@ align-items: center; border: 1px solid rgba(var(--countdown-background), 0.05); background: rgba(var(--countdown-background), 0.05); - z-index: -1; + z-index: 0; } .label { diff --git a/packages/proton-web-renderer/src/ui/components/Modal.svelte b/packages/proton-web-renderer/src/ui/components/Modal.svelte index 7b47fea..42980ab 100644 --- a/packages/proton-web-renderer/src/ui/components/Modal.svelte +++ b/packages/proton-web-renderer/src/ui/components/Modal.svelte @@ -1,7 +1,8 @@ - + {@render content?.()} @@ -100,6 +105,19 @@ &::backdrop { background: rgba(0, 0, 0, 0.75); } + + &[data-embedded='true'] { + position: absolute; + inset: 0; + margin: 0; + width: 100%; + max-width: 100%; + height: 100%; + max-height: 100%; + border: none; + border-radius: 0; + --max-modal-content-height: 100%; + } } .dialog-content-old { diff --git a/packages/proton-web-renderer/src/ui/store.ts b/packages/proton-web-renderer/src/ui/store.ts index 37137e8..757c00c 100644 --- a/packages/proton-web-renderer/src/ui/store.ts +++ b/packages/proton-web-renderer/src/ui/store.ts @@ -19,6 +19,9 @@ const defaultUIProps: UIProps = {} export const app_props = writable(defaultUIProps) +/** Whether the dialog is mounted into a custom renderTarget (non-modal mode) */ +export const embeddedMode = writable(false) + /** Whether or not the interface is active in the browser */ export const active = writable(false) diff --git a/packages/proton-web-sdk/src/connect.ts b/packages/proton-web-sdk/src/connect.ts index fdc488d..c06fdf5 100644 --- a/packages/proton-web-sdk/src/connect.ts +++ b/packages/proton-web-sdk/src/connect.ts @@ -53,9 +53,15 @@ export const ConnectWallet = async ({ if (!renderer) { renderer = new WebRenderer(uiOptions) + } else if (uiOptions.renderTarget) { + renderer.setRenderTarget(uiOptions.renderTarget) } - return await login({selectorOptions, linkOptions, transportOptions}) + const result = await login({selectorOptions, linkOptions, transportOptions}) + if (result) { + ;(result as ConnectWalletRet).renderer = renderer + } + return result } const login = async ( diff --git a/packages/proton-web-sdk/src/types.ts b/packages/proton-web-sdk/src/types.ts index 7f66f9a..138a010 100644 --- a/packages/proton-web-sdk/src/types.ts +++ b/packages/proton-web-sdk/src/types.ts @@ -1,7 +1,7 @@ import type {Link, LinkOptions, LinkSession, LinkStorage, LoginResult} from '@proton/link' import type {BrowserTransportOptions} from '@proton/browser-transport' import type {ProtonWebLink} from './links/protonWeb' -import type {UIRendererOptions} from '@proton/web-renderer' +import type {UIRendererOptions, WebRenderer} from '@proton/web-renderer' type PartialBy = Omit & Partial> @@ -31,6 +31,7 @@ export interface ConnectWalletRet { session?: LinkSession link?: ProtonWebLink | Link loginResult?: LoginResult + renderer?: WebRenderer error?: any } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56c7956..af59553 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,34 @@ importers: specifier: ~5.9.2 version: 5.9.3 + examples/react-render-target: + dependencies: + '@proton/web-sdk': + specifier: workspace:^ + version: link:../../packages/proton-web-sdk + react: + specifier: ^19.1.0 + version: 19.2.4 + react-dom: + specifier: ^19.1.0 + version: 19.2.4(react@19.2.4) + devDependencies: + '@types/react': + specifier: ^19.1.2 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.1.2 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^4.4.1 + version: 4.7.0(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.1)(yaml@2.8.3)) + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vite: + specifier: ^6.3.5 + version: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(terser@5.46.1)(yaml@2.8.3) + examples/svelte: dependencies: '@proton/js':