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.
- package/CHANGELOG.md +9 -0
- package/README.md +2 -2
- package/dist/browser/index.js +43 -41
- package/dist/chunks/chunk-NZWKURF4.js +236 -0
- package/dist/editor.js +10 -1
- package/dist/hit.d.ts +88 -0
- package/dist/index.js +6 -167
- package/dist/measure.d.ts +2 -74
- package/dist/playground/editor-options.d.ts +2 -0
- package/dist/playground/index.js +10 -5
- package/dist/playground/playground.js +19 -19
- package/dist/playground/viewer.d.ts +51 -6
- package/dist/playground/viewer.js +160 -5
- package/dist/tsquare.min.js +45 -45
- package/package.json +2 -2
- package/dist/chunks/chunk-W5RDSAXT.js +0 -65
|
@@ -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 +
|
|
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)
|
|
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("
|
|
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();
|