@eintrek/erp-shell 0.1.63 → 0.1.64

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/index.esm.js CHANGED
@@ -14,7 +14,7 @@ import { createPortal } from 'react-dom';
14
14
  import { useTheme, ThemeProvider as ThemeProvider$1 } from 'next-themes';
15
15
  import axios, { isAxiosError, AxiosHeaders } from 'axios';
16
16
  export { isAxiosError } from 'axios';
17
- import { computePosition, offset, flip, shift, arrow } from '@floating-ui/dom';
17
+ import { computePosition, offset, shift, arrow } from '@floating-ui/dom';
18
18
  import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
19
19
 
20
20
  /**
@@ -1956,7 +1956,7 @@ function platformApiBase(app) {
1956
1956
  * This package's version, attached to help messages so the back office can
1957
1957
  * tell which shell a report came from. Keep in step with package.json.
1958
1958
  */
1959
- const SHELL_VERSION = "0.1.63";
1959
+ const SHELL_VERSION = "0.1.64";
1960
1960
 
1961
1961
  const HELP_APP_LABEL = {
1962
1962
  platform: "แพลตฟอร์ม",
@@ -4033,7 +4033,7 @@ function announceStep(d, suite, env) {
4033
4033
  const step = mission.steps[s.index];
4034
4034
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
4035
4035
  const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
4036
- const why = s.bridge ? COPY.bridgeWhy(suite.appName) : resolveText(step.why, ctx);
4036
+ const why = s.bridge ? COPY.bridgeWhy(suite.appName) : plainTitle(resolveText(step.why, ctx));
4037
4037
  d.effects.push({
4038
4038
  type: "announce",
4039
4039
  text: `ขั้นที่ ${s.index + 1} จาก ${mission.steps.length}. ${title}${why ? `. ${why}` : ""}`,
@@ -4195,6 +4195,7 @@ function startMission(d, suite, env, key, replay) {
4195
4195
  const entry = d.state.progress.missions[key];
4196
4196
  // A mission already done is only ever replayed; its done status stays.
4197
4197
  const isReplay = replay || entry?.status === "done";
4198
+ const before = d.state.progress;
4198
4199
  d.state = { ...d.state, panelOpen: false, justDone: null };
4199
4200
  if (d.state.progress.welcome === "not_seen")
4200
4201
  withProgress(d, { welcome: "started" });
@@ -4212,7 +4213,9 @@ function startMission(d, suite, env, key, replay) {
4212
4213
  index = checkpointAtOrBefore(mission.steps, at);
4213
4214
  }
4214
4215
  enterStep(d, suite, env, mission, index, { replay: isReplay });
4215
- if (isReplay)
4216
+ // A replay never writes progress; only what starting it changed (the
4217
+ // welcome, first seen through a replay) is saved.
4218
+ if (isReplay && d.state.progress !== before)
4216
4219
  save(d);
4217
4220
  }
4218
4221
  function exitMission(d) {
@@ -4786,6 +4789,27 @@ function putBody(p, app) {
4786
4789
  TERMINAL.includes(p.server_status),
4787
4790
  };
4788
4791
  }
4792
+ function stable(v) {
4793
+ if (Array.isArray(v))
4794
+ return `[${v.map(stable).join(",")}]`;
4795
+ if (isRecord(v)) {
4796
+ return `{${Object.keys(v)
4797
+ .sort()
4798
+ .map(k => `${JSON.stringify(k)}:${stable(v[k])}`)
4799
+ .join(",")}}`;
4800
+ }
4801
+ return JSON.stringify(v) ?? "null";
4802
+ }
4803
+ /**
4804
+ * What a PUT would store, without the rev it is checked against: two equal
4805
+ * keys mean the save would change nothing, and it is not sent (a replay
4806
+ * starting, a step entered again, never bump the row's rev). Key order does
4807
+ * not matter.
4808
+ */
4809
+ function payloadKey(p, app) {
4810
+ const { rev: _rev, restart: _restart, ...rest } = putBody(p, app);
4811
+ return stable(rest);
4812
+ }
4789
4813
  const MISSION_RANK = { todo: 0, skipped: 1, active: 1, done: 3 };
4790
4814
  const WELCOME_RANK = { not_seen: 0, skipped: 1, started: 1 };
4791
4815
  /**
@@ -4920,8 +4944,9 @@ function isPresent(el) {
4920
4944
  if (r.width <= 0 || r.height <= 0)
4921
4945
  return false;
4922
4946
  // Slid off the side (a closed drawer); off the top or bottom is only
4923
- // scrolled away, and the tour scrolls it back.
4924
- if (r.right <= 0 || r.left >= window.innerWidth)
4947
+ // scrolled away, and the tour scrolls it back. So is a tab scrolled out of
4948
+ // a row of tabs that scrolls sideways, as long as the row is on screen.
4949
+ if ((r.right <= 0 || r.left >= window.innerWidth) && !inSidewaysScroller(el))
4925
4950
  return false;
4926
4951
  if (el.getAttribute("data-state") === "closed")
4927
4952
  return false;
@@ -5001,6 +5026,26 @@ function sameRect(a, b) {
5001
5026
  const POPPER_SELECTOR = "[data-radix-popper-content-wrapper]";
5002
5027
  /** Toasts stay usable above the tour. */
5003
5028
  const TOASTER_SELECTOR = "[data-sonner-toaster]";
5029
+ /** Open dialogs (Radix dialog, alert dialog, sheet, popover content). */
5030
+ const OPEN_DIALOG_SELECTOR = '[role="dialog"][data-state="open"],[role="alertdialog"][data-state="open"]';
5031
+ /**
5032
+ * Whether an open dialog of the app's covers the target: the target is
5033
+ * neither in it nor around it (a confirm opened by the step's own button).
5034
+ * The ring and the hand would be drawn over the dialog, pointing at a button
5035
+ * behind it.
5036
+ */
5037
+ function coveredByDialog(el) {
5038
+ if (!hasDocument() || !el)
5039
+ return false;
5040
+ for (const d of Array.from(document.querySelectorAll(OPEN_DIALOG_SELECTOR))) {
5041
+ if (inGuideLayer(d) || !isPresent(d))
5042
+ continue;
5043
+ if (d.contains(el) || el.contains(d))
5044
+ continue;
5045
+ return true;
5046
+ }
5047
+ return false;
5048
+ }
5004
5049
  function openPoppers() {
5005
5050
  if (!hasDocument())
5006
5051
  return [];
@@ -5018,18 +5063,181 @@ function prefersReducedMotion() {
5018
5063
  function isPhoneWidth() {
5019
5064
  return typeof window !== "undefined" && window.innerWidth < 768;
5020
5065
  }
5021
- /** Scrolls the target to the middle when it is out of view (instant under reduced motion). */
5066
+ /* ---------------- what is really on screen ---------------- */
5067
+ const INFINITE = 1e7;
5068
+ /** The overlap of two rectangles, or null when they do not meet. */
5069
+ function intersectRect(a, b) {
5070
+ const x = Math.max(a.x, b.x);
5071
+ const y = Math.max(a.y, b.y);
5072
+ const right = Math.min(a.x + a.width, b.x + b.width);
5073
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
5074
+ if (right - x <= 0 || bottom - y <= 0)
5075
+ return null;
5076
+ return { x, y, width: right - x, height: bottom - y };
5077
+ }
5078
+ /** The part of `rect` inside every clip (scroll boxes, the viewport); null when none is. */
5079
+ function visibleRect(rect, clips) {
5080
+ let r = rect;
5081
+ for (const c of clips) {
5082
+ if (!r)
5083
+ return null;
5084
+ r = intersectRect(r, c);
5085
+ }
5086
+ return r;
5087
+ }
5088
+ const clipsX = (v) => v !== "visible";
5089
+ const scrollsX = (v) => v === "auto" || v === "scroll";
5090
+ /**
5091
+ * The ancestors that cut the element off: anything with overflow other than
5092
+ * visible on an axis. The body and the root scroll the page and are covered by
5093
+ * the viewport. Read once per target; their rectangles are read every frame.
5094
+ */
5095
+ function clipAncestors(el) {
5096
+ const out = [];
5097
+ if (typeof getComputedStyle !== "function")
5098
+ return out;
5099
+ for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
5100
+ if (n.hasAttribute(GUIDE_LAYER_ATTRIBUTE))
5101
+ break;
5102
+ const s = getComputedStyle(n);
5103
+ const x = clipsX(s.overflowX);
5104
+ const y = clipsX(s.overflowY);
5105
+ if (x || y)
5106
+ out.push({ el: n, x, y });
5107
+ if (s.position === "fixed")
5108
+ break;
5109
+ }
5110
+ return out;
5111
+ }
5112
+ /** An ancestor's clip as a rectangle: unbounded on an axis it does not clip. */
5113
+ function clipRectOf(c) {
5114
+ const r = c.el.getBoundingClientRect();
5115
+ return {
5116
+ x: c.x ? r.left : -INFINITE,
5117
+ y: c.y ? r.top : -INFINITE,
5118
+ width: c.x ? r.width : INFINITE * 2,
5119
+ height: c.y ? r.height : INFINITE * 2,
5120
+ };
5121
+ }
5122
+ function viewportRect() {
5123
+ const vv = typeof window !== "undefined" ? window.visualViewport : undefined;
5124
+ return {
5125
+ x: 0,
5126
+ y: vv ? vv.offsetTop : 0,
5127
+ width: window.innerWidth,
5128
+ height: vv ? vv.height : window.innerHeight,
5129
+ };
5130
+ }
5131
+ /**
5132
+ * The part of the element a person can see: its box cut by every scroll box
5133
+ * around it and by the viewport. The spotlight, its hole, the ring and the
5134
+ * hand use this, so a tab half out of a sideways-scrolling row of tabs is not
5135
+ * spotlit where it cannot be pressed. Null when none of it is on screen.
5136
+ */
5137
+ function visibleRectOf(el, ancestors = clipAncestors(el)) {
5138
+ return visibleRect(rectOf(el), [...ancestors.map(clipRectOf), viewportRect()]);
5139
+ }
5140
+ /** Inside a box that scrolls sideways (with somewhere to scroll) that is itself on screen. */
5141
+ function inSidewaysScroller(el) {
5142
+ if (typeof getComputedStyle !== "function")
5143
+ return false;
5144
+ for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
5145
+ const s = getComputedStyle(n);
5146
+ if (scrollsX(s.overflowX) && n.scrollWidth > n.clientWidth + 1) {
5147
+ const r = n.getBoundingClientRect();
5148
+ return r.width > 0 && r.right > 0 && r.left < window.innerWidth;
5149
+ }
5150
+ }
5151
+ return false;
5152
+ }
5153
+ /**
5154
+ * How to bring a target into view, or null when it already is: to the middle
5155
+ * when it is above or below the comfortable band (under the header, at the
5156
+ * bottom edge), the nearest edge when only a side is cut off (a tab out of a
5157
+ * sideways-scrolling row, on a phone).
5158
+ */
5159
+ function revealPlan(rect, visible, viewportHeight) {
5160
+ const offVertically = rect.y < 70 || rect.y + rect.height > viewportHeight - 20;
5161
+ const cutSideways = !visible || visible.width < rect.width - 1;
5162
+ if (!offVertically && !cutSideways)
5163
+ return null;
5164
+ return { block: offVertically ? "center" : "nearest", inline: "nearest" };
5165
+ }
5166
+ /** Scrolls the target into view (instant under reduced motion). */
5022
5167
  function revealTarget(el) {
5023
- const r = el.getBoundingClientRect();
5024
5168
  const vh = window.visualViewport?.height ?? window.innerHeight;
5025
- if (r.top < 70 || r.bottom > vh - 20) {
5026
- try {
5027
- el.scrollIntoView({ block: "center", behavior: prefersReducedMotion() ? "auto" : "smooth" });
5169
+ const plan = revealPlan(rectOf(el), visibleRectOf(el), vh);
5170
+ if (!plan)
5171
+ return;
5172
+ try {
5173
+ el.scrollIntoView({ ...plan, behavior: prefersReducedMotion() ? "auto" : "smooth" });
5174
+ }
5175
+ catch {
5176
+ el.scrollIntoView();
5177
+ }
5178
+ }
5179
+ /* ---------------- the scrim's holes, as one union ---------------- */
5180
+ /**
5181
+ * The scrim's blocking area: the screen (with a 10 px margin) minus the union
5182
+ * of the holes, as rectangles that do not overlap. Cutting each hole out with
5183
+ * an even-odd fill made the place where two holes overlap (a field and the
5184
+ * select list open over it) solid again, and the option there could not be
5185
+ * pressed.
5186
+ *
5187
+ * Every hole edge splits the screen into a grid; a cell is blocked when no
5188
+ * hole covers it. Cells in a row are joined, and identical rows stacked.
5189
+ */
5190
+ function blockedRects(holes, width, height) {
5191
+ const x0 = -10;
5192
+ const y0 = -10;
5193
+ const x1 = width + 10;
5194
+ const y1 = height + 10;
5195
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
5196
+ const hs = holes
5197
+ .filter(h => h.width > 0 && h.height > 0)
5198
+ .map(h => ({
5199
+ l: clamp(h.x, x0, x1),
5200
+ t: clamp(h.y, y0, y1),
5201
+ r: clamp(h.x + h.width, x0, x1),
5202
+ b: clamp(h.y + h.height, y0, y1),
5203
+ }))
5204
+ .filter(h => h.r > h.l && h.b > h.t);
5205
+ if (!hs.length)
5206
+ return [{ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }];
5207
+ const uniq = (v) => Array.from(new Set(v)).sort((a, b) => a - b);
5208
+ const xs = uniq([x0, x1, ...hs.flatMap(h => [h.l, h.r])]);
5209
+ const ys = uniq([y0, y1, ...hs.flatMap(h => [h.t, h.b])]);
5210
+ const out = [];
5211
+ let prev = null;
5212
+ for (let j = 0; j < ys.length - 1; j++) {
5213
+ const top = ys[j];
5214
+ const bottom = ys[j + 1];
5215
+ const cy = (top + bottom) / 2;
5216
+ const row = [];
5217
+ for (let i = 0; i < xs.length - 1; i++) {
5218
+ const left = xs[i];
5219
+ const right = xs[i + 1];
5220
+ const cx = (left + right) / 2;
5221
+ const covered = hs.some(h => cx > h.l && cx < h.r && cy > h.t && cy < h.b);
5222
+ if (covered)
5223
+ continue;
5224
+ const last = row[row.length - 1];
5225
+ if (last && last.x + last.width === left)
5226
+ last.width = right - last.x;
5227
+ else
5228
+ row.push({ x: left, y: top, width: right - left, height: bottom - top });
5028
5229
  }
5029
- catch {
5030
- el.scrollIntoView();
5230
+ const key = row.map(r => `${r.x},${r.width}`).join("|");
5231
+ if (prev && prev.key === key) {
5232
+ for (const r of prev.rects)
5233
+ r.height += bottom - top;
5234
+ }
5235
+ else {
5236
+ out.push(...row);
5237
+ prev = { key, rects: row };
5031
5238
  }
5032
5239
  }
5240
+ return out;
5033
5241
  }
5034
5242
 
5035
5243
  /**
@@ -5051,7 +5259,8 @@ const GUIDE_CSS = `
5051
5259
  .eg-i{pointer-events:auto}
5052
5260
  .eg-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
5053
5261
  .eg-scrim-svg{position:fixed;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}
5054
- .eg-scrim-svg path{fill:var(--eg-scrim);pointer-events:visiblePainted;fill-rule:evenodd}
5262
+ .eg-scrim-svg .eg-scrim-fill{fill:var(--eg-scrim);pointer-events:none}
5263
+ .eg-scrim-svg .eg-scrim-hit{fill:transparent;pointer-events:all}
5055
5264
  .eg-scrim{position:fixed;inset:0;background:var(--eg-scrim);pointer-events:auto}
5056
5265
  .eg-scrim.eg-light{background:rgba(12,20,34,.45)}
5057
5266
  .eg-scrim.eg-faint{background:rgba(12,20,34,.35)}
@@ -5160,6 +5369,7 @@ const GUIDE_CSS = `
5160
5369
  .eg-q-item.eg-done .eg-t b{color:var(--eg-muted)}
5161
5370
  .eg-q-item .eg-t small{display:block;font-size:12px;color:var(--eg-muted)}
5162
5371
  .eg-q-item .eg-t small.eg-sk{color:#b06a00}
5372
+ .eg-q-item .eg-t small.eg-lock{color:var(--eg-fg);font-weight:500}
5163
5373
  .eg-q-item .eg-btn{height:32px;padding:0 12px;font-size:13px}
5164
5374
  .eg-q-acts{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
5165
5375
  .eg-q-acts .eg-link{height:22px;font-size:12px;padding:0 2px}
@@ -5224,15 +5434,51 @@ function roundRect(r, radius) {
5224
5434
  `H${x + rad}A${rad} ${rad} 0 0 1 ${x} ${y + h - rad}` +
5225
5435
  `V${y + rad}A${rad} ${rad} 0 0 1 ${x + rad} ${y}Z`);
5226
5436
  }
5437
+ const rectPath = (r) => `M${r.x} ${r.y}H${r.x + r.width}V${r.y + r.height}H${r.x}Z`;
5227
5438
  /**
5228
- * The dim layer that eats every press outside the holes. One SVG path with
5229
- * even-odd holes: a press inside a hole reaches the page underneath, any
5230
- * other lands on the path (marked data-eg-block) and is cancelled.
5439
+ * The dim layer that eats every press outside the holes. The holes are a
5440
+ * union: where two overlap (a field and the select list open over it) the
5441
+ * page stays open.
5442
+ *
5443
+ * - The look: one rectangle masked by the rounded holes (a mask adds holes
5444
+ * up, so an overlap is not filled back in).
5445
+ * - The presses: a transparent path made of the screen minus the holes, as
5446
+ * rectangles that do not overlap (blockedRects). A press inside a hole
5447
+ * reaches the page underneath; any other lands on the path (marked
5448
+ * data-eg-block) and is cancelled.
5231
5449
  */
5232
5450
  function Scrim({ holes, width, height }) {
5233
- const outer = `M-10 -10H${width + 10}V${height + 10}H-10Z`;
5234
- const d = outer + holes.map(h => roundRect(h, 12)).join("");
5235
- return (jsx("svg", { className: "eg-scrim-svg", width: width, height: height, "aria-hidden": "true", children: jsx("path", { d: d, "data-eg-block": "" }) }));
5451
+ const maskId = "eg-scrim-mask-" + React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
5452
+ const block = blockedRects(holes, width, height).map(rectPath).join("");
5453
+ return (jsxs("svg", { className: "eg-scrim-svg", width: width, height: height, "aria-hidden": "true", children: [holes.length ? (jsx("defs", { children: jsxs("mask", { id: maskId, maskUnits: "userSpaceOnUse", x: -10, y: -10, width: width + 20, height: height + 20, children: [jsx("rect", { x: -10, y: -10, width: width + 20, height: height + 20, fill: "#fff" }), holes.map((h, i) => (jsx("path", { d: roundRect(h, 12), fill: "#000" }, i)))] }) })) : null, jsx("rect", { className: "eg-scrim-fill", x: -10, y: -10, width: width + 20, height: height + 20, mask: holes.length ? `url(#${maskId})` : undefined }), jsx("path", { className: "eg-scrim-hit", d: block, "data-eg-block": "" })] }));
5454
+ }
5455
+ /** Whether two rectangles overlap. */
5456
+ const meets = (a, b) => !!intersectRect(a, b);
5457
+ /** The smallest rectangle around all of them. */
5458
+ function boundingRect(rs) {
5459
+ const x = Math.min(...rs.map(r => r.x));
5460
+ const y = Math.min(...rs.map(r => r.y));
5461
+ const right = Math.max(...rs.map(r => r.x + r.width));
5462
+ const bottom = Math.max(...rs.map(r => r.y + r.height));
5463
+ return { x, y, width: right - x, height: bottom - y };
5464
+ }
5465
+ /** Where the bubble may go, in order of preference. */
5466
+ const BUBBLE_PLACEMENTS = ["right", "bottom", "top", "left"];
5467
+ /**
5468
+ * The first placement (in BUBBLE_PLACEMENTS order) whose bubble is on screen,
5469
+ * off the target (with its open list), and off everything to keep clear;
5470
+ * failing that, the first that is only on screen and off the target; -1 when
5471
+ * none is.
5472
+ */
5473
+ function pickPlacement(results, bw, bh, anchor, clear, bounds) {
5474
+ const box = (r) => ({ x: r.x, y: r.y, width: bw, height: bh });
5475
+ const fits = (b) => b.x >= bounds.x - 1 && b.y >= bounds.y - 1 && b.x + b.width <= bounds.x + bounds.width + 1 && b.y + b.height <= bounds.y + bounds.height + 1;
5476
+ const ok = results.map(r => {
5477
+ const b = box(r);
5478
+ return fits(b) && !meets(b, anchor);
5479
+ });
5480
+ const best = results.findIndex((r, i) => ok[i] && !clear.some(c => meets(box(r), c)));
5481
+ return best >= 0 ? best : ok.indexOf(true);
5236
5482
  }
5237
5483
  const BUBBLE_TITLE_ID = "eg-bubble-title";
5238
5484
  const BUBBLE_WHY_ID = "eg-bubble-why";
@@ -5272,12 +5518,21 @@ function StepLayer(p) {
5272
5518
  const gap = p.showHand ? 34 : 14;
5273
5519
  const spotKey = spot ? `${spot.x}|${spot.y}|${spot.width}|${spot.height}` : "";
5274
5520
  const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.showNext}`;
5521
+ // What the bubble sits beside: the target, with an open list over it (the
5522
+ // list is what the person is choosing from now). What it must not cover:
5523
+ // open lists, and the other anchors the step allows (a map step's
5524
+ // coordinate fields).
5525
+ const popoverList = p.popovers ?? [];
5526
+ const anchor = spot ? boundingRect([spot, ...popoverList.filter(o => meets(o, spot))]) : null;
5527
+ const clear = [...popoverList, ...p.holes];
5528
+ const anchorKey = anchor ? `${anchor.x}|${anchor.y}|${anchor.width}|${anchor.height}` : "";
5529
+ const clearKey = clear.map(r => `${r.x},${r.y},${r.width},${r.height}`).join(";");
5275
5530
  useLayoutEffect(() => {
5276
5531
  const el = bubbleRef.current;
5277
5532
  if (!el || !showBubble)
5278
5533
  return;
5279
5534
  const m = 16;
5280
- if (!spot) {
5535
+ if (!spot || !anchor) {
5281
5536
  // Nothing to point at: the middle of the screen (the bottom on a phone).
5282
5537
  const bw = el.offsetWidth;
5283
5538
  const bh = el.offsetHeight;
@@ -5288,13 +5543,13 @@ function StepLayer(p) {
5288
5543
  }
5289
5544
  if (phone) {
5290
5545
  // Docked to the top or the bottom of the screen, whichever covers less
5291
- // of the target (target in the lower half: top).
5546
+ // of the target and its open list (target in the lower half: top).
5292
5547
  const vh = viewport.height;
5293
5548
  const oy = viewport.offsetTop;
5294
5549
  const bh = el.offsetHeight;
5295
5550
  const topT = oy + m + 4;
5296
5551
  const botT = oy + vh - bh - m;
5297
- const { y, height: h } = spot;
5552
+ const { y, height: h } = anchor;
5298
5553
  const overlapTop = Math.max(0, topT + bh + 8 - y);
5299
5554
  const overlapBot = Math.max(0, y + h + gap - botT);
5300
5555
  const top = y + h / 2 > vh / 2
@@ -5308,40 +5563,42 @@ function StepLayer(p) {
5308
5563
  return;
5309
5564
  }
5310
5565
  let cancelled = false;
5566
+ const a = anchor;
5311
5567
  const ref = {
5312
5568
  getBoundingClientRect: () => ({
5313
- x: spot.x,
5314
- y: spot.y,
5315
- left: spot.x,
5316
- top: spot.y,
5317
- width: spot.width,
5318
- height: spot.height,
5319
- right: spot.x + spot.width,
5320
- bottom: spot.y + spot.height,
5569
+ x: a.x,
5570
+ y: a.y,
5571
+ left: a.x,
5572
+ top: a.y,
5573
+ width: a.width,
5574
+ height: a.height,
5575
+ right: a.x + a.width,
5576
+ bottom: a.y + a.height,
5321
5577
  }),
5322
5578
  };
5323
- computePosition(ref, el, {
5579
+ const at = (placement) => computePosition(ref, el, {
5324
5580
  strategy: "fixed",
5325
- placement: "right",
5581
+ placement,
5326
5582
  middleware: [
5327
- offset(({ placement }) => (placement.startsWith("bottom") ? gap : 14)),
5328
- flip({ fallbackPlacements: ["bottom", "top", "left"], padding: m }),
5583
+ offset(placement.startsWith("bottom") ? gap : 14),
5329
5584
  shift({ padding: m }),
5330
5585
  ...(arrowRef.current ? [arrow({ element: arrowRef.current, padding: 14 })] : []),
5331
5586
  ],
5332
- })
5333
- .then(({ x, y, placement, middlewareData }) => {
5587
+ });
5588
+ Promise.all(BUBBLE_PLACEMENTS.map(at))
5589
+ .then(results => {
5334
5590
  if (cancelled)
5335
5591
  return;
5336
5592
  const bw = el.offsetWidth;
5337
5593
  const bh = el.offsetHeight;
5338
- const fits = x >= m - 1 && y >= m - 1 && x + bw <= window.innerWidth - m + 1 && y + bh <= viewport.height + viewport.offsetTop - m + 1;
5339
- const overlaps = x < spot.x + spot.width && x + bw > spot.x && y < spot.y + spot.height && y + bh > spot.y;
5340
- if (!fits || overlaps) {
5594
+ const bounds = { x: m, y: viewport.offsetTop + m, width: window.innerWidth - 2 * m, height: viewport.height - 2 * m };
5595
+ const i = pickPlacement(results, bw, bh, a, clear, bounds);
5596
+ if (i < 0) {
5341
5597
  // Nowhere beside it: the bottom-right corner.
5342
5598
  setPos({ left: window.innerWidth - bw - m, top: viewport.height + viewport.offsetTop - bh - m, side: "dock" });
5343
5599
  return;
5344
5600
  }
5601
+ const { x, y, placement, middlewareData } = results[i];
5345
5602
  const side = placement.split("-")[0];
5346
5603
  setPos({ left: x, top: y, side, ax: middlewareData.arrow?.x, ay: middlewareData.arrow?.y });
5347
5604
  })
@@ -5352,11 +5609,14 @@ function StepLayer(p) {
5352
5609
  return () => {
5353
5610
  cancelled = true;
5354
5611
  };
5355
- // spotKey/contentKey stand for spot and the bubble's size.
5612
+ // The keys stand for spot, the lists, the holes and the bubble's size.
5356
5613
  // eslint-disable-next-line react-hooks/exhaustive-deps
5357
- }, [spotKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop]);
5358
- const holes = [...(spot ? [spot] : []), ...p.holes];
5359
- const hand = spot && p.showHand && p.phase === "ready" ? handPosition(spot, viewport.height) : null;
5614
+ }, [spotKey, anchorKey, clearKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop]);
5615
+ const popovers = p.popovers ?? [];
5616
+ const covered = !!p.covered;
5617
+ const holes = [...(spot && !covered ? [spot] : []), ...popovers, ...p.holes];
5618
+ const ringShown = !!spot && p.phase === "ready" && !covered && !popovers.some(o => meets(o, spot));
5619
+ const hand = spot && p.showHand && p.phase === "ready" && !covered && !popovers.length ? handPosition(spot, viewport.height) : null;
5360
5620
  let arrowStyle;
5361
5621
  if (pos && pos.side !== "dock") {
5362
5622
  if (pos.side === "right")
@@ -5368,7 +5628,7 @@ function StepLayer(p) {
5368
5628
  else
5369
5629
  arrowStyle = { bottom: -7, left: pos.ax };
5370
5630
  }
5371
- return (jsxs(Fragment$1, { children: [jsx(Scrim, { holes: p.phase === "ready" ? holes : [], width: viewport.width, height: Math.max(viewport.height + viewport.offsetTop, 0) }), spot && p.phase === "ready" ? (jsx("div", { ref: ringRef, className: "eg-ring" + (p.moving ? " eg-move" : ""), style: { left: spot.x, top: spot.y, width: spot.width, height: spot.height }, "aria-hidden": "true" })) : null, hand ? (jsx("div", { className: "eg-hand" + (hand.flip ? " eg-flip" : ""), style: { left: hand.left, top: hand.top }, "aria-hidden": "true", children: jsx("svg", { viewBox: "0 0 24 24", children: jsx("path", { d: HAND_PATH }) }) })) : null, showBubble ? (jsxs("div", { ref: bubbleRef, className: "eg-bubble" + (p.bubble.error ? " eg-err" : ""), role: "dialog", "aria-modal": "false", "aria-labelledby": BUBBLE_TITLE_ID, "aria-describedby": BUBBLE_WHY_ID, tabIndex: -1, "data-eg-bubble": "", style: {
5631
+ return (jsxs(Fragment$1, { children: [jsx(Scrim, { holes: p.phase === "ready" ? holes : [], width: viewport.width, height: Math.max(viewport.height + viewport.offsetTop, 0) }), spot && ringShown ? (jsx("div", { ref: ringRef, className: "eg-ring" + (p.moving ? " eg-move" : ""), style: { left: spot.x, top: spot.y, width: spot.width, height: spot.height }, "aria-hidden": "true" })) : null, hand ? (jsx("div", { className: "eg-hand" + (hand.flip ? " eg-flip" : ""), style: { left: hand.left, top: hand.top }, "aria-hidden": "true", children: jsx("svg", { viewBox: "0 0 24 24", children: jsx("path", { d: HAND_PATH }) }) })) : null, showBubble ? (jsxs("div", { ref: bubbleRef, className: "eg-bubble" + (p.bubble.error ? " eg-err" : ""), role: "dialog", "aria-modal": "false", "aria-labelledby": BUBBLE_TITLE_ID, "aria-describedby": BUBBLE_WHY_ID, tabIndex: -1, "data-eg-bubble": "", style: {
5372
5632
  left: pos?.left ?? 0,
5373
5633
  top: pos?.top ?? 0,
5374
5634
  width: phone ? viewport.width - 32 : undefined,
@@ -5379,7 +5639,7 @@ function BubbleBody(p) {
5379
5639
  const b = p.bubble;
5380
5640
  const title = b.error ? b.error.title : b.title;
5381
5641
  const why = b.error ? (b.error.detail ?? "") : b.why;
5382
- return (jsxs(Fragment$1, { children: [jsxs("div", { className: "eg-b-top", children: [jsxs("span", { className: "eg-mis", children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", b.missionNo, " \u00B7 ", b.missionTitle, b.replay ? " (ทบทวน)" : ""] }), jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", b.index + 1, "/", b.total] })] }), jsx("div", { className: "eg-pips", "aria-hidden": "true", children: Array.from({ length: b.total }, (_, i) => (jsx("i", { className: i < b.index ? "eg-d" : i === b.index ? "eg-c" : undefined }, i))) }), jsx("p", { className: "eg-b-t", id: BUBBLE_TITLE_ID, children: jsx(GuideTitle, { text: title }) }), jsx("p", { className: "eg-b-w", id: BUBBLE_WHY_ID, hidden: !why, children: why }), jsxs("div", { className: "eg-b-f", children: [jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: b.replay ? "จบการทบทวน" : "ออกจากไกด์" }), jsxs("span", { style: { display: "flex", gap: 6 }, children: [b.showEndWithoutSave ? (jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: p.onEndWithoutSave, children: "\u0E08\u0E1A\u0E42\u0E14\u0E22\u0E44\u0E21\u0E48\u0E1A\u0E31\u0E19\u0E17\u0E36\u0E01" })) : null, b.showNext ? (jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onNext, children: "\u0E16\u0E31\u0E14\u0E44\u0E1B" })) : null] })] })] }));
5642
+ return (jsxs(Fragment$1, { children: [jsxs("div", { className: "eg-b-top", children: [jsxs("span", { className: "eg-mis", children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", b.missionNo, " \u00B7 ", b.missionTitle, b.replay ? " (ทบทวน)" : ""] }), jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", b.index + 1, "/", b.total] })] }), jsx("div", { className: "eg-pips", "aria-hidden": "true", children: Array.from({ length: b.total }, (_, i) => (jsx("i", { className: i < b.index ? "eg-d" : i === b.index ? "eg-c" : undefined }, i))) }), jsx("p", { className: "eg-b-t", id: BUBBLE_TITLE_ID, children: jsx(GuideTitle, { text: title }) }), jsx("p", { className: "eg-b-w", id: BUBBLE_WHY_ID, hidden: !why, children: jsx(GuideTitle, { text: why }) }), jsxs("div", { className: "eg-b-f", children: [jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: b.replay ? "จบการทบทวน" : "ออกจากไกด์" }), jsxs("span", { style: { display: "flex", gap: 6 }, children: [b.showEndWithoutSave ? (jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: p.onEndWithoutSave, children: "\u0E08\u0E1A\u0E42\u0E14\u0E22\u0E44\u0E21\u0E48\u0E1A\u0E31\u0E19\u0E17\u0E36\u0E01" })) : null, b.showNext ? (jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onNext, children: "\u0E16\u0E31\u0E14\u0E44\u0E1B" })) : null] })] })] }));
5383
5643
  }
5384
5644
  /** The fingertip at ≈62% across and down the target; upside down near the bottom. */
5385
5645
  function handPosition(spot, viewportHeight) {
@@ -5492,6 +5752,9 @@ function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
5492
5752
  meta = jsx("span", { children: "\u0E02\u0E49\u0E32\u0E21\u0E44\u0E27\u0E49" });
5493
5753
  else if (v === "waiting" && gate)
5494
5754
  meta = gate.pending;
5755
+ // Locked: what to do first gets its own line under the title, so the title
5756
+ // keeps the row's width and the button stays short.
5757
+ const lockHint = v === "locked" && item.lockedBy ? `ทำ ${item.lockedBy} ก่อน` : null;
5495
5758
  let button;
5496
5759
  if (v === "done")
5497
5760
  button = jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onStart(item.key, true), children: "\u0E17\u0E33\u0E0B\u0E49\u0E33" });
@@ -5500,11 +5763,11 @@ function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
5500
5763
  else if (v === "waiting" && gate)
5501
5764
  button = (jsx("a", { className: "eg-btn eg-ghost", href: gate.href, style: { textDecoration: "none" }, children: gate.label }));
5502
5765
  else if (v === "locked")
5503
- button = (jsxs("button", { type: "button", className: "eg-btn eg-ghost", disabled: true, children: ["\u0E17\u0E33 ", item.lockedBy, " \u0E01\u0E48\u0E2D\u0E19"] }));
5766
+ button = (jsx("button", { type: "button", className: "eg-btn eg-ghost", disabled: true, "aria-label": lockHint ? `เริ่ม (${lockHint})` : undefined, children: "\u0E40\u0E23\u0E34\u0E48\u0E21" }));
5504
5767
  else
5505
5768
  button = (jsx("button", { type: "button", className: "eg-btn " + (v === "next" ? "eg-primary" : "eg-ghost"), onClick: () => onStart(item.key, false), children: v === "skipped" || v === "active" ? "ทำต่อ" : "เริ่ม" }));
5506
5769
  const dismissable = item.optional && (v === "todo" || v === "next");
5507
- return (jsxs("li", { className: cls, children: [jsx("span", { className: "eg-n", children: ticked ? jsx(IconCheck, {}) : n }), jsxs("div", { className: "eg-t", children: [jsx("b", { children: item.title }), jsx("small", { className: v === "skipped" ? "eg-sk" : undefined, children: meta })] }), dismissable ? (jsxs("span", { className: "eg-q-acts", children: [button, jsx("button", { type: "button", className: "eg-btn eg-link", onClick: () => onDismiss(item.key), children: "\u0E44\u0E21\u0E48\u0E43\u0E0A\u0E49\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E19\u0E35\u0E49" })] })) : (button)] }));
5770
+ return (jsxs("li", { className: cls, children: [jsx("span", { className: "eg-n", children: ticked ? jsx(IconCheck, {}) : n }), jsxs("div", { className: "eg-t", children: [jsx("b", { children: item.title }), lockHint ? jsx("small", { className: "eg-lock", children: lockHint }) : null, jsx("small", { className: v === "skipped" ? "eg-sk" : undefined, children: meta })] }), dismissable ? (jsxs("span", { className: "eg-q-acts", children: [button, jsx("button", { type: "button", className: "eg-btn eg-link", onClick: () => onDismiss(item.key), children: "\u0E44\u0E21\u0E48\u0E43\u0E0A\u0E49\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E19\u0E35\u0E49" })] })) : (button)] }));
5508
5771
  }
5509
5772
  function GuideToast({ text }) {
5510
5773
  return (jsx("div", { className: "eg-toast", role: "status", children: text }));
@@ -5692,6 +5955,9 @@ function GuideHostInner({ config, organizationId, code }) {
5692
5955
  /* ---------------- machine ---------------- */
5693
5956
  const [state, setState] = useState(null);
5694
5957
  const stateRef = useRef(null);
5958
+ // What the server holds (payloadKey), so a save that would change nothing
5959
+ // is not sent and does not bump its rev.
5960
+ const savedKey = useRef(null);
5695
5961
  useEffect(() => {
5696
5962
  if (!answer || answer.audience === "none" || answer.newerOnServer) {
5697
5963
  stateRef.current = null;
@@ -5700,6 +5966,7 @@ function GuideHostInner({ config, organizationId, code }) {
5700
5966
  }
5701
5967
  if (stateRef.current)
5702
5968
  return;
5969
+ savedKey.current = answer.progress && answer.progress.rev > 0 ? payloadKey(answer.progress, suite.app) : null;
5703
5970
  // Resume from whichever copy is further along: the server's, or this
5704
5971
  // tab's session copy from before a refresh (its rev is the server's).
5705
5972
  const mirror = readMirror(organizationId, suite);
@@ -5756,7 +6023,11 @@ function GuideHostInner({ config, organizationId, code }) {
5756
6023
  const cur = stateRef.current;
5757
6024
  if (!cur)
5758
6025
  return;
6026
+ if (payloadKey(cur.progress, suite.app) === savedKey.current)
6027
+ continue;
5759
6028
  const row = await saveGuides(organizationId, suite, cur.progress);
6029
+ if (row)
6030
+ savedKey.current = payloadKey(row, suite.app);
5760
6031
  if (row && stateRef.current) {
5761
6032
  // eslint-disable-next-line @typescript-eslint/no-use-before-define
5762
6033
  dispatchRef.current({ type: "serverRow", progress: row });
@@ -5933,7 +6204,9 @@ function GuideHostInner({ config, organizationId, code }) {
5933
6204
  scrolling, dialogs animating in, the drawer sliding, the keyboard. */
5934
6205
  const [layout, setLayout] = useState({
5935
6206
  spot: null,
6207
+ covered: false,
5936
6208
  holes: [],
6209
+ popovers: [],
5937
6210
  vw: 0,
5938
6211
  vh: 0,
5939
6212
  oy: 0,
@@ -5945,9 +6218,22 @@ function GuideHostInner({ config, organizationId, code }) {
5945
6218
  }
5946
6219
  let raf = 0;
5947
6220
  let lostSince = null;
5948
- let last = { spot: null, holes: [], vw: 0, vh: 0, oy: 0 };
6221
+ let last = { spot: null, covered: false, holes: [], popovers: [], vw: 0, vh: 0, oy: 0 };
5949
6222
  targetElRef.current = target ? findAnchor(target.anchor, target.id) : null;
5950
6223
  const allow = stepInfo?.step.allow ?? [];
6224
+ // The spotlight is the part of the target a person can see: cut by the
6225
+ // scroll boxes around it (a row of tabs that scrolls sideways) and the
6226
+ // viewport. Wholly out of view (scrolled away up or down), its own box,
6227
+ // and the tour scrolls it back.
6228
+ let clipFor = null;
6229
+ let clips = [];
6230
+ const spotOf = (el) => {
6231
+ if (el !== clipFor) {
6232
+ clipFor = el;
6233
+ clips = clipAncestors(el);
6234
+ }
6235
+ return padRect(visibleRectOf(el, clips) ?? rectOf(el), TARGET_PAD);
6236
+ };
5951
6237
  const tick = () => {
5952
6238
  raf = requestAnimationFrame(tick);
5953
6239
  const vv = window.visualViewport;
@@ -5962,14 +6248,14 @@ function GuideHostInner({ config, organizationId, code }) {
5962
6248
  let spot = null;
5963
6249
  if (target && el && isPresent(el)) {
5964
6250
  lostSince = null;
5965
- spot = padRect(rectOf(el), TARGET_PAD);
6251
+ spot = spotOf(el);
5966
6252
  }
5967
6253
  else if (target) {
5968
6254
  // Maybe re-rendered under another node.
5969
6255
  const again = findAnchor(target.anchor, target.id);
5970
6256
  if (again) {
5971
6257
  targetElRef.current = again;
5972
- spot = padRect(rectOf(again), TARGET_PAD);
6258
+ spot = spotOf(again);
5973
6259
  lostSince = null;
5974
6260
  }
5975
6261
  else if (phase === "ready") {
@@ -5982,21 +6268,25 @@ function GuideHostInner({ config, organizationId, code }) {
5982
6268
  }
5983
6269
  if (phase === "waiting" && spot)
5984
6270
  dispatchRef.current({ type: "sync" });
5985
- const holes = [];
6271
+ const popovers = [];
5986
6272
  for (const p of openPoppers())
5987
- holes.push(padRect(rectOf(p), 2));
6273
+ popovers.push(padRect(rectOf(p), 2));
6274
+ const holes = [];
5988
6275
  for (const a of allow) {
5989
6276
  const ael = findAnchor(a);
5990
6277
  if (ael)
5991
6278
  holes.push(padRect(rectOf(ael), TARGET_PAD));
5992
6279
  }
5993
- const next = { spot, holes, vw, vh, oy };
6280
+ const covered = !!spot && coveredByDialog(targetElRef.current);
6281
+ const next = { spot, covered, holes, popovers, vw, vh, oy };
6282
+ const sameList = (a, b) => a.length === b.length && a.every((h, i) => sameRect(h, b[i]));
5994
6283
  if (!sameRect(next.spot, last.spot) ||
6284
+ next.covered !== last.covered ||
5995
6285
  next.vw !== last.vw ||
5996
6286
  next.vh !== last.vh ||
5997
6287
  next.oy !== last.oy ||
5998
- next.holes.length !== last.holes.length ||
5999
- next.holes.some((h, i) => !sameRect(h, last.holes[i]))) {
6288
+ !sameList(next.holes, last.holes) ||
6289
+ !sameList(next.popovers, last.popovers)) {
6000
6290
  last = next;
6001
6291
  setLayout(next);
6002
6292
  }
@@ -6380,7 +6670,7 @@ function GuideHostInner({ config, organizationId, code }) {
6380
6670
  const { mission, step, missionNo } = stepInfo;
6381
6671
  const kind = stepKind(step);
6382
6672
  const bridge = screen.bridge;
6383
- content = (jsx(StepLayer, { spot: layout.spot, holes: layout.holes, viewport: { width: layout.vw || window.innerWidth, height: layout.vh || window.innerHeight, offsetTop: layout.oy }, phone: phone, showHand: bridge || (kind !== "input" && kind !== "view"), bubble: {
6673
+ content = (jsx(StepLayer, { spot: layout.spot, holes: layout.holes, popovers: layout.popovers, covered: layout.covered, viewport: { width: layout.vw || window.innerWidth, height: layout.vh || window.innerHeight, offsetTop: layout.oy }, phone: phone, showHand: bridge || (kind !== "input" && kind !== "view"), bubble: {
6384
6674
  missionNo,
6385
6675
  missionTitle: mission.title,
6386
6676
  replay: screen.replay,