@firetable/project-xiaochun 0.1.13 → 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.13 | 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() {
@@ -282,7 +462,7 @@ function createXiaochun(options) {
282
462
  const f = document.createElement("iframe");
283
463
  f.title = "Project XiaoChun";
284
464
  f.loading = "lazy";
285
- f.allow = "microphone; autoplay";
465
+ f.allow = options.crossOriginIsolated === true ? "microphone; autoplay; cross-origin-isolated" : "microphone; autoplay";
286
466
  f.referrerPolicy = "strict-origin-when-cross-origin";
287
467
  if (options.sandbox !== false) {
288
468
  f.setAttribute("sandbox", options.sandbox ?? "allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox");
@@ -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);
@@ -397,8 +632,26 @@ function createXiaochun(options) {
397
632
  break;
398
633
  }
399
634
  }
635
+ const POINTER_MIN_INTERVAL_MS = 32;
636
+ const POINTER_MIN_MOVE_PX = 2;
400
637
  let pointerRaf = 0;
401
638
  let lastPtr = null;
639
+ let lastPosted = null;
640
+ let rectCache = null;
641
+ let rectRo = null;
642
+ let rectAt = 0;
643
+ const invalidateRect = () => {
644
+ rectCache = null;
645
+ };
646
+ const getRect = () => {
647
+ if (!iframe) return null;
648
+ const now = performance.now();
649
+ if (!rectCache || now - rectAt > 400) {
650
+ rectCache = iframe.getBoundingClientRect();
651
+ rectAt = now;
652
+ }
653
+ return rectCache;
654
+ };
402
655
  const onHostPointer = (e) => {
403
656
  if (!iframe || !port || iframe.style.pointerEvents === "auto") return;
404
657
  lastPtr = { x: e.clientX, y: e.clientY };
@@ -406,20 +659,63 @@ function createXiaochun(options) {
406
659
  pointerRaf = requestAnimationFrame(() => {
407
660
  pointerRaf = 0;
408
661
  if (!iframe || !port || !lastPtr) return;
409
- const r = iframe.getBoundingClientRect();
662
+ const r = getRect();
663
+ if (!r) return;
410
664
  const x = lastPtr.x - r.left, y = lastPtr.y - r.top;
411
665
  if (x < 0 || y < 0 || x > r.width || y > r.height) {
666
+ lastPosted = null;
412
667
  if (lastHit) {
413
668
  lastHit = false;
414
669
  iframe.style.pointerEvents = "none";
415
670
  }
416
671
  return;
417
672
  }
673
+ const now = performance.now();
674
+ if (lastPosted && now - lastPosted.t < POINTER_MIN_INTERVAL_MS) return;
675
+ if (lastPosted && Math.abs(x - lastPosted.x) < POINTER_MIN_MOVE_PX && Math.abs(y - lastPosted.y) < POINTER_MIN_MOVE_PX) return;
676
+ lastPosted = { x, y, t: now };
418
677
  port.postMessage(xcMessage("xc.pointer", { x, y }));
419
678
  });
420
679
  };
421
- function setupPassthrough() {
422
- if (passthrough) window.addEventListener("pointermove", onHostPointer, { passive: true });
680
+ let passthroughActive = false;
681
+ function enablePassthrough() {
682
+ if (passthroughActive || !iframe) return;
683
+ passthroughActive = true;
684
+ window.addEventListener("pointermove", onHostPointer, { passive: true });
685
+ window.addEventListener("pointerdown", onHostPointer, { passive: true });
686
+ window.addEventListener("scroll", invalidateRect, { passive: true, capture: true });
687
+ window.addEventListener("resize", invalidateRect, { passive: true });
688
+ if (typeof ResizeObserver !== "undefined") {
689
+ rectRo = new ResizeObserver(invalidateRect);
690
+ rectRo.observe(iframe);
691
+ }
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";
423
719
  }
424
720
  let pauseIo = null;
425
721
  function setupAutoPause() {
@@ -472,49 +768,123 @@ function createXiaochun(options) {
472
768
  }, { rootMargin: `${options.lazyMargin ?? 200}px` });
473
769
  io.observe(wrapper);
474
770
  }
475
- let handle = null;
476
- if (options.draggable && position !== "inline") {
477
- handle = document.createElement("div");
478
- handle.setAttribute("aria-label", "drag");
479
- Object.assign(handle.style, {
480
- position: "absolute",
481
- left: "0",
482
- top: "0",
483
- width: "28px",
484
- height: "28px",
485
- cursor: "grab",
486
- pointerEvents: "auto",
487
- borderRadius: "8px",
488
- background: "rgba(0,0,0,.25)",
489
- color: "#fff",
490
- font: "14px/28px system-ui",
491
- textAlign: "center",
492
- userSelect: "none",
493
- touchAction: "none",
494
- zIndex: "2",
495
- opacity: "0.5"
496
- });
497
- handle.textContent = "\u283F";
498
- let drag = null;
499
- handle.addEventListener("pointerdown", (e) => {
500
- const r = wrapper.getBoundingClientRect();
501
- drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };
502
- handle.setPointerCapture(e.pointerId);
503
- });
504
- handle.addEventListener("pointermove", (e) => {
505
- if (!drag) return;
506
- ws.left = `${Math.min(Math.max(0, e.clientX - drag.dx), window.innerWidth - 40)}px`;
507
- 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`;
508
801
  ws.right = "auto";
509
802
  ws.bottom = "auto";
510
- });
511
- const end = () => {
512
- drag = null;
513
- };
514
- handle.addEventListener("pointerup", end);
515
- handle.addEventListener("pointercancel", end);
516
- 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
+ }
517
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 }));
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 });
518
888
  scheduleLazy();
519
889
  const api = {
520
890
  element: wrapper,
@@ -529,8 +899,65 @@ function createXiaochun(options) {
529
899
  motion: (m) => send("xc.motion", typeof m === "string" ? m.startsWith("http") || m.startsWith("/") ? { url: m } : { name: m } : m),
530
900
  expression: (name) => send("xc.expression", { name }),
531
901
  lookAt: (x, y) => send("xc.lookAt", { x, y }),
532
- 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
+ },
533
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
+ },
534
961
  startListening: () => send("xc.mic", { enabled: true }),
535
962
  stopListening: () => send("xc.mic", { enabled: false }),
536
963
  mic: (enabled) => send("xc.mic", { enabled }),
@@ -555,7 +982,10 @@ function createXiaochun(options) {
555
982
  if (handshakeTimer) clearTimeout(handshakeTimer);
556
983
  if (pointerRaf) cancelAnimationFrame(pointerRaf);
557
984
  window.removeEventListener("message", onWindowMessage);
558
- window.removeEventListener("pointermove", onHostPointer);
985
+ window.removeEventListener("resize", onWindowResize);
986
+ lockSelection(false);
987
+ disablePassthrough();
988
+ resolveHandshake();
559
989
  try {
560
990
  port?.close();
561
991
  } catch {
@@ -594,7 +1024,11 @@ var EVENT_MAP = [
594
1024
  ["stt", "onStt"],
595
1025
  ["utterance", "onUtterance"],
596
1026
  ["hit-region", "onHitRegion"],
1027
+ ["outfit-changed", "onOutfitChanged"],
1028
+ ["scene-changed", "onSceneChanged"],
597
1029
  ["error", "onError"],
1030
+ ["move", "onMove"],
1031
+ ["resize", "onResize"],
598
1032
  ["destroy", "onDestroy"]
599
1033
  ];
600
1034
  function useXiaochun(options = {}) {
@@ -609,8 +1043,8 @@ function useXiaochun(options = {}) {
609
1043
  (0, import_react.useEffect)(() => {
610
1044
  if (!el) return;
611
1045
  const cur = live.current;
612
- 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;
613
- 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];
614
1048
  const inst = createXiaochun({ ...create, allowedOrigins: allowedOrigins?.slice(), container: el });
615
1049
  const offs = [];
616
1050
  for (const [ev, cbName] of EVENT_MAP) {
@@ -640,20 +1074,23 @@ function useXiaochun(options = {}) {
640
1074
  o.lazyMargin,
641
1075
  o.placeholder,
642
1076
  o.transparent,
643
- o.width,
644
- o.height,
645
1077
  o.position,
646
- o.draggable,
647
- o.ui,
1078
+ JSON.stringify(o.ui ?? null),
648
1079
  o.heavy,
649
1080
  o.controls,
650
1081
  o.autoPause,
651
1082
  o.passthrough,
652
1083
  o.sandbox,
653
1084
  o.handshakeTimeout,
654
- o.zIndex
1085
+ o.crossOriginIsolated,
1086
+ o.zIndex,
1087
+ o.allowCustomModel,
1088
+ o.persist,
1089
+ Array.isArray(o.prefetch) ? o.prefetch.join(",") : o.prefetch
655
1090
  ]);
656
- 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);
657
1094
  (0, import_react.useEffect)(() => {
658
1095
  if (!client || first.current.lang === o.lang) return;
659
1096
  first.current.lang = o.lang;
@@ -661,11 +1098,32 @@ function useXiaochun(options = {}) {
661
1098
  });
662
1099
  }, [client, o.lang]);
663
1100
  (0, import_react.useEffect)(() => {
664
- if (!client || first.current.model === o.model) return;
665
- first.current.model = o.model;
666
- 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(() => {
667
1104
  });
668
- }, [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]);
669
1127
  (0, import_react.useEffect)(() => {
670
1128
  if (!client || o.paused === void 0) return;
671
1129
  if (o.paused) client.pause();
@@ -705,6 +1163,11 @@ function XiaochunInner(props, ref) {
705
1163
  motion: (m) => c()?.motion(m) ?? reject(),
706
1164
  expression: (n) => c()?.expression(n) ?? reject(),
707
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(),
708
1171
  setModel: (m) => c()?.setModel(m) ?? reject(),
709
1172
  setConfig: (cfg) => c()?.setConfig(cfg) ?? reject(),
710
1173
  startListening: () => c()?.startListening() ?? reject(),