@firetable/project-xiaochun 0.1.15 → 0.1.17

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.15 | MIT | https://github.com/FireTable/project-xiaochun */
1
+ /*! @firetable/project-xiaochun v0.1.17 | MIT | https://github.com/FireTable/project-xiaochun */
2
2
  "use strict";
3
3
  "use client";
4
4
  var __defProp = Object.defineProperty;
@@ -33,7 +33,7 @@ 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
35
  var XC_WINDOW_CORNER_RADIUS = 20;
36
- var XC_UI_PARTS = ["chat", "bubble", "outfit", "scene"];
36
+ var XC_UI_PARTS = ["chat", "bubble", "outfit", "scene", "lang", "github"];
37
37
  var XC_UI_PART_SET = new Set(XC_UI_PARTS);
38
38
  var XC_UI_LEGACY_PARTS = ["chat", "bubble"];
39
39
  function parseXcUiList(items) {
@@ -51,6 +51,49 @@ function normalizeXcUiOption(v) {
51
51
  if (Array.isArray(v)) return parseXcUiList(v);
52
52
  return { parts: [], unknown: [], legacy: false };
53
53
  }
54
+ function parseXcUiAutoHide(raw) {
55
+ if (raw === true || raw === false || raw === "transparent") return raw;
56
+ if (typeof raw !== "string") return void 0;
57
+ const v = raw.trim().toLowerCase();
58
+ if (v === "1" || v === "true") return true;
59
+ if (v === "0" || v === "false") return false;
60
+ if (v === "transparent") return "transparent";
61
+ return void 0;
62
+ }
63
+ var XC_CAMERA_RANGES = {
64
+ fov: [15, 60],
65
+ distance: [1, 15],
66
+ height: [-1, 1]
67
+ };
68
+ var XC_CAMERA_NUM_KEYS = ["fov", "distance", "height"];
69
+ var XC_CAMERA_KEYS = ["fov", "distance", "height", "intro"];
70
+ function normalizeXcCamera(raw) {
71
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, error: "camera must be an object" };
72
+ const src = raw;
73
+ for (const k of Object.keys(src)) {
74
+ if (!XC_CAMERA_KEYS.includes(k)) return { ok: false, error: `unknown camera key: ${k} (expected ${XC_CAMERA_KEYS.join(" | ")})` };
75
+ }
76
+ const out = {};
77
+ const clamped = [];
78
+ for (const k of XC_CAMERA_NUM_KEYS) {
79
+ const v = src[k];
80
+ if (v === void 0) continue;
81
+ if (v === null) {
82
+ out[k] = null;
83
+ continue;
84
+ }
85
+ if (typeof v !== "number" || !Number.isFinite(v)) return { ok: false, error: `camera.${k} must be a finite number or null` };
86
+ const [lo, hi] = XC_CAMERA_RANGES[k];
87
+ const c = Math.min(hi, Math.max(lo, v));
88
+ if (c !== v) clamped.push(k);
89
+ out[k] = c;
90
+ }
91
+ if (src.intro !== void 0) {
92
+ if (src.intro !== null && typeof src.intro !== "boolean") return { ok: false, error: "camera.intro must be a boolean or null" };
93
+ out.intro = src.intro;
94
+ }
95
+ return { ok: true, camera: out, clamped };
96
+ }
54
97
  var XC_ID_RE = /^[a-z][a-z0-9_]{0,63}$/;
55
98
  var isXcId = (v) => typeof v === "string" && XC_ID_RE.test(v);
56
99
  function xcMessage(type, payload, id) {
@@ -167,7 +210,20 @@ var GestureGate = class {
167
210
  var FALLBACK_PLACEHOLDER = "data:image/svg+xml;utf8," + encodeURIComponent(
168
211
  '<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>'
169
212
  );
213
+ var noSelect = (st) => {
214
+ st.setProperty("user-select", "none");
215
+ st.setProperty("-webkit-user-select", "none");
216
+ st.setProperty("-webkit-touch-callout", "none");
217
+ st.setProperty("-webkit-tap-highlight-color", "transparent");
218
+ };
170
219
  var css = (v, d) => v === void 0 ? d : typeof v === "number" ? `${v}px` : v;
220
+ var XC_DEFAULT_WIDTH = 600;
221
+ var XC_DEFAULT_HEIGHT = 1080;
222
+ var defaultWidthCss = (floating = false) => floating ? `min(${XC_DEFAULT_WIDTH}px, calc(100vw - 2 * var(--xc-offset-x, 16px)))` : `min(${XC_DEFAULT_WIDTH}px, 100vw)`;
223
+ var defaultHeightCss = (floating = false) => {
224
+ const unit = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("height", "1svh") ? "svh" : "vh";
225
+ return floating ? `min(${XC_DEFAULT_HEIGHT}px, calc(100${unit} - 2 * var(--xc-offset-y, 16px)))` : `min(${XC_DEFAULT_HEIGHT}px, 100${unit})`;
226
+ };
171
227
  function createXiaochun(options) {
172
228
  if (typeof window === "undefined" || typeof document === "undefined") {
173
229
  throw new Error("[project-xiaochun] createXiaochun must run in a browser");
@@ -222,6 +278,16 @@ function createXiaochun(options) {
222
278
  }
223
279
  const uiParse = normalizeXcUiOption(options.ui);
224
280
  const uiParts = uiParse.parts;
281
+ const uiAutoHide = parseXcUiAutoHide(options.uiAutoHide);
282
+ if (options.uiAutoHide !== void 0 && uiAutoHide === void 0) console.warn(`[project-xiaochun] ignoring invalid uiAutoHide: ${String(options.uiAutoHide)} (use true | false | 'transparent')`);
283
+ let camera = {};
284
+ if (options.camera !== void 0) {
285
+ const r = normalizeXcCamera(options.camera);
286
+ if (r.ok) {
287
+ camera = r.camera;
288
+ if (r.clamped.length) console.warn(`[project-xiaochun] camera.${r.clamped.join(", camera.")} out of range, clamped (fov ${XC_CAMERA_RANGES.fov.join("\u2013")}, distance ${XC_CAMERA_RANGES.distance.join("\u2013")}, height ${XC_CAMERA_RANGES.height.join("\u2013")})`);
289
+ } else console.warn(`[project-xiaochun] ignoring invalid camera option: ${r.error}`);
290
+ }
225
291
  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
292
  if (uiParse.unknown.length) console.warn(`[project-xiaochun] ignoring unknown ui part(s): ${uiParse.unknown.join(", ")} (known: ${XC_UI_PARTS.join(", ")})`);
227
293
  const outfitOpt = options.outfit ?? (isXcId(options.model) ? options.model : void 0);
@@ -236,11 +302,12 @@ function createXiaochun(options) {
236
302
  wrapper.setAttribute("data-xiaochun", "");
237
303
  const ws = wrapper.style;
238
304
  ws.position = position === "inline" ? "relative" : "fixed";
239
- ws.width = css(options.width, "320px");
240
- ws.height = css(options.height, "480px");
305
+ ws.width = css(options.width, defaultWidthCss(position !== "inline"));
306
+ ws.height = css(options.height, defaultHeightCss(position !== "inline"));
241
307
  ws.maxWidth = "100%";
242
308
  ws.overflow = "visible";
243
309
  ws.contain = "layout style";
310
+ noSelect(ws);
244
311
  ws.boxShadow = "var(--xc-shadow, none)";
245
312
  const applyWrapperBg = () => {
246
313
  ws.background = transparent ? "" : "var(--xc-bg, transparent)";
@@ -275,8 +342,8 @@ function createXiaochun(options) {
275
342
  img.src = options.placeholder || FALLBACK_PLACEHOLDER;
276
343
  img.alt = "Project XiaoChun";
277
344
  img.decoding = "async";
278
- img.width = 320;
279
- img.height = 480;
345
+ img.width = XC_DEFAULT_WIDTH;
346
+ img.height = XC_DEFAULT_HEIGHT;
280
347
  placeholderEl = img;
281
348
  }
282
349
  const ps = placeholderEl.style;
@@ -290,6 +357,7 @@ function createXiaochun(options) {
290
357
  ps.transition = "opacity .25s ease";
291
358
  ps.pointerEvents = "auto";
292
359
  ps.cursor = lazy === false ? "default" : "pointer";
360
+ noSelect(ps);
293
361
  placeholderEl.addEventListener("click", () => activate());
294
362
  wrapper.appendChild(placeholderEl);
295
363
  }
@@ -448,6 +516,11 @@ function createXiaochun(options) {
448
516
  if (transparent) u.searchParams.set("transparent", "1");
449
517
  if (uiParts.length) u.searchParams.set("ui", uiParts.join(","));
450
518
  if (options.lang) u.searchParams.set("lang", options.lang);
519
+ if (uiAutoHide !== void 0) u.searchParams.set("uiAutoHide", String(uiAutoHide));
520
+ if (typeof camera.fov === "number") u.searchParams.set("cameraFov", String(camera.fov));
521
+ if (typeof camera.distance === "number") u.searchParams.set("cameraDistance", String(camera.distance));
522
+ if (typeof camera.height === "number") u.searchParams.set("cameraHeight", String(camera.height));
523
+ if (typeof camera.intro === "boolean") u.searchParams.set("cameraIntro", camera.intro ? "1" : "0");
451
524
  if (options.heavy) u.searchParams.set("heavy", options.heavy);
452
525
  if (options.controls === false) u.searchParams.set("controls", "0");
453
526
  if (initialScene && isXcId(initialScene)) u.searchParams.set("scene", initialScene);
@@ -479,6 +552,7 @@ function createXiaochun(options) {
479
552
  s.colorScheme = "normal";
480
553
  s.borderRadius = `var(--xc-radius, ${radiusValue()})`;
481
554
  f.setAttribute("part", "iframe");
555
+ noSelect(s);
482
556
  s.opacity = "0";
483
557
  s.transition = "opacity .25s ease";
484
558
  s.pointerEvents = passthrough ? "none" : "auto";
@@ -584,6 +658,9 @@ function createXiaochun(options) {
584
658
  ack(data.id);
585
659
  break;
586
660
  }
661
+ case "xc.lang-changed":
662
+ emit("lang-changed", data.payload);
663
+ break;
587
664
  case "xc.prefetched":
588
665
  ack(data.id, data.payload);
589
666
  break;
@@ -828,6 +905,7 @@ function createXiaochun(options) {
828
905
  emit(activeGesture.kind, { phase: "end", ...b });
829
906
  activeGesture = null;
830
907
  lockSelection(false);
908
+ saveBox();
831
909
  }
832
910
  function onGesture(kind, raw) {
833
911
  if (destroyed || !caps?.gestures) return;
@@ -856,6 +934,7 @@ function createXiaochun(options) {
856
934
  if (p.phase === "end") {
857
935
  activeGesture = null;
858
936
  lockSelection(false);
937
+ saveBox();
859
938
  }
860
939
  }
861
940
  function syncGestures() {
@@ -880,11 +959,63 @@ function createXiaochun(options) {
880
959
  const onWindowResize = () => {
881
960
  if (!userMoved || activeGesture || destroyed) return;
882
961
  const cur = readBox();
883
- const fit = fitBox(cur, viewport());
962
+ const vp = viewport();
963
+ const fit = position === "inline" ? { ...fitBox({ ...cur, top: 0 }, { width: vp.width, height: Number.MAX_SAFE_INTEGER }), top: Math.max(cur.top, -window.scrollY) } : fitBox(cur, vp);
884
964
  if (fit.left === cur.left && fit.top === cur.top && fit.width === cur.width && fit.height === cur.height) return;
885
965
  applyBox(fit, position === "inline" ? { left: cur.left - inlineShift.x, top: cur.top - inlineShift.y } : null, fit.width !== cur.width || fit.height !== cur.height);
886
966
  };
887
967
  window.addEventListener("resize", onWindowResize, { passive: true });
968
+ const boxKey = options.persistBox === true ? "xiaochun:box" : typeof options.persistBox === "string" && options.persistBox ? `xiaochun:box:${options.persistBox}` : null;
969
+ const saveBox = () => {
970
+ if (!boxKey) return;
971
+ try {
972
+ const b = readBox();
973
+ const at = position === "inline" ? inlineShift : { x: b.left, y: b.top };
974
+ window.localStorage.setItem(boxKey, JSON.stringify({ v: 1, mode: position, x: Math.round(at.x), y: Math.round(at.y), width: Math.round(b.width), height: Math.round(b.height) }));
975
+ } catch {
976
+ }
977
+ };
978
+ const loadBox = () => {
979
+ if (!boxKey) return null;
980
+ try {
981
+ const raw = window.localStorage.getItem(boxKey);
982
+ if (raw === null) return null;
983
+ let o = null;
984
+ try {
985
+ o = JSON.parse(raw);
986
+ } catch {
987
+ }
988
+ const ok = (v, lo, hi) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi;
989
+ if (o && o.v === 1 && o.mode === position && ok(o.x, -1e5, 1e5) && ok(o.y, -1e5, 1e5) && ok(o.width, 1, 1e5) && ok(o.height, 1, 1e5)) {
990
+ return { x: o.x, y: o.y, width: o.width, height: o.height };
991
+ }
992
+ window.localStorage.removeItem(boxKey);
993
+ } catch {
994
+ }
995
+ return null;
996
+ };
997
+ function restoreBox() {
998
+ if (!boxKey || !draggableOn && resizeLimits === null) return;
999
+ const st = loadBox();
1000
+ if (!st) return;
1001
+ const vp = viewport();
1002
+ const lim = resizeLimits ?? DEFAULT_RESIZE_LIMITS;
1003
+ const cur = readBox();
1004
+ const useW = resizeLimits !== null && options.width === void 0;
1005
+ const useH = resizeLimits !== null && options.height === void 0;
1006
+ const clampN = (v, lo, hi) => Math.min(Math.max(v, lo), Math.max(lo, hi));
1007
+ const width = useW ? clampN(st.width, lim.minWidth, Math.min(lim.maxWidth, vp.width)) : cur.width;
1008
+ const height = useH ? clampN(st.height, lim.minHeight, Math.min(lim.maxHeight, vp.height)) : cur.height;
1009
+ if (position === "inline") {
1010
+ const base = { left: cur.left - inlineShift.x, top: cur.top - inlineShift.y };
1011
+ const left = clampN(base.left + st.x, 0, vp.width - width);
1012
+ const top = Math.max(base.top + st.y, -window.scrollY);
1013
+ applyBox({ left, top, width, height }, base, useW || useH);
1014
+ } else {
1015
+ applyBox(fitBox({ left: st.x, top: st.y, width, height }, vp), null, useW || useH);
1016
+ }
1017
+ }
1018
+ restoreBox();
888
1019
  scheduleLazy();
889
1020
  const api = {
890
1021
  element: wrapper,
@@ -941,11 +1072,44 @@ function createXiaochun(options) {
941
1072
  setConfig: (cfg) => send("xc.setConfig", cfg),
942
1073
  setSize(width, height) {
943
1074
  if (destroyed) return;
944
- ws.width = css(width, "320px");
945
- ws.height = css(height, "480px");
1075
+ ws.width = css(width, defaultWidthCss(position !== "inline"));
1076
+ ws.height = css(height, defaultHeightCss(position !== "inline"));
1077
+ if (ws.maxWidth === "none") ws.maxWidth = "100%";
946
1078
  invalidateRect();
947
1079
  },
948
1080
  getBox: () => readBox(),
1081
+ clearPersistedBox(opts) {
1082
+ if (boxKey) {
1083
+ try {
1084
+ window.localStorage.removeItem(boxKey);
1085
+ } catch {
1086
+ }
1087
+ }
1088
+ if (destroyed || opts?.reset !== true) return;
1089
+ endActiveGesture();
1090
+ ws.width = css(options.width, defaultWidthCss(position !== "inline"));
1091
+ ws.height = css(options.height, defaultHeightCss(position !== "inline"));
1092
+ ws.maxWidth = "100%";
1093
+ if (position === "inline") {
1094
+ inlineShift = { x: 0, y: 0 };
1095
+ ws.removeProperty("translate");
1096
+ } else {
1097
+ ws.left = "";
1098
+ ws.top = "";
1099
+ ws.right = "";
1100
+ ws.bottom = "";
1101
+ if (position === "bottom-right") {
1102
+ ws.right = "var(--xc-offset-x, 16px)";
1103
+ ws.bottom = "var(--xc-offset-y, 16px)";
1104
+ }
1105
+ if (position === "bottom-left") {
1106
+ ws.left = "var(--xc-offset-x, 16px)";
1107
+ ws.bottom = "var(--xc-offset-y, 16px)";
1108
+ }
1109
+ }
1110
+ userMoved = false;
1111
+ invalidateRect();
1112
+ },
949
1113
  setBorderRadius(radius) {
950
1114
  radiusOpt = radius;
951
1115
  applyRadius();
@@ -1026,6 +1190,7 @@ var EVENT_MAP = [
1026
1190
  ["hit-region", "onHitRegion"],
1027
1191
  ["outfit-changed", "onOutfitChanged"],
1028
1192
  ["scene-changed", "onSceneChanged"],
1193
+ ["lang-changed", "onLangChanged"],
1029
1194
  ["error", "onError"],
1030
1195
  ["move", "onMove"],
1031
1196
  ["resize", "onResize"],
@@ -1043,7 +1208,7 @@ function useXiaochun(options = {}) {
1043
1208
  (0, import_react.useEffect)(() => {
1044
1209
  if (!el) return;
1045
1210
  const cur = live.current;
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;
1211
+ const { onHandshake: _a, onReady: _b, onProgress: _c, onState: _d, onStt: _e, onUtterance: _f, onHitRegion: _g, onError: _h, onDestroy: _i, onOutfitChanged: _j, onSceneChanged: _k, onLangChanged: _l, onMove: _q, onResize: _r, paused: _p, mic: _m, allowedOrigins, ...create } = cur;
1047
1212
  void [_a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _p, _m, _q, _r];
1048
1213
  const inst = createXiaochun({ ...create, allowedOrigins: allowedOrigins?.slice(), container: el });
1049
1214
  const offs = [];
@@ -1076,6 +1241,7 @@ function useXiaochun(options = {}) {
1076
1241
  o.transparent,
1077
1242
  o.position,
1078
1243
  JSON.stringify(o.ui ?? null),
1244
+ o.uiAutoHide,
1079
1245
  o.heavy,
1080
1246
  o.controls,
1081
1247
  o.autoPause,
@@ -1086,10 +1252,12 @@ function useXiaochun(options = {}) {
1086
1252
  o.zIndex,
1087
1253
  o.allowCustomModel,
1088
1254
  o.persist,
1255
+ o.persistBox,
1089
1256
  Array.isArray(o.prefetch) ? o.prefetch.join(",") : o.prefetch
1090
1257
  ]);
1091
1258
  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 });
1259
+ const cameraKey = JSON.stringify(o.camera ?? null);
1260
+ const first = (0, import_react.useRef)({ lang: o.lang, outfit: wantOutfit, scene: o.scene, width: o.width, height: o.height, camera: cameraKey });
1093
1261
  const resizableKey = JSON.stringify(o.resizable ?? false);
1094
1262
  (0, import_react.useEffect)(() => {
1095
1263
  if (!client || first.current.lang === o.lang) return;
@@ -1103,6 +1271,13 @@ function useXiaochun(options = {}) {
1103
1271
  if (wantOutfit) void (isXcId(wantOutfit) ? client.setOutfit(wantOutfit) : client.setModel(wantOutfit)).catch(() => {
1104
1272
  });
1105
1273
  }, [client, wantOutfit]);
1274
+ (0, import_react.useEffect)(() => {
1275
+ if (!client || first.current.camera === cameraKey) return;
1276
+ first.current.camera = cameraKey;
1277
+ const c = o.camera;
1278
+ void client.setConfig({ camera: { fov: c?.fov ?? null, distance: c?.distance ?? null, height: c?.height ?? null, intro: c?.intro ?? null } }).catch(() => {
1279
+ });
1280
+ }, [client, cameraKey]);
1106
1281
  (0, import_react.useEffect)(() => {
1107
1282
  if (!client || first.current.scene === o.scene) return;
1108
1283
  first.current.scene = o.scene;
@@ -1113,7 +1288,7 @@ function useXiaochun(options = {}) {
1113
1288
  if (!client || first.current.width === o.width && first.current.height === o.height) return;
1114
1289
  first.current.width = o.width;
1115
1290
  first.current.height = o.height;
1116
- client.setSize(o.width ?? 320, o.height ?? 480);
1291
+ client.setSize(o.width, o.height);
1117
1292
  }, [client, o.width, o.height]);
1118
1293
  (0, import_react.useEffect)(() => {
1119
1294
  client?.setDraggable(o.draggable === true);
@@ -1167,6 +1342,7 @@ function XiaochunInner(props, ref) {
1167
1342
  setScene: (id) => c()?.setScene(id) ?? reject(),
1168
1343
  getOutfits: () => c()?.getOutfits() ?? reject(),
1169
1344
  getScenes: () => c()?.getScenes() ?? reject(),
1345
+ clearPersistedBox: (opts) => c()?.clearPersistedBox(opts),
1170
1346
  prefetch: (ids) => c()?.prefetch(ids) ?? reject(),
1171
1347
  setModel: (m) => c()?.setModel(m) ?? reject(),
1172
1348
  setConfig: (cfg) => c()?.setConfig(cfg) ?? reject(),
@@ -1187,8 +1363,8 @@ function XiaochunInner(props, ref) {
1187
1363
  }, []);
1188
1364
  const box = {
1189
1365
  ...style,
1190
- width: css2(props.width, "320px"),
1191
- height: css2(props.height, "480px"),
1366
+ width: css2(props.width, defaultWidthCss()),
1367
+ height: css2(props.height, defaultHeightCss()),
1192
1368
  maxWidth: "100%",
1193
1369
  position: style?.position ?? "relative"
1194
1370
  };