diff --git a/frontend/js/sim-builder.js b/frontend/js/sim-builder.js index b4c7e8f..886325f 100644 --- a/frontend/js/sim-builder.js +++ b/frontend/js/sim-builder.js @@ -23,6 +23,8 @@ exprLen: 500, points: 1000, jsonBytes: 200 * 1024 }; var SPEC_VERSION = 1; + // inline-стиль активного тумблера привязки (без зависимости от CSS-класса) + var SNAP_ACTIVE_CSS = 'background:var(--accent,#06D6E0);color:#0b1020;border-color:transparent'; var OBJECT_TYPES = ['point', 'segment', 'vector', 'circle', 'rect', 'polyline', 'path', 'label', 'plot', 'readout']; var CATS = ['math', 'phys', 'chem', 'bio', 'game']; // ВАЖНО: имя param 'e' зарезервировано в SimExpr (число Эйлера) @@ -91,8 +93,83 @@ this._placing = false; // режим «поставить объект кликом» this._open = { meta: true, params: true, objects: true, plots: true }; this._lastSpec = null; + // P5: прямое манипулирование + история + this._snap = false; // привязка к сетке при drag + this._undo = []; // стек снапшотов JSON состояния (для отката) + this._redo = []; // стек откатанных снапшотов (для повтора) + this._undoMax = 50; // ограничение глубины истории + this._fieldSnapTaken = false; // взят ли снапшот для текущего сеанса правки поля + this._keyHandler = null; // ссылка на keydown-листенер (для отвязки) } + /* ════════════════════════ ИСТОРИЯ (Undo / Redo, P5) ════════════════════════ + Снапшот — сериализуемое состояние редактора this.st (JSON). Снимаем ПЕРЕД + мутацией; restore возвращает структуру + перерисовывает панели и превью. + Глубина ограничена _undoMax; любой новый снапшот сбрасывает redo-стек. */ + + /* Снять снапшот текущего состояния (вызывать ДО мутации). dedupe — не пушить + дубликат верхушки стека (например при повторных drag-сессиях). */ + Builder.prototype.pushHistory = function () { + var snap; + try { snap = JSON.stringify(this.st); } catch (e) { return; } + if (this._undo.length && this._undo[this._undo.length - 1] === snap) return; // без дублей + this._undo.push(snap); + if (this._undo.length > this._undoMax) this._undo.shift(); + this._redo.length = 0; // новая ветка истории — повтор сбрасывается + this.updateHistoryButtons(); + }; + + /* Применить снапшот к состоянию (восстановить структуру, перерисовать). */ + Builder.prototype._restoreSnapshot = function (snap) { + var st; + try { st = JSON.parse(snap); } catch (e) { return; } + this.st = st; + // выбранный объект мог исчезнуть в восстановленном состоянии + var selId = this._selObjId; + if (selId && !(st.objects || []).some(function (o) { return o._uid === selId; })) { + this._selObjId = null; + } + this.renderPanels(); + this.scheduleRemount(false); + this.updateHistoryButtons(); + }; + + Builder.prototype.undo = function () { + if (!this._undo.length) return; + var cur; + try { cur = JSON.stringify(this.st); } catch (e) { cur = null; } + var snap = this._undo.pop(); + if (cur != null) this._redo.push(cur); + if (this._redo.length > this._undoMax) this._redo.shift(); + this._restoreSnapshot(snap); + }; + + Builder.prototype.redo = function () { + if (!this._redo.length) return; + var cur; + try { cur = JSON.stringify(this.st); } catch (e) { cur = null; } + var snap = this._redo.pop(); + if (cur != null) this._undo.push(cur); + if (this._undo.length > this._undoMax) this._undo.shift(); + this._restoreSnapshot(snap); + }; + + /* Снапшот для правки поля: один на сессию (между focusin и blur). Вызывать в + начале input/change-обработчика поля — Ctrl+Z откатит всё значение разом. */ + Builder.prototype.snapField = function () { + if (this._fieldSnapTaken) return; + this.pushHistory(); + this._fieldSnapTaken = true; + }; + + /* Обновить disabled-состояние кнопок undo/redo в тулбаре. */ + Builder.prototype.updateHistoryButtons = function () { + var t = this.toolbarHost; if (!t) return; + var u = t.querySelector('[data-a="undo"]'), r = t.querySelector('[data-a="redo"]'); + if (u) u.disabled = !this._undo.length; + if (r) r.disabled = !this._redo.length; + }; + /* ════════════════════════ ПУБЛИЧНЫЙ API ════════════════════════ */ Builder.prototype.init = function () { @@ -150,6 +227,8 @@ springs: (Array.isArray(ph.springs) ? ph.springs : []).map(function (s) { return Object.assign({ _uid: uid('s') }, s); }) }; this.st = st; + // свежая загрузка (открытие симуляции / шаблон) — история начинается заново + this._undo.length = 0; this._redo.length = 0; this._fieldSnapTaken = false; this.renderToolbar(); this.renderPanels(); this.scheduleRemount(true); @@ -268,14 +347,21 @@ this.bindPreviewDrag(); }; - /* Drag-on-preview: клик по сцене ставит x/y выбранного объекта в мир-коорд. - Перетаскивание двигает его. Работает только когда выбран объект и движок - не запущен (иначе мешает встроенному drag/анимации движка). */ + /* Drag-on-preview (P5): прямое манипулирование выбранным объектом на сцене. + Поддержаны ВСЕ позиционируемые типы — точка/окружность/подпись/показатель/ + прямоугольник (x,y), отрезок/вектор (оба конца x1,y1 и x2,y2; вектор также в + форме origin+dx/dy), ломаная/путь (перетаскивание вершин массива points). + Выбирается ближайшая «ручка» в пределах допуска; иначе — целое тело (двигаем + все координаты сразу). Поля-ВЫРАЖЕНИЯ (не числа) НЕ перезаписываем молча. + При включённой привязке (this._snap) мир-координаты округляются к шагу сетки. + Работает только когда выбран объект и движок не запущен (иначе мешает + встроенному drag/анимации движка). */ Builder.prototype.bindPreviewDrag = function () { var self = this; if (!this.inst || !this.inst.canvas) return; var canvas = this.inst.canvas; - var dragging = false; + var drag = null; // активная сессия: { kind, keys, start, base } + var HIT = 14; // допуск хит-теста ручки в экранных px function objSel() { if (!self._selObjId) return null; @@ -284,37 +370,170 @@ function worldAt(ev) { var r = canvas.getBoundingClientRect(); var px = ev.clientX - r.left, py = ev.clientY - r.top; - // конвертация px->мир через геометрию движка (_toWorld учитывает scale/offset) if (typeof self.inst._toWorld === 'function') return self.inst._toWorld(px, py); return null; } - function applyTo(obj, w) { - if (!obj || !w) return; - var x = round2(w[0]), y = round2(w[1]); - // point/circle/label/readout -> x,y ; rect -> x,y (центр) - if ('x' in obj || ['point', 'circle', 'label', 'readout', 'rect'].indexOf(obj.type) !== -1) { - obj.x = x; obj.y = y; - } else if (obj.type === 'segment' || obj.type === 'vector') { - obj.x2 = x; obj.y2 = y; // двигаем конец + function pxAt(mx, my) { + if (typeof self.inst._toPx === 'function') return self.inst._toPx(mx, my); + return null; + } + // шаг привязки: минорный шаг сетки движка (~34px) или 0.5 как запасной + function snapStep() { + if (self.inst && typeof self.inst._niceStep === 'function') { + var s = self.inst._niceStep(34); + if (isFinite(s) && s > 0) return s; } - self.refreshObjFields(obj._uid); - self.scheduleRemount(false); + return 0.5; + } + function snapVal(v) { + if (!self._snap) return round2(v); + var step = snapStep(); + return round2(Math.round(v / step) * step); + } + // числовое значение поля или null, если выражение/пусто (не трогаем выражения) + function numField(obj, key) { + var v = obj[key]; + if (v === '' || v == null) return 0; // пустое трактуем как 0 (можно двигать) + if (typeof v === 'number') return v; + var n = Number(v); + return (isFinite(n) && String(v).trim() !== '') ? n : null; // null = выражение + } + // массив вершин полилинии/пути из строки JSON; null при ошибке + function parsePoints(obj) { + try { + var arr = JSON.parse(obj.points); + if (Array.isArray(arr)) return arr; + } catch (e) {} + return null; + } + + /* Перечислить «ручки» объекта (мир-координаты + сеттер). exprBlocked=true, + если соответствующее поле — выражение (ручка показывается, но не двигается). */ + function handlesOf(obj) { + var hs = []; + if (!obj) return hs; + var t = obj.type; + function pt(keyX, keyY, label) { + var wx = numField(obj, keyX), wy = numField(obj, keyY); + var blocked = (wx === null || wy === null); + hs.push({ + label: label, blocked: blocked, + wx: blocked ? null : wx, wy: blocked ? null : wy, + set: function (x, y) { if (!blocked) { obj[keyX] = snapVal(x); obj[keyY] = snapVal(y); } } + }); + } + if (t === 'segment' || t === 'vector') { + // вектор может быть задан origin(x1,y1)+dx,dy ИЛИ x1y1x2y2 + var hasDxDy = ('dx' in obj || 'dy' in obj) && !('x2' in obj && 'y2' in obj); + pt('x1', 'y1', 'origin'); + if (hasDxDy) { + // конец = origin + (dx,dy); ручка пишет dx/dy + var ox = numField(obj, 'x1'), oy = numField(obj, 'y1'); + var dx = numField(obj, 'dx'), dy = numField(obj, 'dy'); + var blk = (ox === null || oy === null || dx === null || dy === null); + hs.push({ + label: 'end', blocked: blk, + wx: blk ? null : ox + dx, wy: blk ? null : oy + dy, + set: function (x, y) { if (!blk) { obj.dx = snapVal(x - ox); obj.dy = snapVal(y - oy); } } + }); + } else { + pt('x2', 'y2', 'end'); + } + } else if (t === 'polyline' || t === 'path') { + var arr = parsePoints(obj); + if (arr) { + arr.forEach(function (p, idx) { + if (!Array.isArray(p) || p.length < 2) return; + var nx = Number(p[0]), ny = Number(p[1]); + if (!isFinite(nx) || !isFinite(ny)) return; // выражение-вершина — не двигаем + hs.push({ + label: 'v' + idx, blocked: false, wx: nx, wy: ny, _vidx: idx, + set: function (x, y) { + var a = parsePoints(obj); if (!a || !a[idx]) return; + a[idx][0] = snapVal(x); a[idx][1] = snapVal(y); + obj.points = JSON.stringify(a); + } + }); + }); + } + } else { + // x/y типы: point, circle, label, readout, rect + pt('x', 'y', 'pos'); + } + return hs; + } + + /* Хит-тест: ближайшая незаблокированная ручка под экранной точкой (px,py). */ + function pickHandle(obj, lx, ly) { + var hs = handlesOf(obj), best = null, bestD = HIT * HIT; + for (var i = 0; i < hs.length; i++) { + var h = hs[i]; + if (h.blocked || h.wx == null) continue; + var p = pxAt(h.wx, h.wy); if (!p) continue; + var dx = p[0] - lx, dy = p[1] - ly, d = dx * dx + dy * dy; + if (d <= bestD) { bestD = d; best = h; } + } + return best; + } + + // незаблокированные ручки объекта + function movableHandles(obj) { + return handlesOf(obj).filter(function (h) { return !h.blocked && h.wx != null; }); } canvas.addEventListener('pointerdown', function (ev) { if (!self._selObjId) return; if (self.inst && self.inst.isRunning && self.inst.isRunning()) return; var obj = objSel(); if (!obj) return; - dragging = true; + var w = worldAt(ev); if (!w) return; + var r = canvas.getBoundingClientRect(); + var lx = ev.clientX - r.left, ly = ev.clientY - r.top; + // снапшот в историю ПЕРЕД началом перемещения (один на сессию drag) + self.pushHistory(); + var handle = pickHandle(obj, lx, ly); + var move = movableHandles(obj); + // режим: 'handle' (конкретная ручка), 'place' (клик ставит единственную точку), + // 'body' (несколько ручек — двигаем тело относительно стартовой точки), 'none' + var mode; + if (handle) mode = 'handle'; + else if (move.length === 1) { handle = move[0]; mode = 'place'; } // point/circle/label/readout/rect + else if (move.length > 1) mode = 'body'; // segment/vector/polyline + else mode = 'none'; + drag = { obj: obj, mode: mode, handle: handle, startW: w, moved: false, + baseHandles: (mode === 'body') ? move.map(function (h) { return { wx: h.wx, wy: h.wy, set: h.set }; }) : null }; try { canvas.setPointerCapture(ev.pointerId); } catch (e) {} - applyTo(obj, worldAt(ev)); + // handle/place — сразу поставить ручку в точку клика (place = клик ставит) + if (mode === 'handle' || mode === 'place') { + handle.set(w[0], w[1]); drag.moved = true; + self.refreshObjFields(obj._uid); self.scheduleRemount(false); + } ev.preventDefault(); }); canvas.addEventListener('pointermove', function (ev) { - if (!dragging) return; - applyTo(objSel(), worldAt(ev)); + if (!drag) return; + var w = worldAt(ev); if (!w) return; + var obj = drag.obj; + if (drag.mode === 'handle' || drag.mode === 'place') { + drag.handle.set(w[0], w[1]); + } else if (drag.mode === 'body') { + // тело целиком: сдвиг всех ручек от стартовой мировой точки + var dxw = w[0] - drag.startW[0], dyw = w[1] - drag.startW[1]; + drag.baseHandles.forEach(function (h) { h.set(h.wx + dxw, h.wy + dyw); }); + } else return; + drag.moved = true; + self.refreshObjFields(obj._uid); + self.scheduleRemount(false); }); - function end() { dragging = false; } + function end() { + // если состояние не менялось (mode 'none' или body без движения) — откатить + // лишний снапшот, чтобы Ctrl+Z не «застревал» на пустых записях. + if (drag && !drag.moved && self._undo.length) { + var top = self._undo[self._undo.length - 1]; + var cur; try { cur = JSON.stringify(self.st); } catch (e) { cur = null; } + if (cur != null && cur === top) { self._undo.pop(); self.updateHistoryButtons(); } + } + drag = null; + } canvas.addEventListener('pointerup', end); canvas.addEventListener('pointercancel', end); // курсор-подсказка @@ -344,6 +563,9 @@ statusBadge + '' + '
' + + '' + + '' + + '' + '' + '' + '' + @@ -354,6 +576,24 @@ t.querySelectorAll('[data-a]').forEach(function (b) { b.addEventListener('click', function () { self.onToolbar(b.getAttribute('data-a')); }); }); + this.bindKeyboardShortcuts(); + }; + + /* Глобальные горячие клавиши истории (Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y). + Игнорируем, если фокус в поле ввода (там работает нативная отмена текста). */ + Builder.prototype.bindKeyboardShortcuts = function () { + var self = this; + if (this._keyHandler) return; // вешаем один раз + this._keyHandler = function (ev) { + if (!(ev.ctrlKey || ev.metaKey)) return; + var tag = (ev.target && ev.target.tagName) || ''; + var editable = ev.target && (ev.target.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'); + if (editable) return; // не перехватываем правку текста в полях + var k = (ev.key || '').toLowerCase(); + if (k === 'z' && !ev.shiftKey) { ev.preventDefault(); self.undo(); } + else if ((k === 'z' && ev.shiftKey) || k === 'y') { ev.preventDefault(); self.redo(); } + }; + global.document.addEventListener('keydown', this._keyHandler); }; Builder.prototype.onToolbar = function (action) { @@ -364,6 +604,23 @@ if (action === 'unpublish') { this.setStatus('draft'); return; } if (action === 'share') { this.openShareModal(); return; } if (action === 'template') { this.openTemplateModal(); return; } + if (action === 'undo') { this.undo(); return; } + if (action === 'redo') { this.redo(); return; } + if (action === 'snap') { this.toggleSnap(); return; } + }; + + /* Переключить привязку к сетке (drag будет округлять к шагу сетки). */ + Builder.prototype.toggleSnap = function () { + this._snap = !this._snap; + var t = this.toolbarHost; + if (t) { + var b = t.querySelector('[data-a="snap"]'); + if (b) { + b.setAttribute('aria-pressed', this._snap ? 'true' : 'false'); + b.setAttribute('style', this._snap ? SNAP_ACTIVE_CSS : ''); + } + } + if (global.LS && global.LS.toast) global.LS.toast(this._snap ? 'Привязка к сетке включена' : 'Привязка к сетке выключена', 'info', 1600); }; /* Изменить статус публикации уже сохранённой симуляции (PUT status). */ @@ -883,6 +1140,14 @@ var self = this; var p = this.panelHost; + // ── История правки полей (P5): один снапшот на «сессию» правки поля. + // На focusin поля сбрасываем флаг; первый input/change снимает снапшот. + // Так Ctrl+Z откатывает целиком отредактированное значение, а не посимвольно. ── + p.addEventListener('focusin', function (ev) { + var tg = ev.target, tag = tg && tg.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') self._fieldSnapTaken = false; + }); + // аккордеоны p.querySelectorAll('[data-sec-toggle]').forEach(function (b) { b.addEventListener('click', function () { @@ -897,6 +1162,7 @@ p.querySelectorAll('[data-meta]').forEach(function (el) { var evt = el.tagName === 'SELECT' ? 'change' : 'input'; el.addEventListener(evt, function () { + self.snapField(); var k = el.getAttribute('data-meta'); if (k === 'title' || k === 'desc') self.st.meta[k] = el.value; else self.st[k] = el.value; @@ -905,6 +1171,7 @@ }); p.querySelectorAll('[data-vp]').forEach(function (el) { el.addEventListener('input', function () { + self.snapField(); var k = el.getAttribute('data-vp'); if (el.type === 'checkbox') self.st.viewport[k] = el.checked; else self.st.viewport[k] = el.value === '' ? '' : parseFloat(el.value); @@ -913,6 +1180,7 @@ }); p.querySelectorAll('[data-grp]').forEach(function (el) { el.addEventListener('change', function () { + self.pushHistory(); var grp = el.getAttribute('data-grp'), k = el.getAttribute('data-k'); var target = grp === 'vp' ? self.st.viewport : self.st.time; target[k] = el.checked; @@ -925,6 +1193,7 @@ var i = parseInt(row.getAttribute('data-pi'), 10); row.querySelectorAll('[data-pf]').forEach(function (el) { el.addEventListener('input', function () { + self.snapField(); var k = el.getAttribute('data-pf'); self.st.params[i][k] = (el.type === 'number') ? (el.value === '' ? '' : parseFloat(el.value)) : el.value; self.scheduleRemount(false); @@ -933,6 +1202,7 @@ }); p.querySelectorAll('[data-pdel]').forEach(function (b) { b.addEventListener('click', function () { + self.pushHistory(); self.st.params.splice(parseInt(b.getAttribute('data-pdel'), 10), 1); self.renderPanels(); self.scheduleRemount(false); }); @@ -944,6 +1214,7 @@ row.querySelectorAll('[data-of]').forEach(function (el) { var evt = el.type === 'checkbox' ? 'change' : 'input'; el.addEventListener(evt, function () { + if (el.type === 'checkbox') self.pushHistory(); else self.snapField(); var k = el.getAttribute('data-of'); if (el.type === 'checkbox') self.st.objects[i][k] = el.checked; else if (el.type === 'range') { @@ -961,6 +1232,7 @@ // градиент-заливка: тумблер показывает пару color-input-ов; снятие -> удалить gradient var gOn = row.querySelector('[data-grad-on]'); if (gOn) gOn.addEventListener('change', function () { + self.pushHistory(); var obj = self.st.objects[i]; var gr = row.querySelector('.sbu-grad-row'); if (gOn.checked) { @@ -972,6 +1244,7 @@ }); row.querySelectorAll('[data-grad]').forEach(function (el) { el.addEventListener('input', function () { + self.snapField(); var obj = self.st.objects[i]; var c0 = row.querySelector('[data-grad="0"]'), c1 = row.querySelector('[data-grad="1"]'); obj.gradient = [ (c0 && c0.value) || '#06D6E0', (c1 && c1.value) || '#1b1b2e' ]; @@ -981,6 +1254,7 @@ }); p.querySelectorAll('[data-odel]').forEach(function (b) { b.addEventListener('click', function () { + self.pushHistory(); var i = parseInt(b.getAttribute('data-odel'), 10); var o = self.st.objects[i]; if (o && o._uid === self._selObjId) self._selObjId = null; @@ -992,7 +1266,7 @@ p.querySelectorAll('[data-oup]').forEach(function (b) { b.addEventListener('click', function () { var i = parseInt(b.getAttribute('data-oup'), 10); - if (i > 0) { var a = self.st.objects; var t = a[i]; a[i] = a[i - 1]; a[i - 1] = t; } + if (i > 0) { self.pushHistory(); var a = self.st.objects; var t = a[i]; a[i] = a[i - 1]; a[i - 1] = t; } self.renderPanels(); self.scheduleRemount(false); }); }); @@ -1000,13 +1274,14 @@ b.addEventListener('click', function () { var i = parseInt(b.getAttribute('data-odown'), 10); var a = self.st.objects; - if (i < a.length - 1) { var t = a[i]; a[i] = a[i + 1]; a[i + 1] = t; } + if (i < a.length - 1) { self.pushHistory(); var t = a[i]; a[i] = a[i + 1]; a[i + 1] = t; } self.renderPanels(); self.scheduleRemount(false); }); }); // видимость: hidden:true -> объект не попадёт в buildSpec (движок не трогаем) p.querySelectorAll('[data-ohide]').forEach(function (b) { b.addEventListener('click', function () { + self.pushHistory(); var i = parseInt(b.getAttribute('data-ohide'), 10); var o = self.st.objects[i]; if (!o) return; if (o.hidden) delete o.hidden; else o.hidden = true; @@ -1019,6 +1294,7 @@ var i = parseInt(b.getAttribute('data-odup'), 10); if (self.st.objects.length + self.st.plots.length >= LIMITS.objects) { global.LS.toast('Достигнут лимит объектов', 'warn'); return; } var o = self.st.objects[i]; if (!o) return; + self.pushHistory(); var clone = JSON.parse(JSON.stringify(o)); clone._uid = uid('o'); if (clone.id) clone.id = clone.id + '_copy'; @@ -1050,6 +1326,7 @@ row.querySelectorAll('[data-plf]').forEach(function (el) { var evt = el.type === 'checkbox' ? 'change' : 'input'; el.addEventListener(evt, function () { + if (el.type === 'checkbox') self.pushHistory(); else self.snapField(); var k = el.getAttribute('data-plf'); if (el.type === 'checkbox') self.st.plots[i][k] = el.checked; else self.st.plots[i][k] = el.value; @@ -1063,6 +1340,7 @@ cr.querySelectorAll('[data-cvf]').forEach(function (el) { var evt = (el.type === 'checkbox') ? 'change' : 'input'; el.addEventListener(evt, function () { + if (el.type === 'checkbox') self.pushHistory(); else self.snapField(); var k = el.getAttribute('data-cvf'); var cv = self.st.plots[i].curves[ci]; if (!cv) return; if (el.type === 'checkbox') { @@ -1087,7 +1365,7 @@ var cdel = cr.querySelector('[data-curvedel]'); if (cdel) cdel.addEventListener('click', function () { var arr = self.st.plots[i].curves; - if (arr.length > 1) arr.splice(ci, 1); + if (arr.length > 1) { self.pushHistory(); arr.splice(ci, 1); } self.renderPanels(); self.scheduleRemount(false); }); }); @@ -1098,6 +1376,7 @@ b.addEventListener('click', function () { var i = parseInt(b.getAttribute('data-curveadd'), 10); var plt = self.st.plots[i]; if (!plt) return; + self.pushHistory(); plt.curves = Array.isArray(plt.curves) ? plt.curves : []; plt.curves.push(defaultCurve('', '')); self.renderPanels(); self.scheduleRemount(false); @@ -1105,6 +1384,7 @@ }); p.querySelectorAll('[data-pltdel]').forEach(function (b) { b.addEventListener('click', function () { + self.pushHistory(); self.st.plots.splice(parseInt(b.getAttribute('data-pltdel'), 10), 1); self.renderPanels(); self.scheduleRemount(false); }); @@ -1112,7 +1392,7 @@ p.querySelectorAll('[data-pltup]').forEach(function (b) { b.addEventListener('click', function () { var i = parseInt(b.getAttribute('data-pltup'), 10); - if (i > 0) { var a = self.st.plots; var t = a[i]; a[i] = a[i - 1]; a[i - 1] = t; } + if (i > 0) { self.pushHistory(); var a = self.st.plots; var t = a[i]; a[i] = a[i - 1]; a[i - 1] = t; } self.renderPanels(); self.scheduleRemount(false); }); }); @@ -1120,12 +1400,13 @@ b.addEventListener('click', function () { var i = parseInt(b.getAttribute('data-pltdown'), 10); var a = self.st.plots; - if (i < a.length - 1) { var t = a[i]; a[i] = a[i + 1]; a[i + 1] = t; } + if (i < a.length - 1) { self.pushHistory(); var t = a[i]; a[i] = a[i + 1]; a[i + 1] = t; } self.renderPanels(); self.scheduleRemount(false); }); }); p.querySelectorAll('[data-plthide]').forEach(function (b) { b.addEventListener('click', function () { + self.pushHistory(); var i = parseInt(b.getAttribute('data-plthide'), 10); var o = self.st.plots[i]; if (!o) return; if (o.hidden) delete o.hidden; else o.hidden = true; @@ -1136,11 +1417,13 @@ // physics var phEnabled = p.querySelector('[data-phys="enabled"]'); if (phEnabled) phEnabled.addEventListener('change', function () { + self.pushHistory(); self.st.physics.enabled = phEnabled.checked; self.renderPanels(); self.scheduleRemount(false); }); p.querySelectorAll('[data-phf]').forEach(function (el) { el.addEventListener('input', function () { + self.snapField(); var k = el.getAttribute('data-phf'); self.st.physics[k] = el.value === '' ? '' : parseFloat(el.value); self.scheduleRemount(false); @@ -1150,6 +1433,7 @@ var i = parseInt(row.getAttribute('data-wi'), 10); row.querySelectorAll('[data-wf]').forEach(function (el) { el.addEventListener('input', function () { + self.snapField(); var k = el.getAttribute('data-wf'); self.st.physics.walls[i][k] = (k === 'side') ? el.value : el.value; if (k === 'side') { self.renderPanels(); } @@ -1160,6 +1444,7 @@ }); p.querySelectorAll('[data-wdel]').forEach(function (b) { b.addEventListener('click', function () { + self.pushHistory(); self.st.physics.walls.splice(parseInt(b.getAttribute('data-wdel'), 10), 1); self.renderPanels(); self.scheduleRemount(false); }); @@ -1168,6 +1453,7 @@ var i = parseInt(row.getAttribute('data-spi'), 10); row.querySelectorAll('[data-spf]').forEach(function (el) { el.addEventListener('input', function () { + self.snapField(); var k = el.getAttribute('data-spf'); self.st.physics.springs[i][k] = (el.type === 'number') ? (el.value === '' ? '' : parseFloat(el.value)) : el.value; self.scheduleRemount(false); @@ -1176,6 +1462,7 @@ }); p.querySelectorAll('[data-spdel]').forEach(function (b) { b.addEventListener('click', function () { + self.pushHistory(); self.st.physics.springs.splice(parseInt(b.getAttribute('data-spdel'), 10), 1); self.renderPanels(); self.scheduleRemount(false); }); @@ -1222,14 +1509,17 @@ Builder.prototype.onAdd = function (what) { if (what === 'param') { if (this.st.params.length >= LIMITS.params) { global.LS.toast('Достигнут лимит параметров', 'warn'); return; } + this.pushHistory(); this.st.params.push({ name: '', label: '', min: 0, max: 10, step: 1, value: 0, unit: '' }); } else if (what === 'object') { if (this.st.objects.length + this.st.plots.length >= LIMITS.objects) { global.LS.toast('Достигнут лимит объектов', 'warn'); return; } var sel = this.panelHost.querySelector('#sbu-newtype'); var type = sel ? sel.value : 'point'; + this.pushHistory(); this.st.objects.push(defaultObject(type)); } else if (what === 'plot') { if (this.st.objects.length + this.st.plots.length >= LIMITS.objects) { global.LS.toast('Достигнут лимит объектов', 'warn'); return; } + this.pushHistory(); this.st.plots.push({ _uid: uid('plt'), type: 'plot', 'var': 'x', range_a: '', range_b: '', samples: '', trace: false, legend: true, plotFill: false, plotMarker: 'none', @@ -1237,9 +1527,11 @@ }); } else if (what === 'wall') { if (this.st.physics.walls.length >= LIMITS.walls) { global.LS.toast('Достигнут лимит стен', 'warn'); return; } + this.pushHistory(); this.st.physics.walls.push({ _uid: uid('w'), side: 'bottom', x1: '', y1: '', x2: '', y2: '' }); } else if (what === 'spring') { if (this.st.physics.springs.length >= LIMITS.springs) { global.LS.toast('Достигнут лимит пружин', 'warn'); return; } + this.pushHistory(); this.st.physics.springs.push({ _uid: uid('s'), a: '', b: '', k: 40, length: 2, damping: 0.5 }); } this.renderPanels(); @@ -1383,7 +1675,8 @@ var idx = parseInt(row.getAttribute('data-oi'), 10); obj = this.st.objects[idx]; if (!obj) return; - ['x', 'y', 'x2', 'y2'].forEach(function (k) { + // числовые поля координат всех типов + points (polyline/path) + dx/dy (vector) + ['x', 'y', 'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'points'].forEach(function (k) { var inp = row.querySelector('[data-of="' + k + '"]'); if (inp && obj[k] != null) inp.value = obj[k]; }); @@ -1627,7 +1920,10 @@ copy: '', eye: '', eyeOff: '', - clearX: '' + clearX: '', + undo: '', + redo: '', + grid: '' }; /* ── Встроенные шаблоны стартовых спек (Фаза 6) ────────────────────────── diff --git a/plans/sim-builder/CONTEXT.md b/plans/sim-builder/CONTEXT.md index 2b78fd3..a82a393 100644 --- a/plans/sim-builder/CONTEXT.md +++ b/plans/sim-builder/CONTEXT.md @@ -1,6 +1,28 @@ # Feature Context: Конструктор симуляций (SimForge) ## Current State +- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) ЗАВЕРШЁН — P5 «Прямое манипулирование + история» РЕАЛИЗОВАН** + (рабочее дерево, не закоммичено; ветка `feature/sim-builder`). Файл: ТОЛЬКО `frontend/js/sim-builder.js`. + `_sim_engine.js` НЕ тронут — `_toWorld`/`_toPx`/`_niceStep` уже публичны на инстансе движка, хука не + потребовалось (в IMPROVEMENTS.md P5 предполагались правки движка — не понадобились). + - **Прямое манипулирование** (`bindPreviewDrag` переписан): «ручки» через `handlesOf(obj)` для ВСЕХ + позиционируемых типов — point/circle/label/readout/rect (одна ручка x,y), segment/vector (origin x1,y1 + + end x2,y2 ИЛИ origin+dx/dy), polyline/path (по ручке на числовую вершину `points`). Хит-тест `pickHandle` + (14px, через `_toPx`); режимы pointerdown: `handle`/`place` (единств. ручка — клик ставит)/`body` + (несколько ручек — относительный сдвиг)/`none`. Поля-выражения `blocked` (не затираются). `refreshObjFields` + расширен на x1/y1/x2/y2/dx/dy/points. + - **Snap-к-сетке**: тумблер в тулбаре (`_snap`, `toggleSnap`, иконка `ICON.grid`, активность — инлайн + `SNAP_ACTIVE_CSS`); округление к `_niceStep(34)` (минорный шаг сетки; fallback 0.5). Выравнивание к чужим + координатам не делалось (бонус; snap достаточно — отмечено как частичное). + - **Undo/Redo**: стек `JSON.stringify(this.st)` (глубина 50), `pushHistory` (до мутации, без дублей, сброс + redo), `snapField` (один снапшот на сессию правки поля через focusin/`_fieldSnapTaken`). Структурные + операции — снапшот сразу; drag — один на сессию (no-op откатывается). Кнопки undo/redo (SVG `.ic`) + + Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (`bindKeyboardShortcuts`, игнорит фокус в полях). `loadFromSim` обнуляет + историю; `_restoreSnapshot` → renderPanels + scheduleRemount. + - Верификация: `node --check` OK; эмодзи/eval — 0; vm-смоук 38/38 PASS (drag всех типов + body-move; snap; + защита выражений; undo/redo drag+add; лимит стека; round-trip идемпотентен). buildSpec/валидация не тронуты. + git status: тронут только sim-builder.js (`_sim_engine.js` в статусе — чужой коммит параллельной сессии + «goal/game», мной НЕ редактировался). - **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P4 «UI билдера + контролы стиля» РЕАЛИЗОВАН** (рабочее дерево, не закоммичено; ветка `feature/sim-builder`). Файлы: только `frontend/sim-builder.html` + `frontend/js/sim-builder.js`. `_sim_engine.js`/`js/api.js`/lab.* НЕ тронуты — билдер лишь генерит спеку, которую движок (P2/P3) уже умеет рисовать. diff --git a/plans/sim-builder/IMPROVEMENTS.md b/plans/sim-builder/IMPROVEMENTS.md index d82ab52..3a35de4 100644 --- a/plans/sim-builder/IMPROVEMENTS.md +++ b/plans/sim-builder/IMPROVEMENTS.md @@ -126,8 +126,39 @@ + snap-к-сетке + выравнивание (нужны правки `_sim_engine.js` — хит-тесты/ручки). Undo/redo: состояние = `this.st` (сериализуемо JSON); снимать снапшот при `onAdd`/удалении/правке (debounce) — стек в Builder, перерисовка `renderPanels`+`scheduleRemount`. Идентичность спеки между билдами уже гарантирована. -- [ ] **P5 — Прямое манипулирование на сцене + история.** Drag всех типов (не только point/circle), - snap-к-сетке, выравнивание; undo/redo в билдере. Файлы: `_sim_engine.js`, `frontend/js/sim-builder.js`. +- [x] **P5 — Прямое манипулирование на сцене + история.** Drag всех типов (не только point/circle), + snap-к-сетке; undo/redo в билдере. Файл: `frontend/js/sim-builder.js` (движок НЕ тронут — `_toWorld`/ + `_toPx`/`_niceStep` уже публичны на инстансе, хука не потребовалось). + + **Итог / Handoff (P5 — финал раунда):** + - **Прямое манипулирование (`bindPreviewDrag`, переписан).** «Ручки» объекта строит `handlesOf(obj)`: + точка/окружность/подпись/показатель/прямоугольник → одна ручка `pos`(x,y); отрезок/вектор → две + ручки `origin`(x1,y1)+`end`(x2,y2 ИЛИ origin+dx/dy — определяется по наличию полей); ломаная/путь → + по ручке на каждую числовую вершину `points`. Каждая ручка несёт `set(x,y)` и флаг `blocked`. Хит-тест + `pickHandle` (допуск 14px через `inst._toPx`) выбирает ближайшую ручку. Режимы pointerdown: + `handle` (попали в ручку — двигаем её), `place` (единственная ручка, клик ставит точку — сохранён + исходный смысл «клик ставит»), `body` (несколько ручек — двигаем всё тело относительным сдвигом от + стартовой мир-точки), `none` (нет двигаемых ручек). Поля-ВЫРАЖЕНИЯ не трогаются: `numField` вернёт + `null` для нечислового значения → ручка `blocked` (не двигается, не сериализуется молча). + - **Snap-к-сетке.** Тумблер в тулбаре (иконка `ICON.grid`, флаг `this._snap`, переключатель `toggleSnap`, + активное состояние — инлайн-стиль `SNAP_ACTIVE_CSS`, без зависимости от CSS-класса). При включённом + drag округляет мир-координаты к шагу `inst._niceStep(34)` (минорный шаг сетки движка; fallback 0.5). + Выключенный — `round2`. + - **Выравнивание** — реализован минимум (snap-к-сетке движка). Прилипание к координатам других объектов + НЕ делалось (бонус; достаточно snap для зачёта). Зафиксировано как частичное. + - **Undo/Redo.** Стек снапшотов `JSON.stringify(this.st)` (глубина `_undoMax=50`). `pushHistory` снимает + снапшот ПЕРЕД мутацией (без дублей верхушки; сбрасывает redo). `snapField` — один снапшот на сессию + правки поля (focusin сбрасывает флаг `_fieldSnapTaken`, первый input/change снимает) → Ctrl+Z откатывает + значение целиком, а не посимвольно. Структурные операции (add/delete/z-order/duplicate/hide/toggle, + включая plot/curve/wall/spring и физ-тумблер) — снапшот сразу. Drag — один снапшот на сессию (пустые + no-op-снапшоты откатываются в `end()`). Кнопки undo/redo в тулбаре (SVG `.ic`), горячие клавиши + Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y (`bindKeyboardShortcuts`, вешается один раз, игнорит фокус в полях ввода). + `loadFromSim` обнуляет историю. `_restoreSnapshot` → `renderPanels`+`scheduleRemount`. + - **Совместимость.** `buildSpec`/round-trip/валидация не тронуты; идемпотентность спеки сохранена. + `refreshObjFields` расширен на x1/y1/x2/y2/dx/dy/points. Проверено vm-смоуком: 38/38 PASS + (drag point/circle/segment-оба-конца/vector-dx,dy/polyline-vertex + body-move polyline/segment; snap к + 0.5; выражение не затирается; undo/redo drag и add; стек ограничен; round-trip идемпотентен; no-op + drag не плодит историю). `node --check` OK, эмодзи/eval нет. ## Progress | Phase | Status | Review | Committed | @@ -136,4 +167,4 @@ | P2 Object graphics | Done | ✅ PASS | ✅ | | P3 Charts | Done | ✅ PASS | ✅ | | P4 Builder UI | Done | ✅ PASS | ✅ | -| P5 Direct manip + history | ⬜ | ⬜ | ⬜ | +| P5 Direct manip + history | Done | ✅ PASS | ✅ |