CDN Integration: Minimal Pendant Generator Example
This document demonstrates how to build a minimal pendant generator from scratch using the script tag method.
For new generators, the recommended official shell is now generator-workbench. This page is still useful for understanding low-level manual SDK wiring or compatibility work, but the default platform layout should be mounted through the workbench rather than recreated in each project.
Our goals are:
- Integrate the Login SDK at the top.
- Display the user avatar after login.
- Add an "Open in Studio" button at the bottom.
Step 1: Create the HTML Structure
Prepare a minimal HTML file. While we won't write full styles here, we need to set up the DOM nodes that our scripts will use:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pendant Generator</title>
</head>
<body>
<div class="app">
<header class="topbar">
<button id="loginBtn" type="button">Log in</button>
<div id="userMenu" style="display: none;">
<img id="userAvatar" alt="User avatar" />
<button id="logoutBtn" type="button">Log out</button>
</div>
</header>
<main class="workspace">
<section class="stage">
<div id="previewHost"></div>
</section>
<aside class="panel">
<label for="widthRange">Width</label>
<span id="widthValue">96 mm</span>
<input id="widthRange" type="range" min="60" max="180" step="1" value="96" />
<label for="heightRange">Height</label>
<span id="heightValue">128 mm</span>
<input id="heightRange" type="range" min="80" max="220" step="1" value="128" />
<button id="exportBtn" type="button">Open in Studio</button>
</aside>
</main>
<canvas id="exportCanvas" style="display: none;"></canvas>
</div>
<div id="loadingOverlay" style="display: none;">
Opening xTool Studio...
</div>
</body>
</html>The focus here is preparing the nodes: loginBtn, userMenu, userAvatar, logoutBtn, previewHost, widthRange, heightRange, exportBtn, exportCanvas, and loadingOverlay.
Step 2: Include the SDK
Include the generator-sdk via CDN:
<script src="https://static-res.atomm.com/scripts/js/generator-sdk/index.umd.js"></script>This makes GeneratorSDK available globally.
Step 3: Initialize the SDK
Initialize the SDK with an appKey that serves as the generator ID. You can define this ID yourself as long as it stays stable for the same generator.
<script>
const sdk = GeneratorSDK.init({
appKey: 'your_generator_id',
env: 'prod',
})
</script>Step 4: Integrate Login
Login requires three actions:
- Call
sdk.auth.login()when the login button is clicked. - Listen to
sdk.auth.onChange(). - Display
status.userInfo.headpicas the avatar after login.
<script>
const loginBtn = document.getElementById('loginBtn')
const logoutBtn = document.getElementById('logoutBtn')
const userMenu = document.getElementById('userMenu')
const userAvatar = document.getElementById('userAvatar')
loginBtn.addEventListener('click', async () => {
try {
await sdk.auth.login()
} catch (error) {}
})
logoutBtn.addEventListener('click', async () => {
try {
await sdk.auth.logout()
} catch (error) {}
})
function updateAuthUI(status) {
const isLogin = !!status && status.isLogin
loginBtn.style.display = isLogin ? 'none' : ''
userMenu.style.display = isLogin ? '' : 'none'
userAvatar.src = isLogin ? status.userInfo.headpic : ''
}
sdk.auth.onChange(updateAuthUI)
updateAuthUI(sdk.auth.getStatus())
</script>Key points:
- Use
status.userInfo.headpicfor the avatar. - No need to implement your own login modal.
- "Open in Studio" depends on the login state, so this step must be completed first.
Step 5: Prepare Pendant Preview
The pendant in this demo is a minimalist rectangular tag with a hole inside, containing the word "generator" and a 2px stroke.
Important: Use two separate SVG builders — one for on-screen preview (with white fill + shadow), one for export (stroke only, no fill). If you reuse the same fill="#FFFFFF" SVG for export, Studio will render the shapes as solid black when imported.
<script>
// For page preview — white fill + drop-shadow filter
function buildPendantSvgMarkup(width, height) {
return `
<svg id="previewSvg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 420">
<defs>
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#000000" flood-opacity="0.16" />
</filter>
</defs>
<g filter="url(#softShadow)">
<path d="M 96 96 L 264 96 Q 276 96 276 108 L 276 312 Q 276 324 264 324 L 96 324 Q 84 324 84 312 L 84 108 Q 84 96 96 96 Z"
fill="#ffffff" stroke="#111111" stroke-width="2"/>
<circle cx="180" cy="132" r="12" fill="#ffffff" stroke="#111111" stroke-width="2"/>
<text x="180" y="228" text-anchor="middle" dominant-baseline="middle"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="24" font-weight="600" letter-spacing="0.06em" fill="#111111">generator</text>
</g>
</svg>`
}
// For export — root sets fill="none"; <path> and <circle> have NO fill attribute.
// Do NOT add fill="#FFFFFF" to child elements — it overrides the root and
// causes Studio to import shapes as solid black.
function buildExportSvg(width, height) {
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}mm" height="${height}mm" viewBox="0 0 360 420" fill="none">
<path d="M 96 96 L 264 96 Q 276 96 276 108 L 276 312 Q 276 324 264 324 L 96 324 Q 84 324 84 312 L 84 108 Q 84 96 96 96 Z"
stroke="#111111" stroke-width="2"/>
<circle cx="180" cy="132" r="12" stroke="#111111" stroke-width="2"/>
<text x="180" y="228" text-anchor="middle" dominant-baseline="middle"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="24" font-weight="600" letter-spacing="0.06em" fill="#111111">generator</text>
</svg>`
}
</script>Use buildPendantSvgMarkup to render the on-screen preview; use buildExportSvg to provide the SVG string for Studio.
Step 6: Register the Export Provider
openInStudio() retrieves export data from the registered provider. Use getExportData to return SVG directly when format === 'svg', and fall back to the hidden canvas for other formats.
<script>
const exportCanvas = document.getElementById('exportCanvas')
sdk.export.register({
getExportData: (purpose, format) => {
if (format === 'svg') {
return { type: 'svg', svgString: buildExportSvg(96, 128) }
}
return { type: 'canvas', canvas: exportCanvas }
},
getFileName: () => 'pendant.svg',
})
async function syncExportCanvas() {
const svgText = buildExportSvg(96, 128)
const blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(blob)
const image = new Image()
const ctx = exportCanvas.getContext('2d')
try {
await new Promise((resolve, reject) => {
image.onload = resolve
image.onerror = reject
image.src = url
})
exportCanvas.width = 1080
exportCanvas.height = 1260
ctx.clearRect(0, 0, exportCanvas.width, exportCanvas.height)
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height)
ctx.drawImage(image, 0, 0, exportCanvas.width, exportCanvas.height)
} finally {
URL.revokeObjectURL(url)
}
}
</script>Key points:
- When
format === 'svg', return{ type: 'svg', svgString }directly — Studio receives the clean stroke-only SVG. - The hidden canvas is kept as a fallback for other export formats (PNG, etc.).
Step 7: Open in xTool Studio
When the button is clicked, show a loading state and then call sdk.export.openInStudio():
<script>
const loadingOverlay = document.getElementById('loadingOverlay')
const exportBtn = document.getElementById('exportBtn')
function setStudioLoading(isLoading) {
loadingOverlay.style.display = isLoading ? 'flex' : 'none'
exportBtn.disabled = isLoading
exportBtn.textContent = isLoading ? 'Opening...' : 'Open in Studio'
}
exportBtn.addEventListener('click', async () => {
try {
setStudioLoading(true)
await syncExportCanvas()
await sdk.export.openInStudio({ format: 'svg' })
} finally {
setStudioLoading(false)
}
})
</script>Key points:
- Pass
{ format: 'svg' }so the SDK callsgetExportDatawithformat === 'svg'and sends the stroke-only SVG to Studio. openInStudio()launches xTool Studio rather than downloading an SVG file.- This feature requires a login token.
- Hide the loading state once the button becomes clickable again.
Step 8: Upload Resources
- Package resources into a zip file. The zip file must contain an
htmlfile. - Upload the zip file to https://www.atomm.com/generator-upload. Note: If you don't have an xTool account, you need to register one first.
Key Steps Recap
Just remember these 4 things:
- Include
generator-sdkvia<script>. - Initialize with
GeneratorSDK.init({ appKey, env }). - Integrate login via
sdk.auth.login()+sdk.auth.onChange()and readuserInfo.headpic. - Register the hidden canvas via
sdk.export.register()and launch xTool Studio viasdk.export.openInStudio().
Complete Source Code
The following is the complete source code of pendant-cdn-demo.html.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pendant Generator</title>
<style>
:root {
--bg: #f3f3f3;
--panel: #ffffff;
--line: #d4d4d4;
--text: #111111;
--muted: #6b7280;
--shadow: 0 18px 48px rgba(0, 0, 0, 0.08);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background: var(--bg);
overflow: hidden;
}
.app {
height: 100vh;
display: flex;
flex-direction: column;
}
.topbar {
height: 64px;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255,1);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(12px);
flex-shrink: 0;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-badge {
width: 34px;
height: 34px;
border-radius: 4px;
display: grid;
place-items: center;
background: #111111;
color: #ffffff;
font-size: 16px;
font-weight: 700;
}
.brand h1 {
font-size: 16px;
font-weight: 700;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.ghost-btn,
.primary-btn {
border-radius: 4px;
padding: 10px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.18s ease;
}
.ghost-btn {
border: 1px solid var(--line);
background: #ffffff;
color: #111111;
}
.ghost-btn:hover {
border-color: #111111;
background: #f5f5f5;
}
.primary-btn {
width: 100%;
border: 1px solid #111111;
background: #111111;
color: #ffffff;
}
.primary-btn:hover:not(:disabled) {
background: #000000;
}
.primary-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.user-menu {
position: relative;
}
.user-menu-trigger {
border: 1px solid var(--line);
background: #ffffff;
border-radius: 4px;
padding: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.user-menu-trigger:hover {
border-color: #111111;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
display: block;
background: #111111;
}
.user-menu-panel {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 132px;
padding: 8px;
border: 1px solid var(--line);
border-radius: 4px;
background: rgba(255, 255, 255, 0.96);
box-shadow: var(--shadow);
display: none;
z-index: 10;
}
.user-menu:hover .user-menu-panel,
.user-menu:focus-within .user-menu-panel {
display: block;
}
.workspace {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
}
.stage {
padding: 32px;
display: flex;
align-items: center;
justify-content: center;
background:
linear-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.05) 1px, transparent 1px),
#f3f3f3;
background-size: 24px 24px, 24px 24px, auto;
}
.artboard {
width: 100%;
height: 100%;
}
#previewSvg {
width: 100%;
height: 100%;
filter: drop-shadow(0 24px 30px rgba(0, 0, 0, 0.14));
}
.panel {
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.88);
border-left: 1px solid var(--line);
min-height: 0;
}
.panel-scroll {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.card {
border: 1px solid var(--line);
border-radius: 4px;
background: #ffffff;
padding: 16px;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.04);
}
.field + .field {
margin-top: 18px;
}
.field-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
gap: 12px;
}
.field-head label {
font-size: 13px;
font-weight: 600;
}
.field-value {
font-size: 12px;
font-weight: 700;
}
input[type="range"] {
width: 100%;
appearance: none;
height: 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.14);
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 18px;
height: 18px;
border-radius: 4px;
background: #ffffff;
border: 1px solid #111111;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.14);
cursor: pointer;
}
.panel-footer {
padding: 16px 24px 24px;
border-top: 1px solid var(--line);
background: rgba(255, 255, 255, 0.96);
}
.loading-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(6px);
z-index: 50;
}
.loading-card {
min-width: 180px;
padding: 18px 20px;
border: 1px solid var(--line);
border-radius: 4px;
background: #ffffff;
box-shadow: var(--shadow);
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
font-weight: 600;
}
.loading-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(0, 0, 0, 0.14);
border-top-color: #111111;
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
.is-hidden {
display: none !important;
}
#exportCanvas {
display: none;
}
@media (max-width: 1080px) {
body {
overflow: auto;
}
.app {
height: auto;
min-height: 100vh;
}
.workspace {
grid-template-columns: 1fr;
}
.panel {
border-left: 0;
border-top: 1px solid var(--line);
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<div class="brand-badge">P</div>
<h1>Pendant Generator</h1>
</div>
<div class="topbar-actions">
<button id="loginBtn" class="ghost-btn">Log in</button>
<div id="userMenu" class="user-menu is-hidden">
<button class="user-menu-trigger" type="button">
<img id="userAvatar" class="avatar" alt="User avatar" />
</button>
<div class="user-menu-panel">
<button id="logoutBtn" class="ghost-btn" style="width: 100%;">Log out</button>
</div>
</div>
</div>
</header>
<main class="workspace">
<section class="stage">
<div id="previewHost"></div>
</section>
<aside class="panel">
<div class="panel-scroll">
<div class="card">
<div class="field">
<div class="field-head">
<label for="widthRange">Width</label>
<span id="widthValue" class="field-value">96 mm</span>
</div>
<input id="widthRange" type="range" min="60" max="180" step="1" value="96" />
</div>
<div class="field">
<div class="field-head">
<label for="heightRange">Height</label>
<span id="heightValue" class="field-value">128 mm</span>
</div>
<input id="heightRange" type="range" min="80" max="220" step="1" value="128" />
</div>
</div>
</div>
<div class="panel-footer">
<button id="exportBtn" class="primary-btn">Open in Studio</button>
</div>
</aside>
</main>
<canvas id="exportCanvas" width="800" height="800" aria-hidden="true"></canvas>
</div>
<div id="loadingOverlay" class="loading-overlay is-hidden" aria-live="polite" aria-busy="true">
<div class="loading-card">
<div class="loading-spinner" aria-hidden="true"></div>
<span>Opening xTool Studio...</span>
</div>
</div>
<script src="https://static-res.atomm.com/scripts/js/generator-sdk/index.umd.js?v=1.0.5"></script>
<script>
const params = new URLSearchParams(window.location.search);
const appKey = params.get('appKey') || 'demo_pendant_generator';
const env = params.get('env') || 'dev';
const state = {
width: 96,
height: 128,
lastSvg: '',
};
const elements = {
loginBtn: document.getElementById('loginBtn'),
logoutBtn: document.getElementById('logoutBtn'),
userMenu: document.getElementById('userMenu'),
userAvatar: document.getElementById('userAvatar'),
widthRange: document.getElementById('widthRange'),
heightRange: document.getElementById('heightRange'),
widthValue: document.getElementById('widthValue'),
heightValue: document.getElementById('heightValue'),
previewHost: document.getElementById('previewHost'),
exportCanvas: document.getElementById('exportCanvas'),
exportBtn: document.getElementById('exportBtn'),
loadingOverlay: document.getElementById('loadingOverlay'),
};
let sdk = null;
let exportSyncToken = 0;
const fallbackAvatar = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><rect width="32" height="32" rx="16" fill="%23000"/></svg>';
function getPendantGeometry(width, height) {
const viewBoxWidth = 360;
const viewBoxHeight = 420;
const bodyWidth = Math.max(168, width * 2.08);
const bodyHeight = Math.max(210, height * 2.08);
const bodyX = (viewBoxWidth - bodyWidth) / 2;
const bodyY = 96;
const cornerRadius = Math.max(14, Math.min(bodyWidth, bodyHeight) * 0.06);
const holeRadius = Math.max(10, Math.min(width, height) * 0.1);
const holeCenterX = viewBoxWidth / 2;
const holeCenterY = bodyY + Math.max(26, bodyHeight * 0.14);
const textY = bodyY + bodyHeight * 0.58;
const textSize = Math.max(20, Math.min(28, bodyWidth * 0.11));
return {
viewBoxWidth, viewBoxHeight,
bodyWidth, bodyHeight, bodyX, bodyY,
cornerRadius, holeRadius,
holeCenterX, holeCenterY,
textY, textSize,
};
}
// Preview SVG — uses white fill + drop-shadow for on-screen display
function buildPendantSvgMarkup(width, height) {
const g = getPendantGeometry(width, height);
const { viewBoxWidth, viewBoxHeight, bodyX, bodyY, bodyWidth, bodyHeight,
cornerRadius, holeRadius, holeCenterX, holeCenterY, textY, textSize } = g;
return `
<svg id="previewSvg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 ${viewBoxWidth} ${viewBoxHeight}" role="img" aria-label="Pendant preview">
<defs>
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#000000" flood-opacity="0.16" />
</filter>
</defs>
<g filter="url(#softShadow)">
<path d="M ${bodyX + cornerRadius} ${bodyY}
L ${bodyX + bodyWidth - cornerRadius} ${bodyY}
Q ${bodyX + bodyWidth} ${bodyY} ${bodyX + bodyWidth} ${bodyY + cornerRadius}
L ${bodyX + bodyWidth} ${bodyY + bodyHeight - cornerRadius}
Q ${bodyX + bodyWidth} ${bodyY + bodyHeight} ${bodyX + bodyWidth - cornerRadius} ${bodyY + bodyHeight}
L ${bodyX + cornerRadius} ${bodyY + bodyHeight}
Q ${bodyX} ${bodyY + bodyHeight} ${bodyX} ${bodyY + bodyHeight - cornerRadius}
L ${bodyX} ${bodyY + cornerRadius}
Q ${bodyX} ${bodyY} ${bodyX + cornerRadius} ${bodyY} Z"
fill="#ffffff" stroke="#111111" stroke-width="2" />
<circle cx="${holeCenterX}" cy="${holeCenterY}" r="${holeRadius}"
fill="#ffffff" stroke="#111111" stroke-width="2" />
<text x="${holeCenterX}" y="${textY}" text-anchor="middle" dominant-baseline="middle"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="${textSize}" font-weight="600" letter-spacing="0.06em"
fill="#111111">generator</text>
</g>
</svg>`;
}
// Export SVG — fill="none" on root; <path> and <circle> have NO fill attribute.
// Explicit fill="#FFFFFF" on child elements would override the root and cause
// Studio to render shapes as solid black when imported.
function buildExportSvg(width, height) {
const g = getPendantGeometry(width, height);
const { bodyX, bodyY, bodyWidth, bodyHeight, cornerRadius,
holeRadius, holeCenterX, holeCenterY, textY, textSize } = g;
const safeWidth = Number(width).toFixed(0);
const safeHeight = Number(height).toFixed(0);
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
width="${safeWidth}mm" height="${safeHeight}mm"
viewBox="0 0 360 420" fill="none">
<path d="M ${bodyX + cornerRadius} ${bodyY} L ${bodyX + bodyWidth - cornerRadius} ${bodyY} Q ${bodyX + bodyWidth} ${bodyY} ${bodyX + bodyWidth} ${bodyY + cornerRadius} L ${bodyX + bodyWidth} ${bodyY + bodyHeight - cornerRadius} Q ${bodyX + bodyWidth} ${bodyY + bodyHeight} ${bodyX + bodyWidth - cornerRadius} ${bodyY + bodyHeight} L ${bodyX + cornerRadius} ${bodyY + bodyHeight} Q ${bodyX} ${bodyY + bodyHeight} ${bodyX} ${bodyY + bodyHeight - cornerRadius} L ${bodyX} ${bodyY + cornerRadius} Q ${bodyX} ${bodyY} ${bodyX + cornerRadius} ${bodyY} Z"
stroke="#111111" stroke-width="2"/>
<circle cx="${holeCenterX}" cy="${holeCenterY}" r="${holeRadius}"
stroke="#111111" stroke-width="2"/>
<text x="${holeCenterX}" y="${textY}" text-anchor="middle" dominant-baseline="middle"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="${textSize}" font-weight="600" letter-spacing="0.06em"
fill="#111111">generator</text>
</svg>`;
}
function setStudioLoading(isLoading) {
elements.loadingOverlay.classList.toggle('is-hidden', !isLoading);
elements.exportBtn.disabled = isLoading;
elements.exportBtn.textContent = isLoading ? 'Opening...' : 'Open in Studio';
}
async function syncExportCanvas() {
const svgText = buildExportSvg(state.width, state.height);
state.lastSvg = svgText;
const currentToken = ++exportSyncToken;
const blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const canvas = elements.exportCanvas;
const ctx = canvas.getContext('2d');
const image = new Image();
try {
await new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = reject;
image.src = url;
});
if (currentToken !== exportSyncToken) return;
canvas.width = 1080;
canvas.height = 1260;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
} finally {
URL.revokeObjectURL(url);
}
}
async function renderPendant() {
elements.widthValue.textContent = state.width + ' mm';
elements.heightValue.textContent = state.height + ' mm';
elements.previewHost.innerHTML = buildPendantSvgMarkup(state.width, state.height);
await syncExportCanvas();
}
function updateAuthUI(status) {
const isLogin = !!status && status.isLogin;
elements.loginBtn.classList.toggle('is-hidden', isLogin);
elements.userMenu.classList.toggle('is-hidden', !isLogin);
if (!isLogin) {
elements.userAvatar.src = fallbackAvatar;
return;
}
const userInfo = status.userInfo || {};
elements.userAvatar.src = userInfo.headpic || fallbackAvatar;
}
async function initSdk() {
if (typeof window.GeneratorSDK === 'undefined') return;
try {
sdk = window.GeneratorSDK.init({ appKey, env });
sdk.export.register({
getExportData: (purpose, format) => {
if (format === 'svg') {
return { type: 'svg', svgString: buildExportSvg(state.width, state.height) };
}
return { type: 'canvas', canvas: elements.exportCanvas };
},
getFileName: () => `pendant-${state.width}x${state.height}.svg`,
});
sdk.auth.onChange(updateAuthUI);
updateAuthUI(sdk.auth.getStatus());
} catch (error) {}
}
elements.loginBtn.addEventListener('click', async () => {
if (!sdk) return;
try { await sdk.auth.login(); } catch (error) {}
});
elements.logoutBtn.addEventListener('click', async () => {
if (!sdk) return;
try { await sdk.auth.logout(); } catch (error) {}
});
elements.widthRange.addEventListener('input', async (event) => {
state.width = Number(event.target.value);
await renderPendant();
});
elements.heightRange.addEventListener('input', async (event) => {
state.height = Number(event.target.value);
await renderPendant();
});
elements.exportBtn.addEventListener('click', async () => {
if (!sdk) return;
try {
setStudioLoading(true);
await syncExportCanvas();
await sdk.export.openInStudio({ format: 'svg' });
} finally {
setStudioLoading(false);
}
});
(async function bootstrap() {
elements.userAvatar.src = fallbackAvatar;
await renderPendant();
await initSdk();
window.__PENDANT_DEMO__ = {
getState: () => ({ width: state.width, height: state.height }),
getSvg: () => state.lastSvg,
sdk,
};
})();
</script>
</body>
</html>