tsquare 0.13.2 → 0.14.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.
@@ -2,15 +2,35 @@
2
2
  * The zoomable board: zoom, pan, pinch, and zoom to a screen. Browser code,
3
3
  * published as `tsquare/viewer`: the playground page uses it, and so does the
4
4
  * VS Code extension's preview. Each brings its own markup and controls and
5
- * passes the elements in; the board comes from renderWireframeSvg + boardLayout.
5
+ * passes the elements in; the board comes from renderWireframeSvg + measureBoardLayout.
6
6
  *
7
7
  * The viewer sets the styles its math depends on (structuralStyles, and the
8
8
  * board image's size in show()), so the host only needs to size the stage and
9
9
  * optionally style the "panning", "animate" and "stale" classes it toggles on
10
10
  * the stage.
11
+ *
12
+ * Clicks and hovers on elements (0.14.0) need the board's measureElements
13
+ * entries, which the host makes where it renders and hands over with
14
+ * setElements: the viewer hit-tests them (hit.ts, nothing heavy) but never
15
+ * renders or measures.
11
16
  */
17
+ import { type Measured, type MeasuredBox } from "../hit.js";
12
18
  import type { BoardItem } from "../render.js";
13
- export type { BoardItem };
19
+ export type { BoardItem, Measured, MeasuredBox };
20
+ export interface ViewerOptions {
21
+ /** A screen chip was clicked. */
22
+ onScreenZoom?: () => void;
23
+ /** The zoom level changed. */
24
+ onZoom?: (scale: number) => void;
25
+ /**
26
+ * A click on the board (a press that hardly moves; dragging pans): the element most likely meant
27
+ * and everything under the point, most likely first (elementsAt's order), from setElements'
28
+ * entries. Only once elements are set; `hit` is null outside every element.
29
+ */
30
+ onElementClick?: (hit: Measured | null, hits: Measured[], event: PointerEvent) => void;
31
+ /** The pointer is over a different element than before (null: over none), while no button is pressed. */
32
+ onElementHover?: (hit: Measured | null, hits: Measured[]) => void;
33
+ }
14
34
  export interface ViewerElements {
15
35
  /**
16
36
  * The visible area; give it a size. It gets these classes, for optional styling:
@@ -40,8 +60,33 @@ export interface Viewer {
40
60
  zoomOut(): void;
41
61
  /** Stop: remove the viewer's listeners and release the board image. For hosts that remove the viewer (a React component unmounting). */
42
62
  destroy(): void;
63
+ /**
64
+ * The board's measureElements entries, for clicks, hovers and zoomTo; null (or none) turns
65
+ * element clicks off. Set them after each show() of a new board: they describe one board.
66
+ */
67
+ setElements(elements: Measured[] | null): void;
68
+ /**
69
+ * Outlines entries on the board, inside the zoomed canvas: a box per element, the line and label
70
+ * of a flow. Each kind replaces its own earlier ones ([] clears it); "selected" (the default) is
71
+ * solid, "hover" lighter. The color is the CSS variable --tsquare-highlight (default #3B99FB).
72
+ */
73
+ highlight(entries: Measured[], opts?: {
74
+ kind?: "selected" | "hover";
75
+ }): void;
76
+ /** Zooms to an entry's box (a flow: its line and label) or any box in board pixels, and stays on it as the stage resizes. */
77
+ zoomTo(target: Measured | MeasuredBox, opts?: {
78
+ animate?: boolean;
79
+ }): void;
80
+ /** A point on the page (a pointer event's clientX/clientY) in board pixels, where measureElements' boxes are. */
81
+ toBoard(clientX: number, clientY: number): {
82
+ x: number;
83
+ y: number;
84
+ };
85
+ /**
86
+ * An element over the board for the host's own marks (comment pins, a text field), positioned in
87
+ * board pixels: it pans and zooms with the board. It ignores the pointer; give children that
88
+ * should be clickable pointer-events: auto.
89
+ */
90
+ readonly layer: HTMLElement;
43
91
  }
44
- export declare function createViewer(el: ViewerElements, opts?: {
45
- onScreenZoom?: () => void;
46
- onZoom?: (scale: number) => void;
47
- }): Viewer;
92
+ export declare function createViewer(el: ViewerElements, opts?: ViewerOptions): Viewer;
@@ -1,3 +1,44 @@
1
+ // src/hit.ts
2
+ var isFlow = (e) => e.type === "Flow";
3
+ var OVERLAYS = /* @__PURE__ */ new Set(["Modal", "Drawer", "Toast"]);
4
+ var CONTAINERS = /* @__PURE__ */ new Set(["Board", "Screen", "Stack", "Grid", "Card", "Accordion", "List", "Modal", "Drawer"]);
5
+ var inside = (b, x, y) => !!b && b.width > 0 && b.height > 0 && x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height;
6
+ var center = (b) => [b.x + b.width / 2, b.y + b.height / 2];
7
+ function distanceToPath(path, x, y) {
8
+ let best = Infinity;
9
+ for (let i = 1; i < path.length; i++) {
10
+ const [ax, ay] = path[i - 1], [bx, by] = path[i];
11
+ const dx = bx - ax, dy = by - ay, len = dx * dx + dy * dy;
12
+ const t = len ? Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / len)) : 0;
13
+ best = Math.min(best, Math.hypot(x - ax - t * dx, y - ay - t * dy));
14
+ }
15
+ return best;
16
+ }
17
+ var isContainer = (e) => CONTAINERS.has(e.type);
18
+ function elementsAt(elements, x, y, opts = {}) {
19
+ const tolerance = opts.tolerance ?? 6;
20
+ const order = new Map(elements.map((e, i) => [e, i]));
21
+ const topFirst = (a, b) => order.get(b) - order.get(a);
22
+ const covered = /* @__PURE__ */ new Set();
23
+ const screens = elements.filter((e) => e.type === "Screen");
24
+ const screenAround = (b) => screens.find((s) => inside(s.box, ...center(b)));
25
+ for (const [i, m] of elements.entries()) {
26
+ if (isFlow(m) || !OVERLAYS.has(m.type)) continue;
27
+ const screen = screenAround(m.box);
28
+ if (!screen || !inside(m.box, x, y) && !(m.scrim && inside(screen.box, x, y))) continue;
29
+ for (const e of elements.slice(0, i)) if (!isFlow(e) && e.type !== "Screen" && e.type !== "Board" && screenAround(e.box) === screen) covered.add(e);
30
+ }
31
+ const owners = [], labels = [], leaves = [], lines = [], containers = [];
32
+ for (const e of elements) {
33
+ if (isFlow(e)) {
34
+ if (inside(e.labelBox, x, y)) labels.push(e);
35
+ else if (distanceToPath(e.path, x, y) <= tolerance) lines.push(e);
36
+ } else if (e.overlays?.some((o) => inside(o, x, y))) owners.push(e);
37
+ else if (!covered.has(e) && (inside(e.box, x, y) || inside(e.labelBox, x, y))) (isContainer(e) ? containers : leaves).push(e);
38
+ }
39
+ return [owners, labels, leaves, lines, containers].flatMap((g) => g.sort(topFirst));
40
+ }
41
+
1
42
  // src/playground/viewer.ts
2
43
  function structuralStyles(stage, canvas) {
3
44
  if (getComputedStyle(stage).position === "static") stage.style.position = "relative";
@@ -16,6 +57,16 @@ function createViewer(el, opts = {}) {
16
57
  let scale = 1, x = 0, y = 0;
17
58
  let mode = "fit";
18
59
  const PAD = 16;
60
+ let elements = [];
61
+ const overlay = () => {
62
+ const d = document.createElement("div");
63
+ Object.assign(d.style, { position: "absolute", left: "0px", top: "0px", width: "0px", height: "0px", pointerEvents: "none" });
64
+ return d;
65
+ };
66
+ const marks = overlay(), layer = overlay();
67
+ marks.className = "tsquare-highlights";
68
+ layer.className = "tsquare-layer";
69
+ const highlights = { selected: [], hover: [] };
19
70
  const label = () => {
20
71
  if (el.zoomFit) el.zoomFit.textContent = `${Math.round(scale * 100)}%`;
21
72
  opts.onZoom?.(scale);
@@ -23,6 +74,7 @@ function createViewer(el, opts = {}) {
23
74
  const apply = (animate = false) => {
24
75
  stage.classList.toggle("animate", animate);
25
76
  canvas.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
77
+ canvas.style.setProperty("--tsquare-viewer-scale", String(scale));
26
78
  label();
27
79
  };
28
80
  const fitRect = (r, animate) => {
@@ -38,6 +90,7 @@ function createViewer(el, opts = {}) {
38
90
  if (!size.width) return;
39
91
  if (mode === "fit") fitRect({ x: 0, y: 0, ...size }, animate);
40
92
  else if (typeof mode === "number" && items[mode]) fitRect(items[mode], animate);
93
+ else if (typeof mode === "object") fitRect(mode, animate);
41
94
  else apply();
42
95
  markScreens();
43
96
  }
@@ -60,7 +113,7 @@ function createViewer(el, opts = {}) {
60
113
  img = document.createElement("img");
61
114
  img.alt = "Rendered wireframe";
62
115
  img.draggable = false;
63
- canvas.replaceChildren(img);
116
+ canvas.replaceChildren(img, marks, layer);
64
117
  el.empty?.remove();
65
118
  }
66
119
  const old = img.src;
@@ -71,13 +124,14 @@ function createViewer(el, opts = {}) {
71
124
  img.width = width;
72
125
  img.height = height;
73
126
  Object.assign(img.style, { width: `${width}px`, height: `${height}px`, maxWidth: "none", maxHeight: "none" });
127
+ for (const o of [marks, layer]) Object.assign(o.style, { width: `${width}px`, height: `${height}px` });
74
128
  const screens = list.filter((i) => i.type === "Screen");
75
129
  if (screens.map((s) => s.name).join("\n") !== items.filter((i) => i.type === "Screen").map((s) => s.name).join("\n")) {
76
130
  if (typeof mode === "number") mode = "fit";
77
131
  buildScreenChips(screens);
78
132
  }
79
133
  items = screens;
80
- if (resized && mode === "manual") mode = "fit";
134
+ if (resized && (mode === "manual" || typeof mode === "object")) mode = "fit";
81
135
  refresh();
82
136
  }
83
137
  function buildScreenChips(screens) {
@@ -98,8 +152,9 @@ function createViewer(el, opts = {}) {
98
152
  opts.onScreenZoom?.();
99
153
  }));
100
154
  }
155
+ const inLayer = (e) => e.target !== layer && layer.contains(e.target);
101
156
  stage.addEventListener("wheel", (e) => {
102
- if (!size.width) return;
157
+ if (!size.width || inLayer(e)) return;
103
158
  e.preventDefault();
104
159
  const r = stage.getBoundingClientRect();
105
160
  if (e.ctrlKey || e.metaKey) zoomAt(Math.exp(-e.deltaY * 0.01), e.clientX - r.left, e.clientY - r.top);
@@ -117,15 +172,35 @@ function createViewer(el, opts = {}) {
117
172
  const [a, b] = [...pointers.values()];
118
173
  return { mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, dist: Math.hypot(a.x - b.x, a.y - b.y) };
119
174
  };
175
+ let press = null;
176
+ const hitsAt = (clientX, clientY) => {
177
+ const p = toBoard(clientX, clientY);
178
+ return elementsAt(elements, p.x, p.y, { tolerance: 8 / scale });
179
+ };
180
+ let hovered = null;
181
+ const hover = (hit, hits) => {
182
+ if (hit === hovered) return;
183
+ hovered = hit;
184
+ opts.onElementHover?.(hit, hits);
185
+ };
120
186
  stage.addEventListener("pointerdown", (e) => {
187
+ if (inLayer(e)) return;
121
188
  stage.setPointerCapture(e.pointerId);
189
+ press = pointers.size ? null : { id: e.pointerId, x: e.clientX, y: e.clientY, moved: false };
122
190
  pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
123
191
  stage.classList.add("panning");
124
192
  pinch = pointers.size === 2 ? twoFingers() : null;
125
193
  }, { signal });
126
194
  stage.addEventListener("pointermove", (e) => {
127
195
  const prev = pointers.get(e.pointerId);
128
- if (!prev) return;
196
+ if (!prev) {
197
+ if (opts.onElementHover && elements.length && e.pointerType !== "touch") {
198
+ const hits = hitsAt(e.clientX, e.clientY);
199
+ hover(hits[0] ?? null, hits);
200
+ }
201
+ return;
202
+ }
203
+ if (press && (press.id !== e.pointerId || Math.hypot(e.clientX - press.x, e.clientY - press.y) > 4)) press.moved = true;
129
204
  pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
130
205
  if (pointers.size === 2 && pinch) {
131
206
  const now = twoFingers();
@@ -148,18 +223,80 @@ function createViewer(el, opts = {}) {
148
223
  }
149
224
  }, { signal });
150
225
  const release = (e) => {
226
+ if (e.type === "pointerup" && press && press.id === e.pointerId && !press.moved && pointers.size === 1 && opts.onElementClick && elements.length) {
227
+ const hits = hitsAt(e.clientX, e.clientY);
228
+ opts.onElementClick(hits[0] ?? null, hits, e);
229
+ }
230
+ if (press?.id === e.pointerId) press = null;
151
231
  pointers.delete(e.pointerId);
152
232
  pinch = pointers.size === 2 ? twoFingers() : null;
153
233
  if (!pointers.size) stage.classList.remove("panning");
154
234
  };
155
235
  stage.addEventListener("pointerup", release, { signal });
156
236
  stage.addEventListener("pointercancel", release, { signal });
157
- stage.addEventListener("dblclick", fit, { signal });
237
+ stage.addEventListener("pointerleave", () => {
238
+ if (!pointers.size) hover(null, []);
239
+ }, { signal });
240
+ stage.addEventListener("dblclick", (e) => {
241
+ if (!inLayer(e)) fit();
242
+ }, { signal });
158
243
  const resize = new ResizeObserver(() => refresh());
159
244
  resize.observe(stage);
160
245
  el.zoomIn?.addEventListener("click", () => zoomAt(1.25), { signal });
161
246
  el.zoomOut?.addEventListener("click", () => zoomAt(0.8), { signal });
162
247
  el.zoomFit?.addEventListener("click", fit, { signal });
248
+ function toBoard(clientX, clientY) {
249
+ const r = stage.getBoundingClientRect();
250
+ return { x: (clientX - r.left - stage.clientLeft - x) / scale, y: (clientY - r.top - stage.clientTop - y) / scale };
251
+ }
252
+ function boxOf(e) {
253
+ if (!("type" in e)) return e;
254
+ if (!isFlow(e)) return e.box;
255
+ const xs = e.path.map((p) => p[0]), ys = e.path.map((p) => p[1]);
256
+ if (e.labelBox) xs.push(e.labelBox.x, e.labelBox.x + e.labelBox.width), ys.push(e.labelBox.y, e.labelBox.y + e.labelBox.height);
257
+ const [x0, y0] = [Math.min(...xs), Math.min(...ys)];
258
+ return { x: x0, y: y0, width: Math.max(...xs) - x0, height: Math.max(...ys) - y0 };
259
+ }
260
+ const SVG = "http://www.w3.org/2000/svg";
261
+ function mark(e, kind) {
262
+ const color = "var(--tsquare-highlight, #3B99FB)";
263
+ const width = "calc(2px / var(--tsquare-viewer-scale, 1))";
264
+ if (isFlow(e)) {
265
+ const svg = document.createElementNS(SVG, "svg");
266
+ svg.setAttribute("width", String(size.width));
267
+ svg.setAttribute("height", String(size.height));
268
+ Object.assign(svg.style, { position: "absolute", left: "0px", top: "0px", overflow: "visible" });
269
+ const line = document.createElementNS(SVG, "polyline");
270
+ line.setAttribute("points", e.path.map((p) => p.join(",")).join(" "));
271
+ line.setAttribute("fill", "none");
272
+ Object.assign(line.style, { stroke: color, strokeWidth: `calc(6px / var(--tsquare-viewer-scale, 1))`, strokeLinejoin: "round", strokeLinecap: "round", opacity: kind === "hover" ? "0.35" : "0.5" });
273
+ svg.append(line);
274
+ if (e.labelBox) {
275
+ const r = document.createElementNS(SVG, "rect");
276
+ for (const [k, v] of Object.entries({ x: e.labelBox.x, y: e.labelBox.y, width: e.labelBox.width, height: e.labelBox.height, rx: 12 })) r.setAttribute(k, String(v));
277
+ Object.assign(r.style, { fill: "none", stroke: color, strokeWidth: width, opacity: kind === "hover" ? "0.6" : "1" });
278
+ svg.append(r);
279
+ }
280
+ svg.setAttribute("class", `tsquare-highlight tsquare-highlight-${kind}`);
281
+ return svg;
282
+ }
283
+ const b = e.box;
284
+ const d = document.createElement("div");
285
+ d.className = `tsquare-highlight tsquare-highlight-${kind}`;
286
+ Object.assign(d.style, {
287
+ position: "absolute",
288
+ left: `${b.x}px`,
289
+ top: `${b.y}px`,
290
+ width: `${b.width}px`,
291
+ height: `${b.height}px`,
292
+ boxSizing: "border-box",
293
+ outline: `${width} ${kind === "hover" ? "dashed" : "solid"} ${color}`,
294
+ outlineOffset: "1px",
295
+ borderRadius: "3px",
296
+ background: kind === "selected" ? "color-mix(in srgb, var(--tsquare-highlight, #3B99FB) 8%, transparent)" : "transparent"
297
+ });
298
+ return d;
299
+ }
163
300
  return {
164
301
  show,
165
302
  refresh,
@@ -169,6 +306,24 @@ function createViewer(el, opts = {}) {
169
306
  fit,
170
307
  zoomIn: () => zoomAt(1.25),
171
308
  zoomOut: () => zoomAt(0.8),
309
+ setElements(list) {
310
+ elements = list ?? [];
311
+ if (hovered && !elements.includes(hovered)) hover(null, []);
312
+ },
313
+ highlight(entries, o = {}) {
314
+ const kind = o.kind ?? "selected";
315
+ for (const m of highlights[kind]) m.remove();
316
+ highlights[kind] = entries.filter((e) => isFlow(e) || e.box.width > 0 && e.box.height > 0).map((e) => mark(e, kind));
317
+ if (kind === "hover") marks.prepend(...highlights.hover);
318
+ else marks.append(...highlights.selected);
319
+ },
320
+ zoomTo(target, o = {}) {
321
+ const b = boxOf(target), m = 24;
322
+ mode = { x: b.x - m, y: b.y - m, width: b.width + 2 * m, height: b.height + 2 * m };
323
+ refresh(o.animate ?? true);
324
+ },
325
+ toBoard,
326
+ layer,
172
327
  destroy() {
173
328
  stop.abort();
174
329
  resize.disconnect();