rails-schema 0.1.6 → 0.1.7
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/CLAUDE.md +5 -1
- data/README.md +5 -1
- data/docs/index.html +270 -9
- data/lib/rails/schema/assets/app.js +204 -7
- data/lib/rails/schema/assets/style.css +54 -0
- data/lib/rails/schema/assets/template.html.erb +11 -1
- data/lib/rails/schema/version.rb +1 -1
- metadata +1 -1
|
@@ -23,6 +23,27 @@
|
|
|
23
23
|
showThroughEdges = this.checked;
|
|
24
24
|
render();
|
|
25
25
|
if (selectedNode) selectNode(selectedNode);
|
|
26
|
+
autoSave();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Freeze layout toggle — when enabled, all simulation forces are removed so
|
|
31
|
+
// nodes stay wherever they are dropped without being pushed by the simulation.
|
|
32
|
+
var manualPositioningCheckbox = document.getElementById("manual-positioning");
|
|
33
|
+
function removeAllForces() {
|
|
34
|
+
["link", "charge", "center", "collide", "x", "y", "group"].forEach(function(name) {
|
|
35
|
+
simulation.force(name, null);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (manualPositioningCheckbox) {
|
|
39
|
+
manualPositioningCheckbox.addEventListener("change", function () {
|
|
40
|
+
if (this.checked) {
|
|
41
|
+
removeAllForces();
|
|
42
|
+
} else {
|
|
43
|
+
render();
|
|
44
|
+
if (selectedNode) selectNode(selectedNode);
|
|
45
|
+
}
|
|
46
|
+
autoSave();
|
|
26
47
|
});
|
|
27
48
|
}
|
|
28
49
|
|
|
@@ -38,6 +59,113 @@
|
|
|
38
59
|
var NODE_COLUMN_HEIGHT = 18;
|
|
39
60
|
var NODE_PADDING = 8;
|
|
40
61
|
|
|
62
|
+
// Layout persistence
|
|
63
|
+
function computeFingerprint() {
|
|
64
|
+
var ids = nodes.map(function(n) { return n.id; }).sort().join(",");
|
|
65
|
+
var hash = 5381;
|
|
66
|
+
for (var i = 0; i < ids.length; i++) {
|
|
67
|
+
hash = ((hash << 5) + hash + ids.charCodeAt(i)) & 0xffffffff;
|
|
68
|
+
}
|
|
69
|
+
return hash.toString(16);
|
|
70
|
+
}
|
|
71
|
+
var layoutStorageKey = "rails-schema-layout:" + computeFingerprint();
|
|
72
|
+
|
|
73
|
+
function serializeLayout() {
|
|
74
|
+
var positions = {};
|
|
75
|
+
nodes.forEach(function(n) {
|
|
76
|
+
if (n.x !== undefined && n.y !== undefined) {
|
|
77
|
+
positions[n.id] = { x: n.x, y: n.y };
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return {
|
|
81
|
+
version: 1,
|
|
82
|
+
schemaFingerprint: computeFingerprint(),
|
|
83
|
+
nodePositions: positions,
|
|
84
|
+
visibleModels: Array.from(visibleModels),
|
|
85
|
+
zoomTransform: { k: zoomTransform.k, x: zoomTransform.x, y: zoomTransform.y },
|
|
86
|
+
collapsedGroups: Array.from(collapsedGroups),
|
|
87
|
+
manualPositioning: manualPositioningCheckbox ? manualPositioningCheckbox.checked : false,
|
|
88
|
+
showThroughEdges: showThroughEdges
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function applyLayout(layout) {
|
|
93
|
+
if (!layout || layout.version !== 1) return;
|
|
94
|
+
|
|
95
|
+
var nodeIdSet = new Set(nodes.map(function(n) { return n.id; }));
|
|
96
|
+
|
|
97
|
+
// Restore visibility
|
|
98
|
+
if (layout.visibleModels) {
|
|
99
|
+
visibleModels.clear();
|
|
100
|
+
layout.visibleModels.forEach(function(id) {
|
|
101
|
+
if (nodeIdSet.has(id)) visibleModels.add(id);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Restore collapsed groups
|
|
106
|
+
collapsedGroups.clear();
|
|
107
|
+
if (layout.collapsedGroups) {
|
|
108
|
+
layout.collapsedGroups.forEach(function(g) { collapsedGroups.add(g); });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Restore through edges
|
|
112
|
+
if (layout.showThroughEdges !== undefined) {
|
|
113
|
+
showThroughEdges = layout.showThroughEdges;
|
|
114
|
+
if (throughCheckbox) throughCheckbox.checked = showThroughEdges;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Rebuild with restored visibility
|
|
118
|
+
buildSidebar();
|
|
119
|
+
render();
|
|
120
|
+
|
|
121
|
+
var manual = !!layout.manualPositioning;
|
|
122
|
+
|
|
123
|
+
// Apply positions AFTER render created the simulation nodes. Pin with fx/fy
|
|
124
|
+
// only when restoring into manual mode; otherwise seed x/y as initial
|
|
125
|
+
// conditions and let the simulation evolve from there.
|
|
126
|
+
if (layout.nodePositions) {
|
|
127
|
+
nodes.forEach(function(n) {
|
|
128
|
+
var saved = layout.nodePositions[n.id];
|
|
129
|
+
if (saved) {
|
|
130
|
+
n.x = saved.x;
|
|
131
|
+
n.y = saved.y;
|
|
132
|
+
if (manual) {
|
|
133
|
+
n.fx = saved.x;
|
|
134
|
+
n.fy = saved.y;
|
|
135
|
+
} else {
|
|
136
|
+
n.fx = null;
|
|
137
|
+
n.fy = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (manualPositioningCheckbox) {
|
|
144
|
+
manualPositioningCheckbox.checked = manual;
|
|
145
|
+
}
|
|
146
|
+
if (manual) {
|
|
147
|
+
removeAllForces();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Update display
|
|
151
|
+
ticked();
|
|
152
|
+
|
|
153
|
+
// Restore zoom
|
|
154
|
+
if (layout.zoomTransform) {
|
|
155
|
+
var t = layout.zoomTransform;
|
|
156
|
+
var transform = d3.zoomIdentity.translate(t.x, t.y).scale(t.k);
|
|
157
|
+
svg.call(zoom.transform, transform);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function autoSave() {
|
|
162
|
+
try {
|
|
163
|
+
localStorage.setItem(layoutStorageKey, JSON.stringify(serializeLayout()));
|
|
164
|
+
} catch (e) {
|
|
165
|
+
// localStorage full or unavailable — silently ignore
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
41
169
|
// Bezier curve tuning for edge routing
|
|
42
170
|
var MIN_LOOP_OFFSET = 60; // Minimum control-point distance for same-side (loop) edges
|
|
43
171
|
var LOOP_OFFSET_RATIO = 0.5; // Proportion of vertical gap used for loop curve spread
|
|
@@ -287,7 +415,8 @@
|
|
|
287
415
|
.force("charge", d3.forceManyBody().strength(-600))
|
|
288
416
|
.force("center", d3.forceCenter(svgEl.clientWidth / 2, svgEl.clientHeight / 2))
|
|
289
417
|
.force("collide", d3.forceCollide().radius(function(d) { return Math.max(NODE_WIDTH, d._height) / 2 + 50; }))
|
|
290
|
-
.on("tick", ticked)
|
|
418
|
+
.on("tick", ticked)
|
|
419
|
+
.on("end", autoSave);
|
|
291
420
|
|
|
292
421
|
if (config.grouping_enabled) {
|
|
293
422
|
simulation
|
|
@@ -491,6 +620,9 @@
|
|
|
491
620
|
currentSim = setupSimulation();
|
|
492
621
|
renderEdges(currentSim.simEdges);
|
|
493
622
|
renderNodes(currentSim.simNodes);
|
|
623
|
+
if (manualPositioningCheckbox && manualPositioningCheckbox.checked) {
|
|
624
|
+
removeAllForces();
|
|
625
|
+
}
|
|
494
626
|
}
|
|
495
627
|
|
|
496
628
|
function renderEdges(simEdges) {
|
|
@@ -669,8 +801,10 @@
|
|
|
669
801
|
}
|
|
670
802
|
|
|
671
803
|
function ticked() {
|
|
672
|
-
|
|
673
|
-
|
|
804
|
+
if (!manualPositioningCheckbox?.checked) {
|
|
805
|
+
layoutOrphans();
|
|
806
|
+
layoutSelfRefNodes();
|
|
807
|
+
}
|
|
674
808
|
edgeGroup.selectAll(".edge-group").each(function(d) {
|
|
675
809
|
var g = d3.select(this);
|
|
676
810
|
var src = d.source;
|
|
@@ -820,8 +954,11 @@
|
|
|
820
954
|
function dragEnded(event, d) {
|
|
821
955
|
if (isOrphan(d)) return;
|
|
822
956
|
if (!event.active) simulation.alphaTarget(0);
|
|
823
|
-
|
|
824
|
-
|
|
957
|
+
if (!manualPositioningCheckbox?.checked) {
|
|
958
|
+
d.fx = null;
|
|
959
|
+
d.fy = null;
|
|
960
|
+
}
|
|
961
|
+
autoSave();
|
|
825
962
|
}
|
|
826
963
|
|
|
827
964
|
// Zoom
|
|
@@ -831,6 +968,7 @@
|
|
|
831
968
|
zoomTransform = event.transform;
|
|
832
969
|
container.attr("transform", event.transform);
|
|
833
970
|
document.getElementById("zoom-info").textContent = Math.round(event.transform.k * 100) + "%";
|
|
971
|
+
autoSave();
|
|
834
972
|
});
|
|
835
973
|
|
|
836
974
|
svg.call(zoom);
|
|
@@ -870,6 +1008,7 @@
|
|
|
870
1008
|
render();
|
|
871
1009
|
selectNode(nodeId);
|
|
872
1010
|
setTimeout(fitToScreen, 100);
|
|
1011
|
+
autoSave();
|
|
873
1012
|
}
|
|
874
1013
|
|
|
875
1014
|
function deselectNode() {
|
|
@@ -979,6 +1118,7 @@
|
|
|
979
1118
|
buildSidebar();
|
|
980
1119
|
render();
|
|
981
1120
|
setTimeout(fitToScreen, 100);
|
|
1121
|
+
autoSave();
|
|
982
1122
|
});
|
|
983
1123
|
|
|
984
1124
|
div.addEventListener("click", function(e) {
|
|
@@ -1039,6 +1179,7 @@
|
|
|
1039
1179
|
buildSidebar();
|
|
1040
1180
|
render();
|
|
1041
1181
|
setTimeout(fitToScreen, 100);
|
|
1182
|
+
autoSave();
|
|
1042
1183
|
});
|
|
1043
1184
|
|
|
1044
1185
|
var clickTimer = null;
|
|
@@ -1057,6 +1198,7 @@
|
|
|
1057
1198
|
var nowCollapsed = collapsedGroups.has(groupKey);
|
|
1058
1199
|
content.style.display = nowCollapsed ? "none" : "";
|
|
1059
1200
|
toggle.innerHTML = nowCollapsed ? "▶" : "▼";
|
|
1201
|
+
autoSave();
|
|
1060
1202
|
}, 250);
|
|
1061
1203
|
});
|
|
1062
1204
|
|
|
@@ -1073,6 +1215,7 @@
|
|
|
1073
1215
|
buildSidebar();
|
|
1074
1216
|
render();
|
|
1075
1217
|
setTimeout(fitToScreen, 100);
|
|
1218
|
+
autoSave();
|
|
1076
1219
|
});
|
|
1077
1220
|
|
|
1078
1221
|
return header;
|
|
@@ -1180,6 +1323,7 @@
|
|
|
1180
1323
|
buildSidebar();
|
|
1181
1324
|
render();
|
|
1182
1325
|
setTimeout(fitToScreen, 100);
|
|
1326
|
+
autoSave();
|
|
1183
1327
|
});
|
|
1184
1328
|
|
|
1185
1329
|
document.getElementById("deselect-all-btn").addEventListener("click", function() {
|
|
@@ -1187,6 +1331,7 @@
|
|
|
1187
1331
|
buildSidebar();
|
|
1188
1332
|
render();
|
|
1189
1333
|
setTimeout(fitToScreen, 100);
|
|
1334
|
+
autoSave();
|
|
1190
1335
|
});
|
|
1191
1336
|
|
|
1192
1337
|
// Collapse/Expand all groups
|
|
@@ -1221,6 +1366,7 @@
|
|
|
1221
1366
|
document.querySelectorAll(".group-toggle").forEach(function(el) {
|
|
1222
1367
|
el.innerHTML = shouldExpand ? "\u25BC" : "\u25B6";
|
|
1223
1368
|
});
|
|
1369
|
+
autoSave();
|
|
1224
1370
|
});
|
|
1225
1371
|
}
|
|
1226
1372
|
|
|
@@ -1372,13 +1518,64 @@
|
|
|
1372
1518
|
triggerDownload(blob, "schema-diagram.mmd");
|
|
1373
1519
|
}
|
|
1374
1520
|
|
|
1521
|
+
// File menu dropdown
|
|
1522
|
+
var fileMenuBtn = document.getElementById("file-menu-btn");
|
|
1523
|
+
var fileMenu = document.getElementById("file-menu");
|
|
1524
|
+
fileMenuBtn.addEventListener("click", function(e) {
|
|
1525
|
+
e.stopPropagation();
|
|
1526
|
+
fileMenu.classList.toggle("open");
|
|
1527
|
+
});
|
|
1528
|
+
document.addEventListener("click", function() {
|
|
1529
|
+
fileMenu.classList.remove("open");
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1375
1532
|
// Export button listener
|
|
1376
1533
|
document.getElementById("export-mermaid-btn").addEventListener("click", function() { exportMermaid(); });
|
|
1377
1534
|
|
|
1535
|
+
// Save Layout
|
|
1536
|
+
document.getElementById("save-layout-btn").addEventListener("click", function() {
|
|
1537
|
+
var layout = serializeLayout();
|
|
1538
|
+
var blob = new Blob([JSON.stringify(layout, null, 2)], { type: "application/json" });
|
|
1539
|
+
var title = document.title.replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-");
|
|
1540
|
+
triggerDownload(blob, title + "-layout.json");
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
// Load Layout
|
|
1544
|
+
var loadLayoutInput = document.getElementById("load-layout-input");
|
|
1545
|
+
document.getElementById("load-layout-btn").addEventListener("click", function() {
|
|
1546
|
+
loadLayoutInput.click();
|
|
1547
|
+
});
|
|
1548
|
+
loadLayoutInput.addEventListener("change", function() {
|
|
1549
|
+
var file = this.files[0];
|
|
1550
|
+
if (!file) return;
|
|
1551
|
+
var reader = new FileReader();
|
|
1552
|
+
reader.onload = function(e) {
|
|
1553
|
+
try {
|
|
1554
|
+
var layout = JSON.parse(e.target.result);
|
|
1555
|
+
applyLayout(layout);
|
|
1556
|
+
autoSave();
|
|
1557
|
+
} catch (err) {
|
|
1558
|
+
console.error("Failed to load layout:", err);
|
|
1559
|
+
}
|
|
1560
|
+
};
|
|
1561
|
+
reader.readAsText(file);
|
|
1562
|
+
this.value = "";
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1378
1565
|
// Init
|
|
1379
1566
|
buildSidebar();
|
|
1380
1567
|
render();
|
|
1381
1568
|
|
|
1382
|
-
//
|
|
1383
|
-
|
|
1569
|
+
// Restore saved layout or fit to screen
|
|
1570
|
+
var savedLayout = null;
|
|
1571
|
+
try {
|
|
1572
|
+
var stored = localStorage.getItem(layoutStorageKey);
|
|
1573
|
+
if (stored) savedLayout = JSON.parse(stored);
|
|
1574
|
+
} catch (e) { /* ignore */ }
|
|
1575
|
+
|
|
1576
|
+
if (savedLayout) {
|
|
1577
|
+
applyLayout(savedLayout);
|
|
1578
|
+
} else {
|
|
1579
|
+
setTimeout(function() { fitToScreen(); }, 1500);
|
|
1580
|
+
}
|
|
1384
1581
|
})();
|
|
@@ -142,11 +142,65 @@ body {
|
|
|
142
142
|
margin: 0 4px;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
#toolbar label.toolbar-label {
|
|
146
|
+
display: flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
gap: 4px;
|
|
149
|
+
font-size: 13px;
|
|
150
|
+
color: var(--text-secondary);
|
|
151
|
+
cursor: pointer;
|
|
152
|
+
white-space: nowrap;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#toolbar label.toolbar-label input[type="checkbox"] {
|
|
156
|
+
margin: 0;
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
accent-color: var(--accent);
|
|
159
|
+
}
|
|
160
|
+
|
|
145
161
|
#toolbar .shortcuts-hint {
|
|
146
162
|
font-size: 11px;
|
|
147
163
|
color: var(--text-muted);
|
|
148
164
|
}
|
|
149
165
|
|
|
166
|
+
.toolbar-dropdown {
|
|
167
|
+
position: relative;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.toolbar-dropdown-menu {
|
|
171
|
+
display: none;
|
|
172
|
+
position: absolute;
|
|
173
|
+
top: 100%;
|
|
174
|
+
right: 0;
|
|
175
|
+
margin-top: 4px;
|
|
176
|
+
background: var(--bg-primary);
|
|
177
|
+
border: 1px solid var(--border-color);
|
|
178
|
+
border-radius: 6px;
|
|
179
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
180
|
+
z-index: 300;
|
|
181
|
+
min-width: 160px;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.toolbar-dropdown-menu.open {
|
|
185
|
+
display: flex;
|
|
186
|
+
flex-direction: column;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.toolbar-dropdown-menu button {
|
|
190
|
+
border: none;
|
|
191
|
+
border-radius: 0;
|
|
192
|
+
text-align: left;
|
|
193
|
+
padding: 8px 12px;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.toolbar-dropdown-menu button:first-child {
|
|
197
|
+
border-radius: 6px 6px 0 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.toolbar-dropdown-menu button:last-of-type {
|
|
201
|
+
border-radius: 0 0 6px 6px;
|
|
202
|
+
}
|
|
203
|
+
|
|
150
204
|
/* Sidebar */
|
|
151
205
|
#sidebar {
|
|
152
206
|
width: var(--sidebar-width);
|
|
@@ -42,7 +42,17 @@
|
|
|
42
42
|
<button id="fit-btn" title="Fit to screen (F)">Fit</button>
|
|
43
43
|
<button id="theme-btn" title="Toggle theme">Theme</button>
|
|
44
44
|
<span class="toolbar-divider"></span>
|
|
45
|
-
<
|
|
45
|
+
<label class="toolbar-label" for="manual-positioning"><input type="checkbox" id="manual-positioning">Manual Positioning</label>
|
|
46
|
+
<span class="toolbar-divider"></span>
|
|
47
|
+
<div class="toolbar-dropdown">
|
|
48
|
+
<button id="file-menu-btn">File ▼</button>
|
|
49
|
+
<div class="toolbar-dropdown-menu" id="file-menu">
|
|
50
|
+
<button id="export-mermaid-btn">Export Mermaid</button>
|
|
51
|
+
<button id="save-layout-btn">Save Layout</button>
|
|
52
|
+
<button id="load-layout-btn">Load Layout</button>
|
|
53
|
+
<input type="file" id="load-layout-input" accept=".json" style="display:none">
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
46
56
|
<span class="shortcuts-hint">/ search · Esc deselect · F fit</span>
|
|
47
57
|
</div>
|
|
48
58
|
|
data/lib/rails/schema/version.rb
CHANGED