feat(sim-builder): улучшение P5 — прямое манипулирование (drag всех типов, snap) + undo/redo в билдере
This commit is contained in:
+323
-27
@@ -23,6 +23,8 @@
|
|||||||
exprLen: 500, points: 1000, jsonBytes: 200 * 1024
|
exprLen: 500, points: 1000, jsonBytes: 200 * 1024
|
||||||
};
|
};
|
||||||
var SPEC_VERSION = 1;
|
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 OBJECT_TYPES = ['point', 'segment', 'vector', 'circle', 'rect', 'polyline', 'path', 'label', 'plot', 'readout'];
|
||||||
var CATS = ['math', 'phys', 'chem', 'bio', 'game'];
|
var CATS = ['math', 'phys', 'chem', 'bio', 'game'];
|
||||||
// ВАЖНО: имя param 'e' зарезервировано в SimExpr (число Эйлера)
|
// ВАЖНО: имя param 'e' зарезервировано в SimExpr (число Эйлера)
|
||||||
@@ -91,8 +93,83 @@
|
|||||||
this._placing = false; // режим «поставить объект кликом»
|
this._placing = false; // режим «поставить объект кликом»
|
||||||
this._open = { meta: true, params: true, objects: true, plots: true };
|
this._open = { meta: true, params: true, objects: true, plots: true };
|
||||||
this._lastSpec = null;
|
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 ════════════════════════ */
|
/* ════════════════════════ ПУБЛИЧНЫЙ API ════════════════════════ */
|
||||||
|
|
||||||
Builder.prototype.init = function () {
|
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); })
|
springs: (Array.isArray(ph.springs) ? ph.springs : []).map(function (s) { return Object.assign({ _uid: uid('s') }, s); })
|
||||||
};
|
};
|
||||||
this.st = st;
|
this.st = st;
|
||||||
|
// свежая загрузка (открытие симуляции / шаблон) — история начинается заново
|
||||||
|
this._undo.length = 0; this._redo.length = 0; this._fieldSnapTaken = false;
|
||||||
this.renderToolbar();
|
this.renderToolbar();
|
||||||
this.renderPanels();
|
this.renderPanels();
|
||||||
this.scheduleRemount(true);
|
this.scheduleRemount(true);
|
||||||
@@ -268,14 +347,21 @@
|
|||||||
this.bindPreviewDrag();
|
this.bindPreviewDrag();
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Drag-on-preview: клик по сцене ставит x/y выбранного объекта в мир-коорд.
|
/* Drag-on-preview (P5): прямое манипулирование выбранным объектом на сцене.
|
||||||
Перетаскивание двигает его. Работает только когда выбран объект и движок
|
Поддержаны ВСЕ позиционируемые типы — точка/окружность/подпись/показатель/
|
||||||
не запущен (иначе мешает встроенному drag/анимации движка). */
|
прямоугольник (x,y), отрезок/вектор (оба конца x1,y1 и x2,y2; вектор также в
|
||||||
|
форме origin+dx/dy), ломаная/путь (перетаскивание вершин массива points).
|
||||||
|
Выбирается ближайшая «ручка» в пределах допуска; иначе — целое тело (двигаем
|
||||||
|
все координаты сразу). Поля-ВЫРАЖЕНИЯ (не числа) НЕ перезаписываем молча.
|
||||||
|
При включённой привязке (this._snap) мир-координаты округляются к шагу сетки.
|
||||||
|
Работает только когда выбран объект и движок не запущен (иначе мешает
|
||||||
|
встроенному drag/анимации движка). */
|
||||||
Builder.prototype.bindPreviewDrag = function () {
|
Builder.prototype.bindPreviewDrag = function () {
|
||||||
var self = this;
|
var self = this;
|
||||||
if (!this.inst || !this.inst.canvas) return;
|
if (!this.inst || !this.inst.canvas) return;
|
||||||
var canvas = this.inst.canvas;
|
var canvas = this.inst.canvas;
|
||||||
var dragging = false;
|
var drag = null; // активная сессия: { kind, keys, start, base }
|
||||||
|
var HIT = 14; // допуск хит-теста ручки в экранных px
|
||||||
|
|
||||||
function objSel() {
|
function objSel() {
|
||||||
if (!self._selObjId) return null;
|
if (!self._selObjId) return null;
|
||||||
@@ -284,37 +370,170 @@
|
|||||||
function worldAt(ev) {
|
function worldAt(ev) {
|
||||||
var r = canvas.getBoundingClientRect();
|
var r = canvas.getBoundingClientRect();
|
||||||
var px = ev.clientX - r.left, py = ev.clientY - r.top;
|
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);
|
if (typeof self.inst._toWorld === 'function') return self.inst._toWorld(px, py);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
function applyTo(obj, w) {
|
function pxAt(mx, my) {
|
||||||
if (!obj || !w) return;
|
if (typeof self.inst._toPx === 'function') return self.inst._toPx(mx, my);
|
||||||
var x = round2(w[0]), y = round2(w[1]);
|
return null;
|
||||||
// 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; // двигаем конец
|
|
||||||
}
|
}
|
||||||
self.refreshObjFields(obj._uid);
|
// шаг привязки: минорный шаг сетки движка (~34px) или 0.5 как запасной
|
||||||
self.scheduleRemount(false);
|
function snapStep() {
|
||||||
|
if (self.inst && typeof self.inst._niceStep === 'function') {
|
||||||
|
var s = self.inst._niceStep(34);
|
||||||
|
if (isFinite(s) && s > 0) return s;
|
||||||
|
}
|
||||||
|
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) {
|
canvas.addEventListener('pointerdown', function (ev) {
|
||||||
if (!self._selObjId) return;
|
if (!self._selObjId) return;
|
||||||
if (self.inst && self.inst.isRunning && self.inst.isRunning()) return;
|
if (self.inst && self.inst.isRunning && self.inst.isRunning()) return;
|
||||||
var obj = objSel(); if (!obj) 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) {}
|
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();
|
ev.preventDefault();
|
||||||
});
|
});
|
||||||
canvas.addEventListener('pointermove', function (ev) {
|
canvas.addEventListener('pointermove', function (ev) {
|
||||||
if (!dragging) return;
|
if (!drag) return;
|
||||||
applyTo(objSel(), worldAt(ev));
|
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('pointerup', end);
|
||||||
canvas.addEventListener('pointercancel', end);
|
canvas.addEventListener('pointercancel', end);
|
||||||
// курсор-подсказка
|
// курсор-подсказка
|
||||||
@@ -344,6 +563,9 @@
|
|||||||
statusBadge +
|
statusBadge +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="sbu-tb-right">' +
|
'<div class="sbu-tb-right">' +
|
||||||
|
'<button class="btn-ghost sbu-tb-btn sbu-tb-ico" data-a="undo" title="Отменить (Ctrl+Z)"' + (this._undo.length ? '' : ' disabled') + '>' + ICON.undo + '</button>' +
|
||||||
|
'<button class="btn-ghost sbu-tb-btn sbu-tb-ico" data-a="redo" title="Повторить (Ctrl+Shift+Z)"' + (this._redo.length ? '' : ' disabled') + '>' + ICON.redo + '</button>' +
|
||||||
|
'<button class="btn-ghost sbu-tb-btn sbu-tb-ico" data-a="snap" title="Привязка к сетке" aria-pressed="' + (this._snap ? 'true' : 'false') + '"' + (this._snap ? ' style="' + SNAP_ACTIVE_CSS + '"' : '') + '>' + ICON.grid + '</button>' +
|
||||||
'<button class="btn-ghost sbu-tb-btn" data-a="template" title="Создать из шаблона">' + ICON.template + ' Шаблон</button>' +
|
'<button class="btn-ghost sbu-tb-btn" data-a="template" title="Создать из шаблона">' + ICON.template + ' Шаблон</button>' +
|
||||||
'<button class="btn-ghost sbu-tb-btn" data-a="test" title="Запустить превью">' + ICON.play + ' Тест</button>' +
|
'<button class="btn-ghost sbu-tb-btn" data-a="test" title="Запустить превью">' + ICON.play + ' Тест</button>' +
|
||||||
'<button class="btn-ghost sbu-tb-btn" data-a="reset" title="Сброс превью">' + ICON.reset + ' Сброс</button>' +
|
'<button class="btn-ghost sbu-tb-btn" data-a="reset" title="Сброс превью">' + ICON.reset + ' Сброс</button>' +
|
||||||
@@ -354,6 +576,24 @@
|
|||||||
t.querySelectorAll('[data-a]').forEach(function (b) {
|
t.querySelectorAll('[data-a]').forEach(function (b) {
|
||||||
b.addEventListener('click', function () { self.onToolbar(b.getAttribute('data-a')); });
|
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) {
|
Builder.prototype.onToolbar = function (action) {
|
||||||
@@ -364,6 +604,23 @@
|
|||||||
if (action === 'unpublish') { this.setStatus('draft'); return; }
|
if (action === 'unpublish') { this.setStatus('draft'); return; }
|
||||||
if (action === 'share') { this.openShareModal(); return; }
|
if (action === 'share') { this.openShareModal(); return; }
|
||||||
if (action === 'template') { this.openTemplateModal(); 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). */
|
/* Изменить статус публикации уже сохранённой симуляции (PUT status). */
|
||||||
@@ -883,6 +1140,14 @@
|
|||||||
var self = this;
|
var self = this;
|
||||||
var p = this.panelHost;
|
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) {
|
p.querySelectorAll('[data-sec-toggle]').forEach(function (b) {
|
||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
@@ -897,6 +1162,7 @@
|
|||||||
p.querySelectorAll('[data-meta]').forEach(function (el) {
|
p.querySelectorAll('[data-meta]').forEach(function (el) {
|
||||||
var evt = el.tagName === 'SELECT' ? 'change' : 'input';
|
var evt = el.tagName === 'SELECT' ? 'change' : 'input';
|
||||||
el.addEventListener(evt, function () {
|
el.addEventListener(evt, function () {
|
||||||
|
self.snapField();
|
||||||
var k = el.getAttribute('data-meta');
|
var k = el.getAttribute('data-meta');
|
||||||
if (k === 'title' || k === 'desc') self.st.meta[k] = el.value;
|
if (k === 'title' || k === 'desc') self.st.meta[k] = el.value;
|
||||||
else self.st[k] = el.value;
|
else self.st[k] = el.value;
|
||||||
@@ -905,6 +1171,7 @@
|
|||||||
});
|
});
|
||||||
p.querySelectorAll('[data-vp]').forEach(function (el) {
|
p.querySelectorAll('[data-vp]').forEach(function (el) {
|
||||||
el.addEventListener('input', function () {
|
el.addEventListener('input', function () {
|
||||||
|
self.snapField();
|
||||||
var k = el.getAttribute('data-vp');
|
var k = el.getAttribute('data-vp');
|
||||||
if (el.type === 'checkbox') self.st.viewport[k] = el.checked;
|
if (el.type === 'checkbox') self.st.viewport[k] = el.checked;
|
||||||
else self.st.viewport[k] = el.value === '' ? '' : parseFloat(el.value);
|
else self.st.viewport[k] = el.value === '' ? '' : parseFloat(el.value);
|
||||||
@@ -913,6 +1180,7 @@
|
|||||||
});
|
});
|
||||||
p.querySelectorAll('[data-grp]').forEach(function (el) {
|
p.querySelectorAll('[data-grp]').forEach(function (el) {
|
||||||
el.addEventListener('change', function () {
|
el.addEventListener('change', function () {
|
||||||
|
self.pushHistory();
|
||||||
var grp = el.getAttribute('data-grp'), k = el.getAttribute('data-k');
|
var grp = el.getAttribute('data-grp'), k = el.getAttribute('data-k');
|
||||||
var target = grp === 'vp' ? self.st.viewport : self.st.time;
|
var target = grp === 'vp' ? self.st.viewport : self.st.time;
|
||||||
target[k] = el.checked;
|
target[k] = el.checked;
|
||||||
@@ -925,6 +1193,7 @@
|
|||||||
var i = parseInt(row.getAttribute('data-pi'), 10);
|
var i = parseInt(row.getAttribute('data-pi'), 10);
|
||||||
row.querySelectorAll('[data-pf]').forEach(function (el) {
|
row.querySelectorAll('[data-pf]').forEach(function (el) {
|
||||||
el.addEventListener('input', function () {
|
el.addEventListener('input', function () {
|
||||||
|
self.snapField();
|
||||||
var k = el.getAttribute('data-pf');
|
var k = el.getAttribute('data-pf');
|
||||||
self.st.params[i][k] = (el.type === 'number') ? (el.value === '' ? '' : parseFloat(el.value)) : el.value;
|
self.st.params[i][k] = (el.type === 'number') ? (el.value === '' ? '' : parseFloat(el.value)) : el.value;
|
||||||
self.scheduleRemount(false);
|
self.scheduleRemount(false);
|
||||||
@@ -933,6 +1202,7 @@
|
|||||||
});
|
});
|
||||||
p.querySelectorAll('[data-pdel]').forEach(function (b) {
|
p.querySelectorAll('[data-pdel]').forEach(function (b) {
|
||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
|
self.pushHistory();
|
||||||
self.st.params.splice(parseInt(b.getAttribute('data-pdel'), 10), 1);
|
self.st.params.splice(parseInt(b.getAttribute('data-pdel'), 10), 1);
|
||||||
self.renderPanels(); self.scheduleRemount(false);
|
self.renderPanels(); self.scheduleRemount(false);
|
||||||
});
|
});
|
||||||
@@ -944,6 +1214,7 @@
|
|||||||
row.querySelectorAll('[data-of]').forEach(function (el) {
|
row.querySelectorAll('[data-of]').forEach(function (el) {
|
||||||
var evt = el.type === 'checkbox' ? 'change' : 'input';
|
var evt = el.type === 'checkbox' ? 'change' : 'input';
|
||||||
el.addEventListener(evt, function () {
|
el.addEventListener(evt, function () {
|
||||||
|
if (el.type === 'checkbox') self.pushHistory(); else self.snapField();
|
||||||
var k = el.getAttribute('data-of');
|
var k = el.getAttribute('data-of');
|
||||||
if (el.type === 'checkbox') self.st.objects[i][k] = el.checked;
|
if (el.type === 'checkbox') self.st.objects[i][k] = el.checked;
|
||||||
else if (el.type === 'range') {
|
else if (el.type === 'range') {
|
||||||
@@ -961,6 +1232,7 @@
|
|||||||
// градиент-заливка: тумблер показывает пару color-input-ов; снятие -> удалить gradient
|
// градиент-заливка: тумблер показывает пару color-input-ов; снятие -> удалить gradient
|
||||||
var gOn = row.querySelector('[data-grad-on]');
|
var gOn = row.querySelector('[data-grad-on]');
|
||||||
if (gOn) gOn.addEventListener('change', function () {
|
if (gOn) gOn.addEventListener('change', function () {
|
||||||
|
self.pushHistory();
|
||||||
var obj = self.st.objects[i];
|
var obj = self.st.objects[i];
|
||||||
var gr = row.querySelector('.sbu-grad-row');
|
var gr = row.querySelector('.sbu-grad-row');
|
||||||
if (gOn.checked) {
|
if (gOn.checked) {
|
||||||
@@ -972,6 +1244,7 @@
|
|||||||
});
|
});
|
||||||
row.querySelectorAll('[data-grad]').forEach(function (el) {
|
row.querySelectorAll('[data-grad]').forEach(function (el) {
|
||||||
el.addEventListener('input', function () {
|
el.addEventListener('input', function () {
|
||||||
|
self.snapField();
|
||||||
var obj = self.st.objects[i];
|
var obj = self.st.objects[i];
|
||||||
var c0 = row.querySelector('[data-grad="0"]'), c1 = row.querySelector('[data-grad="1"]');
|
var c0 = row.querySelector('[data-grad="0"]'), c1 = row.querySelector('[data-grad="1"]');
|
||||||
obj.gradient = [ (c0 && c0.value) || '#06D6E0', (c1 && c1.value) || '#1b1b2e' ];
|
obj.gradient = [ (c0 && c0.value) || '#06D6E0', (c1 && c1.value) || '#1b1b2e' ];
|
||||||
@@ -981,6 +1254,7 @@
|
|||||||
});
|
});
|
||||||
p.querySelectorAll('[data-odel]').forEach(function (b) {
|
p.querySelectorAll('[data-odel]').forEach(function (b) {
|
||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
|
self.pushHistory();
|
||||||
var i = parseInt(b.getAttribute('data-odel'), 10);
|
var i = parseInt(b.getAttribute('data-odel'), 10);
|
||||||
var o = self.st.objects[i];
|
var o = self.st.objects[i];
|
||||||
if (o && o._uid === self._selObjId) self._selObjId = null;
|
if (o && o._uid === self._selObjId) self._selObjId = null;
|
||||||
@@ -992,7 +1266,7 @@
|
|||||||
p.querySelectorAll('[data-oup]').forEach(function (b) {
|
p.querySelectorAll('[data-oup]').forEach(function (b) {
|
||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
var i = parseInt(b.getAttribute('data-oup'), 10);
|
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);
|
self.renderPanels(); self.scheduleRemount(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1000,13 +1274,14 @@
|
|||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
var i = parseInt(b.getAttribute('data-odown'), 10);
|
var i = parseInt(b.getAttribute('data-odown'), 10);
|
||||||
var a = self.st.objects;
|
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);
|
self.renderPanels(); self.scheduleRemount(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// видимость: hidden:true -> объект не попадёт в buildSpec (движок не трогаем)
|
// видимость: hidden:true -> объект не попадёт в buildSpec (движок не трогаем)
|
||||||
p.querySelectorAll('[data-ohide]').forEach(function (b) {
|
p.querySelectorAll('[data-ohide]').forEach(function (b) {
|
||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
|
self.pushHistory();
|
||||||
var i = parseInt(b.getAttribute('data-ohide'), 10);
|
var i = parseInt(b.getAttribute('data-ohide'), 10);
|
||||||
var o = self.st.objects[i]; if (!o) return;
|
var o = self.st.objects[i]; if (!o) return;
|
||||||
if (o.hidden) delete o.hidden; else o.hidden = true;
|
if (o.hidden) delete o.hidden; else o.hidden = true;
|
||||||
@@ -1019,6 +1294,7 @@
|
|||||||
var i = parseInt(b.getAttribute('data-odup'), 10);
|
var i = parseInt(b.getAttribute('data-odup'), 10);
|
||||||
if (self.st.objects.length + self.st.plots.length >= LIMITS.objects) { global.LS.toast('Достигнут лимит объектов', 'warn'); return; }
|
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;
|
var o = self.st.objects[i]; if (!o) return;
|
||||||
|
self.pushHistory();
|
||||||
var clone = JSON.parse(JSON.stringify(o));
|
var clone = JSON.parse(JSON.stringify(o));
|
||||||
clone._uid = uid('o');
|
clone._uid = uid('o');
|
||||||
if (clone.id) clone.id = clone.id + '_copy';
|
if (clone.id) clone.id = clone.id + '_copy';
|
||||||
@@ -1050,6 +1326,7 @@
|
|||||||
row.querySelectorAll('[data-plf]').forEach(function (el) {
|
row.querySelectorAll('[data-plf]').forEach(function (el) {
|
||||||
var evt = el.type === 'checkbox' ? 'change' : 'input';
|
var evt = el.type === 'checkbox' ? 'change' : 'input';
|
||||||
el.addEventListener(evt, function () {
|
el.addEventListener(evt, function () {
|
||||||
|
if (el.type === 'checkbox') self.pushHistory(); else self.snapField();
|
||||||
var k = el.getAttribute('data-plf');
|
var k = el.getAttribute('data-plf');
|
||||||
if (el.type === 'checkbox') self.st.plots[i][k] = el.checked;
|
if (el.type === 'checkbox') self.st.plots[i][k] = el.checked;
|
||||||
else self.st.plots[i][k] = el.value;
|
else self.st.plots[i][k] = el.value;
|
||||||
@@ -1063,6 +1340,7 @@
|
|||||||
cr.querySelectorAll('[data-cvf]').forEach(function (el) {
|
cr.querySelectorAll('[data-cvf]').forEach(function (el) {
|
||||||
var evt = (el.type === 'checkbox') ? 'change' : 'input';
|
var evt = (el.type === 'checkbox') ? 'change' : 'input';
|
||||||
el.addEventListener(evt, function () {
|
el.addEventListener(evt, function () {
|
||||||
|
if (el.type === 'checkbox') self.pushHistory(); else self.snapField();
|
||||||
var k = el.getAttribute('data-cvf');
|
var k = el.getAttribute('data-cvf');
|
||||||
var cv = self.st.plots[i].curves[ci]; if (!cv) return;
|
var cv = self.st.plots[i].curves[ci]; if (!cv) return;
|
||||||
if (el.type === 'checkbox') {
|
if (el.type === 'checkbox') {
|
||||||
@@ -1087,7 +1365,7 @@
|
|||||||
var cdel = cr.querySelector('[data-curvedel]');
|
var cdel = cr.querySelector('[data-curvedel]');
|
||||||
if (cdel) cdel.addEventListener('click', function () {
|
if (cdel) cdel.addEventListener('click', function () {
|
||||||
var arr = self.st.plots[i].curves;
|
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);
|
self.renderPanels(); self.scheduleRemount(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1098,6 +1376,7 @@
|
|||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
var i = parseInt(b.getAttribute('data-curveadd'), 10);
|
var i = parseInt(b.getAttribute('data-curveadd'), 10);
|
||||||
var plt = self.st.plots[i]; if (!plt) return;
|
var plt = self.st.plots[i]; if (!plt) return;
|
||||||
|
self.pushHistory();
|
||||||
plt.curves = Array.isArray(plt.curves) ? plt.curves : [];
|
plt.curves = Array.isArray(plt.curves) ? plt.curves : [];
|
||||||
plt.curves.push(defaultCurve('', ''));
|
plt.curves.push(defaultCurve('', ''));
|
||||||
self.renderPanels(); self.scheduleRemount(false);
|
self.renderPanels(); self.scheduleRemount(false);
|
||||||
@@ -1105,6 +1384,7 @@
|
|||||||
});
|
});
|
||||||
p.querySelectorAll('[data-pltdel]').forEach(function (b) {
|
p.querySelectorAll('[data-pltdel]').forEach(function (b) {
|
||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
|
self.pushHistory();
|
||||||
self.st.plots.splice(parseInt(b.getAttribute('data-pltdel'), 10), 1);
|
self.st.plots.splice(parseInt(b.getAttribute('data-pltdel'), 10), 1);
|
||||||
self.renderPanels(); self.scheduleRemount(false);
|
self.renderPanels(); self.scheduleRemount(false);
|
||||||
});
|
});
|
||||||
@@ -1112,7 +1392,7 @@
|
|||||||
p.querySelectorAll('[data-pltup]').forEach(function (b) {
|
p.querySelectorAll('[data-pltup]').forEach(function (b) {
|
||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
var i = parseInt(b.getAttribute('data-pltup'), 10);
|
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);
|
self.renderPanels(); self.scheduleRemount(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1120,12 +1400,13 @@
|
|||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
var i = parseInt(b.getAttribute('data-pltdown'), 10);
|
var i = parseInt(b.getAttribute('data-pltdown'), 10);
|
||||||
var a = self.st.plots;
|
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);
|
self.renderPanels(); self.scheduleRemount(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
p.querySelectorAll('[data-plthide]').forEach(function (b) {
|
p.querySelectorAll('[data-plthide]').forEach(function (b) {
|
||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
|
self.pushHistory();
|
||||||
var i = parseInt(b.getAttribute('data-plthide'), 10);
|
var i = parseInt(b.getAttribute('data-plthide'), 10);
|
||||||
var o = self.st.plots[i]; if (!o) return;
|
var o = self.st.plots[i]; if (!o) return;
|
||||||
if (o.hidden) delete o.hidden; else o.hidden = true;
|
if (o.hidden) delete o.hidden; else o.hidden = true;
|
||||||
@@ -1136,11 +1417,13 @@
|
|||||||
// physics
|
// physics
|
||||||
var phEnabled = p.querySelector('[data-phys="enabled"]');
|
var phEnabled = p.querySelector('[data-phys="enabled"]');
|
||||||
if (phEnabled) phEnabled.addEventListener('change', function () {
|
if (phEnabled) phEnabled.addEventListener('change', function () {
|
||||||
|
self.pushHistory();
|
||||||
self.st.physics.enabled = phEnabled.checked;
|
self.st.physics.enabled = phEnabled.checked;
|
||||||
self.renderPanels(); self.scheduleRemount(false);
|
self.renderPanels(); self.scheduleRemount(false);
|
||||||
});
|
});
|
||||||
p.querySelectorAll('[data-phf]').forEach(function (el) {
|
p.querySelectorAll('[data-phf]').forEach(function (el) {
|
||||||
el.addEventListener('input', function () {
|
el.addEventListener('input', function () {
|
||||||
|
self.snapField();
|
||||||
var k = el.getAttribute('data-phf');
|
var k = el.getAttribute('data-phf');
|
||||||
self.st.physics[k] = el.value === '' ? '' : parseFloat(el.value);
|
self.st.physics[k] = el.value === '' ? '' : parseFloat(el.value);
|
||||||
self.scheduleRemount(false);
|
self.scheduleRemount(false);
|
||||||
@@ -1150,6 +1433,7 @@
|
|||||||
var i = parseInt(row.getAttribute('data-wi'), 10);
|
var i = parseInt(row.getAttribute('data-wi'), 10);
|
||||||
row.querySelectorAll('[data-wf]').forEach(function (el) {
|
row.querySelectorAll('[data-wf]').forEach(function (el) {
|
||||||
el.addEventListener('input', function () {
|
el.addEventListener('input', function () {
|
||||||
|
self.snapField();
|
||||||
var k = el.getAttribute('data-wf');
|
var k = el.getAttribute('data-wf');
|
||||||
self.st.physics.walls[i][k] = (k === 'side') ? el.value : el.value;
|
self.st.physics.walls[i][k] = (k === 'side') ? el.value : el.value;
|
||||||
if (k === 'side') { self.renderPanels(); }
|
if (k === 'side') { self.renderPanels(); }
|
||||||
@@ -1160,6 +1444,7 @@
|
|||||||
});
|
});
|
||||||
p.querySelectorAll('[data-wdel]').forEach(function (b) {
|
p.querySelectorAll('[data-wdel]').forEach(function (b) {
|
||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
|
self.pushHistory();
|
||||||
self.st.physics.walls.splice(parseInt(b.getAttribute('data-wdel'), 10), 1);
|
self.st.physics.walls.splice(parseInt(b.getAttribute('data-wdel'), 10), 1);
|
||||||
self.renderPanels(); self.scheduleRemount(false);
|
self.renderPanels(); self.scheduleRemount(false);
|
||||||
});
|
});
|
||||||
@@ -1168,6 +1453,7 @@
|
|||||||
var i = parseInt(row.getAttribute('data-spi'), 10);
|
var i = parseInt(row.getAttribute('data-spi'), 10);
|
||||||
row.querySelectorAll('[data-spf]').forEach(function (el) {
|
row.querySelectorAll('[data-spf]').forEach(function (el) {
|
||||||
el.addEventListener('input', function () {
|
el.addEventListener('input', function () {
|
||||||
|
self.snapField();
|
||||||
var k = el.getAttribute('data-spf');
|
var k = el.getAttribute('data-spf');
|
||||||
self.st.physics.springs[i][k] = (el.type === 'number') ? (el.value === '' ? '' : parseFloat(el.value)) : el.value;
|
self.st.physics.springs[i][k] = (el.type === 'number') ? (el.value === '' ? '' : parseFloat(el.value)) : el.value;
|
||||||
self.scheduleRemount(false);
|
self.scheduleRemount(false);
|
||||||
@@ -1176,6 +1462,7 @@
|
|||||||
});
|
});
|
||||||
p.querySelectorAll('[data-spdel]').forEach(function (b) {
|
p.querySelectorAll('[data-spdel]').forEach(function (b) {
|
||||||
b.addEventListener('click', function () {
|
b.addEventListener('click', function () {
|
||||||
|
self.pushHistory();
|
||||||
self.st.physics.springs.splice(parseInt(b.getAttribute('data-spdel'), 10), 1);
|
self.st.physics.springs.splice(parseInt(b.getAttribute('data-spdel'), 10), 1);
|
||||||
self.renderPanels(); self.scheduleRemount(false);
|
self.renderPanels(); self.scheduleRemount(false);
|
||||||
});
|
});
|
||||||
@@ -1222,14 +1509,17 @@
|
|||||||
Builder.prototype.onAdd = function (what) {
|
Builder.prototype.onAdd = function (what) {
|
||||||
if (what === 'param') {
|
if (what === 'param') {
|
||||||
if (this.st.params.length >= LIMITS.params) { global.LS.toast('Достигнут лимит параметров', 'warn'); return; }
|
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: '' });
|
this.st.params.push({ name: '', label: '', min: 0, max: 10, step: 1, value: 0, unit: '' });
|
||||||
} else if (what === 'object') {
|
} else if (what === 'object') {
|
||||||
if (this.st.objects.length + this.st.plots.length >= LIMITS.objects) { global.LS.toast('Достигнут лимит объектов', 'warn'); return; }
|
if (this.st.objects.length + this.st.plots.length >= LIMITS.objects) { global.LS.toast('Достигнут лимит объектов', 'warn'); return; }
|
||||||
var sel = this.panelHost.querySelector('#sbu-newtype');
|
var sel = this.panelHost.querySelector('#sbu-newtype');
|
||||||
var type = sel ? sel.value : 'point';
|
var type = sel ? sel.value : 'point';
|
||||||
|
this.pushHistory();
|
||||||
this.st.objects.push(defaultObject(type));
|
this.st.objects.push(defaultObject(type));
|
||||||
} else if (what === 'plot') {
|
} else if (what === 'plot') {
|
||||||
if (this.st.objects.length + this.st.plots.length >= LIMITS.objects) { global.LS.toast('Достигнут лимит объектов', 'warn'); return; }
|
if (this.st.objects.length + this.st.plots.length >= LIMITS.objects) { global.LS.toast('Достигнут лимит объектов', 'warn'); return; }
|
||||||
|
this.pushHistory();
|
||||||
this.st.plots.push({
|
this.st.plots.push({
|
||||||
_uid: uid('plt'), type: 'plot', 'var': 'x', range_a: '', range_b: '', samples: '',
|
_uid: uid('plt'), type: 'plot', 'var': 'x', range_a: '', range_b: '', samples: '',
|
||||||
trace: false, legend: true, plotFill: false, plotMarker: 'none',
|
trace: false, legend: true, plotFill: false, plotMarker: 'none',
|
||||||
@@ -1237,9 +1527,11 @@
|
|||||||
});
|
});
|
||||||
} else if (what === 'wall') {
|
} else if (what === 'wall') {
|
||||||
if (this.st.physics.walls.length >= LIMITS.walls) { global.LS.toast('Достигнут лимит стен', 'warn'); return; }
|
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: '' });
|
this.st.physics.walls.push({ _uid: uid('w'), side: 'bottom', x1: '', y1: '', x2: '', y2: '' });
|
||||||
} else if (what === 'spring') {
|
} else if (what === 'spring') {
|
||||||
if (this.st.physics.springs.length >= LIMITS.springs) { global.LS.toast('Достигнут лимит пружин', 'warn'); return; }
|
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.st.physics.springs.push({ _uid: uid('s'), a: '', b: '', k: 40, length: 2, damping: 0.5 });
|
||||||
}
|
}
|
||||||
this.renderPanels();
|
this.renderPanels();
|
||||||
@@ -1383,7 +1675,8 @@
|
|||||||
var idx = parseInt(row.getAttribute('data-oi'), 10);
|
var idx = parseInt(row.getAttribute('data-oi'), 10);
|
||||||
obj = this.st.objects[idx];
|
obj = this.st.objects[idx];
|
||||||
if (!obj) return;
|
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 + '"]');
|
var inp = row.querySelector('[data-of="' + k + '"]');
|
||||||
if (inp && obj[k] != null) inp.value = obj[k];
|
if (inp && obj[k] != null) inp.value = obj[k];
|
||||||
});
|
});
|
||||||
@@ -1627,7 +1920,10 @@
|
|||||||
copy: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
|
copy: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
|
||||||
eye: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg>',
|
eye: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg>',
|
||||||
eyeOff: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>',
|
eyeOff: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>',
|
||||||
clearX: '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
|
clearX: '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
||||||
|
undo: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M3 7v6h6"/><path d="M3 13a9 9 0 1 0 3-7.7L3 8"/></svg>',
|
||||||
|
redo: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 7v6h-6"/><path d="M21 13a9 9 0 1 1-3-7.7L21 8"/></svg>',
|
||||||
|
grid: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><rect x="3" y="3" width="18" height="18" rx="1.5"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg>'
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Встроенные шаблоны стартовых спек (Фаза 6) ──────────────────────────
|
/* ── Встроенные шаблоны стартовых спек (Фаза 6) ──────────────────────────
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
# Feature Context: Конструктор симуляций (SimForge)
|
# Feature Context: Конструктор симуляций (SimForge)
|
||||||
|
|
||||||
## Current State
|
## 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 билдера + контролы стиля» РЕАЛИЗОВАН** (рабочее дерево, не
|
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P4 «UI билдера + контролы стиля» РЕАЛИЗОВАН** (рабочее дерево, не
|
||||||
закоммичено; ветка `feature/sim-builder`). Файлы: только `frontend/sim-builder.html` + `frontend/js/sim-builder.js`.
|
закоммичено; ветка `feature/sim-builder`). Файлы: только `frontend/sim-builder.html` + `frontend/js/sim-builder.js`.
|
||||||
`_sim_engine.js`/`js/api.js`/lab.* НЕ тронуты — билдер лишь генерит спеку, которую движок (P2/P3) уже умеет рисовать.
|
`_sim_engine.js`/`js/api.js`/lab.* НЕ тронуты — билдер лишь генерит спеку, которую движок (P2/P3) уже умеет рисовать.
|
||||||
|
|||||||
@@ -126,8 +126,39 @@
|
|||||||
+ snap-к-сетке + выравнивание (нужны правки `_sim_engine.js` — хит-тесты/ручки). Undo/redo: состояние
|
+ snap-к-сетке + выравнивание (нужны правки `_sim_engine.js` — хит-тесты/ручки). Undo/redo: состояние
|
||||||
= `this.st` (сериализуемо JSON); снимать снапшот при `onAdd`/удалении/правке (debounce) — стек в
|
= `this.st` (сериализуемо JSON); снимать снапшот при `onAdd`/удалении/правке (debounce) — стек в
|
||||||
Builder, перерисовка `renderPanels`+`scheduleRemount`. Идентичность спеки между билдами уже гарантирована.
|
Builder, перерисовка `renderPanels`+`scheduleRemount`. Идентичность спеки между билдами уже гарантирована.
|
||||||
- [ ] **P5 — Прямое манипулирование на сцене + история.** Drag всех типов (не только point/circle),
|
- [x] **P5 — Прямое манипулирование на сцене + история.** Drag всех типов (не только point/circle),
|
||||||
snap-к-сетке, выравнивание; undo/redo в билдере. Файлы: `_sim_engine.js`, `frontend/js/sim-builder.js`.
|
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
|
## Progress
|
||||||
| Phase | Status | Review | Committed |
|
| Phase | Status | Review | Committed |
|
||||||
@@ -136,4 +167,4 @@
|
|||||||
| P2 Object graphics | Done | ✅ PASS | ✅ |
|
| P2 Object graphics | Done | ✅ PASS | ✅ |
|
||||||
| P3 Charts | Done | ✅ PASS | ✅ |
|
| P3 Charts | Done | ✅ PASS | ✅ |
|
||||||
| P4 Builder UI | Done | ✅ PASS | ✅ |
|
| P4 Builder UI | Done | ✅ PASS | ✅ |
|
||||||
| P5 Direct manip + history | ⬜ | ⬜ | ⬜ |
|
| P5 Direct manip + history | Done | ✅ PASS | ✅ |
|
||||||
|
|||||||
Reference in New Issue
Block a user