Skip to content

CDN 接入:挂件生成器最小示例

这篇文档用 script 方式,从零做一个最小挂件生成器。

对于新的标准生成器,官方推荐的壳层已经收敛为 generator-workbench。这篇文档仍然适合学习底层 SDK 接线或做兼容性改造,但默认平台布局不建议在每个项目里重复手写。

目标只有 3 个:

  1. 顶部接入登录 SDK
  2. 登录后显示头像
  3. 底部点击 Open in Studio

查看完整示例

第 1 步:先写一个 HTML 结构

先准备一个最小 HTML 文件。这里虽然不写完整样式,但要先把后面脚本会用到的 DOM 节点放好:

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>
</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>

这一步的重点不是样式,而是先把 loginBtnuserMenuuserAvatarlogoutBtnpreviewHostwidthRangeheightRangeexportBtnexportCanvasloadingOverlay 这些节点准备好。

第 2 步:引入 SDK

先在页面里通过 CDN 引入 generator-sdk

html
<script src="https://static-res.atomm.com/scripts/js/generator-sdk/index.umd.js"></script>

引入后,全局会得到 GeneratorSDK

第 3 步:初始化 SDK

然后初始化 SDK。这里的 appKey 是生成器的 id,只要对同一个生成器保持稳定即可,开发者可以自己定义。

html
<script>
  const sdk = GeneratorSDK.init({
    appKey: 'your_generator_id',
    env: 'prod',
  })
</script>

第 4 步:接入登录 SDK

登录只需要 3 个动作:

  1. 点击登录按钮时调用 sdk.auth.login()
  2. 监听 sdk.auth.onChange()
  3. 登录后把 status.userInfo.headpic 显示到头像上
html
<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 导入后会把形状渲染为全黑色。

html
<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。

html
<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()

html
<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 步:上传资源

  1. 打包资源为 zip 包,zip 包必须包含 html 文件。
  2. 上传 zip 包到 https://www.atomm.com/generator-upload 地址。注意:如果没有 xTool 账号,需要预先注册 xTool 账号。

关键步骤回顾

只要记住这 4 件事:

  1. <script> 引入 generator-sdk
  2. GeneratorSDK.init({ appKey, env }) 初始化
  3. sdk.auth.login() + sdk.auth.onChange() 接登录,并直接读取 userInfo.headpic
  4. sdk.export.register() 注册隐藏 canvas,再通过 sdk.export.openInStudio() 打开到 xTool Studio

完整源码

以下是 pendant-cdn-demo.html 的完整源码:

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>

MIT Licensed