@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/react.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
  "use client";
4
4
  var __defProp = Object.defineProperty;
@@ -32,6 +32,27 @@ var import_react = require("react");
32
32
  var XC_PROTOCOL_VERSION = 1;
33
33
  var XC_DEFAULT_ORIGIN = "https://xiaochun.firetable.tech";
34
34
  var XC_DEFAULT_SRC = `${XC_DEFAULT_ORIGIN}/embed`;
35
+ var XC_WINDOW_CORNER_RADIUS = 20;
36
+ var XC_UI_PARTS = ["chat", "bubble", "outfit", "scene"];
37
+ var XC_UI_PART_SET = new Set(XC_UI_PARTS);
38
+ var XC_UI_LEGACY_PARTS = ["chat", "bubble"];
39
+ function parseXcUiList(items) {
40
+ const seen = /* @__PURE__ */ new Set();
41
+ const unknown = [];
42
+ for (const it of items) {
43
+ const name = typeof it === "string" ? it.trim().toLowerCase() : "";
44
+ if (XC_UI_PART_SET.has(name)) seen.add(name);
45
+ else unknown.push(typeof it === "string" ? it.slice(0, 40) : String(typeof it));
46
+ }
47
+ return { parts: XC_UI_PARTS.filter((p) => seen.has(p)), unknown, legacy: false };
48
+ }
49
+ function normalizeXcUiOption(v) {
50
+ if (v === true) return { parts: [...XC_UI_LEGACY_PARTS], unknown: [], legacy: true };
51
+ if (Array.isArray(v)) return parseXcUiList(v);
52
+ return { parts: [], unknown: [], legacy: false };
53
+ }
54
+ var XC_ID_RE = /^[a-z][a-z0-9_]{0,63}$/;
55
+ var isXcId = (v) => typeof v === "string" && XC_ID_RE.test(v);
35
56
  function xcMessage(type, payload, id) {
36
57
  const m = { type, v: XC_PROTOCOL_VERSION, payload };
37
58
  if (id !== void 0) m.id = id;
@@ -53,6 +74,95 @@ function normalizeOrigin(input) {
53
74
  }
54
75
  }
55
76
 
77
+ // src/gesture-box.ts
78
+ var DEFAULT_RESIZE_LIMITS = Object.freeze({
79
+ minWidth: 120,
80
+ minHeight: 180,
81
+ maxWidth: Infinity,
82
+ maxHeight: Infinity
83
+ });
84
+ var finitePositive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
85
+ function normalizeResizable(v) {
86
+ if (!v) return null;
87
+ const o = v === true ? {} : v;
88
+ const minWidth = finitePositive(o.minWidth) ? o.minWidth : DEFAULT_RESIZE_LIMITS.minWidth;
89
+ const minHeight = finitePositive(o.minHeight) ? o.minHeight : DEFAULT_RESIZE_LIMITS.minHeight;
90
+ const maxWidth = finitePositive(o.maxWidth) ? Math.max(o.maxWidth, minWidth) : Infinity;
91
+ const maxHeight = finitePositive(o.maxHeight) ? Math.max(o.maxHeight, minHeight) : Infinity;
92
+ return { minWidth, minHeight, maxWidth, maxHeight };
93
+ }
94
+ var clamp = (v, lo, hi) => Math.min(Math.max(v, lo), Math.max(lo, hi));
95
+ function moveBox(start, totalDx, totalDy, vp) {
96
+ return {
97
+ left: clamp(start.left + totalDx, 0, vp.width - start.width),
98
+ top: clamp(start.top + totalDy, 0, vp.height - start.height),
99
+ width: start.width,
100
+ height: start.height
101
+ };
102
+ }
103
+ function resizeBox(start, corner, totalDx, totalDy, limits, vp) {
104
+ const west = corner === "NW" || corner === "SW";
105
+ const north = corner === "NW" || corner === "NE";
106
+ const right = start.left + start.width, bottom = start.top + start.height;
107
+ const wantW = west ? start.width - totalDx : start.width + totalDx;
108
+ const wantH = north ? start.height - totalDy : start.height + totalDy;
109
+ const roomW = west ? right : vp.width - start.left;
110
+ const roomH = north ? bottom : vp.height - start.top;
111
+ const width = clamp(wantW, limits.minWidth, Math.min(limits.maxWidth, roomW));
112
+ const height = clamp(wantH, limits.minHeight, Math.min(limits.maxHeight, roomH));
113
+ return { left: west ? right - width : start.left, top: north ? bottom - height : start.top, width, height };
114
+ }
115
+ function fitBox(b, vp) {
116
+ const width = Math.min(b.width, Math.max(1, vp.width)), height = Math.min(b.height, Math.max(1, vp.height));
117
+ return { left: clamp(b.left, 0, vp.width - width), top: clamp(b.top, 0, vp.height - height), width, height };
118
+ }
119
+ var CORNERS = /* @__PURE__ */ new Set(["NW", "NE", "SW", "SE"]);
120
+ var PHASES = /* @__PURE__ */ new Set(["start", "move", "end"]);
121
+ var REASONS = /* @__PURE__ */ new Set(["up", "cancel", "blur"]);
122
+ var MAX_GESTURE_DELTA = 2e4;
123
+ var delta = (v) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= MAX_GESTURE_DELTA;
124
+ var counter = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
125
+ function parseGesturePayload(kind, raw) {
126
+ if (raw === null || typeof raw !== "object") return null;
127
+ const p = raw;
128
+ if (!counter(p.gesture) || !counter(p.seq) || typeof p.phase !== "string" || !PHASES.has(p.phase)) return null;
129
+ if (!delta(p.dx) || !delta(p.dy) || !delta(p.totalDx) || !delta(p.totalDy)) return null;
130
+ if (p.reason !== void 0 && (typeof p.reason !== "string" || !REASONS.has(p.reason))) return null;
131
+ const out = { gesture: p.gesture, seq: p.seq, phase: p.phase, totalDx: p.totalDx, totalDy: p.totalDy };
132
+ if (kind === "resize") {
133
+ if (typeof p.corner !== "string" || !CORNERS.has(p.corner)) return null;
134
+ out.corner = p.corner;
135
+ }
136
+ return out;
137
+ }
138
+ var GestureGate = class {
139
+ constructor() {
140
+ this.lastGesture = 0;
141
+ this.lastSeq = -1;
142
+ this.open = false;
143
+ }
144
+ get active() {
145
+ return this.open;
146
+ }
147
+ accept(p) {
148
+ if (p.phase === "start") {
149
+ if (p.gesture <= this.lastGesture || p.seq !== 0) return false;
150
+ this.lastGesture = p.gesture;
151
+ this.lastSeq = 0;
152
+ this.open = true;
153
+ return true;
154
+ }
155
+ if (!this.open || p.gesture !== this.lastGesture || p.seq <= this.lastSeq) return false;
156
+ this.lastSeq = p.seq;
157
+ if (p.phase === "end") this.open = false;
158
+ return true;
159
+ }
160
+ /** 宿主主动结束 (关闭手势 / 销毁): 之后同一手势的迟到消息被拒绝。 */
161
+ close() {
162
+ this.open = false;
163
+ }
164
+ };
165
+
56
166
  // src/client.ts
57
167
  var FALLBACK_PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
58
168
  '<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>'
@@ -79,8 +189,46 @@ function createXiaochun(options) {
79
189
  if (!n) throw new Error(`[project-xiaochun] invalid allowedOrigins entry: ${o} (wildcards are not allowed)`);
80
190
  trusted.add(n);
81
191
  }
82
- const transparent = options.transparent ?? false;
83
- const passthrough = options.passthrough ?? transparent;
192
+ const prefsKey = options.persist === "host" ? "xiaochun:prefs" : typeof options.persist === "string" && options.persist ? options.persist : null;
193
+ const readPrefs = () => {
194
+ if (!prefsKey) return {};
195
+ try {
196
+ const raw = JSON.parse(window.localStorage.getItem(prefsKey) ?? "null");
197
+ const out = {};
198
+ if (raw && typeof raw === "object") {
199
+ if (isXcId(raw.outfit)) out.outfit = raw.outfit;
200
+ if (isXcId(raw.scene)) out.scene = raw.scene;
201
+ }
202
+ return out;
203
+ } catch {
204
+ return {};
205
+ }
206
+ };
207
+ const writePrefs = (patch) => {
208
+ if (!prefsKey) return;
209
+ try {
210
+ const next = { ...readPrefs() };
211
+ for (const [k, v] of Object.entries(patch)) {
212
+ if (v) next[k] = v;
213
+ else delete next[k];
214
+ }
215
+ window.localStorage.setItem(prefsKey, JSON.stringify(next));
216
+ } catch {
217
+ }
218
+ };
219
+ const prefs = readPrefs();
220
+ if (options.model !== void 0 && options.outfit === void 0) {
221
+ console.warn('[project-xiaochun] option "model" is deprecated, use "outfit" (built-in outfit id).');
222
+ }
223
+ const uiParse = normalizeXcUiOption(options.ui);
224
+ const uiParts = uiParse.parts;
225
+ if (uiParse.legacy) console.warn(`[project-xiaochun] option "ui: true" is deprecated, use an array of parts, e.g. ui: ['chat', 'bubble', 'outfit', 'scene'].`);
226
+ if (uiParse.unknown.length) console.warn(`[project-xiaochun] ignoring unknown ui part(s): ${uiParse.unknown.join(", ")} (known: ${XC_UI_PARTS.join(", ")})`);
227
+ const outfitOpt = options.outfit ?? (isXcId(options.model) ? options.model : void 0);
228
+ const initialOutfit = outfitOpt ?? prefs.outfit;
229
+ const initialScene = options.scene ?? prefs.scene ?? (options.transparent ? "transparent" : void 0);
230
+ let transparent = initialScene === "transparent" || initialScene !== "light" && initialScene !== "dark" && (options.transparent ?? false);
231
+ let passthrough = options.passthrough ?? transparent;
84
232
  const lazy = options.lazy ?? true;
85
233
  const position = options.position ?? "inline";
86
234
  const handshakeTimeout = options.handshakeTimeout ?? 2e4;
@@ -93,9 +241,20 @@ function createXiaochun(options) {
93
241
  ws.maxWidth = "100%";
94
242
  ws.overflow = "visible";
95
243
  ws.contain = "layout style";
96
- ws.borderRadius = "var(--xc-radius, 0)";
97
244
  ws.boxShadow = "var(--xc-shadow, none)";
98
- if (!transparent) ws.background = "var(--xc-bg, transparent)";
245
+ const applyWrapperBg = () => {
246
+ ws.background = transparent ? "" : "var(--xc-bg, transparent)";
247
+ };
248
+ applyWrapperBg();
249
+ let radiusOpt = options.borderRadius;
250
+ const radiusValue = () => radiusOpt !== void 0 ? css(radiusOpt, "0px") : transparent ? "0px" : `${XC_WINDOW_CORNER_RADIUS}px`;
251
+ const applyRadius = () => {
252
+ const v = `var(--xc-radius, ${radiusValue()})`;
253
+ ws.borderRadius = v;
254
+ if (placeholderEl) placeholderEl.style.borderRadius = v;
255
+ if (iframe) iframe.style.borderRadius = v;
256
+ };
257
+ ws.borderRadius = `var(--xc-radius, ${radiusValue()})`;
99
258
  if (position === "bottom-right") {
100
259
  ws.right = "var(--xc-offset-x, 16px)";
101
260
  ws.bottom = "var(--xc-offset-y, 16px)";
@@ -122,7 +281,7 @@ function createXiaochun(options) {
122
281
  }
123
282
  const ps = placeholderEl.style;
124
283
  placeholderEl.setAttribute("part", "placeholder");
125
- ps.borderRadius = "var(--xc-radius, 0)";
284
+ ps.borderRadius = `var(--xc-radius, ${radiusValue()})`;
126
285
  ps.position = "absolute";
127
286
  ps.inset = "0";
128
287
  ps.width = "100%";
@@ -159,25 +318,45 @@ function createXiaochun(options) {
159
318
  let autoPaused = false;
160
319
  let loaded = false;
161
320
  let lastHit = false;
321
+ let caps = null;
322
+ let currentOutfit = null;
323
+ let currentScene = null;
162
324
  const queue = [];
163
325
  const pending = /* @__PURE__ */ new Map();
164
326
  let idSeq = 0;
165
327
  let handshakeTimer = null;
166
328
  let io = null;
167
329
  let idleHandle = null;
168
- const postToFrame = (type, payload, id, transfer) => {
330
+ let resolveHandshake;
331
+ const handshaked = new Promise((res) => {
332
+ resolveHandshake = res;
333
+ });
334
+ const failLocal = (type, id, code, message) => {
335
+ emit("error", { code, message, command: type });
336
+ const p = id ? pending.get(id) : void 0;
337
+ if (p && id) {
338
+ pending.delete(id);
339
+ p.reject(new Error(`[${code}] ${message}`));
340
+ }
341
+ };
342
+ 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)`);
343
+ const postToFrame = (type, payload, id, transfer, requires) => {
169
344
  if (!port) {
170
- queue.push({ type, payload, id, transfer });
345
+ queue.push({ type, payload, id, transfer, requires });
346
+ return;
347
+ }
348
+ if (requires && !caps?.[requires]) {
349
+ unsupportedByFrame(type, id);
171
350
  return;
172
351
  }
173
352
  port.postMessage(xcMessage(type, payload, id), transfer ?? []);
174
353
  };
175
- const send = (type, payload, wait = false, transfer, fixedId) => {
354
+ const send = (type, payload, wait = false, transfer, fixedId, requires) => {
176
355
  if (destroyed) return Promise.reject(new Error("[project-xiaochun] instance destroyed"));
177
356
  if (!iframe) activate();
178
357
  const id = fixedId ?? `c${++idSeq}`;
179
358
  const p = wait ? new Promise((resolve, reject) => pending.set(id, { resolve, reject, sawEnd: false })) : Promise.resolve();
180
- postToFrame(type, payload, id, transfer);
359
+ postToFrame(type, payload, id, transfer, requires);
181
360
  return p;
182
361
  };
183
362
  const toBuffer = (d) => d instanceof ArrayBuffer ? d : d.buffer.slice(d.byteOffset, d.byteOffset + d.byteLength);
@@ -227,7 +406,7 @@ function createXiaochun(options) {
227
406
  let ended = false;
228
407
  let fmt = rest.format === "pcm16" || rest.format === "float32" ? rest.format : null;
229
408
  let done = Promise.resolve();
230
- const handle2 = {
409
+ const handle = {
231
410
  write(data) {
232
411
  if (ended) throw new Error("[project-xiaochun] audio stream already ended");
233
412
  if (destroyed) throw new Error("[project-xiaochun] instance destroyed");
@@ -261,17 +440,18 @@ function createXiaochun(options) {
261
440
  return done;
262
441
  }
263
442
  };
264
- return handle2;
443
+ return handle;
265
444
  };
266
445
  function buildUrl() {
267
446
  const u = new URL(srcUrl.href);
268
447
  u.searchParams.set("host", window.location.origin);
269
448
  if (transparent) u.searchParams.set("transparent", "1");
270
- if (options.ui) u.searchParams.set("ui", "1");
449
+ if (uiParts.length) u.searchParams.set("ui", uiParts.join(","));
271
450
  if (options.lang) u.searchParams.set("lang", options.lang);
272
451
  if (options.heavy) u.searchParams.set("heavy", options.heavy);
273
- if (options.controls) u.searchParams.set("controls", "1");
274
- if (options.model) u.searchParams.set("outfit", options.model);
452
+ if (options.controls === false) u.searchParams.set("controls", "0");
453
+ if (initialScene && isXcId(initialScene)) u.searchParams.set("scene", initialScene);
454
+ if (initialOutfit) u.searchParams.set("outfit", initialOutfit);
275
455
  return u.href;
276
456
  }
277
457
  function activate() {
@@ -297,7 +477,7 @@ function createXiaochun(options) {
297
477
  s.border = "0";
298
478
  s.background = "transparent";
299
479
  s.colorScheme = "normal";
300
- s.borderRadius = "var(--xc-radius, 0)";
480
+ s.borderRadius = `var(--xc-radius, ${radiusValue()})`;
301
481
  f.setAttribute("part", "iframe");
302
482
  s.opacity = "0";
303
483
  s.transition = "opacity .25s ease";
@@ -310,7 +490,7 @@ function createXiaochun(options) {
310
490
  if (!port) emit("error", { code: "timeout", message: `no xc.ready from ${frameOrigin} within ${handshakeTimeout}ms` });
311
491
  }, handshakeTimeout);
312
492
  setupAutoPause();
313
- setupPassthrough();
493
+ if (passthrough) enablePassthrough();
314
494
  }
315
495
  const onWindowMessage = (ev) => {
316
496
  if (destroyed || !iframe || ev.source !== iframe.contentWindow) return;
@@ -323,20 +503,39 @@ function createXiaochun(options) {
323
503
  const ch = new MessageChannel();
324
504
  port = ch.port1;
325
505
  port.onmessage = onPortMessage;
506
+ caps = data.payload?.capabilities ?? null;
507
+ const gWant = wantGestures();
508
+ sentGestures = gWant;
509
+ const initConfig = {
510
+ ...options.allowCustomModel ? { allowCustomModel: true } : {},
511
+ ...gWant.move || gWant.resize ? { gestures: gWant } : {}
512
+ };
326
513
  iframe.contentWindow.postMessage(
327
- xcMessage("xc.init", { hostOrigin: window.location.origin }),
514
+ xcMessage("xc.init", { hostOrigin: window.location.origin, ...Object.keys(initConfig).length ? { config: initConfig } : {} }),
328
515
  ev.origin,
329
516
  [ch.port2]
330
517
  );
518
+ warnGestureUnsupported();
331
519
  if (handshakeTimer) {
332
520
  clearTimeout(handshakeTimer);
333
521
  handshakeTimer = null;
334
522
  }
335
523
  emit("handshake", data.payload);
336
- for (const m of queue.splice(0)) port.postMessage(xcMessage(m.type, m.payload, m.id), m.transfer ?? []);
524
+ resolveHandshake();
525
+ for (const m of queue.splice(0)) {
526
+ if (m.requires && !caps?.[m.requires]) unsupportedByFrame(m.type, m.id);
527
+ else port.postMessage(xcMessage(m.type, m.payload, m.id), m.transfer ?? []);
528
+ }
337
529
  syncPause();
338
530
  };
339
531
  window.addEventListener("message", onWindowMessage);
532
+ function ack(id, value) {
533
+ const p = id ? pending.get(id) : void 0;
534
+ if (p && id) {
535
+ pending.delete(id);
536
+ p.resolve(value);
537
+ }
538
+ }
340
539
  function onPortMessage(ev) {
341
540
  const data = ev.data;
342
541
  if (!isXcEnvelope(data)) return;
@@ -347,6 +546,11 @@ function createXiaochun(options) {
347
546
  case "xc.loaded":
348
547
  if (!loaded) {
349
548
  loaded = true;
549
+ if (options.prefetch && options.heavy === "eager" && caps?.prefetch) {
550
+ const ids = Array.isArray(options.prefetch) ? options.prefetch : void 0;
551
+ void api.prefetch(ids).catch(() => {
552
+ });
553
+ }
350
554
  if (iframe) iframe.style.opacity = "1";
351
555
  if (placeholderEl) {
352
556
  const el = placeholderEl;
@@ -359,6 +563,30 @@ function createXiaochun(options) {
359
563
  }
360
564
  emit("ready", data.payload);
361
565
  break;
566
+ case "xc.outfit-changed": {
567
+ const pl = data.payload;
568
+ if (!pl.noop) {
569
+ currentOutfit = pl.id;
570
+ emit("outfit-changed", pl);
571
+ if (!pl.initial && pl.id) writePrefs({ outfit: pl.id });
572
+ }
573
+ ack(data.id);
574
+ break;
575
+ }
576
+ case "xc.scene-changed": {
577
+ const pl = data.payload;
578
+ currentScene = pl.id;
579
+ if (pl.transparent !== transparent) applyTransparency(pl.transparent);
580
+ if (!pl.noop) {
581
+ emit("scene-changed", pl);
582
+ if (!pl.initial) writePrefs({ scene: pl.id });
583
+ }
584
+ ack(data.id);
585
+ break;
586
+ }
587
+ case "xc.prefetched":
588
+ ack(data.id, data.payload);
589
+ break;
362
590
  case "xc.state":
363
591
  emit("state", data.payload);
364
592
  break;
@@ -383,9 +611,16 @@ function createXiaochun(options) {
383
611
  }
384
612
  break;
385
613
  }
614
+ case "xc.gesture-move":
615
+ onGesture("move", data.payload);
616
+ break;
617
+ case "xc.gesture-resize":
618
+ onGesture("resize", data.payload);
619
+ break;
386
620
  case "xc.error": {
387
621
  const { code, message, command } = data.payload;
388
622
  emit("error", { code, message, command });
623
+ if (code === "unknown_id" && (command === "outfit" || command === "scene")) writePrefs({ [command]: null });
389
624
  const p = data.id ? pending.get(data.id) : void 0;
390
625
  if (p && data.id) {
391
626
  pending.delete(data.id);
@@ -442,16 +677,46 @@ function createXiaochun(options) {
442
677
  port.postMessage(xcMessage("xc.pointer", { x, y }));
443
678
  });
444
679
  };
445
- function setupPassthrough() {
446
- if (!passthrough) return;
680
+ let passthroughActive = false;
681
+ function enablePassthrough() {
682
+ if (passthroughActive || !iframe) return;
683
+ passthroughActive = true;
447
684
  window.addEventListener("pointermove", onHostPointer, { passive: true });
685
+ window.addEventListener("pointerdown", onHostPointer, { passive: true });
448
686
  window.addEventListener("scroll", invalidateRect, { passive: true, capture: true });
449
687
  window.addEventListener("resize", invalidateRect, { passive: true });
450
- if (typeof ResizeObserver !== "undefined" && iframe) {
688
+ if (typeof ResizeObserver !== "undefined") {
451
689
  rectRo = new ResizeObserver(invalidateRect);
452
690
  rectRo.observe(iframe);
453
691
  }
454
692
  }
693
+ function disablePassthrough() {
694
+ if (!passthroughActive) return;
695
+ passthroughActive = false;
696
+ window.removeEventListener("pointermove", onHostPointer);
697
+ window.removeEventListener("pointerdown", onHostPointer);
698
+ window.removeEventListener("scroll", invalidateRect, true);
699
+ window.removeEventListener("resize", invalidateRect);
700
+ rectRo?.disconnect();
701
+ rectRo = null;
702
+ if (pointerRaf) {
703
+ cancelAnimationFrame(pointerRaf);
704
+ pointerRaf = 0;
705
+ }
706
+ lastPtr = null;
707
+ }
708
+ function applyTransparency(next) {
709
+ transparent = next;
710
+ applyWrapperBg();
711
+ applyRadius();
712
+ passthrough = next ? options.passthrough ?? true : false;
713
+ lastHit = false;
714
+ lastPosted = null;
715
+ rectCache = null;
716
+ if (passthrough) enablePassthrough();
717
+ else disablePassthrough();
718
+ if (iframe) iframe.style.pointerEvents = passthrough ? "none" : "auto";
719
+ }
455
720
  let pauseIo = null;
456
721
  function setupAutoPause() {
457
722
  if (options.autoPause === false || typeof IntersectionObserver === "undefined") return;
@@ -503,50 +768,123 @@ function createXiaochun(options) {
503
768
  }, { rootMargin: `${options.lazyMargin ?? 200}px` });
504
769
  io.observe(wrapper);
505
770
  }
506
- let handle = null;
507
- if (options.draggable && position !== "inline") {
508
- handle = document.createElement("div");
509
- handle.setAttribute("aria-label", "drag");
510
- Object.assign(handle.style, {
511
- position: "absolute",
512
- left: "0",
513
- top: "0",
514
- width: "28px",
515
- height: "28px",
516
- cursor: "grab",
517
- pointerEvents: "auto",
518
- borderRadius: "8px",
519
- background: "rgba(0,0,0,.25)",
520
- color: "#fff",
521
- font: "14px/28px system-ui",
522
- textAlign: "center",
523
- userSelect: "none",
524
- touchAction: "none",
525
- zIndex: "2",
526
- opacity: "0.5"
527
- });
528
- handle.textContent = "\u283F";
529
- let drag = null;
530
- handle.addEventListener("pointerdown", (e) => {
531
- const r = wrapper.getBoundingClientRect();
532
- drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };
533
- handle.setPointerCapture(e.pointerId);
534
- });
535
- handle.addEventListener("pointermove", (e) => {
536
- if (!drag) return;
537
- ws.left = `${Math.min(Math.max(0, e.clientX - drag.dx), window.innerWidth - 40)}px`;
538
- ws.top = `${Math.min(Math.max(0, e.clientY - drag.dy), window.innerHeight - 40)}px`;
771
+ let draggableOn = options.draggable === true;
772
+ let resizeLimits = normalizeResizable(options.resizable);
773
+ const gates = { move: new GestureGate(), resize: new GestureGate() };
774
+ let activeGesture = null;
775
+ let inlineShift = { x: 0, y: 0 };
776
+ let userMoved = false;
777
+ let sentGestures = null;
778
+ let warnedGesture = false;
779
+ const viewport = () => ({
780
+ width: document.documentElement.clientWidth || window.innerWidth,
781
+ height: document.documentElement.clientHeight || window.innerHeight
782
+ });
783
+ const readBox = () => {
784
+ const r = wrapper.getBoundingClientRect();
785
+ return { left: r.left, top: r.top, width: r.width, height: r.height };
786
+ };
787
+ const wantGestures = () => ({ move: draggableOn && caps?.gestures?.move === true, resize: resizeLimits !== null && caps?.gestures?.resize === true });
788
+ function applyBox(b, inlineBase, withSize) {
789
+ if (withSize) {
790
+ ws.width = `${b.width}px`;
791
+ ws.height = `${b.height}px`;
792
+ ws.maxWidth = "none";
793
+ }
794
+ if (position === "inline") {
795
+ const base = inlineBase ?? { left: b.left - inlineShift.x, top: b.top - inlineShift.y };
796
+ inlineShift = { x: b.left - base.left, y: b.top - base.top };
797
+ ws.setProperty("translate", `${inlineShift.x}px ${inlineShift.y}px`);
798
+ } else {
799
+ ws.left = `${b.left}px`;
800
+ ws.top = `${b.top}px`;
539
801
  ws.right = "auto";
540
802
  ws.bottom = "auto";
541
- invalidateRect();
542
- });
543
- const end = () => {
544
- drag = null;
545
- };
546
- handle.addEventListener("pointerup", end);
547
- handle.addEventListener("pointercancel", end);
548
- wrapper.appendChild(handle);
803
+ }
804
+ userMoved = true;
805
+ invalidateRect();
806
+ }
807
+ let selectionLock = null;
808
+ function lockSelection(on) {
809
+ const de = document.documentElement;
810
+ if (on && !selectionLock) {
811
+ selectionLock = { us: de.style.getPropertyValue("user-select"), wus: de.style.getPropertyValue("-webkit-user-select") };
812
+ de.style.setProperty("user-select", "none");
813
+ de.style.setProperty("-webkit-user-select", "none");
814
+ try {
815
+ window.getSelection()?.removeAllRanges();
816
+ } catch {
817
+ }
818
+ } else if (!on && selectionLock) {
819
+ de.style.setProperty("user-select", selectionLock.us);
820
+ de.style.setProperty("-webkit-user-select", selectionLock.wus);
821
+ selectionLock = null;
822
+ }
823
+ }
824
+ function endActiveGesture(kind) {
825
+ if (!activeGesture || kind && activeGesture.kind !== kind) return;
826
+ gates[activeGesture.kind].close();
827
+ const b = readBox();
828
+ emit(activeGesture.kind, { phase: "end", ...b });
829
+ activeGesture = null;
830
+ lockSelection(false);
831
+ }
832
+ function onGesture(kind, raw) {
833
+ if (destroyed || !caps?.gestures) return;
834
+ if (kind === "move" ? !draggableOn : resizeLimits === null) return;
835
+ const p = parseGesturePayload(kind, raw);
836
+ if (!p) return;
837
+ if (p.phase === "start" ? activeGesture !== null : activeGesture?.kind !== kind) return;
838
+ if (!gates[kind].accept(p)) return;
839
+ if (p.phase === "start") {
840
+ const start = readBox();
841
+ activeGesture = {
842
+ kind,
843
+ start,
844
+ corner: p.corner,
845
+ inlineBase: position === "inline" ? { left: start.left - inlineShift.x, top: start.top - inlineShift.y } : null
846
+ };
847
+ lockSelection(true);
848
+ emit(kind, { phase: "start", ...start });
849
+ return;
850
+ }
851
+ const a = activeGesture;
852
+ const vp = viewport();
853
+ const next = kind === "move" ? moveBox(a.start, p.totalDx, p.totalDy, vp) : resizeBox(a.start, a.corner, p.totalDx, p.totalDy, resizeLimits, vp);
854
+ applyBox(next, a.inlineBase, kind === "resize");
855
+ emit(kind, { phase: p.phase, ...next });
856
+ if (p.phase === "end") {
857
+ activeGesture = null;
858
+ lockSelection(false);
859
+ }
860
+ }
861
+ function syncGestures() {
862
+ if (destroyed) return;
863
+ const want = wantGestures();
864
+ if (!want.move) endActiveGesture("move");
865
+ if (!want.resize) endActiveGesture("resize");
866
+ if (!port || !caps) return;
867
+ warnGestureUnsupported();
868
+ if (sentGestures && sentGestures.move === want.move && sentGestures.resize === want.resize) return;
869
+ sentGestures = want;
870
+ if (caps.gestures) port.postMessage(xcMessage("xc.setConfig", { gestures: want }));
549
871
  }
872
+ function warnGestureUnsupported() {
873
+ if (warnedGesture || !caps || caps.gestures) return;
874
+ const needMove = draggableOn;
875
+ if (!needMove && resizeLimits === null) return;
876
+ warnedGesture = true;
877
+ const what = [needMove ? "draggable" : "", resizeLimits !== null ? "resizable" : ""].filter(Boolean).join(" / ");
878
+ emit("error", { code: "unsupported", message: `${what} needs gesture support (capabilities.gestures), which this /embed deployment does not advertise (older version)`, command: "gestures" });
879
+ }
880
+ const onWindowResize = () => {
881
+ if (!userMoved || activeGesture || destroyed) return;
882
+ const cur = readBox();
883
+ const fit = fitBox(cur, viewport());
884
+ if (fit.left === cur.left && fit.top === cur.top && fit.width === cur.width && fit.height === cur.height) return;
885
+ applyBox(fit, position === "inline" ? { left: cur.left - inlineShift.x, top: cur.top - inlineShift.y } : null, fit.width !== cur.width || fit.height !== cur.height);
886
+ };
887
+ window.addEventListener("resize", onWindowResize, { passive: true });
550
888
  scheduleLazy();
551
889
  const api = {
552
890
  element: wrapper,
@@ -561,8 +899,65 @@ function createXiaochun(options) {
561
899
  motion: (m) => send("xc.motion", typeof m === "string" ? m.startsWith("http") || m.startsWith("/") ? { url: m } : { name: m } : m),
562
900
  expression: (name) => send("xc.expression", { name }),
563
901
  lookAt: (x, y) => send("xc.lookAt", { x, y }),
564
- setModel: (m) => send("xc.setModel", typeof m === "string" ? /^(https?:)?\/|\.vrm/i.test(m) ? { url: m } : { outfit: m } : m),
902
+ setOutfit: (id) => {
903
+ if (!isXcId(id)) return Promise.reject(new Error("[bad_request] invalid outfit id"));
904
+ return send("xc.setOutfit", { id }, true, void 0, void 0, "outfits");
905
+ },
906
+ setScene: (id) => {
907
+ if (!isXcId(id)) return Promise.reject(new Error("[bad_request] invalid scene id"));
908
+ return send("xc.setScene", { id }, true, void 0, void 0, "scenes");
909
+ },
910
+ prefetch: (ids) => {
911
+ if (ids !== void 0 && (!Array.isArray(ids) || ids.some((x) => !isXcId(x)))) {
912
+ return Promise.reject(new Error("[bad_request] prefetch ids must be an array of outfit ids"));
913
+ }
914
+ return send("xc.prefetch", ids ? { ids } : {}, true, void 0, void 0, "prefetch");
915
+ },
916
+ getOutfits: async () => {
917
+ if (!iframe) activate();
918
+ await handshaked;
919
+ return caps?.outfits ? caps.outfits.slice() : [];
920
+ },
921
+ getScenes: async () => {
922
+ if (!iframe) activate();
923
+ await handshaked;
924
+ return caps?.scenes ? caps.scenes.slice() : [];
925
+ },
926
+ get outfit() {
927
+ return currentOutfit;
928
+ },
929
+ get scene() {
930
+ return currentScene;
931
+ },
932
+ setModel: (m) => {
933
+ const msg = typeof m === "string" ? /^(https?:)?\/|\.vrm/i.test(m) ? { url: m } : { outfit: m } : m;
934
+ if (msg.url !== void 0 && msg.outfit === void 0 && !options.allowCustomModel) {
935
+ 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";
936
+ emit("error", { code: "unsupported", message, command: "xc.setModel" });
937
+ return Promise.reject(new Error(`[unsupported] ${message}`));
938
+ }
939
+ return send("xc.setModel", msg);
940
+ },
565
941
  setConfig: (cfg) => send("xc.setConfig", cfg),
942
+ setSize(width, height) {
943
+ if (destroyed) return;
944
+ ws.width = css(width, "320px");
945
+ ws.height = css(height, "480px");
946
+ invalidateRect();
947
+ },
948
+ getBox: () => readBox(),
949
+ setBorderRadius(radius) {
950
+ radiusOpt = radius;
951
+ applyRadius();
952
+ },
953
+ setDraggable(on) {
954
+ draggableOn = on === true;
955
+ syncGestures();
956
+ },
957
+ setResizable(on) {
958
+ resizeLimits = normalizeResizable(on);
959
+ syncGestures();
960
+ },
566
961
  startListening: () => send("xc.mic", { enabled: true }),
567
962
  stopListening: () => send("xc.mic", { enabled: false }),
568
963
  mic: (enabled) => send("xc.mic", { enabled }),
@@ -587,10 +982,10 @@ function createXiaochun(options) {
587
982
  if (handshakeTimer) clearTimeout(handshakeTimer);
588
983
  if (pointerRaf) cancelAnimationFrame(pointerRaf);
589
984
  window.removeEventListener("message", onWindowMessage);
590
- window.removeEventListener("pointermove", onHostPointer);
591
- window.removeEventListener("scroll", invalidateRect, true);
592
- window.removeEventListener("resize", invalidateRect);
593
- rectRo?.disconnect();
985
+ window.removeEventListener("resize", onWindowResize);
986
+ lockSelection(false);
987
+ disablePassthrough();
988
+ resolveHandshake();
594
989
  try {
595
990
  port?.close();
596
991
  } catch {
@@ -629,7 +1024,11 @@ var EVENT_MAP = [
629
1024
  ["stt", "onStt"],
630
1025
  ["utterance", "onUtterance"],
631
1026
  ["hit-region", "onHitRegion"],
1027
+ ["outfit-changed", "onOutfitChanged"],
1028
+ ["scene-changed", "onSceneChanged"],
632
1029
  ["error", "onError"],
1030
+ ["move", "onMove"],
1031
+ ["resize", "onResize"],
633
1032
  ["destroy", "onDestroy"]
634
1033
  ];
635
1034
  function useXiaochun(options = {}) {
@@ -644,8 +1043,8 @@ function useXiaochun(options = {}) {
644
1043
  (0, import_react.useEffect)(() => {
645
1044
  if (!el) return;
646
1045
  const cur = live.current;
647
- const { onHandshake: _a, onReady: _b, onProgress: _c, onState: _d, onStt: _e, onUtterance: _f, onHitRegion: _g, onError: _h, onDestroy: _i, paused: _p, mic: _m, allowedOrigins, ...create } = cur;
648
- void [_a, _b, _c, _d, _e, _f, _g, _h, _i, _p, _m];
1046
+ const { onHandshake: _a, onReady: _b, onProgress: _c, onState: _d, onStt: _e, onUtterance: _f, onHitRegion: _g, onError: _h, onDestroy: _i, onOutfitChanged: _j, onSceneChanged: _k, onMove: _q, onResize: _r, paused: _p, mic: _m, allowedOrigins, ...create } = cur;
1047
+ void [_a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _p, _m, _q, _r];
649
1048
  const inst = createXiaochun({ ...create, allowedOrigins: allowedOrigins?.slice(), container: el });
650
1049
  const offs = [];
651
1050
  for (const [ev, cbName] of EVENT_MAP) {
@@ -675,11 +1074,8 @@ function useXiaochun(options = {}) {
675
1074
  o.lazyMargin,
676
1075
  o.placeholder,
677
1076
  o.transparent,
678
- o.width,
679
- o.height,
680
1077
  o.position,
681
- o.draggable,
682
- o.ui,
1078
+ JSON.stringify(o.ui ?? null),
683
1079
  o.heavy,
684
1080
  o.controls,
685
1081
  o.autoPause,
@@ -687,9 +1083,14 @@ function useXiaochun(options = {}) {
687
1083
  o.sandbox,
688
1084
  o.handshakeTimeout,
689
1085
  o.crossOriginIsolated,
690
- o.zIndex
1086
+ o.zIndex,
1087
+ o.allowCustomModel,
1088
+ o.persist,
1089
+ Array.isArray(o.prefetch) ? o.prefetch.join(",") : o.prefetch
691
1090
  ]);
692
- const first = (0, import_react.useRef)({ lang: o.lang, model: o.model });
1091
+ const wantOutfit = o.outfit ?? o.model;
1092
+ const first = (0, import_react.useRef)({ lang: o.lang, outfit: wantOutfit, scene: o.scene, width: o.width, height: o.height });
1093
+ const resizableKey = JSON.stringify(o.resizable ?? false);
693
1094
  (0, import_react.useEffect)(() => {
694
1095
  if (!client || first.current.lang === o.lang) return;
695
1096
  first.current.lang = o.lang;
@@ -697,11 +1098,32 @@ function useXiaochun(options = {}) {
697
1098
  });
698
1099
  }, [client, o.lang]);
699
1100
  (0, import_react.useEffect)(() => {
700
- if (!client || first.current.model === o.model) return;
701
- first.current.model = o.model;
702
- if (o.model) void client.setModel(o.model).catch(() => {
1101
+ if (!client || first.current.outfit === wantOutfit) return;
1102
+ first.current.outfit = wantOutfit;
1103
+ if (wantOutfit) void (isXcId(wantOutfit) ? client.setOutfit(wantOutfit) : client.setModel(wantOutfit)).catch(() => {
703
1104
  });
704
- }, [client, o.model]);
1105
+ }, [client, wantOutfit]);
1106
+ (0, import_react.useEffect)(() => {
1107
+ if (!client || first.current.scene === o.scene) return;
1108
+ first.current.scene = o.scene;
1109
+ if (o.scene) void client.setScene(o.scene).catch(() => {
1110
+ });
1111
+ }, [client, o.scene]);
1112
+ (0, import_react.useEffect)(() => {
1113
+ if (!client || first.current.width === o.width && first.current.height === o.height) return;
1114
+ first.current.width = o.width;
1115
+ first.current.height = o.height;
1116
+ client.setSize(o.width ?? 320, o.height ?? 480);
1117
+ }, [client, o.width, o.height]);
1118
+ (0, import_react.useEffect)(() => {
1119
+ client?.setDraggable(o.draggable === true);
1120
+ }, [client, o.draggable]);
1121
+ (0, import_react.useEffect)(() => {
1122
+ client?.setBorderRadius(o.borderRadius);
1123
+ }, [client, o.borderRadius]);
1124
+ (0, import_react.useEffect)(() => {
1125
+ client?.setResizable(o.resizable ?? false);
1126
+ }, [client, resizableKey]);
705
1127
  (0, import_react.useEffect)(() => {
706
1128
  if (!client || o.paused === void 0) return;
707
1129
  if (o.paused) client.pause();
@@ -741,6 +1163,11 @@ function XiaochunInner(props, ref) {
741
1163
  motion: (m) => c()?.motion(m) ?? reject(),
742
1164
  expression: (n) => c()?.expression(n) ?? reject(),
743
1165
  lookAt: (x, y) => c()?.lookAt(x, y) ?? reject(),
1166
+ setOutfit: (id) => c()?.setOutfit(id) ?? reject(),
1167
+ setScene: (id) => c()?.setScene(id) ?? reject(),
1168
+ getOutfits: () => c()?.getOutfits() ?? reject(),
1169
+ getScenes: () => c()?.getScenes() ?? reject(),
1170
+ prefetch: (ids) => c()?.prefetch(ids) ?? reject(),
744
1171
  setModel: (m) => c()?.setModel(m) ?? reject(),
745
1172
  setConfig: (cfg) => c()?.setConfig(cfg) ?? reject(),
746
1173
  startListening: () => c()?.startListening() ?? reject(),