@open-wxa/wxa-runner-core 0.4.1 → 0.5.0

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.
@@ -433,8 +433,11 @@
433
433
  var SOURCES = ["camera", "album"];
434
434
  var SOURCE_LABELS = { camera: "\u62CD\u6444", album: "\u4ECE\u624B\u673A\u76F8\u518C\u9009\u62E9" };
435
435
  var DEFAULT_COUNT = 9;
436
+ var MEDIA_KINDS = ["image", "video"];
437
+ var FIRST_FRAME_TIMEOUT = 1e3;
438
+ var countOf = (args) => Math.max(1, Number(args.count) || DEFAULT_COUNT);
436
439
  function createMediaApis({ showActionSheet, chooseFiles: chooseFiles2, hasUserActivation: hasUserActivation2, showImagePreview }) {
437
- async function chooseImages(args) {
440
+ async function pickFiles(args, accept) {
438
441
  const allowed = SOURCES.filter((source2) => args.sourceType?.includes(source2));
439
442
  const sources = allowed.length ? allowed : SOURCES;
440
443
  let source = sources[0];
@@ -443,28 +446,33 @@
443
446
  if (index === null) return null;
444
447
  source = sources[index];
445
448
  }
446
- const count = Math.max(1, Number(args.count) || DEFAULT_COUNT);
447
- const files = await chooseFiles2({ accept: "image/*", multiple: count > 1, capture: source === "camera" });
448
- return files && files.slice(0, count);
449
+ return chooseFiles2({ accept, multiple: countOf(args) > 1, capture: source === "camera" });
449
450
  }
450
- const saveAll = (files) => Promise.all(files.map((file) => saveTempFile(file, tempFileType(file.name, file.type, "jpg"))));
451
+ const saveImage = (file) => saveTempFile(file, tempFileType(file.name, file.type, "jpg"));
451
452
  return {
452
453
  chooseImage: async (args, { api }) => {
453
- const files = await chooseImages(args);
454
+ const files = (await pickFiles(args, "image/*"))?.slice(0, countOf(args));
454
455
  if (!files) return fail(api, "cancel");
455
- const paths = await saveAll(files);
456
+ const paths = await Promise.all(files.map(saveImage));
456
457
  return ok(api, { tempFilePaths: paths, tempFiles: files.map((file, i) => ({ path: paths[i], size: file.size })) });
457
458
  },
458
459
  chooseMedia: async (args, { api }) => {
459
- const mediaType = args.mediaType?.length ? args.mediaType : ["image", "video"];
460
- if (!mediaType.includes("image") && !mediaType.includes("mix")) return fail(api, "wxa-runner \u6682\u4E0D\u652F\u6301\u9009\u62E9\u89C6\u9891");
461
- const files = await chooseImages(args);
462
- if (!files) return fail(api, "cancel");
463
- const paths = await saveAll(files);
464
- return ok(api, {
465
- type: "image",
466
- tempFiles: files.map((file, i) => ({ tempFilePath: paths[i], size: file.size, fileType: "image" }))
467
- });
460
+ const mediaType = args.mediaType?.length ? args.mediaType : MEDIA_KINDS;
461
+ const mix = mediaType.includes("mix");
462
+ const requested = MEDIA_KINDS.filter((kind) => mix || mediaType.includes(kind));
463
+ const kinds = requested.length ? requested : MEDIA_KINDS;
464
+ const picked = await pickFiles(args, kinds.map((kind) => `${kind}/*`).join(","));
465
+ if (!picked) return fail(api, "cancel");
466
+ const kindOf = (file) => kinds.length === 1 ? kinds[0] : file.type.startsWith("video/") ? "video" : "image";
467
+ const videos = picked.filter((file) => kindOf(file) === "video");
468
+ const files = (mix || !videos.length ? picked : videos).slice(0, countOf(args));
469
+ const tempFiles = await Promise.all(files.map(async (file) => kindOf(file) === "video" ? { ...await saveVideo(file), fileType: "video" } : { tempFilePath: await saveImage(file), size: file.size, fileType: "image" }));
470
+ return ok(api, { type: mix ? "mix" : tempFiles[0].fileType, tempFiles });
471
+ },
472
+ chooseVideo: async (args, { api }) => {
473
+ const file = (await pickFiles({ sourceType: args.sourceType, count: 1 }, "video/*"))?.[0];
474
+ if (!file) return fail(api, "cancel");
475
+ return ok(api, await saveVideo(file));
468
476
  },
469
477
  // 基础库已经把 current 换成了 urls 里的地址;也兼容直接传下标
470
478
  previewImage: (args) => {
@@ -493,6 +501,45 @@
493
501
  }
494
502
  };
495
503
  }
504
+ async function saveVideo(file) {
505
+ const [tempFilePath, { duration, width, height, thumb }] = await Promise.all([
506
+ saveTempFile(file, tempFileType(file.name, file.type, "mp4")),
507
+ readVideo(file)
508
+ ]);
509
+ const thumbTempFilePath = thumb ? await saveTempFile(thumb, "jpg") : "";
510
+ return { tempFilePath, size: file.size, duration, height, width, thumbTempFilePath };
511
+ }
512
+ async function readVideo(file) {
513
+ const video = document.createElement("video");
514
+ video.muted = true;
515
+ video.preload = "auto";
516
+ video.src = URL.createObjectURL(file);
517
+ try {
518
+ await new Promise((resolve, reject) => {
519
+ video.addEventListener("loadedmetadata", resolve, { once: true });
520
+ video.addEventListener("error", reject, { once: true });
521
+ });
522
+ const { videoWidth: width, videoHeight: height } = video;
523
+ const duration = Number.isFinite(video.duration) ? video.duration : 0;
524
+ return { duration, width, height, thumb: width && height ? await firstFrame(video) : null };
525
+ } catch {
526
+ return { duration: 0, width: 0, height: 0, thumb: null };
527
+ } finally {
528
+ URL.revokeObjectURL(video.src);
529
+ }
530
+ }
531
+ async function firstFrame(video) {
532
+ const loaded = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA || await Promise.race([
533
+ new Promise((resolve) => video.addEventListener("loadeddata", () => resolve(true), { once: true })),
534
+ new Promise((resolve) => setTimeout(() => resolve(false), FIRST_FRAME_TIMEOUT))
535
+ ]);
536
+ if (!loaded) return null;
537
+ const canvas = document.createElement("canvas");
538
+ canvas.width = video.videoWidth;
539
+ canvas.height = video.videoHeight;
540
+ canvas.getContext("2d").drawImage(video, 0, 0);
541
+ return new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg"));
542
+ }
496
543
  async function fetchFile(path) {
497
544
  const url = /^[a-z][a-z\d+.-]*:/i.test(path) ? path : "/" + path.replace(/^\//, "");
498
545
  const res = await fetch(url);
@@ -798,11 +845,10 @@
798
845
  });
799
846
  return regionTree;
800
847
  };
801
- const showCascade = (model, headerText) => {
848
+ const showCascade = (model) => {
802
849
  const session = showPicker({
803
850
  columns: model.columns(),
804
851
  current: model.current(),
805
- headerText,
806
852
  onColumnChange(column, index) {
807
853
  model.select(column, index);
808
854
  const columns = model.columns();
@@ -816,8 +862,7 @@
816
862
  showPickerView: async (args, { api }) => {
817
863
  const result = await showPicker({
818
864
  columns: [labels(args.array)],
819
- current: [Number(args.current) || 0],
820
- headerText: args.headerText
865
+ current: [Number(args.current) || 0]
821
866
  }).result;
822
867
  return result ? ok(api, { index: result[0] }) : fail(api, "cancel");
823
868
  },
@@ -825,7 +870,6 @@
825
870
  const session = showPicker({
826
871
  columns: (args.array || []).map(labels),
827
872
  current: (args.current || []).map((index) => Number(index) || 0),
828
- headerText: args.headerText,
829
873
  onColumnChange: (column, current) => emitToWebview(webviewId, "onMultiPickerViewChange", { column, current })
830
874
  });
831
875
  multiPicker = session;
@@ -840,7 +884,7 @@
840
884
  },
841
885
  showDatePickerView: async (args, { api }) => {
842
886
  const model = new DateCascade(args.mode === "time" ? "time" : "date", args.fields, args.range || {}, String(args.current || ""));
843
- const result = await showCascade(model, args.headerText);
887
+ const result = await showCascade(model);
844
888
  return result ? ok(api, { value: model.value() }) : fail(api, "cancel");
845
889
  },
846
890
  showAddressPickerView: async (args, { api }) => {
@@ -920,7 +964,7 @@
920
964
  }
921
965
  label(column, value) {
922
966
  if (this.mode === "time") return pad(value);
923
- return value + ["\u5E74", "\u6708", "\u65E5"][column];
967
+ return column === 0 ? value + "\u5E74" : pad(value) + ["\u6708", "\u65E5"][column - 1];
924
968
  }
925
969
  };
926
970
  function parseParts(text) {
@@ -1578,6 +1622,18 @@
1578
1622
  if (this.bridge && this.win) fn(this.bridge, this.win);
1579
1623
  else this.pending.push(fn);
1580
1624
  }
1625
+ /**
1626
+ * 把逻辑层的画布放进这一页编号为 canvasNumber 的 wx-canvas,盖在它自己的 canvas 上(样式见 webviewHtml 的 .wxa-service-canvas)。
1627
+ * 编号在 wx-canvas 的组件实例上,DOM 节点经 __wxElement 找到组件实例
1628
+ */
1629
+ placeCanvas(canvas, canvasNumber) {
1630
+ const target = Array.from(this.win?.document.querySelectorAll("wx-canvas") ?? []).find((element2) => element2.__wxElement?._canvasNumber === canvasNumber);
1631
+ const own = target?.querySelector(":scope > canvas");
1632
+ if (!own) return false;
1633
+ canvas.classList.add("wxa-service-canvas");
1634
+ own.after(canvas);
1635
+ return true;
1636
+ }
1581
1637
  /** 页面样式里的 env(safe-area-inset-*) 构建时已改写成这些变量,见 rewriteSafeAreaInsets */
1582
1638
  setSafeAreaInsets(insets) {
1583
1639
  this.dispatch((_bridge, win) => {
@@ -1694,7 +1750,7 @@
1694
1750
  var DEVICE_BEZEL = 13;
1695
1751
  var STAGE_PADDING = 24;
1696
1752
  var STAGE_GAP = 16;
1697
- var PICKER_ITEM_HEIGHT = 34;
1753
+ var PICKER_ITEM_HEIGHT = 48;
1698
1754
  var SHELL_CSS = `
1699
1755
  html, body { margin: 0; height: 100%; background: radial-gradient(120% 80% at 50% 0%, #f6f7f9 0%, #e6e9ee 100%); font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; }
1700
1756
  .wxa-stage { min-height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: ${STAGE_GAP}px; padding: ${STAGE_PADDING}px; box-sizing: border-box; }
@@ -1748,18 +1804,23 @@ html, body { margin: 0; height: 100%; background: radial-gradient(120% 80% at 50
1748
1804
  .wxa-actionsheet-alert { padding: 16px; font-size: 13px; color: #888; text-align: center; background: #fff; }
1749
1805
  .wxa-actionsheet button { display: block; width: 100%; height: 56px; border: 0; border-top: 1px solid rgba(0,0,0,.06); background: #fff; font-size: 17px; cursor: pointer; }
1750
1806
  .wxa-actionsheet .wxa-actionsheet-cancel { margin-top: 8px; border-top: 0; }
1751
- .wxa-picker { position: absolute; left: 0; right: 0; bottom: 0; padding-bottom: var(--wxa-safe-bottom, 0px); background: #fff; border-radius: 12px 12px 0 0; pointer-events: auto; user-select: none; }
1752
- .wxa-picker-header { display: flex; align-items: center; height: 56px; padding: 0 8px; }
1753
- .wxa-picker-header button { flex: none; border: 0; background: none; padding: 8px 12px; font-size: 17px; cursor: pointer; }
1754
- .wxa-picker-cancel { color: rgba(0,0,0,.9); }
1755
- .wxa-picker-confirm { color: #07c160; font-weight: 600; }
1756
- .wxa-picker-title { flex: 1; overflow: hidden; font-size: 17px; font-weight: 600; text-align: center; white-space: nowrap; text-overflow: ellipsis; }
1757
- .wxa-picker-body { position: relative; display: flex; height: ${PICKER_ITEM_HEIGHT * 5}px; margin: 0 16px 24px; }
1807
+ .wxa-picker-layer .wxa-mask.is-dim { background: rgba(0,0,0,.6); animation: wxa-fade-in .3s ease forwards; }
1808
+ .wxa-picker { position: absolute; left: 0; right: 0; bottom: 0; padding-bottom: var(--wxa-safe-bottom, 0px); background: #fff; pointer-events: auto; user-select: none; animation: wxa-slide-up .3s ease forwards; }
1809
+ .wxa-picker-header { position: relative; display: flex; padding: 16px; font-size: 17px; line-height: 1.4; }
1810
+ .wxa-picker-header::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; border-bottom: 1px solid rgba(0,0,0,.1); transform: scaleY(.5); transform-origin: 0 100%; }
1811
+ .wxa-picker-header button { flex: 1; padding: 0; border: 0; background: none; font: inherit; cursor: pointer; }
1812
+ .wxa-picker-cancel { color: rgba(0,0,0,.5); text-align: left; }
1813
+ .wxa-picker-confirm { color: #07c160; text-align: right; }
1814
+ .wxa-picker-body { position: relative; display: flex; height: ${PICKER_ITEM_HEIGHT * 5}px; overflow: hidden; }
1758
1815
  .wxa-picker-column { flex: 1; min-width: 0; overflow-y: auto; padding: ${PICKER_ITEM_HEIGHT * 2}px 0; box-sizing: border-box; scroll-snap-type: y mandatory; overscroll-behavior: contain; scrollbar-width: none; }
1759
1816
  .wxa-picker-column::-webkit-scrollbar { display: none; }
1760
- .wxa-picker-item { height: ${PICKER_ITEM_HEIGHT}px; line-height: ${PICKER_ITEM_HEIGHT}px; padding: 0 4px; overflow: hidden; font-size: 17px; color: rgba(0,0,0,.4); text-align: center; white-space: nowrap; text-overflow: ellipsis; scroll-snap-align: center; cursor: pointer; }
1761
- .wxa-picker-item.is-selected { color: rgba(0,0,0,.9); }
1762
- .wxa-picker-indicator { position: absolute; left: 0; right: 0; top: ${PICKER_ITEM_HEIGHT * 2}px; height: ${PICKER_ITEM_HEIGHT}px; border-radius: 8px; background: rgba(0,0,0,.05); pointer-events: none; }
1817
+ /* \u5F00\u53D1\u8005\u5DE5\u5177\u91CC\u9009\u9879\u6CA1\u6709\u5355\u72EC\u8BBE\u7F6E\u5B57\u53F7\uFF0C\u7EE7\u627F\u5230\u7684\u662F 11px\uFF08\u6309\u5B83\u7684\u622A\u56FE\u91CF\u5F97\uFF09 */
1818
+ .wxa-picker-item { height: ${PICKER_ITEM_HEIGHT}px; line-height: ${PICKER_ITEM_HEIGHT}px; overflow: hidden; font-size: 11px; color: rgba(0,0,0,.9); text-align: center; white-space: nowrap; text-overflow: ellipsis; scroll-snap-align: center; cursor: pointer; }
1819
+ .wxa-picker-mask { position: absolute; inset: 0; z-index: 1; pointer-events: none; background-image: linear-gradient(180deg, rgba(255,255,255,.95), rgba(255,255,255,.6)), linear-gradient(0deg, rgba(255,255,255,.95), rgba(255,255,255,.6)); background-position: top, bottom; background-size: 100% 92px; background-repeat: no-repeat; }
1820
+ .wxa-picker-indicator { position: absolute; left: 0; right: 0; top: 92px; z-index: 1; height: 56px; pointer-events: none; }
1821
+ .wxa-picker-indicator::before, .wxa-picker-indicator::after { content: ''; position: absolute; left: 0; right: 0; height: 1px; transform: scaleY(.5); }
1822
+ .wxa-picker-indicator::before { top: 0; border-top: 1px solid rgba(0,0,0,.1); transform-origin: 0 0; }
1823
+ .wxa-picker-indicator::after { bottom: 0; border-bottom: 1px solid rgba(0,0,0,.1); transform-origin: 0 100%; }
1763
1824
  .wxa-preview { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: #000; pointer-events: auto; }
1764
1825
  .wxa-preview-image { max-width: 100%; max-height: 100%; object-fit: contain; }
1765
1826
  .wxa-preview-position { position: absolute; top: 60px; left: 0; right: 0; color: #fff; font-size: 15px; text-align: center; pointer-events: none; }
@@ -1778,6 +1839,8 @@ html, body { margin: 0; height: 100%; background: radial-gradient(120% 80% at 50
1778
1839
  .wxa-notice-close { flex: none; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 50%; background: none; color: #999; font-size: 16px; line-height: 20px; cursor: pointer; }
1779
1840
  .wxa-notice-close:hover { background: rgba(0,0,0,.06); color: #111214; }
1780
1841
  @keyframes wxa-spin { to { transform: rotate(360deg); } }
1842
+ @keyframes wxa-fade-in { from { opacity: 0; } }
1843
+ @keyframes wxa-slide-up { from { transform: translateY(100%); } }
1781
1844
  @keyframes wxa-notice-in { from { opacity: 0; transform: translateY(-8px); } }
1782
1845
  `;
1783
1846
 
@@ -1795,10 +1858,10 @@ html, body { margin: 0; height: 100%; background: radial-gradient(120% 80% at 50
1795
1858
  const header = element("div", "wxa-picker-header");
1796
1859
  const cancel = element("button", "wxa-picker-cancel", "\u53D6\u6D88");
1797
1860
  const confirm = element("button", "wxa-picker-confirm", "\u786E\u5B9A");
1798
- header.append(cancel, element("div", "wxa-picker-title", options.headerText || ""), confirm);
1861
+ header.append(cancel, confirm);
1799
1862
  const body = element("div", "wxa-picker-body");
1800
1863
  const columns = options.columns.map((_, index) => new PickerColumn((next) => options.onColumnChange?.(index, next)));
1801
- body.append(...columns.map((column) => column.el), element("div", "wxa-picker-indicator"));
1864
+ body.append(...columns.map((column) => column.el), element("div", "wxa-picker-mask"), element("div", "wxa-picker-indicator"));
1802
1865
  sheet.append(header, body);
1803
1866
  layer.append(mask, sheet);
1804
1867
  container.appendChild(layer);
@@ -2200,6 +2263,8 @@ html, body { margin: 0; height: 100%; background: radial-gradient(120% 80% at 50
2200
2263
  // src/runtime/host/Host.ts
2201
2264
  var ROUTE_DONE_DELAY = 300;
2202
2265
  var PAGE_LOAD_TIMEOUT = 15e3;
2266
+ var BINDING_CANVAS_TYPES = /* @__PURE__ */ new Set(["2d", "webgl", "webgl2"]);
2267
+ var PROBE_TOP = -54321;
2203
2268
  var Host = class {
2204
2269
  constructor(info, root) {
2205
2270
  this.info = info;
@@ -2222,6 +2287,8 @@ html, body { margin: 0; height: 100%; background: radial-gradient(120% 80% at 50
2222
2287
  this.nextWebviewId = 1;
2223
2288
  this.routeChain = Promise.resolve();
2224
2289
  this.routeListeners = [];
2290
+ /** 渲染层报来的 2d、webgl 画布,按编号记下所在页面和最近的位置,供 placeCanvas 认出逻辑层的画布元素 */
2291
+ this.canvases = /* @__PURE__ */ new Map();
2225
2292
  this.device = info.device;
2226
2293
  this.shell = new PhoneShell(root, info.device);
2227
2294
  this.tabPaths = new Set((info.wxConfig.tabBar?.list || []).map((item) => stripHtml(item.pagePath)));
@@ -2260,6 +2327,30 @@ html, body { margin: 0; height: 100%; background: radial-gradient(120% 80% at 50
2260
2327
  registerAppContext(win) {
2261
2328
  this.appContext = win;
2262
2329
  }
2330
+ /**
2331
+ * 逻辑层的画布元素上没有编号,只能问基础库:给每个还没放好的同类型画布发一条 top 带记号的 canvasUpdated,
2332
+ * 基础库把位置写到那个编号的元素上,记号落在这个元素上就是它;随后用原来的位置再发一次,恢复原样。
2333
+ * 基础库只在类型与画布一致时才调到元素的 getContext,按类型筛选不会漏掉
2334
+ */
2335
+ placeCanvas(canvas, type) {
2336
+ const service = this.service;
2337
+ if (!service) return;
2338
+ const update = (canvasNumber, record, top) => service.bridge.__dispatchSub(
2339
+ "canvasUpdated",
2340
+ this.cloneForService({ data: { canvasNumber, ...record.box, top }, options: { timestamp: Date.now() } }),
2341
+ record.webviewId
2342
+ );
2343
+ for (const [canvasNumber, record] of this.canvases) {
2344
+ if (record.placed || record.type !== type) continue;
2345
+ update(canvasNumber, record, PROBE_TOP);
2346
+ const matched = canvas.style.top === `${PROBE_TOP}px`;
2347
+ update(canvasNumber, record, record.box.top);
2348
+ if (matched) {
2349
+ record.placed = this.pages.get(record.webviewId)?.placeCanvas(canvas, canvasNumber) ?? false;
2350
+ return;
2351
+ }
2352
+ }
2353
+ }
2263
2354
  frameLoaded(kind, webviewId) {
2264
2355
  if (kind === "service") this.serviceLoaded();
2265
2356
  else this.pages.get(webviewId)?.loaded();
@@ -2283,11 +2374,27 @@ html, body { margin: 0; height: 100%; background: radial-gradient(120% 80% at 50
2283
2374
  }
2284
2375
  if (event === "operateMapCallback" && this.mapOperations.receive(data)) return;
2285
2376
  const fromId = this.webviewIdOf(win);
2377
+ this.trackCanvas(event, data?.data, fromId);
2286
2378
  const service = this.service;
2287
2379
  if (!service) return;
2288
2380
  const payload = this.cloneForService(data);
2289
2381
  queueMicrotask(() => service.bridge.__dispatchSub(event, payload, fromId));
2290
2382
  }
2383
+ /**
2384
+ * xr-frame 的画布不记:基础库收到它的 canvasUpdated 会派发 resize 给 xr-frame,探测会带来多余的 resize,
2385
+ * 而 xr-frame 在 wxa-runner 里没有验证过,保持原样
2386
+ */
2387
+ trackCanvas(event, data, webviewId) {
2388
+ if (!data) return;
2389
+ const record = this.canvases.get(data.canvasNumber);
2390
+ if (event === "canvasInserted" && data.type && BINDING_CANVAS_TYPES.has(data.type) && !data.isXrFrame) {
2391
+ this.canvases.set(data.canvasNumber, { webviewId, type: data.type, box: { top: 0, left: 0, width: 0, height: 0, ...boxOf(data) }, placed: false });
2392
+ } else if (event === "canvasUpdated" && record) {
2393
+ record.box = { ...record.box, ...boxOf(data) };
2394
+ } else if (event === "canvasRemoved") {
2395
+ this.canvases.delete(data.canvasNumber);
2396
+ }
2397
+ }
2291
2398
  /** 不指定目标时发给所有页面:旧版 canvas 的绘制指令就是这样发的,由 canvasNumber 找到对应画布 */
2292
2399
  publishToPages(event, data, webviewIds) {
2293
2400
  const targets = webviewIds?.length ? webviewIds.map((id) => this.pages.get(Number(id))) : this.livePages();
@@ -2445,6 +2552,9 @@ html, body { margin: 0; height: 100%; background: radial-gradient(120% 80% at 50
2445
2552
  if (this.tabPages.get(page.path) === page) this.tabPages.delete(page.path);
2446
2553
  this.pages.delete(page.id);
2447
2554
  page.destroy();
2555
+ for (const [canvasNumber, record] of this.canvases) {
2556
+ if (record.webviewId === page.id) this.canvases.delete(canvasNumber);
2557
+ }
2448
2558
  }
2449
2559
  activate(page, openType) {
2450
2560
  for (const other of this.pages.values()) {
@@ -2593,6 +2703,13 @@ html, body { margin: 0; height: 100%; background: radial-gradient(120% 80% at 50
2593
2703
  }
2594
2704
  };
2595
2705
  }
2706
+ function boxOf(data) {
2707
+ const box = {};
2708
+ for (const key of ["top", "left", "width", "height"]) {
2709
+ if (data[key] !== void 0) box[key] = data[key];
2710
+ }
2711
+ return box;
2712
+ }
2596
2713
  function stripHtml(path) {
2597
2714
  return path.replace(/\.html$/, "");
2598
2715
  }