CDN 接入:挂件生成器最小示例
这篇文档用 script 方式,从零做一个最小挂件生成器。
对于新的标准生成器,官方推荐的壳层已经收敛为 generator-workbench。这篇文档仍然适合学习底层 SDK 接线或做兼容性改造,但默认平台布局不建议在每个项目里重复手写。
目标只有 3 个:
- 顶部接入登录 SDK
- 登录后显示头像
- 底部点击
Open in Studio
第 1 步:先写一个 HTML 结构
先准备一个最小 HTML 文件。这里虽然不写完整样式,但要先把后面脚本会用到的 DOM 节点放好:
<!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>这一步的重点不是样式,而是先把 loginBtn、userMenu、userAvatar、logoutBtn、previewHost、widthRange、heightRange、exportBtn、exportCanvas、loadingOverlay 这些节点准备好。
第 2 步:引入 SDK
先在页面里通过 CDN 引入 generator-sdk:
<script src="https://static-res.atomm.com/scripts/js/generator-sdk/index.umd.js"></script>引入后,全局会得到 GeneratorSDK。
第 3 步:初始化 SDK
然后初始化 SDK。这里的 appKey 是生成器的 id,只要对同一个生成器保持稳定即可,开发者可以自己定义。
<script>
const sdk = GeneratorSDK.init({
appKey: 'your_generator_id',
env: 'prod',
})
</script>第 4 步:接入登录 SDK
登录只需要 3 个动作:
- 点击登录按钮时调用
sdk.auth.login() - 监听
sdk.auth.onChange() - 登录后把
status.userInfo.headpic显示到头像上
<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>关键点:
- 头像直接使用
status.userInfo.headpic - 不需要自己实现登录弹窗
Open in Studio依赖登录态,所以这个步骤必须先接好
第 5 步:准备挂件预览
这个 demo 的挂件是一个极简矩形吊牌:挂洞在矩形内部,内部只保留一个单词 generator,描边是 2px。
重要:需要拆成两个 SVG 构建函数——一个用于页面预览(带白色 fill 和阴影),一个用于导出(只有 stroke,不设 fill)。如果用同一份带 fill="#FFFFFF" 的 SVG 导出,Studio 导入后会把形状渲染为全黑色。
<script>
// 页面预览用 —— 带白色 fill 和阴影滤镜
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>`
}
// 导出用 —— 根元素设 fill="none";<path> 和 <circle> 不带 fill 属性。
// 不能给子元素加 fill="#FFFFFF"——它会覆盖根元素,导致 Studio 导入后渲染为全黑色。
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>页面预览调用 buildPendantSvgMarkup;提供给 Studio 的 SVG 字符串调用 buildExportSvg。
第 6 步:注册导出 SDK
openInStudio() 通过已注册的 provider 获取导出数据。使用 getExportData:当 format === 'svg' 时直接返回 SVG 字符串,其他格式回退到隐藏 canvas。
<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>关键点:
format === 'svg'时直接返回{ type: 'svg', svgString },Studio 收到的是纯轮廓 SVG- 隐藏 canvas 保留作为其他导出格式(PNG 等)的回退路径
第 7 步:点击按钮打开 xTool Studio
点击按钮时,先显示 loading,再调用 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>关键点:
- 传
{ format: 'svg' }让 SDK 用format === 'svg'调用getExportData,把纯轮廓 SVG 发送给 Studio openInStudio()打开的是 xTool Studio,不是下载 SVG 文件- 这个能力依赖登录后的 token
- 按钮恢复可点击时,再关闭 loading
第 8 步:上传资源
- 打包资源为 zip 包,zip 包必须包含
html文件。 - 上传 zip 包到 https://www.atomm.com/generator-upload 地址。注意:如果没有 xTool 账号,需要预先注册 xTool 账号。
关键步骤回顾
只要记住这 4 件事:
- 用
<script>引入generator-sdk - 用
GeneratorSDK.init({ appKey, env })初始化 - 用
sdk.auth.login()+sdk.auth.onChange()接登录,并直接读取userInfo.headpic - 用
sdk.export.register()注册隐藏 canvas,再通过sdk.export.openInStudio()打开到 xTool Studio
完整源码
以下是 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,
};
}
function buildPendantSvgMarkup(width, height) {
const {
viewBoxWidth,
viewBoxHeight,
bodyWidth,
bodyHeight,
bodyX,
bodyY,
cornerRadius,
holeRadius,
holeCenterX,
holeCenterY,
textY,
textSize,
} = getPendantGeometry(width, height);
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>`;
}
function buildExportSvg(width, height) {
const {
bodyWidth,
bodyHeight,
bodyX,
bodyY,
cornerRadius,
holeRadius,
holeCenterX,
holeCenterY,
textY,
textSize,
} = getPendantGeometry(width, height);
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>