tsquare 0.8.0 → 0.9.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.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The zoomable board: zoom, pan, pinch, and zoom to a screen. Browser code,
3
+ * published as `tsquare/viewer`: the playground page uses it, and so does the
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.
6
+ *
7
+ * The viewer sets the styles its math depends on (see structuralStyles), so
8
+ * the host only needs to size the stage and optionally style the
9
+ * "panning", "animate" and "stale" classes it toggles on the stage.
10
+ */
11
+ import type { BoardItem } from "../render.js";
12
+ export type { BoardItem };
13
+ export interface ViewerElements {
14
+ /**
15
+ * The visible area; give it a size. It gets these classes, for optional styling:
16
+ * "panning" while dragging (cursor: grabbing), "animate" while zooming to a
17
+ * screen or fitting (transition the canvas's transform), and "stale" from
18
+ * setStale (dim the image).
19
+ */
20
+ stage: HTMLElement;
21
+ /** Inside the stage; holds the board image and is transformed. */
22
+ canvas: HTMLElement;
23
+ /** Where the screen chips go ("All", then one per screen). */
24
+ screens: HTMLElement;
25
+ /** Removed when the first board shows. */
26
+ empty?: HTMLElement | null;
27
+ zoomIn?: HTMLElement | null;
28
+ zoomOut?: HTMLElement | null;
29
+ /** Shows the zoom level; clicking it fits the board. */
30
+ zoomFit?: HTMLElement | null;
31
+ }
32
+ export interface Viewer {
33
+ show(svg: string, width: number, height: number, items: BoardItem[]): void;
34
+ refresh(animate?: boolean): void;
35
+ /** Dims the board, for when the text has problems and this is the last valid render. */
36
+ setStale(stale: boolean): void;
37
+ fit(): void;
38
+ zoomIn(): void;
39
+ zoomOut(): void;
40
+ }
41
+ export declare function createViewer(el: ViewerElements, opts?: {
42
+ onScreenZoom?: () => void;
43
+ onZoom?: (scale: number) => void;
44
+ }): Viewer;
@@ -0,0 +1,172 @@
1
+ // src/playground/viewer.ts
2
+ function structuralStyles(stage, canvas) {
3
+ if (getComputedStyle(stage).position === "static") stage.style.position = "relative";
4
+ stage.style.overflow = "hidden";
5
+ stage.style.touchAction = "none";
6
+ Object.assign(canvas.style, { position: "absolute", left: "0px", top: "0px", transformOrigin: "0 0" });
7
+ }
8
+ function createViewer(el, opts = {}) {
9
+ const { stage, canvas } = el;
10
+ structuralStyles(stage, canvas);
11
+ let img = null;
12
+ let size = { width: 0, height: 0 };
13
+ let items = [];
14
+ let scale = 1, x = 0, y = 0;
15
+ let mode = "fit";
16
+ const PAD = 16;
17
+ const label = () => {
18
+ if (el.zoomFit) el.zoomFit.textContent = `${Math.round(scale * 100)}%`;
19
+ opts.onZoom?.(scale);
20
+ };
21
+ const apply = (animate = false) => {
22
+ stage.classList.toggle("animate", animate);
23
+ canvas.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
24
+ label();
25
+ };
26
+ const fitRect = (r, animate) => {
27
+ const w = stage.clientWidth - PAD * 2, h = stage.clientHeight - PAD * 2;
28
+ if (w <= 0 || h <= 0 || !r.width) return;
29
+ scale = Math.min(w / r.width, h / r.height, 4);
30
+ x = PAD + (w - r.width * scale) / 2 - r.x * scale;
31
+ y = PAD + (h - r.height * scale) / 2 - r.y * scale;
32
+ apply(animate);
33
+ };
34
+ const markScreens = () => el.screens.querySelectorAll("button").forEach((b, i) => b.setAttribute("aria-pressed", String(i === 0 ? mode === "fit" : mode === i - 1)));
35
+ function refresh(animate = false) {
36
+ if (!size.width) return;
37
+ if (mode === "fit") fitRect({ x: 0, y: 0, ...size }, animate);
38
+ else if (typeof mode === "number" && items[mode]) fitRect(items[mode], animate);
39
+ else apply();
40
+ markScreens();
41
+ }
42
+ function zoomAt(factor, cx = stage.clientWidth / 2, cy = stage.clientHeight / 2) {
43
+ const next = Math.min(8, Math.max(0.05, scale * factor));
44
+ x = cx - (cx - x) * (next / scale);
45
+ y = cy - (cy - y) * (next / scale);
46
+ scale = next;
47
+ mode = "manual";
48
+ apply();
49
+ markScreens();
50
+ }
51
+ const fit = () => {
52
+ mode = "fit";
53
+ refresh(true);
54
+ };
55
+ function show(svg, width, height, list) {
56
+ const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
57
+ if (!img) {
58
+ img = document.createElement("img");
59
+ img.alt = "Rendered wireframe";
60
+ img.draggable = false;
61
+ canvas.replaceChildren(img);
62
+ el.empty?.remove();
63
+ }
64
+ const old = img.src;
65
+ img.src = url;
66
+ if (old.startsWith("blob:")) setTimeout(() => URL.revokeObjectURL(old), 1e3);
67
+ const resized = width !== size.width || height !== size.height;
68
+ size = { width, height };
69
+ img.width = width;
70
+ img.height = height;
71
+ const screens = list.filter((i) => i.type === "Screen");
72
+ if (screens.map((s) => s.name).join("\n") !== items.filter((i) => i.type === "Screen").map((s) => s.name).join("\n")) {
73
+ if (typeof mode === "number") mode = "fit";
74
+ buildScreenChips(screens);
75
+ }
76
+ items = screens;
77
+ if (resized && mode === "manual") mode = "fit";
78
+ refresh();
79
+ }
80
+ function buildScreenChips(screens) {
81
+ const bar = el.screens;
82
+ bar.replaceChildren();
83
+ if (screens.length < 2) return;
84
+ const chip = (text, onClick) => {
85
+ const b = document.createElement("button");
86
+ b.type = "button";
87
+ b.textContent = text;
88
+ b.addEventListener("click", onClick);
89
+ bar.appendChild(b);
90
+ };
91
+ chip("All", fit);
92
+ screens.forEach((s, i) => chip(s.name || `Screen ${i + 1}`, () => {
93
+ mode = i;
94
+ refresh(true);
95
+ opts.onScreenZoom?.();
96
+ }));
97
+ }
98
+ stage.addEventListener("wheel", (e) => {
99
+ if (!size.width) return;
100
+ e.preventDefault();
101
+ const r = stage.getBoundingClientRect();
102
+ if (e.ctrlKey || e.metaKey) zoomAt(Math.exp(-e.deltaY * 0.01), e.clientX - r.left, e.clientY - r.top);
103
+ else {
104
+ x -= e.deltaX;
105
+ y -= e.deltaY;
106
+ mode = "manual";
107
+ apply();
108
+ markScreens();
109
+ }
110
+ }, { passive: false });
111
+ const pointers = /* @__PURE__ */ new Map();
112
+ let pinch = null;
113
+ const twoFingers = () => {
114
+ const [a, b] = [...pointers.values()];
115
+ 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) };
116
+ };
117
+ stage.addEventListener("pointerdown", (e) => {
118
+ stage.setPointerCapture(e.pointerId);
119
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
120
+ stage.classList.add("panning");
121
+ pinch = pointers.size === 2 ? twoFingers() : null;
122
+ });
123
+ stage.addEventListener("pointermove", (e) => {
124
+ const prev = pointers.get(e.pointerId);
125
+ if (!prev) return;
126
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
127
+ if (pointers.size === 2 && pinch) {
128
+ const now = twoFingers();
129
+ const r = stage.getBoundingClientRect();
130
+ x += now.mid.x - pinch.mid.x;
131
+ y += now.mid.y - pinch.mid.y;
132
+ if (pinch.dist && now.dist) zoomAt(now.dist / pinch.dist, now.mid.x - r.left, now.mid.y - r.top);
133
+ else {
134
+ mode = "manual";
135
+ apply();
136
+ markScreens();
137
+ }
138
+ pinch = now;
139
+ } else {
140
+ x += e.clientX - prev.x;
141
+ y += e.clientY - prev.y;
142
+ mode = "manual";
143
+ apply();
144
+ markScreens();
145
+ }
146
+ });
147
+ const release = (e) => {
148
+ pointers.delete(e.pointerId);
149
+ pinch = pointers.size === 2 ? twoFingers() : null;
150
+ if (!pointers.size) stage.classList.remove("panning");
151
+ };
152
+ stage.addEventListener("pointerup", release);
153
+ stage.addEventListener("pointercancel", release);
154
+ stage.addEventListener("dblclick", fit);
155
+ new ResizeObserver(() => refresh()).observe(stage);
156
+ el.zoomIn?.addEventListener("click", () => zoomAt(1.25));
157
+ el.zoomOut?.addEventListener("click", () => zoomAt(0.8));
158
+ el.zoomFit?.addEventListener("click", fit);
159
+ return {
160
+ show,
161
+ refresh,
162
+ setStale: (stale) => {
163
+ stage.classList.toggle("stale", stale);
164
+ },
165
+ fit,
166
+ zoomIn: () => zoomAt(1.25),
167
+ zoomOut: () => zoomAt(0.8)
168
+ };
169
+ }
170
+ export {
171
+ createViewer
172
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsquare",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Wireframes in plain text: easy for LLMs to write, fast to render as SVG or PNG.",
5
5
  "keywords": [
6
6
  "wireframe",
@@ -26,11 +26,18 @@
26
26
  "exports": {
27
27
  ".": {
28
28
  "types": "./dist/index.d.ts",
29
- "import": "./dist/index.js"
29
+ "import": "./dist/index.js",
30
+ "default": "./dist/index.js"
30
31
  },
31
32
  "./playground": {
32
33
  "types": "./dist/playground/index.d.ts",
33
- "import": "./dist/playground/index.js"
34
+ "import": "./dist/playground/index.js",
35
+ "default": "./dist/playground/index.js"
36
+ },
37
+ "./viewer": {
38
+ "types": "./dist/playground/viewer.d.ts",
39
+ "import": "./dist/playground/viewer.js",
40
+ "default": "./dist/playground/viewer.js"
34
41
  },
35
42
  "./package.json": "./package.json"
36
43
  },
@@ -87,9 +94,12 @@
87
94
  "@codemirror/state": "^6.7.6",
88
95
  "@codemirror/view": "^6.43.13",
89
96
  "@lezer/highlight": "^1.2.5",
97
+ "@types/jsdom": "^30.0.0",
90
98
  "esbuild": "^0.28.2",
91
99
  "gpt-tokenizer": "^4.0.0",
100
+ "jsdom": "^29.1.1",
92
101
  "tsx": "^4.23.15",
93
102
  "typescript": "^7.0.2"
94
- }
103
+ },
104
+ "gitHead": "c248211e0c39260cb4cbd59008fb1d64b54b3f08"
95
105
  }
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  name: tsquare
3
3
  description: Create low-fidelity UI wireframes and mockups (app screens, several devices side by side, boards for specs and docs) as tsquare text, then render them to PNG or SVG with the tsquare CLI. Use when asked to wireframe, mock up or sketch a UI screen, page or flow, or to add a UI mockup to a document.
4
+ license: MIT
5
+ compatibility: Needs Node.js 20 or newer to run the tsquare CLI (installed, or through npx).
4
6
  ---
5
7
 
6
8
  # tsquare wireframes