Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 34 additions & 17 deletions js/app-library.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ class AppLibrary {
const name = document.getElementById('lib-name').value.trim() || 'Untitled';
const lib = this._getLibrary();
lib.push({ name, date: new Date().toLocaleString(), json: this._snapshot() });
this._saveLibrary(lib);
if (!this._saveLibrary(lib)) {
this._toast(`Could not save “${name}”. Browser storage is full or blocked.`);
return;
}
document.getElementById('lib-name').value = '';
this._renderLibraryList();
this._toast(`Saved “${name}” to your Library`);
Expand All @@ -57,8 +60,10 @@ class AppLibrary {
try { return JSON.parse(localStorage.getItem('sim_library') || '[]'); } catch { return []; }
}

// Returns false when the write fails (storage full or blocked) so callers
// can tell the user instead of toasting a false "Saved".
_saveLibrary(lib) {
try { localStorage.setItem('sim_library', JSON.stringify(lib)); } catch {}
try { localStorage.setItem('sim_library', JSON.stringify(lib)); return true; } catch { return false; }
}

// ── Components (reusable subgraphs) ──────────────────────────────────────────
Expand All @@ -67,8 +72,9 @@ class AppLibrary {
try { return JSON.parse(localStorage.getItem('sim_components') || '[]'); } catch { return []; }
}

// Same contract as _saveLibrary: false = the write did not stick.
_saveComponents(list) {
try { localStorage.setItem('sim_components', JSON.stringify(list)); } catch {}
try { localStorage.setItem('sim_components', JSON.stringify(list)); return true; } catch { return false; }
}

_saveComponent() {
Expand All @@ -80,7 +86,10 @@ class AppLibrary {
.filter(c => ids.has(c.sourceId) && ids.has(c.targetId)).map(c => c.toJSON());
const list = this._getComponents();
list.push({ name, date: new Date().toLocaleString(), nodes, conns });
this._saveComponents(list);
if (!this._saveComponents(list)) {
this._toast(`Could not save "${name}". Browser storage is full or blocked.`);
return;
}
document.getElementById('comp-name').value = '';
this._renderComponentsList();
this._toast(`Saved "${name}" as a component`);
Expand Down Expand Up @@ -139,7 +148,7 @@ class AppLibrary {
delBtn.className = 'btn';
delBtn.addEventListener('click', () => {
list.splice(i, 1);
this._saveComponents(list);
if (!this._saveComponents(list)) this._toast('Could not update components. Browser storage is blocked.');
this._renderComponentsList();
});
btns.appendChild(insertBtn);
Expand Down Expand Up @@ -211,19 +220,27 @@ class AppLibrary {
loadBtn.className = 'btn';
loadBtn.addEventListener('click', async () => {
if (!await this._confirmGuard(`Load "${entry.name}"? Your current diagram will be replaced (Ctrl+Z to undo).`, 'Load from library')) return;
// Parse + validate on a throwaway Diagram BEFORE wiping the current
// one, so a corrupt entry can't leave a wrecked diagram behind.
let data;
try {
data = JSON.parse(entry.json);
new Diagram().loadJSON(data);
} catch (err) {
this._toast(`Could not load "${entry.name}": ${err.message}. Your current diagram is unchanged.`);
return;
}
const prev = this._snapshot();
this._clearAll();
try {
this.diagram.loadJSON(JSON.parse(entry.json));
this._applyMeta();
this.engine.reset();
this.renderer.balls.clear();
this.renderer.flowFx.clear();
this._clearSparklines();
this.editor._select(null, null);
this.renderer.render();
this.renderer.fitView();
} catch (err) { alert('Failed to load: ' + err.message); }
this.diagram.loadJSON(data);
this._applyMeta();
this.engine.reset();
this.renderer.balls.clear();
this.renderer.flowFx.clear();
this._clearSparklines();
this.editor._select(null, null);
this.renderer.render();
this.renderer.fitView();
this._commitReplace(prev);
this._hideModal('lib-overlay');
});
Expand All @@ -233,7 +250,7 @@ class AppLibrary {
delBtn.className = 'btn';
delBtn.addEventListener('click', () => {
lib.splice(i, 1);
this._saveLibrary(lib);
if (!this._saveLibrary(lib)) this._toast('Could not update the Library. Browser storage is blocked.');
this._renderLibraryList();
});
btns.appendChild(loadBtn);
Expand Down
15 changes: 12 additions & 3 deletions js/app-props.js
Original file line number Diff line number Diff line change
Expand Up @@ -679,13 +679,16 @@ class AppProps {
const nodeRow = document.createElement('div'); nodeRow.className = 'prop-row';
const nl = document.createElement('label'); nl.textContent = 'Node';
const ns = document.createElement('select');
// Rendering must not write to the model: a rule without a target only
// *displays* the first interactive node; the model is assigned on
// change, which commits (mutating here would drift undo snapshots).
const shownId = rule.nodeId || (interactives[0] && interactives[0].id);
for (const n of interactives) {
const o = document.createElement('option');
o.value = n.id; o.textContent = n.label || n.type;
if (n.id === rule.nodeId) o.selected = true;
if (n.id === shownId) o.selected = true;
ns.appendChild(o);
}
if (!rule.nodeId && interactives[0]) rule.nodeId = interactives[0].id;
ns.addEventListener('change', () => { rule.nodeId = ns.value; this._commit(); });
nodeRow.appendChild(nl); nodeRow.appendChild(ns); box.appendChild(nodeRow);

Expand Down Expand Up @@ -903,7 +906,7 @@ class AppProps {
const distGrp = mkChipGroup(
[['uniform', 'uniform'], ['gaussian', 'gaussian']],
rv.dist || 'uniform',
v => { rv.dist = v; resample(); }
v => { rv.dist = v; resample(); this._commit(); }
);
footer.appendChild(distGrp);
}
Expand Down Expand Up @@ -1287,6 +1290,10 @@ class AppProps {
else node.addResources(1);
this.renderer.render();
this._refreshResourceCount();
// At rest the count IS the serialized starting baseline, so the
// edit must reach undo/autosave; mid-run it is a transient live
// nudge and deliberately stays out of the history.
if (this.engine.step === 0) this._commit();
});
stepBtns.appendChild(b);
}
Expand Down Expand Up @@ -1566,6 +1573,7 @@ class AppProps {
conn.cpDx = 0; conn.cpDy = 0; conn.bendPct = 0.5; conn.waypoints = [];
this.renderer.render();
this._renderProps();
this._commit();
});
styleGroup.appendChild(btn);
}
Expand Down Expand Up @@ -1782,6 +1790,7 @@ class AppProps {
if (key === 'trigger') { if (!conn.trigger && !conn.reverseTrigger) conn.trigger = true; }
else { conn.trigger = false; conn.reverseTrigger = false; }
this._renderProps(); this.renderer.render();
this._commit();
});
chips.appendChild(chip);
}
Expand Down
128 changes: 89 additions & 39 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,15 @@ class App {

_snapshot() { return JSON.stringify(this.diagram.toJSON()); }

// True when a parsed payload loads cleanly into a throwaway Diagram. Used to
// validate untrusted input (files, library entries, autosave, share URLs)
// BEFORE it touches the real diagram: loadJSON clears everything first, so
// letting it throw mid-load would leave a wrecked diagram behind for the
// next autosave to persist.
_canLoadDiagram(data) {
try { new Diagram().loadJSON(data); return true; } catch { return false; }
}

// Decorative Font Awesome icon element (hidden from the accessibility tree).
_faIcon(name) {
const i = document.createElement('i');
Expand Down Expand Up @@ -451,6 +460,7 @@ class App {
// undo stack and the freshly loaded one becomes the new baseline. Unlike
// _resetHistory(), this preserves the ability to Ctrl+Z back to what you had.
_commitReplace(prevSnap) {
this._dropScenarioState();
const snap = this._snapshot();
if (snap === prevSnap) { this._lastState = snap; this._updateUndoButtons(); return; }
if (prevSnap != null) {
Expand Down Expand Up @@ -518,6 +528,9 @@ class App {
this.diagram.loadJSON(JSON.parse(json));
this._applyMeta();
this.engine.reset();
// Undo/redo may land mid-replay: leave scrub mode so the renderer stops
// overriding node values with the dead run's history and the slider syncs.
this._exitScrub();
this._syncRunButton();
document.getElementById('sim-status').textContent = '';
this.renderer.balls.clear();
Expand Down Expand Up @@ -632,6 +645,17 @@ class App {

// ── Helpers ───────────────────────────────────────────────────────────────

// Drop session-only scenario state (checkpoints + ghost branches). They
// snapshot the current diagram, so any whole-diagram replacement makes them
// stale: forking an old checkpoint would resurrect the replaced diagram.
_dropScenarioState() {
if (!this._checkpoints.length && !this._branches.length) return;
this._checkpoints = [];
this._branches = [];
if (this._timelineVisible) this.timeline.update();
if (this._activeFeature === 'branches') this._renderProps();
}

_clearAll() {
this.diagram.nodes.clear();
this.diagram.connections.clear();
Expand All @@ -643,10 +667,15 @@ class App {
this.diagram.params = {};
this.diagram.customVars = [];
this.diagram.timeMode = 'sync';
this.diagram.seed = '';
this.diagram.aiPlayer = { enabled: false, rules: [] };
this.diagram.meta = Diagram.defaultMeta();
this._applyMeta();
this._dropScenarioState();
this.engine.reset();
// A New/replace while replaying a run must leave scrub mode, or the
// renderer keeps painting the dead run's values over the fresh diagram.
this._exitScrub();
this._syncRunButton();
document.getElementById('sim-status').textContent = '';
this.renderer.balls.clear();
Expand All @@ -667,37 +696,38 @@ class App {
}

// A diagram encoded in the URL hash (#d=…) takes precedence over autosave.
// Validated on a throwaway Diagram first so a corrupt payload can't leave
// a half-loaded diagram behind before the fallback runs.
const shared = this._decodeDiagram();
if (shared) {
try {
this.diagram.loadJSON(shared);
this._applyMeta();
this.engine.reset();
this.renderer.render();
this.renderer.fitView();
this._resetHistory();
this._renderProps();
return;
} catch { /* fall through to autosave or demo */ }
if (shared && this._canLoadDiagram(shared)) {
this.diagram.loadJSON(shared);
this._applyMeta();
this.engine.reset();
this.renderer.render();
this.renderer.fitView();
this._resetHistory();
this._renderProps();
return;
}

// Autosave found → restore silently so the diagram persists across reloads.
const saved = localStorage.getItem('sim_autosave');
if (saved) {
try {
this.diagram.loadJSON(JSON.parse(saved));
this._applyMeta();
this.engine.reset();
this.renderer.balls.clear();
this.renderer.flowFx.clear();
this._clearSparklines();
this.editor._select(null, null);
this.renderer.render();
this.renderer.fitView();
this._resetHistory();
this._renderProps();
return;
} catch { /* corrupted save → fall through to demo */ }
// getItem can throw under blocked storage (Safari private mode / embedded
// iframe); a corrupt save falls through to the empty canvas untouched.
let saved = null;
try { saved = JSON.parse(localStorage.getItem('sim_autosave') || 'null'); } catch { /* blocked storage or corrupted save */ }
if (saved && this._canLoadDiagram(saved)) {
this.diagram.loadJSON(saved);
this._applyMeta();
this.engine.reset();
this.renderer.balls.clear();
this.renderer.flowFx.clear();
this._clearSparklines();
this.editor._select(null, null);
this.renderer.render();
this.renderer.fitView();
this._resetHistory();
this._renderProps();
return;
}

// No autosave (fresh session): start on an empty canvas so first-time users
Expand Down Expand Up @@ -1061,8 +1091,18 @@ class App {
this.engine.speed = parseFloat(speedEl.value);
document.getElementById('speed-label').textContent = `${speedEl.value}×`;
if (this.engine.running) {
// Restart the tick interval at the new speed. run() resamples 'on
// play' custom variables (a fresh Run press should), but a speed
// change mid-run is not a new run: preserve their sampled values.
const keep = (this.diagram.customVars || [])
.filter(rv => (rv.update || 'step') === 'play')
.map(rv => [rv, rv.value]);
this.engine.stop();
this.engine.run();
for (const [rv, val] of keep) {
rv.value = val;
if (rv.name && VALID_IDENT.test(rv.name) && isFinite(val)) this.diagram.variables[rv.name] = val;
}
this._syncRunButton();
}
});
Expand Down Expand Up @@ -1140,18 +1180,27 @@ class App {
if (!file) return;
const reader = new FileReader();
reader.onload = ev => {
// Parse + validate on a throwaway Diagram BEFORE touching the current
// one: loadJSON clears everything first, so a corrupt file would
// otherwise wreck the diagram (and the next autosave persists that).
let data;
try {
this.diagram.loadJSON(JSON.parse(ev.target.result));
this._applyMeta();
this.engine.reset();
this.renderer.balls.clear();
this.renderer.flowFx.clear();
this._clearSparklines();
this.editor._select(null, null);
this.renderer.render();
this.renderer.fitView();
this._resetHistory();
} catch (err) { alert('Invalid file: ' + err.message); }
data = JSON.parse(ev.target.result);
new Diagram().loadJSON(data);
} catch (err) {
this._toast(`Invalid file: ${err.message}. Your current diagram is unchanged.`);
return;
}
this.diagram.loadJSON(data);
this._applyMeta();
this.engine.reset();
this.renderer.balls.clear();
this.renderer.flowFx.clear();
this._clearSparklines();
this.editor._select(null, null);
this.renderer.render();
this.renderer.fitView();
this._resetHistory();
};
reader.readAsText(file);
};
Expand Down Expand Up @@ -1233,7 +1282,8 @@ class App {

// Keyboard: tool shortcuts (plain) + undo/redo/etc (mod).
window.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
const mod = e.ctrlKey || e.metaKey;
const k = e.key.toLowerCase();
if (mod) {
Expand Down
Loading