@firetable/project-xiaochun 0.1.14 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/element.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! @firetable/project-xiaochun v0.1.14 | MIT | https://github.com/FireTable/project-xiaochun */
1
+ /*! @firetable/project-xiaochun v0.1.15 | MIT | https://github.com/FireTable/project-xiaochun */
2
2
  "use strict";
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -30,6 +30,27 @@ module.exports = __toCommonJS(element_exports);
30
30
  var XC_PROTOCOL_VERSION = 1;
31
31
  var XC_DEFAULT_ORIGIN = "https://xiaochun.firetable.tech";
32
32
  var XC_DEFAULT_SRC = `${XC_DEFAULT_ORIGIN}/embed`;
33
+ var XC_WINDOW_CORNER_RADIUS = 20;
34
+ var XC_UI_PARTS = ["chat", "bubble", "outfit", "scene"];
35
+ var XC_UI_PART_SET = new Set(XC_UI_PARTS);
36
+ var XC_UI_LEGACY_PARTS = ["chat", "bubble"];
37
+ function parseXcUiList(items) {
38
+ const seen = /* @__PURE__ */ new Set();
39
+ const unknown = [];
40
+ for (const it of items) {
41
+ const name = typeof it === "string" ? it.trim().toLowerCase() : "";
42
+ if (XC_UI_PART_SET.has(name)) seen.add(name);
43
+ else unknown.push(typeof it === "string" ? it.slice(0, 40) : String(typeof it));
44
+ }
45
+ return { parts: XC_UI_PARTS.filter((p) => seen.has(p)), unknown, legacy: false };
46
+ }
47
+ function normalizeXcUiOption(v) {
48
+ if (v === true) return { parts: [...XC_UI_LEGACY_PARTS], unknown: [], legacy: true };
49
+ if (Array.isArray(v)) return parseXcUiList(v);
50
+ return { parts: [], unknown: [], legacy: false };
51
+ }
52
+ var XC_ID_RE = /^[a-z][a-z0-9_]{0,63}$/;
53
+ var isXcId = (v) => typeof v === "string" && XC_ID_RE.test(v);
33
54
  function xcMessage(type, payload, id) {
34
55
  const m = { type, v: XC_PROTOCOL_VERSION, payload };
35
56
  if (id !== void 0) m.id = id;
@@ -51,6 +72,95 @@ function normalizeOrigin(input) {
51
72
  }
52
73
  }
53
74
 
75
+ // src/gesture-box.ts
76
+ var DEFAULT_RESIZE_LIMITS = Object.freeze({
77
+ minWidth: 120,
78
+ minHeight: 180,
79
+ maxWidth: Infinity,
80
+ maxHeight: Infinity
81
+ });
82
+ var finitePositive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
83
+ function normalizeResizable(v) {
84
+ if (!v) return null;
85
+ const o = v === true ? {} : v;
86
+ const minWidth = finitePositive(o.minWidth) ? o.minWidth : DEFAULT_RESIZE_LIMITS.minWidth;
87
+ const minHeight = finitePositive(o.minHeight) ? o.minHeight : DEFAULT_RESIZE_LIMITS.minHeight;
88
+ const maxWidth = finitePositive(o.maxWidth) ? Math.max(o.maxWidth, minWidth) : Infinity;
89
+ const maxHeight = finitePositive(o.maxHeight) ? Math.max(o.maxHeight, minHeight) : Infinity;
90
+ return { minWidth, minHeight, maxWidth, maxHeight };
91
+ }
92
+ var clamp = (v, lo, hi) => Math.min(Math.max(v, lo), Math.max(lo, hi));
93
+ function moveBox(start, totalDx, totalDy, vp) {
94
+ return {
95
+ left: clamp(start.left + totalDx, 0, vp.width - start.width),
96
+ top: clamp(start.top + totalDy, 0, vp.height - start.height),
97
+ width: start.width,
98
+ height: start.height
99
+ };
100
+ }
101
+ function resizeBox(start, corner, totalDx, totalDy, limits, vp) {
102
+ const west = corner === "NW" || corner === "SW";
103
+ const north = corner === "NW" || corner === "NE";
104
+ const right = start.left + start.width, bottom = start.top + start.height;
105
+ const wantW = west ? start.width - totalDx : start.width + totalDx;
106
+ const wantH = north ? start.height - totalDy : start.height + totalDy;
107
+ const roomW = west ? right : vp.width - start.left;
108
+ const roomH = north ? bottom : vp.height - start.top;
109
+ const width = clamp(wantW, limits.minWidth, Math.min(limits.maxWidth, roomW));
110
+ const height = clamp(wantH, limits.minHeight, Math.min(limits.maxHeight, roomH));
111
+ return { left: west ? right - width : start.left, top: north ? bottom - height : start.top, width, height };
112
+ }
113
+ function fitBox(b, vp) {
114
+ const width = Math.min(b.width, Math.max(1, vp.width)), height = Math.min(b.height, Math.max(1, vp.height));
115
+ return { left: clamp(b.left, 0, vp.width - width), top: clamp(b.top, 0, vp.height - height), width, height };
116
+ }
117
+ var CORNERS = /* @__PURE__ */ new Set(["NW", "NE", "SW", "SE"]);
118
+ var PHASES = /* @__PURE__ */ new Set(["start", "move", "end"]);
119
+ var REASONS = /* @__PURE__ */ new Set(["up", "cancel", "blur"]);
120
+ var MAX_GESTURE_DELTA = 2e4;
121
+ var delta = (v) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= MAX_GESTURE_DELTA;
122
+ var counter = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
123
+ function parseGesturePayload(kind, raw) {
124
+ if (raw === null || typeof raw !== "object") return null;
125
+ const p = raw;
126
+ if (!counter(p.gesture) || !counter(p.seq) || typeof p.phase !== "string" || !PHASES.has(p.phase)) return null;
127
+ if (!delta(p.dx) || !delta(p.dy) || !delta(p.totalDx) || !delta(p.totalDy)) return null;
128
+ if (p.reason !== void 0 && (typeof p.reason !== "string" || !REASONS.has(p.reason))) return null;
129
+ const out = { gesture: p.gesture, seq: p.seq, phase: p.phase, totalDx: p.totalDx, totalDy: p.totalDy };
130
+ if (kind === "resize") {
131
+ if (typeof p.corner !== "string" || !CORNERS.has(p.corner)) return null;
132
+ out.corner = p.corner;
133
+ }
134
+ return out;
135
+ }
136
+ var GestureGate = class {
137
+ constructor() {
138
+ this.lastGesture = 0;
139
+ this.lastSeq = -1;
140
+ this.open = false;
141
+ }
142
+ get active() {
143
+ return this.open;
144
+ }
145
+ accept(p) {
146
+ if (p.phase === "start") {
147
+ if (p.gesture <= this.lastGesture || p.seq !== 0) return false;
148
+ this.lastGesture = p.gesture;
149
+ this.lastSeq = 0;
150
+ this.open = true;
151
+ return true;
152
+ }
153
+ if (!this.open || p.gesture !== this.lastGesture || p.seq <= this.lastSeq) return false;
154
+ this.lastSeq = p.seq;
155
+ if (p.phase === "end") this.open = false;
156
+ return true;
157
+ }
158
+ /** 宿主主动结束 (关闭手势 / 销毁): 之后同一手势的迟到消息被拒绝。 */
159
+ close() {
160
+ this.open = false;
161
+ }
162
+ };
163
+
54
164
  // src/client.ts
55
165
  var FALLBACK_PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
56
166
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 180"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f6b8ae"/><stop offset="1" stop-color="#ea8377"/></linearGradient></defs><ellipse cx="60" cy="170" rx="34" ry="6" fill="#000" opacity=".12"/><circle cx="60" cy="48" r="26" fill="url(#g)" opacity=".75"/><path d="M26 160c2-40 18-62 34-62s32 22 34 62z" fill="url(#g)" opacity=".6"/></svg>'
@@ -77,8 +187,46 @@ function createXiaochun(options) {
77
187
  if (!n) throw new Error(`[project-xiaochun] invalid allowedOrigins entry: ${o} (wildcards are not allowed)`);
78
188
  trusted.add(n);
79
189
  }
80
- const transparent = options.transparent ?? false;
81
- const passthrough = options.passthrough ?? transparent;
190
+ const prefsKey = options.persist === "host" ? "xiaochun:prefs" : typeof options.persist === "string" && options.persist ? options.persist : null;
191
+ const readPrefs = () => {
192
+ if (!prefsKey) return {};
193
+ try {
194
+ const raw = JSON.parse(window.localStorage.getItem(prefsKey) ?? "null");
195
+ const out = {};
196
+ if (raw && typeof raw === "object") {
197
+ if (isXcId(raw.outfit)) out.outfit = raw.outfit;
198
+ if (isXcId(raw.scene)) out.scene = raw.scene;
199
+ }
200
+ return out;
201
+ } catch {
202
+ return {};
203
+ }
204
+ };
205
+ const writePrefs = (patch) => {
206
+ if (!prefsKey) return;
207
+ try {
208
+ const next = { ...readPrefs() };
209
+ for (const [k, v] of Object.entries(patch)) {
210
+ if (v) next[k] = v;
211
+ else delete next[k];
212
+ }
213
+ window.localStorage.setItem(prefsKey, JSON.stringify(next));
214
+ } catch {
215
+ }
216
+ };
217
+ const prefs = readPrefs();
218
+ if (options.model !== void 0 && options.outfit === void 0) {
219
+ console.warn('[project-xiaochun] option "model" is deprecated, use "outfit" (built-in outfit id).');
220
+ }
221
+ const uiParse = normalizeXcUiOption(options.ui);
222
+ const uiParts = uiParse.parts;
223
+ if (uiParse.legacy) console.warn(`[project-xiaochun] option "ui: true" is deprecated, use an array of parts, e.g. ui: ['chat', 'bubble', 'outfit', 'scene'].`);
224
+ if (uiParse.unknown.length) console.warn(`[project-xiaochun] ignoring unknown ui part(s): ${uiParse.unknown.join(", ")} (known: ${XC_UI_PARTS.join(", ")})`);
225
+ const outfitOpt = options.outfit ?? (isXcId(options.model) ? options.model : void 0);
226
+ const initialOutfit = outfitOpt ?? prefs.outfit;
227
+ const initialScene = options.scene ?? prefs.scene ?? (options.transparent ? "transparent" : void 0);
228
+ let transparent = initialScene === "transparent" || initialScene !== "light" && initialScene !== "dark" && (options.transparent ?? false);
229
+ let passthrough = options.passthrough ?? transparent;
82
230
  const lazy = options.lazy ?? true;
83
231
  const position = options.position ?? "inline";
84
232
  const handshakeTimeout = options.handshakeTimeout ?? 2e4;
@@ -91,9 +239,20 @@ function createXiaochun(options) {
91
239
  ws.maxWidth = "100%";
92
240
  ws.overflow = "visible";
93
241
  ws.contain = "layout style";
94
- ws.borderRadius = "var(--xc-radius, 0)";
95
242
  ws.boxShadow = "var(--xc-shadow, none)";
96
- if (!transparent) ws.background = "var(--xc-bg, transparent)";
243
+ const applyWrapperBg = () => {
244
+ ws.background = transparent ? "" : "var(--xc-bg, transparent)";
245
+ };
246
+ applyWrapperBg();
247
+ let radiusOpt = options.borderRadius;
248
+ const radiusValue = () => radiusOpt !== void 0 ? css(radiusOpt, "0px") : transparent ? "0px" : `${XC_WINDOW_CORNER_RADIUS}px`;
249
+ const applyRadius = () => {
250
+ const v = `var(--xc-radius, ${radiusValue()})`;
251
+ ws.borderRadius = v;
252
+ if (placeholderEl) placeholderEl.style.borderRadius = v;
253
+ if (iframe) iframe.style.borderRadius = v;
254
+ };
255
+ ws.borderRadius = `var(--xc-radius, ${radiusValue()})`;
97
256
  if (position === "bottom-right") {
98
257
  ws.right = "var(--xc-offset-x, 16px)";
99
258
  ws.bottom = "var(--xc-offset-y, 16px)";
@@ -120,7 +279,7 @@ function createXiaochun(options) {
120
279
  }
121
280
  const ps = placeholderEl.style;
122
281
  placeholderEl.setAttribute("part", "placeholder");
123
- ps.borderRadius = "var(--xc-radius, 0)";
282
+ ps.borderRadius = `var(--xc-radius, ${radiusValue()})`;
124
283
  ps.position = "absolute";
125
284
  ps.inset = "0";
126
285
  ps.width = "100%";
@@ -157,25 +316,45 @@ function createXiaochun(options) {
157
316
  let autoPaused = false;
158
317
  let loaded = false;
159
318
  let lastHit = false;
319
+ let caps = null;
320
+ let currentOutfit = null;
321
+ let currentScene = null;
160
322
  const queue = [];
161
323
  const pending = /* @__PURE__ */ new Map();
162
324
  let idSeq = 0;
163
325
  let handshakeTimer = null;
164
326
  let io = null;
165
327
  let idleHandle = null;
166
- const postToFrame = (type, payload, id, transfer) => {
328
+ let resolveHandshake;
329
+ const handshaked = new Promise((res) => {
330
+ resolveHandshake = res;
331
+ });
332
+ const failLocal = (type, id, code, message) => {
333
+ emit("error", { code, message, command: type });
334
+ const p = id ? pending.get(id) : void 0;
335
+ if (p && id) {
336
+ pending.delete(id);
337
+ p.reject(new Error(`[${code}] ${message}`));
338
+ }
339
+ };
340
+ const unsupportedByFrame = (type, id) => failLocal(type, id, "unsupported", `${type} is not supported by this /embed deployment (older version; its xc.ready has no capabilities for it)`);
341
+ const postToFrame = (type, payload, id, transfer, requires) => {
167
342
  if (!port) {
168
- queue.push({ type, payload, id, transfer });
343
+ queue.push({ type, payload, id, transfer, requires });
344
+ return;
345
+ }
346
+ if (requires && !caps?.[requires]) {
347
+ unsupportedByFrame(type, id);
169
348
  return;
170
349
  }
171
350
  port.postMessage(xcMessage(type, payload, id), transfer ?? []);
172
351
  };
173
- const send = (type, payload, wait = false, transfer, fixedId) => {
352
+ const send = (type, payload, wait = false, transfer, fixedId, requires) => {
174
353
  if (destroyed) return Promise.reject(new Error("[project-xiaochun] instance destroyed"));
175
354
  if (!iframe) activate();
176
355
  const id = fixedId ?? `c${++idSeq}`;
177
356
  const p = wait ? new Promise((resolve, reject) => pending.set(id, { resolve, reject, sawEnd: false })) : Promise.resolve();
178
- postToFrame(type, payload, id, transfer);
357
+ postToFrame(type, payload, id, transfer, requires);
179
358
  return p;
180
359
  };
181
360
  const toBuffer = (d) => d instanceof ArrayBuffer ? d : d.buffer.slice(d.byteOffset, d.byteOffset + d.byteLength);
@@ -225,7 +404,7 @@ function createXiaochun(options) {
225
404
  let ended = false;
226
405
  let fmt = rest.format === "pcm16" || rest.format === "float32" ? rest.format : null;
227
406
  let done = Promise.resolve();
228
- const handle2 = {
407
+ const handle = {
229
408
  write(data) {
230
409
  if (ended) throw new Error("[project-xiaochun] audio stream already ended");
231
410
  if (destroyed) throw new Error("[project-xiaochun] instance destroyed");
@@ -259,17 +438,18 @@ function createXiaochun(options) {
259
438
  return done;
260
439
  }
261
440
  };
262
- return handle2;
441
+ return handle;
263
442
  };
264
443
  function buildUrl() {
265
444
  const u = new URL(srcUrl.href);
266
445
  u.searchParams.set("host", window.location.origin);
267
446
  if (transparent) u.searchParams.set("transparent", "1");
268
- if (options.ui) u.searchParams.set("ui", "1");
447
+ if (uiParts.length) u.searchParams.set("ui", uiParts.join(","));
269
448
  if (options.lang) u.searchParams.set("lang", options.lang);
270
449
  if (options.heavy) u.searchParams.set("heavy", options.heavy);
271
- if (options.controls) u.searchParams.set("controls", "1");
272
- if (options.model) u.searchParams.set("outfit", options.model);
450
+ if (options.controls === false) u.searchParams.set("controls", "0");
451
+ if (initialScene && isXcId(initialScene)) u.searchParams.set("scene", initialScene);
452
+ if (initialOutfit) u.searchParams.set("outfit", initialOutfit);
273
453
  return u.href;
274
454
  }
275
455
  function activate() {
@@ -295,7 +475,7 @@ function createXiaochun(options) {
295
475
  s.border = "0";
296
476
  s.background = "transparent";
297
477
  s.colorScheme = "normal";
298
- s.borderRadius = "var(--xc-radius, 0)";
478
+ s.borderRadius = `var(--xc-radius, ${radiusValue()})`;
299
479
  f.setAttribute("part", "iframe");
300
480
  s.opacity = "0";
301
481
  s.transition = "opacity .25s ease";
@@ -308,7 +488,7 @@ function createXiaochun(options) {
308
488
  if (!port) emit("error", { code: "timeout", message: `no xc.ready from ${frameOrigin} within ${handshakeTimeout}ms` });
309
489
  }, handshakeTimeout);
310
490
  setupAutoPause();
311
- setupPassthrough();
491
+ if (passthrough) enablePassthrough();
312
492
  }
313
493
  const onWindowMessage = (ev) => {
314
494
  if (destroyed || !iframe || ev.source !== iframe.contentWindow) return;
@@ -321,20 +501,39 @@ function createXiaochun(options) {
321
501
  const ch = new MessageChannel();
322
502
  port = ch.port1;
323
503
  port.onmessage = onPortMessage;
504
+ caps = data.payload?.capabilities ?? null;
505
+ const gWant = wantGestures();
506
+ sentGestures = gWant;
507
+ const initConfig = {
508
+ ...options.allowCustomModel ? { allowCustomModel: true } : {},
509
+ ...gWant.move || gWant.resize ? { gestures: gWant } : {}
510
+ };
324
511
  iframe.contentWindow.postMessage(
325
- xcMessage("xc.init", { hostOrigin: window.location.origin }),
512
+ xcMessage("xc.init", { hostOrigin: window.location.origin, ...Object.keys(initConfig).length ? { config: initConfig } : {} }),
326
513
  ev.origin,
327
514
  [ch.port2]
328
515
  );
516
+ warnGestureUnsupported();
329
517
  if (handshakeTimer) {
330
518
  clearTimeout(handshakeTimer);
331
519
  handshakeTimer = null;
332
520
  }
333
521
  emit("handshake", data.payload);
334
- for (const m of queue.splice(0)) port.postMessage(xcMessage(m.type, m.payload, m.id), m.transfer ?? []);
522
+ resolveHandshake();
523
+ for (const m of queue.splice(0)) {
524
+ if (m.requires && !caps?.[m.requires]) unsupportedByFrame(m.type, m.id);
525
+ else port.postMessage(xcMessage(m.type, m.payload, m.id), m.transfer ?? []);
526
+ }
335
527
  syncPause();
336
528
  };
337
529
  window.addEventListener("message", onWindowMessage);
530
+ function ack(id, value) {
531
+ const p = id ? pending.get(id) : void 0;
532
+ if (p && id) {
533
+ pending.delete(id);
534
+ p.resolve(value);
535
+ }
536
+ }
338
537
  function onPortMessage(ev) {
339
538
  const data = ev.data;
340
539
  if (!isXcEnvelope(data)) return;
@@ -345,6 +544,11 @@ function createXiaochun(options) {
345
544
  case "xc.loaded":
346
545
  if (!loaded) {
347
546
  loaded = true;
547
+ if (options.prefetch && options.heavy === "eager" && caps?.prefetch) {
548
+ const ids = Array.isArray(options.prefetch) ? options.prefetch : void 0;
549
+ void api.prefetch(ids).catch(() => {
550
+ });
551
+ }
348
552
  if (iframe) iframe.style.opacity = "1";
349
553
  if (placeholderEl) {
350
554
  const el = placeholderEl;
@@ -357,6 +561,30 @@ function createXiaochun(options) {
357
561
  }
358
562
  emit("ready", data.payload);
359
563
  break;
564
+ case "xc.outfit-changed": {
565
+ const pl = data.payload;
566
+ if (!pl.noop) {
567
+ currentOutfit = pl.id;
568
+ emit("outfit-changed", pl);
569
+ if (!pl.initial && pl.id) writePrefs({ outfit: pl.id });
570
+ }
571
+ ack(data.id);
572
+ break;
573
+ }
574
+ case "xc.scene-changed": {
575
+ const pl = data.payload;
576
+ currentScene = pl.id;
577
+ if (pl.transparent !== transparent) applyTransparency(pl.transparent);
578
+ if (!pl.noop) {
579
+ emit("scene-changed", pl);
580
+ if (!pl.initial) writePrefs({ scene: pl.id });
581
+ }
582
+ ack(data.id);
583
+ break;
584
+ }
585
+ case "xc.prefetched":
586
+ ack(data.id, data.payload);
587
+ break;
360
588
  case "xc.state":
361
589
  emit("state", data.payload);
362
590
  break;
@@ -381,9 +609,16 @@ function createXiaochun(options) {
381
609
  }
382
610
  break;
383
611
  }
612
+ case "xc.gesture-move":
613
+ onGesture("move", data.payload);
614
+ break;
615
+ case "xc.gesture-resize":
616
+ onGesture("resize", data.payload);
617
+ break;
384
618
  case "xc.error": {
385
619
  const { code, message, command } = data.payload;
386
620
  emit("error", { code, message, command });
621
+ if (code === "unknown_id" && (command === "outfit" || command === "scene")) writePrefs({ [command]: null });
387
622
  const p = data.id ? pending.get(data.id) : void 0;
388
623
  if (p && data.id) {
389
624
  pending.delete(data.id);
@@ -440,16 +675,46 @@ function createXiaochun(options) {
440
675
  port.postMessage(xcMessage("xc.pointer", { x, y }));
441
676
  });
442
677
  };
443
- function setupPassthrough() {
444
- if (!passthrough) return;
678
+ let passthroughActive = false;
679
+ function enablePassthrough() {
680
+ if (passthroughActive || !iframe) return;
681
+ passthroughActive = true;
445
682
  window.addEventListener("pointermove", onHostPointer, { passive: true });
683
+ window.addEventListener("pointerdown", onHostPointer, { passive: true });
446
684
  window.addEventListener("scroll", invalidateRect, { passive: true, capture: true });
447
685
  window.addEventListener("resize", invalidateRect, { passive: true });
448
- if (typeof ResizeObserver !== "undefined" && iframe) {
686
+ if (typeof ResizeObserver !== "undefined") {
449
687
  rectRo = new ResizeObserver(invalidateRect);
450
688
  rectRo.observe(iframe);
451
689
  }
452
690
  }
691
+ function disablePassthrough() {
692
+ if (!passthroughActive) return;
693
+ passthroughActive = false;
694
+ window.removeEventListener("pointermove", onHostPointer);
695
+ window.removeEventListener("pointerdown", onHostPointer);
696
+ window.removeEventListener("scroll", invalidateRect, true);
697
+ window.removeEventListener("resize", invalidateRect);
698
+ rectRo?.disconnect();
699
+ rectRo = null;
700
+ if (pointerRaf) {
701
+ cancelAnimationFrame(pointerRaf);
702
+ pointerRaf = 0;
703
+ }
704
+ lastPtr = null;
705
+ }
706
+ function applyTransparency(next) {
707
+ transparent = next;
708
+ applyWrapperBg();
709
+ applyRadius();
710
+ passthrough = next ? options.passthrough ?? true : false;
711
+ lastHit = false;
712
+ lastPosted = null;
713
+ rectCache = null;
714
+ if (passthrough) enablePassthrough();
715
+ else disablePassthrough();
716
+ if (iframe) iframe.style.pointerEvents = passthrough ? "none" : "auto";
717
+ }
453
718
  let pauseIo = null;
454
719
  function setupAutoPause() {
455
720
  if (options.autoPause === false || typeof IntersectionObserver === "undefined") return;
@@ -501,50 +766,123 @@ function createXiaochun(options) {
501
766
  }, { rootMargin: `${options.lazyMargin ?? 200}px` });
502
767
  io.observe(wrapper);
503
768
  }
504
- let handle = null;
505
- if (options.draggable && position !== "inline") {
506
- handle = document.createElement("div");
507
- handle.setAttribute("aria-label", "drag");
508
- Object.assign(handle.style, {
509
- position: "absolute",
510
- left: "0",
511
- top: "0",
512
- width: "28px",
513
- height: "28px",
514
- cursor: "grab",
515
- pointerEvents: "auto",
516
- borderRadius: "8px",
517
- background: "rgba(0,0,0,.25)",
518
- color: "#fff",
519
- font: "14px/28px system-ui",
520
- textAlign: "center",
521
- userSelect: "none",
522
- touchAction: "none",
523
- zIndex: "2",
524
- opacity: "0.5"
525
- });
526
- handle.textContent = "\u283F";
527
- let drag = null;
528
- handle.addEventListener("pointerdown", (e) => {
529
- const r = wrapper.getBoundingClientRect();
530
- drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };
531
- handle.setPointerCapture(e.pointerId);
532
- });
533
- handle.addEventListener("pointermove", (e) => {
534
- if (!drag) return;
535
- ws.left = `${Math.min(Math.max(0, e.clientX - drag.dx), window.innerWidth - 40)}px`;
536
- ws.top = `${Math.min(Math.max(0, e.clientY - drag.dy), window.innerHeight - 40)}px`;
769
+ let draggableOn = options.draggable === true;
770
+ let resizeLimits = normalizeResizable(options.resizable);
771
+ const gates = { move: new GestureGate(), resize: new GestureGate() };
772
+ let activeGesture = null;
773
+ let inlineShift = { x: 0, y: 0 };
774
+ let userMoved = false;
775
+ let sentGestures = null;
776
+ let warnedGesture = false;
777
+ const viewport = () => ({
778
+ width: document.documentElement.clientWidth || window.innerWidth,
779
+ height: document.documentElement.clientHeight || window.innerHeight
780
+ });
781
+ const readBox = () => {
782
+ const r = wrapper.getBoundingClientRect();
783
+ return { left: r.left, top: r.top, width: r.width, height: r.height };
784
+ };
785
+ const wantGestures = () => ({ move: draggableOn && caps?.gestures?.move === true, resize: resizeLimits !== null && caps?.gestures?.resize === true });
786
+ function applyBox(b, inlineBase, withSize) {
787
+ if (withSize) {
788
+ ws.width = `${b.width}px`;
789
+ ws.height = `${b.height}px`;
790
+ ws.maxWidth = "none";
791
+ }
792
+ if (position === "inline") {
793
+ const base = inlineBase ?? { left: b.left - inlineShift.x, top: b.top - inlineShift.y };
794
+ inlineShift = { x: b.left - base.left, y: b.top - base.top };
795
+ ws.setProperty("translate", `${inlineShift.x}px ${inlineShift.y}px`);
796
+ } else {
797
+ ws.left = `${b.left}px`;
798
+ ws.top = `${b.top}px`;
537
799
  ws.right = "auto";
538
800
  ws.bottom = "auto";
539
- invalidateRect();
540
- });
541
- const end = () => {
542
- drag = null;
543
- };
544
- handle.addEventListener("pointerup", end);
545
- handle.addEventListener("pointercancel", end);
546
- wrapper.appendChild(handle);
801
+ }
802
+ userMoved = true;
803
+ invalidateRect();
547
804
  }
805
+ let selectionLock = null;
806
+ function lockSelection(on) {
807
+ const de = document.documentElement;
808
+ if (on && !selectionLock) {
809
+ selectionLock = { us: de.style.getPropertyValue("user-select"), wus: de.style.getPropertyValue("-webkit-user-select") };
810
+ de.style.setProperty("user-select", "none");
811
+ de.style.setProperty("-webkit-user-select", "none");
812
+ try {
813
+ window.getSelection()?.removeAllRanges();
814
+ } catch {
815
+ }
816
+ } else if (!on && selectionLock) {
817
+ de.style.setProperty("user-select", selectionLock.us);
818
+ de.style.setProperty("-webkit-user-select", selectionLock.wus);
819
+ selectionLock = null;
820
+ }
821
+ }
822
+ function endActiveGesture(kind) {
823
+ if (!activeGesture || kind && activeGesture.kind !== kind) return;
824
+ gates[activeGesture.kind].close();
825
+ const b = readBox();
826
+ emit(activeGesture.kind, { phase: "end", ...b });
827
+ activeGesture = null;
828
+ lockSelection(false);
829
+ }
830
+ function onGesture(kind, raw) {
831
+ if (destroyed || !caps?.gestures) return;
832
+ if (kind === "move" ? !draggableOn : resizeLimits === null) return;
833
+ const p = parseGesturePayload(kind, raw);
834
+ if (!p) return;
835
+ if (p.phase === "start" ? activeGesture !== null : activeGesture?.kind !== kind) return;
836
+ if (!gates[kind].accept(p)) return;
837
+ if (p.phase === "start") {
838
+ const start = readBox();
839
+ activeGesture = {
840
+ kind,
841
+ start,
842
+ corner: p.corner,
843
+ inlineBase: position === "inline" ? { left: start.left - inlineShift.x, top: start.top - inlineShift.y } : null
844
+ };
845
+ lockSelection(true);
846
+ emit(kind, { phase: "start", ...start });
847
+ return;
848
+ }
849
+ const a = activeGesture;
850
+ const vp = viewport();
851
+ const next = kind === "move" ? moveBox(a.start, p.totalDx, p.totalDy, vp) : resizeBox(a.start, a.corner, p.totalDx, p.totalDy, resizeLimits, vp);
852
+ applyBox(next, a.inlineBase, kind === "resize");
853
+ emit(kind, { phase: p.phase, ...next });
854
+ if (p.phase === "end") {
855
+ activeGesture = null;
856
+ lockSelection(false);
857
+ }
858
+ }
859
+ function syncGestures() {
860
+ if (destroyed) return;
861
+ const want = wantGestures();
862
+ if (!want.move) endActiveGesture("move");
863
+ if (!want.resize) endActiveGesture("resize");
864
+ if (!port || !caps) return;
865
+ warnGestureUnsupported();
866
+ if (sentGestures && sentGestures.move === want.move && sentGestures.resize === want.resize) return;
867
+ sentGestures = want;
868
+ if (caps.gestures) port.postMessage(xcMessage("xc.setConfig", { gestures: want }));
869
+ }
870
+ function warnGestureUnsupported() {
871
+ if (warnedGesture || !caps || caps.gestures) return;
872
+ const needMove = draggableOn;
873
+ if (!needMove && resizeLimits === null) return;
874
+ warnedGesture = true;
875
+ const what = [needMove ? "draggable" : "", resizeLimits !== null ? "resizable" : ""].filter(Boolean).join(" / ");
876
+ emit("error", { code: "unsupported", message: `${what} needs gesture support (capabilities.gestures), which this /embed deployment does not advertise (older version)`, command: "gestures" });
877
+ }
878
+ const onWindowResize = () => {
879
+ if (!userMoved || activeGesture || destroyed) return;
880
+ const cur = readBox();
881
+ const fit = fitBox(cur, viewport());
882
+ if (fit.left === cur.left && fit.top === cur.top && fit.width === cur.width && fit.height === cur.height) return;
883
+ applyBox(fit, position === "inline" ? { left: cur.left - inlineShift.x, top: cur.top - inlineShift.y } : null, fit.width !== cur.width || fit.height !== cur.height);
884
+ };
885
+ window.addEventListener("resize", onWindowResize, { passive: true });
548
886
  scheduleLazy();
549
887
  const api = {
550
888
  element: wrapper,
@@ -559,8 +897,65 @@ function createXiaochun(options) {
559
897
  motion: (m) => send("xc.motion", typeof m === "string" ? m.startsWith("http") || m.startsWith("/") ? { url: m } : { name: m } : m),
560
898
  expression: (name) => send("xc.expression", { name }),
561
899
  lookAt: (x, y) => send("xc.lookAt", { x, y }),
562
- setModel: (m) => send("xc.setModel", typeof m === "string" ? /^(https?:)?\/|\.vrm/i.test(m) ? { url: m } : { outfit: m } : m),
900
+ setOutfit: (id) => {
901
+ if (!isXcId(id)) return Promise.reject(new Error("[bad_request] invalid outfit id"));
902
+ return send("xc.setOutfit", { id }, true, void 0, void 0, "outfits");
903
+ },
904
+ setScene: (id) => {
905
+ if (!isXcId(id)) return Promise.reject(new Error("[bad_request] invalid scene id"));
906
+ return send("xc.setScene", { id }, true, void 0, void 0, "scenes");
907
+ },
908
+ prefetch: (ids) => {
909
+ if (ids !== void 0 && (!Array.isArray(ids) || ids.some((x) => !isXcId(x)))) {
910
+ return Promise.reject(new Error("[bad_request] prefetch ids must be an array of outfit ids"));
911
+ }
912
+ return send("xc.prefetch", ids ? { ids } : {}, true, void 0, void 0, "prefetch");
913
+ },
914
+ getOutfits: async () => {
915
+ if (!iframe) activate();
916
+ await handshaked;
917
+ return caps?.outfits ? caps.outfits.slice() : [];
918
+ },
919
+ getScenes: async () => {
920
+ if (!iframe) activate();
921
+ await handshaked;
922
+ return caps?.scenes ? caps.scenes.slice() : [];
923
+ },
924
+ get outfit() {
925
+ return currentOutfit;
926
+ },
927
+ get scene() {
928
+ return currentScene;
929
+ },
930
+ setModel: (m) => {
931
+ const msg = typeof m === "string" ? /^(https?:)?\/|\.vrm/i.test(m) ? { url: m } : { outfit: m } : m;
932
+ if (msg.url !== void 0 && msg.outfit === void 0 && !options.allowCustomModel) {
933
+ const message = "custom model URLs are disabled; pass allowCustomModel: true to createXiaochun() (only for URLs you trust) or use setOutfit() with a built-in id";
934
+ emit("error", { code: "unsupported", message, command: "xc.setModel" });
935
+ return Promise.reject(new Error(`[unsupported] ${message}`));
936
+ }
937
+ return send("xc.setModel", msg);
938
+ },
563
939
  setConfig: (cfg) => send("xc.setConfig", cfg),
940
+ setSize(width, height) {
941
+ if (destroyed) return;
942
+ ws.width = css(width, "320px");
943
+ ws.height = css(height, "480px");
944
+ invalidateRect();
945
+ },
946
+ getBox: () => readBox(),
947
+ setBorderRadius(radius) {
948
+ radiusOpt = radius;
949
+ applyRadius();
950
+ },
951
+ setDraggable(on) {
952
+ draggableOn = on === true;
953
+ syncGestures();
954
+ },
955
+ setResizable(on) {
956
+ resizeLimits = normalizeResizable(on);
957
+ syncGestures();
958
+ },
564
959
  startListening: () => send("xc.mic", { enabled: true }),
565
960
  stopListening: () => send("xc.mic", { enabled: false }),
566
961
  mic: (enabled) => send("xc.mic", { enabled }),
@@ -585,10 +980,10 @@ function createXiaochun(options) {
585
980
  if (handshakeTimer) clearTimeout(handshakeTimer);
586
981
  if (pointerRaf) cancelAnimationFrame(pointerRaf);
587
982
  window.removeEventListener("message", onWindowMessage);
588
- window.removeEventListener("pointermove", onHostPointer);
589
- window.removeEventListener("scroll", invalidateRect, true);
590
- window.removeEventListener("resize", invalidateRect);
591
- rectRo?.disconnect();
983
+ window.removeEventListener("resize", onWindowResize);
984
+ lockSelection(false);
985
+ disablePassthrough();
986
+ resolveHandshake();
592
987
  try {
593
988
  port?.close();
594
989
  } catch {
@@ -621,11 +1016,17 @@ function createXiaochun(options) {
621
1016
  // src/avatar-element.ts
622
1017
  var OBSERVED = [
623
1018
  "src",
1019
+ "outfit",
1020
+ "scene",
624
1021
  "model",
625
1022
  "lang",
626
1023
  "mic",
627
1024
  "transparent",
628
1025
  "draggable",
1026
+ "resizable",
1027
+ "min-size",
1028
+ "max-size",
1029
+ "border-radius",
629
1030
  "position",
630
1031
  "size",
631
1032
  "lazy",
@@ -635,14 +1036,36 @@ var OBSERVED = [
635
1036
  "ui",
636
1037
  "controls",
637
1038
  "allowed-origins",
638
- "cross-origin-isolated"
1039
+ "cross-origin-isolated",
1040
+ "persist",
1041
+ "allow-custom-model",
1042
+ "prefetch"
639
1043
  ];
640
- var REBUILD = /* @__PURE__ */ new Set(["src", "draggable", "position", "size", "lazy", "placeholder", "heavy", "ui", "controls", "allowed-origins", "transparent", "cross-origin-isolated"]);
1044
+ var REBUILD = /* @__PURE__ */ new Set(["src", "position", "lazy", "placeholder", "heavy", "ui", "controls", "allowed-origins", "transparent", "cross-origin-isolated", "persist", "allow-custom-model", "prefetch"]);
1045
+ function radiusAttr(v) {
1046
+ if (v === null || v.trim() === "") return void 0;
1047
+ const t = v.trim();
1048
+ return /^\d+(\.\d+)?$/.test(t) ? Number(t) : t;
1049
+ }
641
1050
  function boolAttr(el, name, dflt) {
642
1051
  const v = el.getAttribute(name);
643
1052
  if (v === null) return dflt;
644
1053
  return v !== "false";
645
1054
  }
1055
+ function prefetchAttr(v) {
1056
+ if (v === null || v === "false") return false;
1057
+ if (v === "" || v === "true") return true;
1058
+ return v.split(",").map((s) => s.trim()).filter(Boolean);
1059
+ }
1060
+ function uiAttr(v) {
1061
+ if (v === null || v === "false" || v === "0") return void 0;
1062
+ if (v === "" || v === "true" || v === "1") return true;
1063
+ return v.split(",").map((s) => s.trim()).filter(Boolean);
1064
+ }
1065
+ function parsePx(v) {
1066
+ const m = v?.trim().match(/^(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)$/i);
1067
+ return m ? { w: Number(m[1]), h: Number(m[2]) } : void 0;
1068
+ }
646
1069
  function parseSize(v) {
647
1070
  if (!v) return { width: "320px", height: "480px" };
648
1071
  const m = v.trim().split(/x/i);
@@ -690,10 +1113,20 @@ function createElementClass() {
690
1113
  return;
691
1114
  }
692
1115
  const c = this.client;
693
- if (name === "model" && newV) void c.setModel(newV).catch(() => {
1116
+ if ((name === "outfit" || name === "model") && newV) {
1117
+ const want = name === "model" && this.hasAttribute("outfit") ? null : newV;
1118
+ if (want) void (isXcId(want) ? c.setOutfit(want) : c.setModel(want)).catch(() => {
1119
+ });
1120
+ } else if (name === "scene" && newV) void c.setScene(newV).catch(() => {
694
1121
  });
695
1122
  else if (name === "lang" && newV) void c.setConfig({ lang: newV }).catch(() => {
696
1123
  });
1124
+ else if (name === "size") {
1125
+ const { width, height } = parseSize(newV);
1126
+ c.setSize(width, height);
1127
+ } else if (name === "border-radius") c.setBorderRadius(radiusAttr(newV));
1128
+ else if (name === "draggable") c.setDraggable(boolAttr(this, "draggable", false));
1129
+ else if (name === "resizable" || name === "min-size" || name === "max-size") c.setResizable(this.resizableOption());
697
1130
  else if (name === "mic") void c.mic(boolAttr(this, "mic", false)).catch(() => {
698
1131
  });
699
1132
  else if (name === "paused") {
@@ -720,11 +1153,17 @@ function createElementClass() {
720
1153
  height,
721
1154
  position: this.getAttribute("position") ?? "inline",
722
1155
  draggable: boolAttr(this, "draggable", false),
1156
+ resizable: this.resizableOption(),
1157
+ borderRadius: radiusAttr(this.getAttribute("border-radius")),
723
1158
  lang: this.getAttribute("lang") ?? void 0,
724
- model: this.getAttribute("model") ?? void 0,
1159
+ outfit: this.getAttribute("outfit") ?? this.getAttribute("model") ?? void 0,
1160
+ scene: this.getAttribute("scene") ?? void 0,
1161
+ persist: this.getAttribute("persist") || void 0,
1162
+ allowCustomModel: boolAttr(this, "allow-custom-model", false),
1163
+ prefetch: prefetchAttr(this.getAttribute("prefetch")),
725
1164
  heavy: this.getAttribute("heavy") ?? void 0,
726
- ui: boolAttr(this, "ui", false),
727
- controls: boolAttr(this, "controls", false),
1165
+ ui: uiAttr(this.getAttribute("ui")),
1166
+ controls: boolAttr(this, "controls", true),
728
1167
  crossOriginIsolated: boolAttr(this, "cross-origin-isolated", false)
729
1168
  });
730
1169
  this.client = c;
@@ -735,10 +1174,20 @@ function createElementClass() {
735
1174
  fwd("stt", "xc-stt");
736
1175
  fwd("utterance", "xc-utterance");
737
1176
  fwd("error", "xc-error");
1177
+ fwd("outfit-changed", "xc-outfit-changed");
1178
+ fwd("scene-changed", "xc-scene-changed");
1179
+ fwd("move", "xc-move");
1180
+ fwd("resize", "xc-resize");
738
1181
  if (boolAttr(this, "paused", false)) c.pause();
739
1182
  if (boolAttr(this, "mic", false)) void c.ready.then(() => c.mic(true)).catch(() => {
740
1183
  });
741
1184
  }
1185
+ /** resizable / min-size / max-size 属性 → SDK 的 resizable 选项 (缺省 / "false" = 关)。 */
1186
+ resizableOption() {
1187
+ if (!boolAttr(this, "resizable", false)) return false;
1188
+ const min = parsePx(this.getAttribute("min-size")), max = parsePx(this.getAttribute("max-size"));
1189
+ return { minWidth: min?.w, minHeight: min?.h, maxWidth: max?.w, maxHeight: max?.h };
1190
+ }
742
1191
  say(text, opts) {
743
1192
  return this.requireClient().say(text, opts);
744
1193
  }
@@ -754,6 +1203,21 @@ function createElementClass() {
754
1203
  expression(name) {
755
1204
  return this.requireClient().expression(name);
756
1205
  }
1206
+ setOutfit(id) {
1207
+ return this.requireClient().setOutfit(id);
1208
+ }
1209
+ setScene(id) {
1210
+ return this.requireClient().setScene(id);
1211
+ }
1212
+ getOutfits() {
1213
+ return this.requireClient().getOutfits();
1214
+ }
1215
+ getScenes() {
1216
+ return this.requireClient().getScenes();
1217
+ }
1218
+ prefetch(ids) {
1219
+ return this.requireClient().prefetch(ids);
1220
+ }
757
1221
  destroy() {
758
1222
  this.client?.destroy();
759
1223
  this.client = null;