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.
@@ -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
- layoutOrphans();
673
- layoutSelfRefNodes();
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
- d.fx = null;
824
- d.fy = null;
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 ? "&#9654;" : "&#9660;";
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
- // Fit after simulation settles
1383
- setTimeout(function() { fitToScreen(); }, 1500);
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
- <button id="export-mermaid-btn" title="Export as Mermaid (Markdown)">Export Mermaid</button>
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 &#9660;</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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rails
4
4
  module Schema
5
- VERSION = "0.1.6"
5
+ VERSION = "0.1.7"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-schema
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Kislichenko