@bilalba/fig-mcp 1.0.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/README.md +112 -0
- package/dist/compare-get-vector.d.ts +2 -0
- package/dist/compare-get-vector.d.ts.map +1 -0
- package/dist/compare-get-vector.js +124 -0
- package/dist/compare-get-vector.js.map +1 -0
- package/dist/compare-mcp-vs-direct.d.ts +2 -0
- package/dist/compare-mcp-vs-direct.d.ts.map +1 -0
- package/dist/compare-mcp-vs-direct.js +173 -0
- package/dist/compare-mcp-vs-direct.js.map +1 -0
- package/dist/compare-renderers.d.ts +2 -0
- package/dist/compare-renderers.d.ts.map +1 -0
- package/dist/compare-renderers.js +110 -0
- package/dist/compare-renderers.js.map +1 -0
- package/dist/debug/debug-stroke-geom.d.ts +2 -0
- package/dist/debug/debug-stroke-geom.d.ts.map +1 -0
- package/dist/debug/debug-stroke-geom.js +67 -0
- package/dist/debug/debug-stroke-geom.js.map +1 -0
- package/dist/debug/debug-transforms.d.ts +2 -0
- package/dist/debug/debug-transforms.d.ts.map +1 -0
- package/dist/debug/debug-transforms.js +97 -0
- package/dist/debug/debug-transforms.js.map +1 -0
- package/dist/debug/debug-vertex.d.ts +2 -0
- package/dist/debug/debug-vertex.d.ts.map +1 -0
- package/dist/debug/debug-vertex.js +72 -0
- package/dist/debug/debug-vertex.js.map +1 -0
- package/dist/debug-group.d.ts +5 -0
- package/dist/debug-group.d.ts.map +1 -0
- package/dist/debug-group.js +44 -0
- package/dist/debug-group.js.map +1 -0
- package/dist/debug-stroke-geom.d.ts +2 -0
- package/dist/debug-stroke-geom.d.ts.map +1 -0
- package/dist/debug-stroke-geom.js +67 -0
- package/dist/debug-stroke-geom.js.map +1 -0
- package/dist/debug-transforms.d.ts +2 -0
- package/dist/debug-transforms.d.ts.map +1 -0
- package/dist/debug-transforms.js +97 -0
- package/dist/debug-transforms.js.map +1 -0
- package/dist/debug-vertex.d.ts +2 -0
- package/dist/debug-vertex.d.ts.map +1 -0
- package/dist/debug-vertex.js +72 -0
- package/dist/debug-vertex.js.map +1 -0
- package/dist/decode-vector-network.d.ts +5 -0
- package/dist/decode-vector-network.d.ts.map +1 -0
- package/dist/decode-vector-network.js +160 -0
- package/dist/decode-vector-network.js.map +1 -0
- package/dist/experimental/paint-utils.d.ts +35 -0
- package/dist/experimental/paint-utils.d.ts.map +1 -0
- package/dist/experimental/paint-utils.js +105 -0
- package/dist/experimental/paint-utils.js.map +1 -0
- package/dist/experimental/render-screen-v2.d.ts +32 -0
- package/dist/experimental/render-screen-v2.d.ts.map +1 -0
- package/dist/experimental/render-screen-v2.js +366 -0
- package/dist/experimental/render-screen-v2.js.map +1 -0
- package/dist/experimental/render-screen.d.ts +26 -0
- package/dist/experimental/render-screen.d.ts.map +1 -0
- package/dist/experimental/render-screen.js +547 -0
- package/dist/experimental/render-screen.js.map +1 -0
- package/dist/experimental/render-types.d.ts +43 -0
- package/dist/experimental/render-types.d.ts.map +1 -0
- package/dist/experimental/render-types.js +22 -0
- package/dist/experimental/render-types.js.map +1 -0
- package/dist/experimental/render-utils.d.ts +38 -0
- package/dist/experimental/render-utils.d.ts.map +1 -0
- package/dist/experimental/render-utils.js +126 -0
- package/dist/experimental/render-utils.js.map +1 -0
- package/dist/experimental/screenshot.d.ts +11 -0
- package/dist/experimental/screenshot.d.ts.map +1 -0
- package/dist/experimental/screenshot.js +26 -0
- package/dist/experimental/screenshot.js.map +1 -0
- package/dist/experimental/vector-renderer.d.ts +31 -0
- package/dist/experimental/vector-renderer.d.ts.map +1 -0
- package/dist/experimental/vector-renderer.js +427 -0
- package/dist/experimental/vector-renderer.js.map +1 -0
- package/dist/explore-images.d.ts +9 -0
- package/dist/explore-images.d.ts.map +1 -0
- package/dist/explore-images.js +307 -0
- package/dist/explore-images.js.map +1 -0
- package/dist/http-server.d.ts +8 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +95 -0
- package/dist/http-server.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect-fig.d.ts +16 -0
- package/dist/inspect-fig.d.ts.map +1 -0
- package/dist/inspect-fig.js +134 -0
- package/dist/inspect-fig.js.map +1 -0
- package/dist/inspect-frame.d.ts +2 -0
- package/dist/inspect-frame.d.ts.map +1 -0
- package/dist/inspect-frame.js +90 -0
- package/dist/inspect-frame.js.map +1 -0
- package/dist/inspect-nodes.d.ts +5 -0
- package/dist/inspect-nodes.d.ts.map +1 -0
- package/dist/inspect-nodes.js +193 -0
- package/dist/inspect-nodes.js.map +1 -0
- package/dist/mcp/server.d.ts +38 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +1524 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/parser/fig-reader.d.ts +29 -0
- package/dist/parser/fig-reader.d.ts.map +1 -0
- package/dist/parser/fig-reader.js +182 -0
- package/dist/parser/fig-reader.js.map +1 -0
- package/dist/parser/index.d.ts +48 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +106 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/kiwi-parser.d.ts +66 -0
- package/dist/parser/kiwi-parser.d.ts.map +1 -0
- package/dist/parser/kiwi-parser.js +491 -0
- package/dist/parser/kiwi-parser.js.map +1 -0
- package/dist/parser/layout-inference.d.ts +63 -0
- package/dist/parser/layout-inference.d.ts.map +1 -0
- package/dist/parser/layout-inference.js +446 -0
- package/dist/parser/layout-inference.js.map +1 -0
- package/dist/parser/types.d.ts +286 -0
- package/dist/parser/types.d.ts.map +1 -0
- package/dist/parser/types.js +6 -0
- package/dist/parser/types.js.map +1 -0
- package/dist/render-single.d.ts +2 -0
- package/dist/render-single.d.ts.map +1 -0
- package/dist/render-single.js +53 -0
- package/dist/render-single.js.map +1 -0
- package/dist/renderer/index.d.ts +16 -0
- package/dist/renderer/index.d.ts.map +1 -0
- package/dist/renderer/index.js +18 -0
- package/dist/renderer/index.js.map +1 -0
- package/dist/renderer/paint-utils.d.ts +35 -0
- package/dist/renderer/paint-utils.d.ts.map +1 -0
- package/dist/renderer/paint-utils.js +105 -0
- package/dist/renderer/paint-utils.js.map +1 -0
- package/dist/renderer/render-screen.d.ts +26 -0
- package/dist/renderer/render-screen.d.ts.map +1 -0
- package/dist/renderer/render-screen.js +547 -0
- package/dist/renderer/render-screen.js.map +1 -0
- package/dist/renderer/render-types.d.ts +43 -0
- package/dist/renderer/render-types.d.ts.map +1 -0
- package/dist/renderer/render-types.js +22 -0
- package/dist/renderer/render-types.js.map +1 -0
- package/dist/renderer/render-utils.d.ts +38 -0
- package/dist/renderer/render-utils.d.ts.map +1 -0
- package/dist/renderer/render-utils.js +126 -0
- package/dist/renderer/render-utils.js.map +1 -0
- package/dist/renderer/screenshot.d.ts +11 -0
- package/dist/renderer/screenshot.d.ts.map +1 -0
- package/dist/renderer/screenshot.js +26 -0
- package/dist/renderer/screenshot.js.map +1 -0
- package/dist/renderer/vector-renderer.d.ts +31 -0
- package/dist/renderer/vector-renderer.d.ts.map +1 -0
- package/dist/renderer/vector-renderer.js +427 -0
- package/dist/renderer/vector-renderer.js.map +1 -0
- package/dist/shared-config.d.ts +9 -0
- package/dist/shared-config.d.ts.map +1 -0
- package/dist/shared-config.js +9 -0
- package/dist/shared-config.js.map +1 -0
- package/dist/test-parser.d.ts +3 -0
- package/dist/test-parser.d.ts.map +1 -0
- package/dist/test-parser.js +74 -0
- package/dist/test-parser.js.map +1 -0
- package/dist/test-render-v2.d.ts +5 -0
- package/dist/test-render-v2.d.ts.map +1 -0
- package/dist/test-render-v2.js +76 -0
- package/dist/test-render-v2.js.map +1 -0
- package/dist/test-render.d.ts +5 -0
- package/dist/test-render.d.ts.map +1 -0
- package/dist/test-render.js +76 -0
- package/dist/test-render.js.map +1 -0
- package/dist/vector-export.d.ts +52 -0
- package/dist/vector-export.d.ts.map +1 -0
- package/dist/vector-export.js +628 -0
- package/dist/vector-export.js.map +1 -0
- package/dist/web-viewer/build-client.d.ts +6 -0
- package/dist/web-viewer/build-client.d.ts.map +1 -0
- package/dist/web-viewer/build-client.js +36 -0
- package/dist/web-viewer/build-client.js.map +1 -0
- package/dist/web-viewer/client/viewer.d.ts +7 -0
- package/dist/web-viewer/client/viewer.d.ts.map +1 -0
- package/dist/web-viewer/client/viewer.js +873 -0
- package/dist/web-viewer/client/viewer.js.map +1 -0
- package/dist/web-viewer/server.d.ts +16 -0
- package/dist/web-viewer/server.d.ts.map +1 -0
- package/dist/web-viewer/server.js +420 -0
- package/dist/web-viewer/server.js.map +1 -0
- package/package.json +66 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fig Viewer - Client Side
|
|
3
|
+
*
|
|
4
|
+
* Handles tree navigation, node selection, and SVG preview rendering.
|
|
5
|
+
*/
|
|
6
|
+
// State
|
|
7
|
+
let currentTree = null;
|
|
8
|
+
let pages = [];
|
|
9
|
+
let selectedPageId = null;
|
|
10
|
+
let selectedNodeId = null;
|
|
11
|
+
let zoomLevel = 1;
|
|
12
|
+
let searchQuery = "";
|
|
13
|
+
// Hover/selection state for canvas interaction
|
|
14
|
+
let currentRenderNodeId = null; // The node being rendered in the canvas
|
|
15
|
+
let flatNodes = []; // Cached flat nodes for the current rendered node
|
|
16
|
+
let hoveredNodeId = null; // Node under cursor
|
|
17
|
+
let renderBounds = null;
|
|
18
|
+
// DOM elements
|
|
19
|
+
const $ = (id) => document.getElementById(id);
|
|
20
|
+
const elements = {
|
|
21
|
+
fileName: $("file-name"),
|
|
22
|
+
openBtn: $("open-btn"),
|
|
23
|
+
search: $("search"),
|
|
24
|
+
pagesList: $("pages-list"),
|
|
25
|
+
tree: $("tree"),
|
|
26
|
+
canvas: $("canvas"),
|
|
27
|
+
canvasPlaceholder: $("canvas-placeholder"),
|
|
28
|
+
zoomIn: $("zoom-in"),
|
|
29
|
+
zoomOut: $("zoom-out"),
|
|
30
|
+
zoomLevel: $("zoom-level"),
|
|
31
|
+
zoomFit: $("zoom-fit"),
|
|
32
|
+
noSelection: $("no-selection"),
|
|
33
|
+
nodeDetails: $("node-details"),
|
|
34
|
+
nodeId: $("node-id"),
|
|
35
|
+
copyId: $("copy-id"),
|
|
36
|
+
nodeType: $("node-type"),
|
|
37
|
+
nodeName: $("node-name"),
|
|
38
|
+
nodeX: $("node-x"),
|
|
39
|
+
nodeY: $("node-y"),
|
|
40
|
+
nodeWidth: $("node-width"),
|
|
41
|
+
nodeHeight: $("node-height"),
|
|
42
|
+
textSection: $("text-section"),
|
|
43
|
+
nodeText: $("node-text"),
|
|
44
|
+
nodeJson: $("node-json"),
|
|
45
|
+
fileDialog: $("file-dialog"),
|
|
46
|
+
filePathInput: $("file-path-input"),
|
|
47
|
+
cancelOpen: $("cancel-open"),
|
|
48
|
+
confirmOpen: $("confirm-open"),
|
|
49
|
+
};
|
|
50
|
+
// API helpers
|
|
51
|
+
async function api(path, options) {
|
|
52
|
+
const res = await fetch(path, options);
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
55
|
+
throw new Error(err.error || "Request failed");
|
|
56
|
+
}
|
|
57
|
+
return res.json();
|
|
58
|
+
}
|
|
59
|
+
async function fetchTree() {
|
|
60
|
+
return api("/api/tree");
|
|
61
|
+
}
|
|
62
|
+
async function fetchNodeDetails(nodeId) {
|
|
63
|
+
return api(`/api/node/${encodeURIComponent(nodeId)}`);
|
|
64
|
+
}
|
|
65
|
+
async function fetchNodeRaw(nodeId) {
|
|
66
|
+
return api(`/api/node-raw/${encodeURIComponent(nodeId)}`);
|
|
67
|
+
}
|
|
68
|
+
async function fetchRenderSvg(nodeId) {
|
|
69
|
+
const res = await fetch(`/api/render/${encodeURIComponent(nodeId)}`);
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
throw new Error("Failed to render");
|
|
72
|
+
}
|
|
73
|
+
return res.text();
|
|
74
|
+
}
|
|
75
|
+
async function fetchFlatNodes(nodeId) {
|
|
76
|
+
const res = await api(`/api/flat-nodes/${encodeURIComponent(nodeId)}`);
|
|
77
|
+
return res.nodes;
|
|
78
|
+
}
|
|
79
|
+
async function openFile(filePath) {
|
|
80
|
+
await api("/api/open", {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
body: JSON.stringify({ filePath }),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// Tree rendering
|
|
87
|
+
function getTypeIcon(type) {
|
|
88
|
+
const icons = {
|
|
89
|
+
DOCUMENT: "D",
|
|
90
|
+
CANVAS: "P",
|
|
91
|
+
FRAME: "F",
|
|
92
|
+
GROUP: "G",
|
|
93
|
+
TEXT: "T",
|
|
94
|
+
RECTANGLE: "R",
|
|
95
|
+
ELLIPSE: "O",
|
|
96
|
+
VECTOR: "V",
|
|
97
|
+
LINE: "L",
|
|
98
|
+
STAR: "*",
|
|
99
|
+
REGULAR_POLYGON: "P",
|
|
100
|
+
COMPONENT: "C",
|
|
101
|
+
COMPONENT_SET: "S",
|
|
102
|
+
INSTANCE: "I",
|
|
103
|
+
BOOLEAN_OPERATION: "B",
|
|
104
|
+
SLICE: "S",
|
|
105
|
+
STICKY: "N",
|
|
106
|
+
SHAPE_WITH_TEXT: "ST",
|
|
107
|
+
CONNECTOR: "CN",
|
|
108
|
+
SECTION: "SE",
|
|
109
|
+
};
|
|
110
|
+
return icons[type] || "?";
|
|
111
|
+
}
|
|
112
|
+
// Pages rendering
|
|
113
|
+
function renderPages() {
|
|
114
|
+
elements.pagesList.innerHTML = "";
|
|
115
|
+
for (const page of pages) {
|
|
116
|
+
const item = document.createElement("div");
|
|
117
|
+
item.className = "page-item";
|
|
118
|
+
if (page.id === selectedPageId) {
|
|
119
|
+
item.classList.add("selected");
|
|
120
|
+
}
|
|
121
|
+
item.dataset.pageId = page.id;
|
|
122
|
+
const icon = document.createElement("span");
|
|
123
|
+
icon.className = "page-icon";
|
|
124
|
+
icon.textContent = "P";
|
|
125
|
+
item.appendChild(icon);
|
|
126
|
+
const name = document.createElement("span");
|
|
127
|
+
name.className = "page-name";
|
|
128
|
+
name.textContent = page.name || "(unnamed)";
|
|
129
|
+
item.appendChild(name);
|
|
130
|
+
item.addEventListener("click", () => selectPage(page.id));
|
|
131
|
+
elements.pagesList.appendChild(item);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function selectPage(pageId) {
|
|
135
|
+
selectedPageId = pageId;
|
|
136
|
+
selectedNodeId = null;
|
|
137
|
+
// Update pages list selection
|
|
138
|
+
elements.pagesList.querySelectorAll(".page-item").forEach((el) => {
|
|
139
|
+
el.classList.toggle("selected", el.getAttribute("data-page-id") === pageId);
|
|
140
|
+
});
|
|
141
|
+
// Re-render tree for selected page
|
|
142
|
+
renderTree();
|
|
143
|
+
// Clear canvas preview
|
|
144
|
+
elements.canvas.innerHTML = `<div id="canvas-placeholder">Select a node to preview</div>`;
|
|
145
|
+
// Hide node details
|
|
146
|
+
elements.noSelection.classList.remove("hidden");
|
|
147
|
+
elements.nodeDetails.classList.add("hidden");
|
|
148
|
+
}
|
|
149
|
+
function matchesSearch(node, query) {
|
|
150
|
+
if (!query)
|
|
151
|
+
return true;
|
|
152
|
+
const q = query.toLowerCase();
|
|
153
|
+
return (node.name.toLowerCase().includes(q) ||
|
|
154
|
+
node.type.toLowerCase().includes(q) ||
|
|
155
|
+
node.id.toLowerCase().includes(q));
|
|
156
|
+
}
|
|
157
|
+
function hasMatchingDescendant(node, query) {
|
|
158
|
+
if (matchesSearch(node, query))
|
|
159
|
+
return true;
|
|
160
|
+
if (node.children) {
|
|
161
|
+
return node.children.some((child) => hasMatchingDescendant(child, query));
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
function renderTreeNode(node, depth = 0) {
|
|
166
|
+
const container = document.createElement("div");
|
|
167
|
+
container.className = "tree-node";
|
|
168
|
+
container.dataset.nodeId = node.id;
|
|
169
|
+
const hasChildren = node.children && node.children.length > 0;
|
|
170
|
+
const isExpanded = depth < 2; // Auto-expand first 2 levels
|
|
171
|
+
if (isExpanded && hasChildren) {
|
|
172
|
+
container.classList.add("expanded");
|
|
173
|
+
}
|
|
174
|
+
// Node row
|
|
175
|
+
const row = document.createElement("div");
|
|
176
|
+
row.className = "tree-node-row";
|
|
177
|
+
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
|
178
|
+
if (node.id === selectedNodeId) {
|
|
179
|
+
row.classList.add("selected");
|
|
180
|
+
}
|
|
181
|
+
// Toggle
|
|
182
|
+
const toggle = document.createElement("span");
|
|
183
|
+
toggle.className = `tree-toggle ${hasChildren ? (isExpanded ? "expanded" : "collapsed") : ""}`;
|
|
184
|
+
if (hasChildren) {
|
|
185
|
+
toggle.addEventListener("click", (e) => {
|
|
186
|
+
e.stopPropagation();
|
|
187
|
+
container.classList.toggle("expanded");
|
|
188
|
+
toggle.classList.toggle("collapsed");
|
|
189
|
+
toggle.classList.toggle("expanded");
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
row.appendChild(toggle);
|
|
193
|
+
// Icon
|
|
194
|
+
const icon = document.createElement("span");
|
|
195
|
+
icon.className = `tree-icon type-${node.type}`;
|
|
196
|
+
icon.textContent = getTypeIcon(node.type);
|
|
197
|
+
row.appendChild(icon);
|
|
198
|
+
// Name
|
|
199
|
+
const name = document.createElement("span");
|
|
200
|
+
name.className = "tree-name";
|
|
201
|
+
name.textContent = node.name || "(unnamed)";
|
|
202
|
+
row.appendChild(name);
|
|
203
|
+
// Type badge
|
|
204
|
+
const typeBadge = document.createElement("span");
|
|
205
|
+
typeBadge.className = "tree-type";
|
|
206
|
+
typeBadge.textContent = node.type;
|
|
207
|
+
row.appendChild(typeBadge);
|
|
208
|
+
// Click handler
|
|
209
|
+
row.addEventListener("click", () => selectNode(node.id));
|
|
210
|
+
container.appendChild(row);
|
|
211
|
+
// Children container
|
|
212
|
+
if (hasChildren) {
|
|
213
|
+
const childrenContainer = document.createElement("div");
|
|
214
|
+
childrenContainer.className = "tree-children";
|
|
215
|
+
for (const child of node.children) {
|
|
216
|
+
if (searchQuery && !hasMatchingDescendant(child, searchQuery)) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
childrenContainer.appendChild(renderTreeNode(child, depth + 1));
|
|
220
|
+
}
|
|
221
|
+
container.appendChild(childrenContainer);
|
|
222
|
+
}
|
|
223
|
+
return container;
|
|
224
|
+
}
|
|
225
|
+
function renderTree() {
|
|
226
|
+
elements.tree.innerHTML = "";
|
|
227
|
+
// Find the selected page
|
|
228
|
+
const selectedPage = pages.find((p) => p.id === selectedPageId);
|
|
229
|
+
if (!selectedPage) {
|
|
230
|
+
elements.tree.innerHTML = `<div style="padding: 20px; color: #999;">Select a page to view its contents</div>`;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
// If searching, expand all matching paths
|
|
234
|
+
if (searchQuery) {
|
|
235
|
+
const expandMatching = (el) => {
|
|
236
|
+
el.classList.add("expanded");
|
|
237
|
+
const toggle = el.querySelector(".tree-toggle");
|
|
238
|
+
if (toggle) {
|
|
239
|
+
toggle.classList.remove("collapsed");
|
|
240
|
+
toggle.classList.add("expanded");
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
const fragment = document.createDocumentFragment();
|
|
244
|
+
fragment.appendChild(renderTreeNode(selectedPage));
|
|
245
|
+
elements.tree.appendChild(fragment);
|
|
246
|
+
// Expand all nodes when searching
|
|
247
|
+
elements.tree.querySelectorAll(".tree-node").forEach((el) => {
|
|
248
|
+
expandMatching(el);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
elements.tree.appendChild(renderTreeNode(selectedPage));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Node selection
|
|
256
|
+
async function selectNode(nodeId, options) {
|
|
257
|
+
selectedNodeId = nodeId;
|
|
258
|
+
// Update tree selection
|
|
259
|
+
elements.tree.querySelectorAll(".tree-node-row.selected").forEach((el) => {
|
|
260
|
+
el.classList.remove("selected");
|
|
261
|
+
});
|
|
262
|
+
const nodeEl = elements.tree.querySelector(`[data-node-id="${nodeId}"] > .tree-node-row`);
|
|
263
|
+
if (nodeEl) {
|
|
264
|
+
nodeEl.classList.add("selected");
|
|
265
|
+
}
|
|
266
|
+
// Show details panel
|
|
267
|
+
elements.noSelection.classList.add("hidden");
|
|
268
|
+
elements.nodeDetails.classList.remove("hidden");
|
|
269
|
+
elements.nodeId.textContent = nodeId;
|
|
270
|
+
try {
|
|
271
|
+
// Fetch full node details
|
|
272
|
+
const [detailsResult, rawResult] = await Promise.allSettled([
|
|
273
|
+
fetchNodeDetails(nodeId),
|
|
274
|
+
fetchNodeRaw(nodeId),
|
|
275
|
+
]);
|
|
276
|
+
if (detailsResult.status !== "fulfilled") {
|
|
277
|
+
throw detailsResult.reason;
|
|
278
|
+
}
|
|
279
|
+
const { node } = detailsResult.value;
|
|
280
|
+
const rawNode = rawResult.status === "fulfilled"
|
|
281
|
+
? rawResult.value.node
|
|
282
|
+
: node;
|
|
283
|
+
elements.nodeType.textContent = node.type;
|
|
284
|
+
elements.nodeName.textContent = node.name || "(unnamed)";
|
|
285
|
+
elements.nodeX.textContent = node.x?.toFixed(1) ?? "-";
|
|
286
|
+
elements.nodeY.textContent = node.y?.toFixed(1) ?? "-";
|
|
287
|
+
elements.nodeWidth.textContent = node.width?.toFixed(1) ?? "-";
|
|
288
|
+
elements.nodeHeight.textContent = node.height?.toFixed(1) ?? "-";
|
|
289
|
+
// Text content
|
|
290
|
+
if (node.characters) {
|
|
291
|
+
elements.textSection.classList.remove("hidden");
|
|
292
|
+
elements.nodeText.textContent = node.characters;
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
elements.textSection.classList.add("hidden");
|
|
296
|
+
}
|
|
297
|
+
// JSON dump
|
|
298
|
+
elements.nodeJson.textContent = JSON.stringify(rawNode, null, 2);
|
|
299
|
+
// Render preview (unless skipRender is true)
|
|
300
|
+
if (!options?.skipRender) {
|
|
301
|
+
await renderPreview(nodeId);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
// Just update the selection highlight on the canvas
|
|
305
|
+
updateHoverOverlay(nodeId);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
console.error("Failed to load node details:", err);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async function renderPreview(nodeId) {
|
|
313
|
+
// Track if this is a new render target (different node being rendered)
|
|
314
|
+
const isNewRenderTarget = nodeId !== currentRenderNodeId;
|
|
315
|
+
try {
|
|
316
|
+
// Fetch SVG and flat nodes in parallel
|
|
317
|
+
const [svg, nodes] = await Promise.all([
|
|
318
|
+
fetchRenderSvg(nodeId),
|
|
319
|
+
fetchFlatNodes(nodeId),
|
|
320
|
+
]);
|
|
321
|
+
elements.canvas.innerHTML = svg;
|
|
322
|
+
elements.canvasPlaceholder?.remove();
|
|
323
|
+
// Store flat nodes for hit testing
|
|
324
|
+
currentRenderNodeId = nodeId;
|
|
325
|
+
flatNodes = nodes;
|
|
326
|
+
// Calculate render bounds from flat nodes
|
|
327
|
+
if (nodes.length > 0) {
|
|
328
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
329
|
+
for (const node of nodes) {
|
|
330
|
+
minX = Math.min(minX, node.absX);
|
|
331
|
+
minY = Math.min(minY, node.absY);
|
|
332
|
+
maxX = Math.max(maxX, node.absX + node.width);
|
|
333
|
+
maxY = Math.max(maxY, node.absY + node.height);
|
|
334
|
+
}
|
|
335
|
+
renderBounds = { minX, minY, width: maxX - minX, height: maxY - minY };
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
renderBounds = null;
|
|
339
|
+
}
|
|
340
|
+
// Create hover overlay
|
|
341
|
+
createHoverOverlay();
|
|
342
|
+
// Only reset zoom and fit for new render targets
|
|
343
|
+
if (isNewRenderTarget) {
|
|
344
|
+
// Reset original dimensions for new SVG
|
|
345
|
+
originalSvgWidth = 0;
|
|
346
|
+
originalSvgHeight = 0;
|
|
347
|
+
zoomLevel = 1;
|
|
348
|
+
const fitsInViewport = applyZoom();
|
|
349
|
+
const container = elements.canvas.parentElement;
|
|
350
|
+
// Auto-fit if content is larger than viewport
|
|
351
|
+
if (!fitsInViewport) {
|
|
352
|
+
zoomFit();
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
// Content fits - flexbox centers it, just reset scroll
|
|
356
|
+
container.scrollLeft = 0;
|
|
357
|
+
container.scrollTop = 0;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
console.error("Failed to render preview:", err);
|
|
363
|
+
elements.canvas.innerHTML = `<div id="canvas-placeholder">Failed to render: ${err}</div>`;
|
|
364
|
+
elements.canvas.style.width = "";
|
|
365
|
+
elements.canvas.style.height = "";
|
|
366
|
+
flatNodes = [];
|
|
367
|
+
renderBounds = null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// Zoom controls
|
|
371
|
+
// Store original SVG dimensions
|
|
372
|
+
let originalSvgWidth = 0;
|
|
373
|
+
let originalSvgHeight = 0;
|
|
374
|
+
// Apply zoom level by modifying SVG dimensions directly (no CSS transform)
|
|
375
|
+
// Returns true if content fits in viewport
|
|
376
|
+
function applyZoom() {
|
|
377
|
+
const container = elements.canvas.parentElement;
|
|
378
|
+
const containerRect = container.getBoundingClientRect();
|
|
379
|
+
elements.zoomLevel.textContent = `${Math.round(zoomLevel * 100)}%`;
|
|
380
|
+
// Remove CSS transform - we're zooming by changing SVG size
|
|
381
|
+
elements.canvas.style.transform = "";
|
|
382
|
+
const svg = elements.canvas.querySelector("svg:not(#hover-overlay)");
|
|
383
|
+
if (svg) {
|
|
384
|
+
// Store original dimensions on first call
|
|
385
|
+
if (originalSvgWidth === 0) {
|
|
386
|
+
originalSvgWidth = svg.width.baseVal.value || parseFloat(svg.getAttribute("width") || "100");
|
|
387
|
+
originalSvgHeight = svg.height.baseVal.value || parseFloat(svg.getAttribute("height") || "100");
|
|
388
|
+
}
|
|
389
|
+
// Scale SVG dimensions directly
|
|
390
|
+
const scaledWidth = originalSvgWidth * zoomLevel;
|
|
391
|
+
const scaledHeight = originalSvgHeight * zoomLevel;
|
|
392
|
+
svg.setAttribute("width", String(scaledWidth));
|
|
393
|
+
svg.setAttribute("height", String(scaledHeight));
|
|
394
|
+
// Also update hover overlay if it exists
|
|
395
|
+
const overlay = elements.canvas.querySelector("#hover-overlay");
|
|
396
|
+
if (overlay) {
|
|
397
|
+
overlay.setAttribute("width", String(scaledWidth));
|
|
398
|
+
overlay.setAttribute("height", String(scaledHeight));
|
|
399
|
+
}
|
|
400
|
+
const padding = 80;
|
|
401
|
+
const contentWidth = scaledWidth + padding;
|
|
402
|
+
const contentHeight = scaledHeight + padding;
|
|
403
|
+
// Content fits if the scaled size fits in container
|
|
404
|
+
const fitsInViewport = contentWidth <= containerRect.width &&
|
|
405
|
+
contentHeight <= containerRect.height;
|
|
406
|
+
if (fitsInViewport) {
|
|
407
|
+
// Content fits - clear dimensions, let flexbox center it
|
|
408
|
+
elements.canvas.style.width = "";
|
|
409
|
+
elements.canvas.style.height = "";
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
// Content larger - set explicit dimensions for proper scrolling
|
|
413
|
+
elements.canvas.style.width = `${contentWidth}px`;
|
|
414
|
+
elements.canvas.style.height = `${contentHeight}px`;
|
|
415
|
+
}
|
|
416
|
+
return fitsInViewport;
|
|
417
|
+
}
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
// Zoom toward a focal point, keeping that point stationary on screen
|
|
421
|
+
function zoomTo(newZoom, focalPoint) {
|
|
422
|
+
const container = elements.canvas.parentElement;
|
|
423
|
+
const containerRect = container.getBoundingClientRect();
|
|
424
|
+
const oldZoom = zoomLevel;
|
|
425
|
+
// Use provided focal point (relative to container), or center of viewport
|
|
426
|
+
const focal = focalPoint || {
|
|
427
|
+
x: containerRect.width / 2,
|
|
428
|
+
y: containerRect.height / 2,
|
|
429
|
+
};
|
|
430
|
+
// Calculate the content position under the focal point before zoom
|
|
431
|
+
// Account for scroll and padding (40px on each side)
|
|
432
|
+
const padding = 40;
|
|
433
|
+
const contentX = (container.scrollLeft + focal.x - padding) / oldZoom;
|
|
434
|
+
const contentY = (container.scrollTop + focal.y - padding) / oldZoom;
|
|
435
|
+
// Apply new zoom level
|
|
436
|
+
zoomLevel = newZoom;
|
|
437
|
+
const fitsInViewport = applyZoom();
|
|
438
|
+
if (fitsInViewport) {
|
|
439
|
+
// Content fits - flexbox handles centering, reset scroll
|
|
440
|
+
container.scrollLeft = 0;
|
|
441
|
+
container.scrollTop = 0;
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
// Calculate new scroll to keep the same content point under focal point
|
|
445
|
+
const newScrollLeft = contentX * newZoom + padding - focal.x;
|
|
446
|
+
const newScrollTop = contentY * newZoom + padding - focal.y;
|
|
447
|
+
// Clamp scroll to valid range
|
|
448
|
+
const maxScrollX = Math.max(0, container.scrollWidth - container.clientWidth);
|
|
449
|
+
const maxScrollY = Math.max(0, container.scrollHeight - container.clientHeight);
|
|
450
|
+
container.scrollLeft = Math.max(0, Math.min(maxScrollX, newScrollLeft));
|
|
451
|
+
container.scrollTop = Math.max(0, Math.min(maxScrollY, newScrollTop));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// Legacy updateZoom for cases that don't need focal point (initial render, etc.)
|
|
455
|
+
function updateZoom() {
|
|
456
|
+
applyZoom();
|
|
457
|
+
}
|
|
458
|
+
function zoomIn() {
|
|
459
|
+
const newZoom = Math.min(zoomLevel * 1.15, 10);
|
|
460
|
+
zoomTo(newZoom);
|
|
461
|
+
}
|
|
462
|
+
function zoomOut() {
|
|
463
|
+
const newZoom = Math.max(zoomLevel / 1.15, 0.1);
|
|
464
|
+
zoomTo(newZoom);
|
|
465
|
+
}
|
|
466
|
+
// Smooth zoom with smaller increments for wheel/pinch
|
|
467
|
+
function zoomByDelta(delta, focalPoint) {
|
|
468
|
+
// Use very small increments for smooth zooming
|
|
469
|
+
// Clamp delta to avoid extreme jumps from trackpad acceleration
|
|
470
|
+
const clampedDelta = Math.max(-100, Math.min(100, delta));
|
|
471
|
+
const factor = 1 + Math.abs(clampedDelta) * 0.003;
|
|
472
|
+
let newZoom;
|
|
473
|
+
if (clampedDelta < 0) {
|
|
474
|
+
newZoom = Math.min(zoomLevel * factor, 10);
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
newZoom = Math.max(zoomLevel / factor, 0.1);
|
|
478
|
+
}
|
|
479
|
+
zoomTo(newZoom, focalPoint);
|
|
480
|
+
}
|
|
481
|
+
function zoomFit() {
|
|
482
|
+
const svg = elements.canvas.querySelector("svg:not(#hover-overlay)");
|
|
483
|
+
if (!svg)
|
|
484
|
+
return;
|
|
485
|
+
const container = elements.canvas.parentElement;
|
|
486
|
+
const containerRect = container.getBoundingClientRect();
|
|
487
|
+
const svgWidth = svg.width.baseVal.value || parseFloat(svg.getAttribute("width") || "100");
|
|
488
|
+
const svgHeight = svg.height.baseVal.value || parseFloat(svg.getAttribute("height") || "100");
|
|
489
|
+
const padding = 80; // Account for padding on both sides
|
|
490
|
+
const scaleX = (containerRect.width - padding) / svgWidth;
|
|
491
|
+
const scaleY = (containerRect.height - padding) / svgHeight;
|
|
492
|
+
zoomLevel = Math.min(scaleX, scaleY, 1);
|
|
493
|
+
const fitsInViewport = applyZoom();
|
|
494
|
+
// Reset scroll - flexbox handles centering when content fits
|
|
495
|
+
container.scrollLeft = 0;
|
|
496
|
+
container.scrollTop = 0;
|
|
497
|
+
}
|
|
498
|
+
// Center the scroll position to show the content in the middle of the viewport
|
|
499
|
+
function centerScrollOnContent() {
|
|
500
|
+
const container = elements.canvas.parentElement;
|
|
501
|
+
const canvas = elements.canvas;
|
|
502
|
+
// Get the actual scrollable dimensions
|
|
503
|
+
const scrollWidth = container.scrollWidth;
|
|
504
|
+
const scrollHeight = container.scrollHeight;
|
|
505
|
+
const viewportWidth = container.clientWidth;
|
|
506
|
+
const viewportHeight = container.clientHeight;
|
|
507
|
+
// Center the scroll position
|
|
508
|
+
if (scrollWidth > viewportWidth) {
|
|
509
|
+
container.scrollLeft = (scrollWidth - viewportWidth) / 2;
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
container.scrollLeft = 0;
|
|
513
|
+
}
|
|
514
|
+
if (scrollHeight > viewportHeight) {
|
|
515
|
+
container.scrollTop = (scrollHeight - viewportHeight) / 2;
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
container.scrollTop = 0;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// ============================================================================
|
|
522
|
+
// Hover Overlay & Hit Testing
|
|
523
|
+
// ============================================================================
|
|
524
|
+
let hoverOverlay = null;
|
|
525
|
+
let svgWrapper = null;
|
|
526
|
+
function createHoverOverlay() {
|
|
527
|
+
// Remove existing wrapper if any
|
|
528
|
+
if (svgWrapper) {
|
|
529
|
+
svgWrapper.remove();
|
|
530
|
+
}
|
|
531
|
+
// Find the content SVG
|
|
532
|
+
const contentSvg = elements.canvas.querySelector("svg");
|
|
533
|
+
if (!contentSvg)
|
|
534
|
+
return;
|
|
535
|
+
// Create a wrapper div that will contain both SVGs
|
|
536
|
+
svgWrapper = document.createElement("div");
|
|
537
|
+
svgWrapper.id = "svg-wrapper";
|
|
538
|
+
svgWrapper.style.cssText = `
|
|
539
|
+
position: relative;
|
|
540
|
+
display: inline-block;
|
|
541
|
+
`;
|
|
542
|
+
// Move the content SVG into the wrapper
|
|
543
|
+
contentSvg.parentNode?.insertBefore(svgWrapper, contentSvg);
|
|
544
|
+
svgWrapper.appendChild(contentSvg);
|
|
545
|
+
// Create the overlay SVG
|
|
546
|
+
hoverOverlay = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
547
|
+
hoverOverlay.id = "hover-overlay";
|
|
548
|
+
hoverOverlay.style.cssText = `
|
|
549
|
+
position: absolute;
|
|
550
|
+
top: 0;
|
|
551
|
+
left: 0;
|
|
552
|
+
pointer-events: none;
|
|
553
|
+
overflow: visible;
|
|
554
|
+
`;
|
|
555
|
+
svgWrapper.appendChild(hoverOverlay);
|
|
556
|
+
}
|
|
557
|
+
function getContentSvg() {
|
|
558
|
+
// Try to find the content SVG (could be in wrapper or directly in canvas)
|
|
559
|
+
if (svgWrapper) {
|
|
560
|
+
return svgWrapper.querySelector("svg:not(#hover-overlay)");
|
|
561
|
+
}
|
|
562
|
+
return elements.canvas.querySelector("svg:not(#hover-overlay)");
|
|
563
|
+
}
|
|
564
|
+
function updateHoverOverlay(nodeId) {
|
|
565
|
+
if (!hoverOverlay || !renderBounds)
|
|
566
|
+
return;
|
|
567
|
+
// Clear existing content
|
|
568
|
+
hoverOverlay.innerHTML = "";
|
|
569
|
+
if (!nodeId)
|
|
570
|
+
return;
|
|
571
|
+
// Find the node in flatNodes
|
|
572
|
+
const node = flatNodes.find((n) => n.id === nodeId);
|
|
573
|
+
if (!node)
|
|
574
|
+
return;
|
|
575
|
+
// Get SVG dimensions
|
|
576
|
+
const svg = getContentSvg();
|
|
577
|
+
if (!svg)
|
|
578
|
+
return;
|
|
579
|
+
const svgWidth = svg.width.baseVal.value || parseFloat(svg.getAttribute("width") || "0");
|
|
580
|
+
const svgHeight = svg.height.baseVal.value || parseFloat(svg.getAttribute("height") || "0");
|
|
581
|
+
// Set viewBox to match SVG
|
|
582
|
+
hoverOverlay.setAttribute("viewBox", `0 0 ${svgWidth} ${svgHeight}`);
|
|
583
|
+
hoverOverlay.setAttribute("width", String(svgWidth));
|
|
584
|
+
hoverOverlay.setAttribute("height", String(svgHeight));
|
|
585
|
+
// Calculate position relative to render bounds
|
|
586
|
+
const x = node.absX - renderBounds.minX;
|
|
587
|
+
const y = node.absY - renderBounds.minY;
|
|
588
|
+
// Create hover rectangle
|
|
589
|
+
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
|
590
|
+
rect.setAttribute("x", String(x));
|
|
591
|
+
rect.setAttribute("y", String(y));
|
|
592
|
+
rect.setAttribute("width", String(node.width));
|
|
593
|
+
rect.setAttribute("height", String(node.height));
|
|
594
|
+
rect.setAttribute("fill", "rgba(0, 120, 212, 0.1)");
|
|
595
|
+
rect.setAttribute("stroke", "#0078d4");
|
|
596
|
+
rect.setAttribute("stroke-width", "2");
|
|
597
|
+
hoverOverlay.appendChild(rect);
|
|
598
|
+
}
|
|
599
|
+
function screenToDesignCoords(clientX, clientY) {
|
|
600
|
+
const svg = getContentSvg();
|
|
601
|
+
if (!svg || !renderBounds)
|
|
602
|
+
return null;
|
|
603
|
+
const svgRect = svg.getBoundingClientRect();
|
|
604
|
+
// Get the viewBox dimensions
|
|
605
|
+
const svgWidth = svg.width.baseVal.value || parseFloat(svg.getAttribute("width") || "0");
|
|
606
|
+
const svgHeight = svg.height.baseVal.value || parseFloat(svg.getAttribute("height") || "0");
|
|
607
|
+
// Calculate position within the SVG element (accounting for zoom via CSS transform)
|
|
608
|
+
const relX = (clientX - svgRect.left) / zoomLevel;
|
|
609
|
+
const relY = (clientY - svgRect.top) / zoomLevel;
|
|
610
|
+
// Convert to design coordinates
|
|
611
|
+
const designX = relX + renderBounds.minX;
|
|
612
|
+
const designY = relY + renderBounds.minY;
|
|
613
|
+
return { x: designX, y: designY };
|
|
614
|
+
}
|
|
615
|
+
function findNodesAtPoint(x, y) {
|
|
616
|
+
// Find all nodes that contain the point, sorted by depth (deepest first)
|
|
617
|
+
return flatNodes
|
|
618
|
+
.filter((node) => {
|
|
619
|
+
return (node.visible &&
|
|
620
|
+
x >= node.absX &&
|
|
621
|
+
x <= node.absX + node.width &&
|
|
622
|
+
y >= node.absY &&
|
|
623
|
+
y <= node.absY + node.height);
|
|
624
|
+
})
|
|
625
|
+
.sort((a, b) => b.depth - a.depth); // Deepest first
|
|
626
|
+
}
|
|
627
|
+
function findOutermostFrame(nodes) {
|
|
628
|
+
// Find the outermost FRAME (not CANVAS) - shallowest depth that isn't CANVAS
|
|
629
|
+
const frames = nodes.filter((n) => n.type === "FRAME" || n.type === "COMPONENT" || n.type === "INSTANCE");
|
|
630
|
+
if (frames.length === 0)
|
|
631
|
+
return null;
|
|
632
|
+
// Sort by depth ascending (shallowest first)
|
|
633
|
+
frames.sort((a, b) => a.depth - b.depth);
|
|
634
|
+
return frames[0];
|
|
635
|
+
}
|
|
636
|
+
function findInnermostSelectable(nodes) {
|
|
637
|
+
// Find the innermost (deepest) node - first in the already sorted array
|
|
638
|
+
if (nodes.length === 0)
|
|
639
|
+
return null;
|
|
640
|
+
return nodes[0];
|
|
641
|
+
}
|
|
642
|
+
function expandToNode(nodeId) {
|
|
643
|
+
// Find and expand all parent nodes in the tree to reveal the target
|
|
644
|
+
const nodeEl = elements.tree.querySelector(`[data-node-id="${nodeId}"]`);
|
|
645
|
+
if (!nodeEl)
|
|
646
|
+
return;
|
|
647
|
+
// Walk up the DOM tree and expand all parent tree-nodes
|
|
648
|
+
let current = nodeEl.parentElement;
|
|
649
|
+
while (current) {
|
|
650
|
+
if (current.classList?.contains("tree-node")) {
|
|
651
|
+
current.classList.add("expanded");
|
|
652
|
+
const toggle = current.querySelector(":scope > .tree-node-row > .tree-toggle");
|
|
653
|
+
if (toggle) {
|
|
654
|
+
toggle.classList.remove("collapsed");
|
|
655
|
+
toggle.classList.add("expanded");
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (current.id === "tree")
|
|
659
|
+
break;
|
|
660
|
+
current = current.parentElement;
|
|
661
|
+
}
|
|
662
|
+
// Scroll the node into view
|
|
663
|
+
const row = nodeEl.querySelector(":scope > .tree-node-row");
|
|
664
|
+
if (row) {
|
|
665
|
+
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
function handleCanvasMouseMove(e) {
|
|
669
|
+
if (flatNodes.length === 0)
|
|
670
|
+
return;
|
|
671
|
+
const coords = screenToDesignCoords(e.clientX, e.clientY);
|
|
672
|
+
if (!coords) {
|
|
673
|
+
hoveredNodeId = null;
|
|
674
|
+
updateHoverOverlay(null);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const nodesAtPoint = findNodesAtPoint(coords.x, coords.y);
|
|
678
|
+
const innermost = findInnermostSelectable(nodesAtPoint);
|
|
679
|
+
if (innermost?.id !== hoveredNodeId) {
|
|
680
|
+
hoveredNodeId = innermost?.id ?? null;
|
|
681
|
+
updateHoverOverlay(hoveredNodeId);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
function handleCanvasClick(e) {
|
|
685
|
+
if (flatNodes.length === 0)
|
|
686
|
+
return;
|
|
687
|
+
const coords = screenToDesignCoords(e.clientX, e.clientY);
|
|
688
|
+
if (!coords)
|
|
689
|
+
return;
|
|
690
|
+
const nodesAtPoint = findNodesAtPoint(coords.x, coords.y);
|
|
691
|
+
const innermost = findInnermostSelectable(nodesAtPoint);
|
|
692
|
+
if (innermost) {
|
|
693
|
+
expandToNode(innermost.id);
|
|
694
|
+
// If the node is in the current flat nodes list, we don't need to re-render
|
|
695
|
+
const isInCurrentView = flatNodes.some((n) => n.id === innermost.id);
|
|
696
|
+
selectNode(innermost.id, { skipRender: isInCurrentView });
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
function handleCanvasDblClick(e) {
|
|
700
|
+
if (flatNodes.length === 0)
|
|
701
|
+
return;
|
|
702
|
+
const coords = screenToDesignCoords(e.clientX, e.clientY);
|
|
703
|
+
if (!coords)
|
|
704
|
+
return;
|
|
705
|
+
const nodesAtPoint = findNodesAtPoint(coords.x, coords.y);
|
|
706
|
+
const innermost = findInnermostSelectable(nodesAtPoint);
|
|
707
|
+
if (innermost) {
|
|
708
|
+
expandToNode(innermost.id);
|
|
709
|
+
selectNode(innermost.id);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
function handleCanvasMouseLeave() {
|
|
713
|
+
hoveredNodeId = null;
|
|
714
|
+
updateHoverOverlay(null);
|
|
715
|
+
}
|
|
716
|
+
// File dialog
|
|
717
|
+
function showFileDialog() {
|
|
718
|
+
elements.fileDialog.classList.remove("hidden");
|
|
719
|
+
elements.filePathInput.focus();
|
|
720
|
+
}
|
|
721
|
+
function hideFileDialog() {
|
|
722
|
+
elements.fileDialog.classList.add("hidden");
|
|
723
|
+
}
|
|
724
|
+
async function handleOpenFile() {
|
|
725
|
+
const filePath = elements.filePathInput.value.trim();
|
|
726
|
+
if (!filePath)
|
|
727
|
+
return;
|
|
728
|
+
try {
|
|
729
|
+
elements.confirmOpen.disabled = true;
|
|
730
|
+
elements.confirmOpen.textContent = "Loading...";
|
|
731
|
+
await openFile(filePath);
|
|
732
|
+
hideFileDialog();
|
|
733
|
+
// Reset page selection for new file
|
|
734
|
+
selectedPageId = null;
|
|
735
|
+
selectedNodeId = null;
|
|
736
|
+
// Reload tree
|
|
737
|
+
await loadTree();
|
|
738
|
+
// Update file name display
|
|
739
|
+
elements.fileName.textContent = filePath.split("/").pop() || filePath;
|
|
740
|
+
}
|
|
741
|
+
catch (err) {
|
|
742
|
+
alert(`Failed to open file: ${err}`);
|
|
743
|
+
}
|
|
744
|
+
finally {
|
|
745
|
+
elements.confirmOpen.disabled = false;
|
|
746
|
+
elements.confirmOpen.textContent = "Open";
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// Initial load
|
|
750
|
+
async function loadTree() {
|
|
751
|
+
try {
|
|
752
|
+
const { tree, meta } = await fetchTree();
|
|
753
|
+
currentTree = tree;
|
|
754
|
+
// Extract pages (CANVAS nodes) from the document
|
|
755
|
+
pages = [];
|
|
756
|
+
if (tree.children) {
|
|
757
|
+
pages = tree.children.filter((child) => child.type === "CANVAS");
|
|
758
|
+
}
|
|
759
|
+
// Auto-select first page if none selected
|
|
760
|
+
if (pages.length > 0 && !selectedPageId) {
|
|
761
|
+
selectedPageId = pages[0].id;
|
|
762
|
+
}
|
|
763
|
+
// Render pages list and tree
|
|
764
|
+
renderPages();
|
|
765
|
+
renderTree();
|
|
766
|
+
// Update file name if available
|
|
767
|
+
if (meta?.name) {
|
|
768
|
+
elements.fileName.textContent = meta.name;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
catch (err) {
|
|
772
|
+
console.error("Failed to load tree:", err);
|
|
773
|
+
pages = [];
|
|
774
|
+
elements.pagesList.innerHTML = "";
|
|
775
|
+
elements.tree.innerHTML = `<div style="padding: 20px; color: #999;">No file loaded. Click "Open File" to start.</div>`;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// Copy to clipboard
|
|
779
|
+
async function copyNodeId() {
|
|
780
|
+
if (!selectedNodeId)
|
|
781
|
+
return;
|
|
782
|
+
try {
|
|
783
|
+
await navigator.clipboard.writeText(selectedNodeId);
|
|
784
|
+
const originalText = elements.copyId.textContent;
|
|
785
|
+
elements.copyId.textContent = "Copied!";
|
|
786
|
+
setTimeout(() => {
|
|
787
|
+
elements.copyId.textContent = originalText;
|
|
788
|
+
}, 1000);
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
// Fallback
|
|
792
|
+
const textarea = document.createElement("textarea");
|
|
793
|
+
textarea.value = selectedNodeId;
|
|
794
|
+
document.body.appendChild(textarea);
|
|
795
|
+
textarea.select();
|
|
796
|
+
document.execCommand("copy");
|
|
797
|
+
document.body.removeChild(textarea);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
// Event listeners
|
|
801
|
+
function init() {
|
|
802
|
+
// Zoom controls
|
|
803
|
+
elements.zoomIn.addEventListener("click", zoomIn);
|
|
804
|
+
elements.zoomOut.addEventListener("click", zoomOut);
|
|
805
|
+
elements.zoomFit.addEventListener("click", zoomFit);
|
|
806
|
+
// Keyboard zoom
|
|
807
|
+
document.addEventListener("keydown", (e) => {
|
|
808
|
+
if (e.target instanceof HTMLInputElement)
|
|
809
|
+
return;
|
|
810
|
+
if (e.key === "=" || e.key === "+") {
|
|
811
|
+
e.preventDefault();
|
|
812
|
+
zoomIn();
|
|
813
|
+
}
|
|
814
|
+
else if (e.key === "-") {
|
|
815
|
+
e.preventDefault();
|
|
816
|
+
zoomOut();
|
|
817
|
+
}
|
|
818
|
+
else if (e.key === "0") {
|
|
819
|
+
e.preventDefault();
|
|
820
|
+
zoomFit();
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
// Mouse wheel zoom (with smooth micro-increments and focal point)
|
|
824
|
+
elements.canvas.parentElement?.addEventListener("wheel", (e) => {
|
|
825
|
+
if (e.ctrlKey || e.metaKey) {
|
|
826
|
+
e.preventDefault();
|
|
827
|
+
// Get mouse position relative to container for focal point
|
|
828
|
+
const container = elements.canvas.parentElement;
|
|
829
|
+
const containerRect = container.getBoundingClientRect();
|
|
830
|
+
const focalPoint = {
|
|
831
|
+
x: e.clientX - containerRect.left,
|
|
832
|
+
y: e.clientY - containerRect.top,
|
|
833
|
+
};
|
|
834
|
+
// Use deltaY directly for smooth, proportional zooming
|
|
835
|
+
zoomByDelta(e.deltaY, focalPoint);
|
|
836
|
+
}
|
|
837
|
+
}, { passive: false });
|
|
838
|
+
// Canvas hover and selection handlers
|
|
839
|
+
elements.canvas.addEventListener("mousemove", handleCanvasMouseMove);
|
|
840
|
+
elements.canvas.addEventListener("click", handleCanvasClick);
|
|
841
|
+
elements.canvas.addEventListener("dblclick", handleCanvasDblClick);
|
|
842
|
+
elements.canvas.addEventListener("mouseleave", handleCanvasMouseLeave);
|
|
843
|
+
// Search
|
|
844
|
+
elements.search.addEventListener("input", (e) => {
|
|
845
|
+
searchQuery = e.target.value;
|
|
846
|
+
renderTree();
|
|
847
|
+
});
|
|
848
|
+
// File dialog
|
|
849
|
+
elements.openBtn.addEventListener("click", showFileDialog);
|
|
850
|
+
elements.cancelOpen.addEventListener("click", hideFileDialog);
|
|
851
|
+
elements.confirmOpen.addEventListener("click", handleOpenFile);
|
|
852
|
+
elements.filePathInput.addEventListener("keydown", (e) => {
|
|
853
|
+
if (e.key === "Enter") {
|
|
854
|
+
handleOpenFile();
|
|
855
|
+
}
|
|
856
|
+
else if (e.key === "Escape") {
|
|
857
|
+
hideFileDialog();
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
elements.fileDialog.addEventListener("click", (e) => {
|
|
861
|
+
if (e.target === elements.fileDialog) {
|
|
862
|
+
hideFileDialog();
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
// Copy ID
|
|
866
|
+
elements.copyId.addEventListener("click", copyNodeId);
|
|
867
|
+
// Initial load
|
|
868
|
+
loadTree();
|
|
869
|
+
}
|
|
870
|
+
// Start
|
|
871
|
+
document.addEventListener("DOMContentLoaded", init);
|
|
872
|
+
export {};
|
|
873
|
+
//# sourceMappingURL=viewer.js.map
|