@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/element.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
  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() {
@@ -280,7 +460,7 @@ function createXiaochun(options) {
280
460
  const f = document.createElement("iframe");
281
461
  f.title = "Project XiaoChun";
282
462
  f.loading = "lazy";
283
- f.allow = "microphone; autoplay";
463
+ f.allow = options.crossOriginIsolated === true ? "microphone; autoplay; cross-origin-isolated" : "microphone; autoplay";
284
464
  f.referrerPolicy = "strict-origin-when-cross-origin";
285
465
  if (options.sandbox !== false) {
286
466
  f.setAttribute("sandbox", options.sandbox ?? "allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox");
@@ -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);
@@ -395,8 +630,26 @@ function createXiaochun(options) {
395
630
  break;
396
631
  }
397
632
  }
633
+ const POINTER_MIN_INTERVAL_MS = 32;
634
+ const POINTER_MIN_MOVE_PX = 2;
398
635
  let pointerRaf = 0;
399
636
  let lastPtr = null;
637
+ let lastPosted = null;
638
+ let rectCache = null;
639
+ let rectRo = null;
640
+ let rectAt = 0;
641
+ const invalidateRect = () => {
642
+ rectCache = null;
643
+ };
644
+ const getRect = () => {
645
+ if (!iframe) return null;
646
+ const now = performance.now();
647
+ if (!rectCache || now - rectAt > 400) {
648
+ rectCache = iframe.getBoundingClientRect();
649
+ rectAt = now;
650
+ }
651
+ return rectCache;
652
+ };
400
653
  const onHostPointer = (e) => {
401
654
  if (!iframe || !port || iframe.style.pointerEvents === "auto") return;
402
655
  lastPtr = { x: e.clientX, y: e.clientY };
@@ -404,20 +657,63 @@ function createXiaochun(options) {
404
657
  pointerRaf = requestAnimationFrame(() => {
405
658
  pointerRaf = 0;
406
659
  if (!iframe || !port || !lastPtr) return;
407
- const r = iframe.getBoundingClientRect();
660
+ const r = getRect();
661
+ if (!r) return;
408
662
  const x = lastPtr.x - r.left, y = lastPtr.y - r.top;
409
663
  if (x < 0 || y < 0 || x > r.width || y > r.height) {
664
+ lastPosted = null;
410
665
  if (lastHit) {
411
666
  lastHit = false;
412
667
  iframe.style.pointerEvents = "none";
413
668
  }
414
669
  return;
415
670
  }
671
+ const now = performance.now();
672
+ if (lastPosted && now - lastPosted.t < POINTER_MIN_INTERVAL_MS) return;
673
+ if (lastPosted && Math.abs(x - lastPosted.x) < POINTER_MIN_MOVE_PX && Math.abs(y - lastPosted.y) < POINTER_MIN_MOVE_PX) return;
674
+ lastPosted = { x, y, t: now };
416
675
  port.postMessage(xcMessage("xc.pointer", { x, y }));
417
676
  });
418
677
  };
419
- function setupPassthrough() {
420
- if (passthrough) window.addEventListener("pointermove", onHostPointer, { passive: true });
678
+ let passthroughActive = false;
679
+ function enablePassthrough() {
680
+ if (passthroughActive || !iframe) return;
681
+ passthroughActive = true;
682
+ window.addEventListener("pointermove", onHostPointer, { passive: true });
683
+ window.addEventListener("pointerdown", onHostPointer, { passive: true });
684
+ window.addEventListener("scroll", invalidateRect, { passive: true, capture: true });
685
+ window.addEventListener("resize", invalidateRect, { passive: true });
686
+ if (typeof ResizeObserver !== "undefined") {
687
+ rectRo = new ResizeObserver(invalidateRect);
688
+ rectRo.observe(iframe);
689
+ }
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";
421
717
  }
422
718
  let pauseIo = null;
423
719
  function setupAutoPause() {
@@ -470,49 +766,123 @@ function createXiaochun(options) {
470
766
  }, { rootMargin: `${options.lazyMargin ?? 200}px` });
471
767
  io.observe(wrapper);
472
768
  }
473
- let handle = null;
474
- if (options.draggable && position !== "inline") {
475
- handle = document.createElement("div");
476
- handle.setAttribute("aria-label", "drag");
477
- Object.assign(handle.style, {
478
- position: "absolute",
479
- left: "0",
480
- top: "0",
481
- width: "28px",
482
- height: "28px",
483
- cursor: "grab",
484
- pointerEvents: "auto",
485
- borderRadius: "8px",
486
- background: "rgba(0,0,0,.25)",
487
- color: "#fff",
488
- font: "14px/28px system-ui",
489
- textAlign: "center",
490
- userSelect: "none",
491
- touchAction: "none",
492
- zIndex: "2",
493
- opacity: "0.5"
494
- });
495
- handle.textContent = "\u283F";
496
- let drag = null;
497
- handle.addEventListener("pointerdown", (e) => {
498
- const r = wrapper.getBoundingClientRect();
499
- drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };
500
- handle.setPointerCapture(e.pointerId);
501
- });
502
- handle.addEventListener("pointermove", (e) => {
503
- if (!drag) return;
504
- ws.left = `${Math.min(Math.max(0, e.clientX - drag.dx), window.innerWidth - 40)}px`;
505
- 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`;
506
799
  ws.right = "auto";
507
800
  ws.bottom = "auto";
508
- });
509
- const end = () => {
510
- drag = null;
511
- };
512
- handle.addEventListener("pointerup", end);
513
- handle.addEventListener("pointercancel", end);
514
- wrapper.appendChild(handle);
801
+ }
802
+ userMoved = true;
803
+ invalidateRect();
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
+ }
515
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 });
516
886
  scheduleLazy();
517
887
  const api = {
518
888
  element: wrapper,
@@ -527,8 +897,65 @@ function createXiaochun(options) {
527
897
  motion: (m) => send("xc.motion", typeof m === "string" ? m.startsWith("http") || m.startsWith("/") ? { url: m } : { name: m } : m),
528
898
  expression: (name) => send("xc.expression", { name }),
529
899
  lookAt: (x, y) => send("xc.lookAt", { x, y }),
530
- 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
+ },
531
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
+ },
532
959
  startListening: () => send("xc.mic", { enabled: true }),
533
960
  stopListening: () => send("xc.mic", { enabled: false }),
534
961
  mic: (enabled) => send("xc.mic", { enabled }),
@@ -553,7 +980,10 @@ function createXiaochun(options) {
553
980
  if (handshakeTimer) clearTimeout(handshakeTimer);
554
981
  if (pointerRaf) cancelAnimationFrame(pointerRaf);
555
982
  window.removeEventListener("message", onWindowMessage);
556
- window.removeEventListener("pointermove", onHostPointer);
983
+ window.removeEventListener("resize", onWindowResize);
984
+ lockSelection(false);
985
+ disablePassthrough();
986
+ resolveHandshake();
557
987
  try {
558
988
  port?.close();
559
989
  } catch {
@@ -586,11 +1016,17 @@ function createXiaochun(options) {
586
1016
  // src/avatar-element.ts
587
1017
  var OBSERVED = [
588
1018
  "src",
1019
+ "outfit",
1020
+ "scene",
589
1021
  "model",
590
1022
  "lang",
591
1023
  "mic",
592
1024
  "transparent",
593
1025
  "draggable",
1026
+ "resizable",
1027
+ "min-size",
1028
+ "max-size",
1029
+ "border-radius",
594
1030
  "position",
595
1031
  "size",
596
1032
  "lazy",
@@ -599,14 +1035,37 @@ var OBSERVED = [
599
1035
  "heavy",
600
1036
  "ui",
601
1037
  "controls",
602
- "allowed-origins"
1038
+ "allowed-origins",
1039
+ "cross-origin-isolated",
1040
+ "persist",
1041
+ "allow-custom-model",
1042
+ "prefetch"
603
1043
  ];
604
- var REBUILD = /* @__PURE__ */ new Set(["src", "draggable", "position", "size", "lazy", "placeholder", "heavy", "ui", "controls", "allowed-origins", "transparent"]);
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
+ }
605
1050
  function boolAttr(el, name, dflt) {
606
1051
  const v = el.getAttribute(name);
607
1052
  if (v === null) return dflt;
608
1053
  return v !== "false";
609
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
+ }
610
1069
  function parseSize(v) {
611
1070
  if (!v) return { width: "320px", height: "480px" };
612
1071
  const m = v.trim().split(/x/i);
@@ -654,10 +1113,20 @@ function createElementClass() {
654
1113
  return;
655
1114
  }
656
1115
  const c = this.client;
657
- 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(() => {
658
1121
  });
659
1122
  else if (name === "lang" && newV) void c.setConfig({ lang: newV }).catch(() => {
660
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());
661
1130
  else if (name === "mic") void c.mic(boolAttr(this, "mic", false)).catch(() => {
662
1131
  });
663
1132
  else if (name === "paused") {
@@ -684,11 +1153,18 @@ function createElementClass() {
684
1153
  height,
685
1154
  position: this.getAttribute("position") ?? "inline",
686
1155
  draggable: boolAttr(this, "draggable", false),
1156
+ resizable: this.resizableOption(),
1157
+ borderRadius: radiusAttr(this.getAttribute("border-radius")),
687
1158
  lang: this.getAttribute("lang") ?? void 0,
688
- 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")),
689
1164
  heavy: this.getAttribute("heavy") ?? void 0,
690
- ui: boolAttr(this, "ui", false),
691
- controls: boolAttr(this, "controls", false)
1165
+ ui: uiAttr(this.getAttribute("ui")),
1166
+ controls: boolAttr(this, "controls", true),
1167
+ crossOriginIsolated: boolAttr(this, "cross-origin-isolated", false)
692
1168
  });
693
1169
  this.client = c;
694
1170
  const fwd = (ev, name) => c.on(ev, (detail) => this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })));
@@ -698,10 +1174,20 @@ function createElementClass() {
698
1174
  fwd("stt", "xc-stt");
699
1175
  fwd("utterance", "xc-utterance");
700
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");
701
1181
  if (boolAttr(this, "paused", false)) c.pause();
702
1182
  if (boolAttr(this, "mic", false)) void c.ready.then(() => c.mic(true)).catch(() => {
703
1183
  });
704
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
+ }
705
1191
  say(text, opts) {
706
1192
  return this.requireClient().say(text, opts);
707
1193
  }
@@ -717,6 +1203,21 @@ function createElementClass() {
717
1203
  expression(name) {
718
1204
  return this.requireClient().expression(name);
719
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
+ }
720
1221
  destroy() {
721
1222
  this.client?.destroy();
722
1223
  this.client = null;