Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34afdafcb1 | |||
| 225e252e3c | |||
| 6743dfcbce | |||
| b6f854fc77 | |||
| 69e219ae8c |
@@ -158,3 +158,30 @@ git push origin master
|
||||
- **Палитра по умолчанию** `DEFAULT_PALETTE` (8 холодно-ярких тонов) — циклически `[i % 8]` в `_prepareObjects`, только если `color` не задан в спеке; явный color сохраняется.
|
||||
- **Верификация P2**: `node --check` OK; headless-смоук (vm + DOM/canvas-стаб со счётчиками вызовов и проверкой баланса save/restore + РЕАЛЬНЫЕ `_sim_expr.js`+`_sim_engine.js`) 23/23: 18-объектная спека (все типы + все новые поля) ×4 кадра без throw; **ctx не протекает** (depth=0, globalAlpha→1, shadowBlur→0, lineDash→[] после кадра); setLineDash/createLinearGradient/fill/stroke/arc вызваны; поля прочитаны; палитра+явный color; трасса накоплена; destroy чист. Эмодзи нет (скан: только пре-существующие →/─/═/∞ в комментариях); eval=0; new Function — только в комментарии стр.15.
|
||||
- **На P3 (графики/диаграммы)**: `_drawPlot` уже зовёт `_applyStroke`. Расширять `_drawPlot` — оси-деления plot, несколько кривых, заливка под кривой, маркеры (переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/`_drawPoint` готовы к переиспользованию.
|
||||
|
||||
### SimForge improvements — P3 (Графики/диаграммы) — Learnings
|
||||
|
||||
Всё в `frontend/js/labs/_sim_engine.js`. Расширен `_drawPlot` + ветка `type==='plot'` в `_prepareObjects`. Оси/сетка/подписи уже из P1 — в P3 не дублировались.
|
||||
|
||||
- **Несколько кривых.** Нормализуются в `prep.curves[]` с приоритетом источника: `curves:[{...}]` → `exprs:['sin(x)','x^2']` → одиночный `expr` (легаси, обратная совместимость). Каждой кривой свой цвет: явный `color` или `DEFAULT_PALETTE[i%8]`. `prep.exprFn` оставлен = первой кривой (нужен trace-режиму `_accumPlotTrace`).
|
||||
- **Поля кривой** (`curves[i]`): `expr`, `color`, `label`(→легенда), `width`, `lineStyle`(solid|dashed|dotted), `opacity`(0..1), `fill`(true→полупрозр. цвет кривой / строка цвета), `marker`(none|dot|ring). Не заданные наследуют plot-уровень (`width/lineStyle/opacity`). **Plot-уровневые `fill`/`marker`** — дефолт для всех кривых.
|
||||
- **Заливка под кривой** — `_fillUnderCurve(ctx,pts,baseY)`: между кривой и осью `y=0` (baseY клиппится к canvas), посегментно — разрывы у не-finite точек НЕ сливаются в один полигон. `fill:true` → `_fillAlpha(color,0.18)` (#RGB/#RRGGBB→rgba; прочие форматы как есть, alpha через globalAlpha).
|
||||
- **Маркеры узлов** — `_drawCurveMarkers` переиспользует `_drawPoint` (dot→filled, ring→hollow), прорежены ~28px по экрану (не сотни точек на 200 сэмплах).
|
||||
- **Легенда** — `_drawLegend` на canvas (НЕ DOM): тёмная плашка (`roundRect` с фолбэком на `fillRect`) + цветной свотч (strokeStyle цвета кривой) + светлый `fillText`. Верх-право, не наезжает на бар кнопок вида. Авто при наличии `label`; `legend:false` отключает. ⛔ Пользовательский цвет — только canvas-сток; текст легенды — фикс. светлый.
|
||||
- **Качество кривой** — пропуск не-finite (разрывы через `started=false`), переиспользован equidistant sampling (`samples` 200/макс 2000), `_applyStroke` даёт dash/opacity/glow/round-стыки. Каждая кривая в своём `ctx.save()/restore()`, легенда — на внешнем уровне → состояние не протекает.
|
||||
- **Новые хелперы модульного уровня** (рядом с `_dashFor`/`_opacity`): `_markerStyle(v)` (none|dot|ring), `_fillAlpha(color,a)` (hex→rgba для заливки).
|
||||
- **Верификация P3**: `node --check` OK; headless vm-смоук (canvas-стаб со счётчиком save/restore + РЕАЛЬНЫЕ `_sim_expr`+`_sim_engine`) 10/10: легаси одиночный expr, exprs[], curves[]+fill+marker+legend, наследование plot-уровня, не-finite (1/x, tan) без throw, legend:false, trace±range, fillUnder+markers с null-разрывами, регресс point/vector/circle/rect — все PASS; **ctx сбалансирован** (depth→0, нет restore-underflow). Эмодзи нет (только пре-существующие → в комментариях); eval=0. Temp-смоук удалён.
|
||||
- **На P4 (билдер)**: дать полям контролы — список кривых (add/del, expr+color+label+width+lineStyle+opacity+fill+marker), plot-уровневые fill/marker, тумблер легенды.
|
||||
|
||||
### SimForge improvements — P4 (UI билдера + контролы стиля) — Learnings
|
||||
|
||||
Всё в `frontend/sim-builder.html` (CSS) + `frontend/js/sim-builder.js` (логика). `_sim_engine.js`/`js/api.js`/lab.* НЕ тронуты — билдер только генерит спеку, которую движок P2/P3 уже рисует.
|
||||
|
||||
- **Контролы стиля = data-driven хелперы** (рядом с `field`/`miniField`): `colorCtl(label,attr,val,clearable)` (нативный `<input type=color>` + текст + опц.очистка), `rangeCtl` (слайдер 0..1 для opacity), `selectCtl` (lineStyle/pointStyle/marker). Блок «Стиль» в каждом объекте — `Builder.styleBlock(o)`, набор полей решает `STYLE_FOR[type]` ({opacity,line,point,glow,grad}).
|
||||
- **Цвет: текст — источник истины, не нативный пикер.** Нативный `<input type=color>` умеет только `#rrggbb`; rgba()/named он бы потерял. Поэтому пикер лишь ПИШЕТ в текстовое поле и диспатчит `input` (его ловит основной `data-of`/`data-cvf`-обработчик). `Builder.wireColorControls(row)` связывает пикер↔текст↔очистку. `toHexColor(v)` приводит #rgb/#rrggbb→#rrggbb (иначе #000000) для нативного пикера. Очистка (fill/trailColor) = пустая строка → `stripObj` выбрасывает → «нет заливки».
|
||||
- **Round-trip как чинили range в Ф4: дефолты НЕ сериализуем.** `stripObj.isDefaultStyle(k,v)` выбрасывает `hidden`, `glow:false`, `lineStyle:'solid'`, `pointStyle:'filled'`, `opacity:1`, `trail/closed:false`. Спека минимальна, а save→load→save идемпотентен (loadFromSim восстанавливает дефолты из контролов). Селекты хранят дефолтную строку в `st`, но она не уходит в спеку. Проверено vm-смоуком.
|
||||
- **Plot теперь — кривые.** UI-модель plot = `{var,range_a/b,samples,trace,legend,plotFill,plotMarker,curves:[{_uid,expr,color,label,width,lineStyle,opacity,fill(bool),fillColor,marker}]}`. `plotEditor`+`curveEditor` рисуют, `loadPlot` (spec→UI: `curves[]`→`exprs[]`→легаси `expr`; легаси plot-level width/lineStyle/opacity наследуются кривой), `normalizePlotForSpec`+`stripCurve` (UI→spec). **Одиночная «простая» кривая (только expr+color, без plot-fill/marker) → легаси `{expr,color}`**, иначе `curves:[...]` — не ломает обратную совместимость. `legend:false` эмитится только при выкл (движок включает легенду авто при label). Валидация: каждая кривая + границы range через `SimExpr.compile`.
|
||||
- **z-order / видимость / дублирование — чисто в билдере** (движок не трогали): z-order = порядок массива `st.objects`/`st.plots` (кнопки вверх/вниз свапают, крайние disabled). Видимость `hidden:true` — билдерский флаг, `buildSpec` фильтрует hidden из спеки (движок про hidden не знает). Дублирование — `JSON.parse(JSON.stringify(o))` + новый `_uid` + `id+'_copy'`, вставка после оригинала. Аналогично у plot.
|
||||
- **Новые ICON** (inline SVG `.ic`, ⛔ без эмодзи): up/down/copy/eye/eyeOff/clearX. Новые CSS-классы в ls.css-стиле; заголовок объекта `flex-wrap` + 26px-кнопки; медиа ≤920px (была) + новый ≤560px (поля/стили в один столбец).
|
||||
- **Верификация P4**: `node --check` sim-builder.js + извлечённого инлайна html — OK; эмодзи/eval/new Function — 0 (скан кодпойнтов обоих файлов); headless vm-смоук (DOM/SimExpr-стаб) 27+12+2 PASS: стили объекта в спеке, round-trip объектов ×2 идемпотентен, plot с 2 кривыми (все поля) + round-trip ×2, легаси-одиночная→легаси-форма, hidden исключён, z-order=порядок, дефолты-стрип, шаблонные легаси-plot save→load→save стабильны. Temp удалены. git status: только sim-builder.html и sim-builder.js.
|
||||
- **На P5 (прямое манипулирование + история)**: drag сейчас только x/y point/circle/label/readout/rect + конец segment/vector (`bindPreviewDrag` через `inst._toWorld`). Расширять до всех типов + snap-к-сетке + выравнивание (нужны хит-тесты/ручки в `_sim_engine.js`). Undo/redo: `this.st` сериализуем JSON → стек снапшотов в Builder, restore + `renderPanels`/`scheduleRemount`.
|
||||
|
||||
@@ -525,7 +525,7 @@ function getFeatures(_req, res) {
|
||||
function updateFeatures(req, res) {
|
||||
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
|
||||
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom',
|
||||
'gamification', 'assistant'];
|
||||
'gamification', 'assistant', 'sim_builder'];
|
||||
const updates = req.body;
|
||||
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
|
||||
const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?");
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const db = require('../db/db');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const sharp = require('sharp');
|
||||
const { UPLOADS_DIR } = require('../config');
|
||||
|
||||
const { checkMagicBytes } = require('../utils/magic');
|
||||
@@ -174,41 +173,16 @@ function uploadFile(req, res) {
|
||||
* teacher library upload above. Image-only; saved into uploads/materials and
|
||||
* served statically (public), so the returned URL renders in <img>, opens in
|
||||
* a new tab and downloads without an auth header. Returns { url }. */
|
||||
async function uploadPersonalFile(req, res) {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
function uploadPersonalFile(req, res) {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const filePath = path.resolve(UPLOADS_DIR, 'materials', req.file.filename);
|
||||
if (!checkMagicBytes(filePath, req.file.mimetype)) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' });
|
||||
}
|
||||
|
||||
// Per-user storage quota: reject before the file becomes usable. Accounting
|
||||
// is by student_materials.bytes (the uploaded file is not a material yet).
|
||||
const used = db.prepare('SELECT COALESCE(SUM(bytes),0) AS b FROM student_materials WHERE user_id = ?').get(req.user.id);
|
||||
const maxBytes = Number(process.env.MATERIALS_MAX_BYTES) || 300 * 1024 * 1024; // 300 MB
|
||||
if (used.b + (req.file.size || 0) > maxBytes) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
return res.status(413).json({ error: 'Превышен лимит хранилища материалов' });
|
||||
}
|
||||
|
||||
const url = '/uploads/materials/' + req.file.filename;
|
||||
// Server-side thumbnail (downscaled webp) for fast grid rendering; the full
|
||||
// image stays for viewing/annotating/download. Best-effort — on any failure
|
||||
// (animated gif, decode error) thumbUrl is null and the client uses `url`.
|
||||
let thumbUrl = null;
|
||||
try {
|
||||
const thumbName = path.basename(req.file.filename, path.extname(req.file.filename)) + '_thumb.webp';
|
||||
const thumbPath = path.resolve(UPLOADS_DIR, 'materials', thumbName);
|
||||
await sharp(filePath).rotate().resize(480, 480, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 78 }).toFile(thumbPath);
|
||||
thumbUrl = '/uploads/materials/' + thumbName;
|
||||
} catch (e) { thumbUrl = null; }
|
||||
|
||||
return res.status(201).json({ url, thumbUrl });
|
||||
} catch (e) {
|
||||
return res.status(500).json({ error: 'Upload failed' });
|
||||
const filePath = path.resolve(UPLOADS_DIR, 'materials', req.file.filename);
|
||||
if (!checkMagicBytes(filePath, req.file.mimetype)) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' });
|
||||
}
|
||||
|
||||
res.status(201).json({ url: '/uploads/materials/' + req.file.filename });
|
||||
}
|
||||
|
||||
/* ── GET /api/files/:id/download ─────────────────────────────────────── */
|
||||
|
||||
@@ -2,69 +2,16 @@
|
||||
/* Student-owned personal materials ("Мои материалы").
|
||||
* A user keeps copies of items saved from live lessons; the copies are
|
||||
* independent of the session lifecycle. */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const db = require('../db/db');
|
||||
const { emit } = require('../sse');
|
||||
|
||||
const KINDS = ['board', 'note', 'link', 'image'];
|
||||
|
||||
// Personal uploads live here (mirrors fileController MATERIALS_DIR). Used for
|
||||
// reference-counted file cleanup when the material(s) pointing at a file go away.
|
||||
const MATERIALS_DIR = path.resolve(__dirname, '..', '..', 'uploads', 'materials');
|
||||
|
||||
// Soft per-user cap on the number of materials. Read at call time so tests can
|
||||
// lower it via env. Byte quota is enforced separately at the upload endpoint.
|
||||
function maxItems() { return Number(process.env.MATERIALS_MAX_ITEMS) || 2000; }
|
||||
|
||||
// Storable URLs are app-relative ("/…", not protocol-relative "//host") or
|
||||
// http(s). Everything else (javascript:, data:, mailto:, …) is rejected: a saved
|
||||
// link is rendered as <a href> on the owner's page AND can be handed out to a
|
||||
// whole class via /share, so a bad scheme would be stored XSS.
|
||||
// Returns the (length-capped) url, '' for empty, or undefined when invalid.
|
||||
function safeUrl(raw) {
|
||||
const u = String(raw == null ? '' : raw).trim();
|
||||
if (!u) return '';
|
||||
if (/^https?:\/\//i.test(u)) return u.slice(0, 2000);
|
||||
if (u[0] === '/' && u[1] !== '/') return u.slice(0, 2000);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Size on disk of a local materials file (0 if absent / non-local).
|
||||
function fileBytes(u) {
|
||||
if (typeof u !== 'string' || !u.startsWith('/uploads/materials/')) return 0;
|
||||
try { return fs.statSync(path.join(MATERIALS_DIR, path.basename(u))).size; } catch (e) { return 0; }
|
||||
}
|
||||
|
||||
// Bytes attributed to a material for quota accounting (server-measured): for
|
||||
// image/board it's the full file + its thumbnail.
|
||||
function measureBytes(kind, url, body, thumbUrl) {
|
||||
if (kind === 'note') return Buffer.byteLength(String(body || ''), 'utf8');
|
||||
if (kind === 'image' || kind === 'board') return fileBytes(url) + fileBytes(thumbUrl);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Reference-counted cleanup: unlink the file backing `url` only when NO material
|
||||
// row references it any more — as either its `url` OR its `thumb_url` (share/
|
||||
// annotate can alias one physical file across rows and columns). Call AFTER the
|
||||
// delete/url-update so the freed row no longer counts. Exported for tests.
|
||||
function releaseFileForUrl(url) {
|
||||
if (typeof url !== 'string' || !url.startsWith('/uploads/materials/')) return;
|
||||
if (db.prepare('SELECT 1 FROM student_materials WHERE url = ? OR thumb_url = ? LIMIT 1').get(url, url)) return;
|
||||
const fp = path.join(MATERIALS_DIR, path.basename(url));
|
||||
if (fp.startsWith(MATERIALS_DIR + path.sep)) { try { fs.unlinkSync(fp); } catch (e) { /* already gone */ } }
|
||||
}
|
||||
|
||||
/* GET /api/materials — list the current user's saved materials + their collections */
|
||||
function list(req, res) {
|
||||
const uid = req.user.id;
|
||||
// Return only a body PREVIEW (first 1000 chars) to keep the payload small for
|
||||
// note-heavy users; the full text is fetched on demand via GET /:id. body_trunc
|
||||
// tells the client a lazy fetch is needed before viewing/editing the note.
|
||||
const materials = db.prepare(`
|
||||
SELECT id, kind, title, substr(body, 1, 1000) AS body,
|
||||
(CASE WHEN body IS NOT NULL AND length(body) > 1000 THEN 1 ELSE 0 END) AS body_trunc,
|
||||
url, thumb_url, source_session_id, source_title, collection_id, tags, created_at
|
||||
SELECT id, kind, title, body, url, source_session_id, source_title, collection_id, tags, created_at
|
||||
FROM student_materials
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
@@ -79,19 +26,6 @@ function list(req, res) {
|
||||
res.json({ materials, collections });
|
||||
}
|
||||
|
||||
/* GET /api/materials/:id — one material with its FULL body (lazy-loaded by the
|
||||
client when a note's preview was truncated). Owner-only. */
|
||||
function getOne(req, res) {
|
||||
const row = db.prepare(`
|
||||
SELECT id, user_id, kind, title, body, url, thumb_url, source_session_id, source_title, collection_id, tags, created_at
|
||||
FROM student_materials WHERE id = ?
|
||||
`).get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'not found' });
|
||||
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' });
|
||||
delete row.user_id;
|
||||
res.json(row);
|
||||
}
|
||||
|
||||
/* Validate that a collection id belongs to the user; returns null if unset/invalid. */
|
||||
function ownCollectionId(raw, uid) {
|
||||
if (raw === null || raw === '' || raw === undefined) return null;
|
||||
@@ -108,21 +42,9 @@ function create(req, res) {
|
||||
const kind = String(b.kind || '');
|
||||
if (!KINDS.includes(kind)) return res.status(400).json({ error: 'invalid kind' });
|
||||
|
||||
if (db.prepare('SELECT COUNT(*) AS n FROM student_materials WHERE user_id = ?').get(req.user.id).n >= maxItems())
|
||||
return res.status(413).json({ error: 'Достигнут лимит числа материалов' });
|
||||
|
||||
const title = String(b.title || '').slice(0, 300);
|
||||
const body = b.body != null ? String(b.body).slice(0, 60000) : null;
|
||||
let url = null;
|
||||
if (b.url != null && b.url !== '') {
|
||||
url = safeUrl(b.url);
|
||||
if (url === undefined) return res.status(400).json({ error: 'Недопустимый адрес ссылки' });
|
||||
}
|
||||
let thumbUrl = null;
|
||||
if (b.thumbUrl != null && b.thumbUrl !== '') {
|
||||
thumbUrl = safeUrl(b.thumbUrl);
|
||||
if (thumbUrl === undefined) return res.status(400).json({ error: 'Недопустимый адрес миниатюры' });
|
||||
}
|
||||
const url = b.url != null ? String(b.url).slice(0, 2000) : null;
|
||||
if ((kind === 'note') && !body) return res.status(400).json({ error: 'body required for note' });
|
||||
if ((kind === 'board' || kind === 'image' || kind === 'link') && !url)
|
||||
return res.status(400).json({ error: 'url required' });
|
||||
@@ -136,56 +58,38 @@ function create(req, res) {
|
||||
const collectionId = ownCollectionId(b.collection_id, req.user.id);
|
||||
const tags = b.tags != null ? String(b.tags).slice(0, 500) : null;
|
||||
|
||||
const bytes = measureBytes(kind, url, body, thumbUrl);
|
||||
const r = db.prepare(`
|
||||
INSERT INTO student_materials (user_id, kind, title, body, url, thumb_url, source_session_id, source_title, collection_id, tags, bytes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(req.user.id, kind, title, body, url, thumbUrl, sourceSessionId, sourceTitle, collectionId, tags, bytes);
|
||||
INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title, collection_id, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(req.user.id, kind, title, body, url, sourceSessionId, sourceTitle, collectionId, tags);
|
||||
res.status(201).json({ id: Number(r.lastInsertRowid) });
|
||||
}
|
||||
|
||||
/* PATCH /api/materials/:id — rename / edit one of the current user's items.
|
||||
Editable: title, body, url (e.g. re-saving an annotated image), collection_id, tags. */
|
||||
function update(req, res) {
|
||||
const row = db.prepare('SELECT user_id, url, thumb_url FROM student_materials WHERE id = ?').get(req.params.id);
|
||||
const row = db.prepare('SELECT user_id FROM student_materials WHERE id = ?').get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'not found' });
|
||||
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' });
|
||||
const b = req.body || {};
|
||||
const fields = [], args = [];
|
||||
if (b.title !== undefined) { fields.push('title = ?'); args.push(String(b.title || '').slice(0, 300)); }
|
||||
if (b.body !== undefined) { fields.push('body = ?'); args.push(b.body != null ? String(b.body).slice(0, 60000) : null); }
|
||||
if (b.url !== undefined) {
|
||||
let nu = null;
|
||||
if (b.url != null && b.url !== '') { nu = safeUrl(b.url); if (nu === undefined) return res.status(400).json({ error: 'Недопустимый адрес ссылки' }); }
|
||||
fields.push('url = ?'); args.push(nu);
|
||||
}
|
||||
if (b.thumbUrl !== undefined) {
|
||||
let nt = null;
|
||||
if (b.thumbUrl != null && b.thumbUrl !== '') { nt = safeUrl(b.thumbUrl); if (nt === undefined) return res.status(400).json({ error: 'Недопустимый адрес миниатюры' }); }
|
||||
fields.push('thumb_url = ?'); args.push(nt);
|
||||
}
|
||||
if (b.url !== undefined) { fields.push('url = ?'); args.push(b.url != null ? String(b.url).slice(0, 2000) : null); }
|
||||
if (b.collection_id !== undefined) { fields.push('collection_id = ?'); args.push(ownCollectionId(b.collection_id, req.user.id)); }
|
||||
if (b.tags !== undefined) { fields.push('tags = ?'); args.push(b.tags != null ? String(b.tags).slice(0, 500) : null); }
|
||||
if (!fields.length) return res.json({ ok: true });
|
||||
args.push(req.params.id);
|
||||
db.prepare(`UPDATE student_materials SET ${fields.join(', ')} WHERE id = ?`).run(...args);
|
||||
// Recompute quota bytes from the persisted row; free the old file(s) if the url
|
||||
// or thumbnail changed (annotate overwrites both) and nothing else references them.
|
||||
const cur = db.prepare('SELECT kind, url, thumb_url, body FROM student_materials WHERE id = ?').get(req.params.id);
|
||||
db.prepare('UPDATE student_materials SET bytes = ? WHERE id = ?').run(measureBytes(cur.kind, cur.url, cur.body, cur.thumb_url), req.params.id);
|
||||
if (b.url !== undefined && row.url && row.url !== cur.url) releaseFileForUrl(row.url);
|
||||
if (b.thumbUrl !== undefined && row.thumb_url && row.thumb_url !== cur.thumb_url) releaseFileForUrl(row.thumb_url);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* DELETE /api/materials/:id — remove one of the current user's items */
|
||||
function remove(req, res) {
|
||||
const row = db.prepare('SELECT user_id, url, thumb_url FROM student_materials WHERE id = ?').get(req.params.id);
|
||||
const row = db.prepare('SELECT user_id FROM student_materials WHERE id = ?').get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'not found' });
|
||||
if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' });
|
||||
db.prepare('DELETE FROM student_materials WHERE id = ?').run(req.params.id);
|
||||
releaseFileForUrl(row.url); // unlink the full image if no other material aliases it
|
||||
releaseFileForUrl(row.thumb_url); // …and its thumbnail
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -231,7 +135,7 @@ function deleteCollection(req, res) {
|
||||
a student. Each recipient gets an independent COPY (survives later edits/
|
||||
deletes by the teacher). Body: { classId } | { userId }. */
|
||||
function share(req, res) {
|
||||
const mat = db.prepare('SELECT user_id, kind, title, body, url, thumb_url FROM student_materials WHERE id = ?').get(req.params.id);
|
||||
const mat = db.prepare('SELECT user_id, kind, title, body, url FROM student_materials WHERE id = ?').get(req.params.id);
|
||||
if (!mat) return res.status(404).json({ error: 'not found' });
|
||||
if (mat.user_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' });
|
||||
|
||||
@@ -260,13 +164,12 @@ function share(req, res) {
|
||||
|
||||
const teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель';
|
||||
const srcTitle = 'Раздатка: ' + teacherName;
|
||||
const bytes = measureBytes(mat.kind, mat.url, mat.body, mat.thumb_url); // each copy counts toward the recipient's quota
|
||||
const ins = db.prepare(`INSERT INTO student_materials (user_id, kind, title, body, url, thumb_url, source_session_id, source_title, bytes) VALUES (?,?,?,?,?,?,NULL,?,?)`);
|
||||
const ins = db.prepare(`INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title) VALUES (?,?,?,?,?,NULL,?)`);
|
||||
let sent = 0;
|
||||
db.transaction(() => {
|
||||
for (const uid of recipients) {
|
||||
if (!uid || uid === req.user.id) continue;
|
||||
ins.run(uid, mat.kind, mat.title, mat.body, mat.url, mat.thumb_url, srcTitle, bytes);
|
||||
ins.run(uid, mat.kind, mat.title, mat.body, mat.url, srcTitle);
|
||||
try {
|
||||
emit(uid, { type: 'notification', notif_type: 'material_shared',
|
||||
message: `Новый материал от ${teacherName}: «${mat.title || 'материал'}»`, link: '/my-materials' });
|
||||
@@ -277,6 +180,4 @@ function share(req, res) {
|
||||
res.json({ ok: true, sent });
|
||||
}
|
||||
|
||||
module.exports = { list, getOne, create, update, remove, createCollection, updateCollection, deleteCollection, share,
|
||||
// exported for tests / reuse
|
||||
safeUrl, measureBytes, releaseFileForUrl };
|
||||
module.exports = { list, create, update, remove, createCollection, updateCollection, deleteCollection, share };
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 073: Storage accounting for «Мои материалы»
|
||||
--
|
||||
-- Per-material byte size, used to enforce a per-user storage quota and to free
|
||||
-- orphaned files. Populated on create/update:
|
||||
-- image|board → size of the file on disk (server-measured)
|
||||
-- note → text length
|
||||
-- link → 0
|
||||
-- Existing rows default to 0 (the next edit recomputes them; quota is a soft cap).
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE student_materials ADD COLUMN bytes INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -1,10 +0,0 @@
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 074: Thumbnail URL for image/board materials
|
||||
--
|
||||
-- Server-generated downscaled preview (sharp → webp, ≤480px) shown in the grid;
|
||||
-- the full image is still used for viewing / annotating / download. NULL when no
|
||||
-- thumb exists (generation failed, animated gif, or a non-uploaded url) — the
|
||||
-- client falls back to the full `url`.
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE student_materials ADD COLUMN thumb_url TEXT;
|
||||
@@ -6,29 +6,35 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { requireFeature } = require('../middleware/features');
|
||||
const c = require('../controllers/customSimController');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
// «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled).
|
||||
// Чтение/проигрывание уже сохранённых симуляций остаётся доступным; гейтим только
|
||||
// авторинг — создание/правку/удаление/раздачу/клон/связи.
|
||||
const gate = requireFeature('sim_builder');
|
||||
|
||||
router.get('/', c.list);
|
||||
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
|
||||
router.get('/:id', c.get);
|
||||
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
|
||||
router.get('/:id/related', c.related);
|
||||
|
||||
router.post('/', requireRole('teacher', 'admin'), c.create);
|
||||
router.post('/', gate, requireRole('teacher', 'admin'), c.create);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.put('/:id', requireRole('teacher', 'admin'), c.update);
|
||||
router.put('/:id', gate, requireRole('teacher', 'admin'), c.update);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.delete('/:id', requireRole('teacher', 'admin'), c.remove);
|
||||
router.delete('/:id', gate, requireRole('teacher', 'admin'), c.remove);
|
||||
|
||||
// Фаза 6 — раздача классу / клон / курикулумные связи. Мутации — inline
|
||||
// requireRole(teacher,admin) + per-row ownership в хендлере.
|
||||
router.post('/:id/share', requireRole('teacher', 'admin'), c.share);
|
||||
router.post('/:id/clone', requireRole('teacher', 'admin'), c.clone);
|
||||
router.post('/:id/share', gate, requireRole('teacher', 'admin'), c.share);
|
||||
router.post('/:id/clone', gate, requireRole('teacher', 'admin'), c.clone);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.post('/:id/links', requireRole('teacher', 'admin'), c.addLink);
|
||||
router.post('/:id/links', gate, requireRole('teacher', 'admin'), c.addLink);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.delete('/:id/links/:linkId', requireRole('teacher', 'admin'), c.removeLink);
|
||||
router.delete('/:id/links/:linkId', gate, requireRole('teacher', 'admin'), c.removeLink);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -19,8 +19,6 @@ router.patch('/collections/:id', c.updateCollection);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.delete('/collections/:id', c.deleteCollection);
|
||||
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.get('/:id', c.getOne);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.patch('/:id', c.update);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Integration tests: /api/materials — «Мои материалы» (v2 hardening).
|
||||
* Covers: auth, CRUD happy-path, ownership (чужой PATCH/DELETE → 403, 404),
|
||||
* collections (create / move / delete keeps material), share-копия (роль + owner
|
||||
* + привязка ученика), URL-allowlist (javascript: → 400), лимит числа материалов,
|
||||
* и ссылочно-подсчётную чистку файла (releaseFileForUrl на временном файле).
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { app, db, inject, getToken, cleanup } = require('./setup');
|
||||
|
||||
// setup.js не монтирует /api/materials — монтируем на общий тест-app.
|
||||
app.use('/api/materials', require('../src/routes/materials'));
|
||||
const ctrl = require('../src/controllers/studentMaterialsController');
|
||||
|
||||
after(() => cleanup());
|
||||
|
||||
describe('/api/materials', () => {
|
||||
let studentToken, studentId, otherToken, teacherToken, teacherId;
|
||||
|
||||
before(async () => {
|
||||
const s = await getToken('student'); studentToken = s.token; studentId = s.userId;
|
||||
otherToken = (await getToken('student')).token;
|
||||
const t = await getToken('teacher'); teacherToken = t.token; teacherId = t.userId;
|
||||
});
|
||||
|
||||
it('GET requires auth (401 without token)', async () => {
|
||||
const res = await inject('GET', '/api/materials', null, null);
|
||||
assert.equal(res.status, 401, `got ${res.status}`);
|
||||
});
|
||||
|
||||
let noteId;
|
||||
it('student can create a note (201) and list it back', async () => {
|
||||
const c = await inject('POST', '/api/materials', { kind: 'note', title: 'Закон Ома', body: 'U=IR' }, studentToken);
|
||||
assert.equal(c.status, 201, JSON.stringify(c.body));
|
||||
noteId = c.body.id;
|
||||
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||
assert.equal(l.status, 200);
|
||||
assert.ok(l.body.materials.some(m => m.id === noteId && m.title === 'Закон Ома'));
|
||||
});
|
||||
|
||||
it('accepts http(s) and app-relative link urls', async () => {
|
||||
const a = await inject('POST', '/api/materials', { kind: 'link', url: 'https://example.com/x' }, studentToken);
|
||||
assert.equal(a.status, 201, JSON.stringify(a.body));
|
||||
const b = await inject('POST', '/api/materials', { kind: 'link', url: '/textbook/phys7#sec-1' }, studentToken);
|
||||
assert.equal(b.status, 201, JSON.stringify(b.body));
|
||||
});
|
||||
|
||||
it('rejects a link with javascript: scheme (400) — stored-XSS guard', async () => {
|
||||
const res = await inject('POST', '/api/materials', { kind: 'link', url: 'javascript:alert(1)' }, studentToken);
|
||||
assert.equal(res.status, 400, JSON.stringify(res.body));
|
||||
});
|
||||
|
||||
it('rejects a protocol-relative url (400)', async () => {
|
||||
const res = await inject('POST', '/api/materials', { kind: 'link', url: '//evil.example.com' }, studentToken);
|
||||
assert.equal(res.status, 400, JSON.stringify(res.body));
|
||||
});
|
||||
|
||||
it('PATCH cannot smuggle a javascript: url (400)', async () => {
|
||||
const res = await inject('PATCH', `/api/materials/${noteId}`, { url: 'javascript:alert(1)' }, studentToken);
|
||||
assert.equal(res.status, 400, JSON.stringify(res.body));
|
||||
});
|
||||
|
||||
it('owner can rename; others get 403; missing → 404', async () => {
|
||||
const ok = await inject('PATCH', `/api/materials/${noteId}`, { title: 'Ом' }, studentToken);
|
||||
assert.equal(ok.status, 200);
|
||||
const forbidden = await inject('PATCH', `/api/materials/${noteId}`, { title: 'hack' }, otherToken);
|
||||
assert.equal(forbidden.status, 403);
|
||||
const missing = await inject('PATCH', '/api/materials/999999', { title: 'x' }, studentToken);
|
||||
assert.equal(missing.status, 404);
|
||||
});
|
||||
|
||||
it('list returns a 1000-char body preview; GET /:id returns the full body (owner only)', async () => {
|
||||
const big = 'x'.repeat(1500);
|
||||
const c = await inject('POST', '/api/materials', { kind: 'note', body: big }, studentToken);
|
||||
const id = c.body.id;
|
||||
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||
const row = l.body.materials.find(m => m.id === id);
|
||||
assert.equal(row.body.length, 1000, 'preview trimmed');
|
||||
assert.equal(row.body_trunc, 1, 'truncation flagged');
|
||||
const one = await inject('GET', `/api/materials/${id}`, null, studentToken);
|
||||
assert.equal(one.status, 200);
|
||||
assert.equal(one.body.body.length, 1500, 'full body returned');
|
||||
assert.equal(one.body.user_id, undefined, 'user_id not leaked');
|
||||
const forbidden = await inject('GET', `/api/materials/${id}`, null, otherToken);
|
||||
assert.equal(forbidden.status, 403);
|
||||
const missing = await inject('GET', '/api/materials/999999', null, studentToken);
|
||||
assert.equal(missing.status, 404);
|
||||
});
|
||||
|
||||
it('collections: create, move material in, delete keeps material (uncategorised)', async () => {
|
||||
const col = await inject('POST', '/api/materials/collections', { name: 'Физика' }, studentToken);
|
||||
assert.equal(col.status, 201, JSON.stringify(col.body));
|
||||
const cid = col.body.id;
|
||||
const mv = await inject('PATCH', `/api/materials/${noteId}`, { collection_id: cid }, studentToken);
|
||||
assert.equal(mv.status, 200);
|
||||
let l = await inject('GET', '/api/materials', null, studentToken);
|
||||
assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, cid);
|
||||
assert.equal(l.body.collections.find(c => c.id === cid).count, 1);
|
||||
const del = await inject('DELETE', `/api/materials/collections/${cid}`, null, studentToken);
|
||||
assert.equal(del.status, 200);
|
||||
l = await inject('GET', '/api/materials', null, studentToken);
|
||||
assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, null, 'material survives folder delete');
|
||||
});
|
||||
|
||||
it('moving into another user\'s collection is ignored (collection_id stays null)', async () => {
|
||||
const col = await inject('POST', '/api/materials/collections', { name: 'Чужая' }, otherToken);
|
||||
const foreignCid = col.body.id;
|
||||
const mv = await inject('PATCH', `/api/materials/${noteId}`, { collection_id: foreignCid }, studentToken);
|
||||
assert.equal(mv.status, 200);
|
||||
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||
assert.equal(l.body.materials.find(m => m.id === noteId).collection_id, null);
|
||||
});
|
||||
|
||||
it('share: student is role-gated (403)', async () => {
|
||||
const res = await inject('POST', `/api/materials/${noteId}/share`, { userId: studentId }, studentToken);
|
||||
assert.equal(res.status, 403, JSON.stringify(res.body));
|
||||
});
|
||||
|
||||
it('share: teacher → linked student copies the material; unlinked → 403', async () => {
|
||||
const tNote = await inject('POST', '/api/materials', { kind: 'note', title: 'Раздатка', body: 'привет' }, teacherToken);
|
||||
const tId = tNote.body.id;
|
||||
// not linked yet
|
||||
const denied = await inject('POST', `/api/materials/${tId}/share`, { userId: studentId }, teacherToken);
|
||||
assert.equal(denied.status, 403, JSON.stringify(denied.body));
|
||||
// link teacher → student, then share
|
||||
db.prepare('INSERT INTO teacher_students (teacher_id, student_id) VALUES (?, ?)').run(teacherId, studentId);
|
||||
const ok = await inject('POST', `/api/materials/${tId}/share`, { userId: studentId }, teacherToken);
|
||||
assert.equal(ok.status, 200, JSON.stringify(ok.body));
|
||||
assert.equal(ok.body.sent, 1);
|
||||
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||
assert.ok(l.body.materials.some(m => m.title === 'Раздатка' && /Раздатка:/.test(m.source_title || '')), 'student received a copy');
|
||||
});
|
||||
|
||||
it('enforces the per-user item cap (413)', async () => {
|
||||
const q = await getToken('student');
|
||||
const prev = process.env.MATERIALS_MAX_ITEMS;
|
||||
process.env.MATERIALS_MAX_ITEMS = '3';
|
||||
try {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const r = await inject('POST', '/api/materials', { kind: 'note', body: 'n' + i }, q.token);
|
||||
assert.equal(r.status, 201, `create #${i}: ${JSON.stringify(r.body)}`);
|
||||
}
|
||||
const over = await inject('POST', '/api/materials', { kind: 'note', body: 'overflow' }, q.token);
|
||||
assert.equal(over.status, 413, JSON.stringify(over.body));
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.MATERIALS_MAX_ITEMS; else process.env.MATERIALS_MAX_ITEMS = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('delete removes the row; owner only', async () => {
|
||||
const m = await inject('POST', '/api/materials', { kind: 'note', body: 'temp' }, studentToken);
|
||||
const id = m.body.id;
|
||||
const forbidden = await inject('DELETE', `/api/materials/${id}`, null, otherToken);
|
||||
assert.equal(forbidden.status, 403);
|
||||
const ok = await inject('DELETE', `/api/materials/${id}`, null, studentToken);
|
||||
assert.equal(ok.status, 200);
|
||||
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||
assert.ok(!l.body.materials.some(x => x.id === id));
|
||||
});
|
||||
|
||||
it('releaseFileForUrl: unlinks the file only when no material references it', () => {
|
||||
const dir = path.join(__dirname, '..', 'uploads', 'materials');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const fname = 'test_' + Date.now() + '.png';
|
||||
const fpath = path.join(dir, fname);
|
||||
const url = '/uploads/materials/' + fname;
|
||||
fs.writeFileSync(fpath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
const ins = db.prepare('INSERT INTO student_materials (user_id, kind, url) VALUES (?, ?, ?)');
|
||||
const r1 = ins.run(studentId, 'image', url).lastInsertRowid;
|
||||
const r2 = ins.run(studentId, 'image', url).lastInsertRowid; // aliasing copy (как при share)
|
||||
|
||||
ctrl.releaseFileForUrl(url);
|
||||
assert.ok(fs.existsSync(fpath), 'file kept while two rows reference it');
|
||||
|
||||
db.prepare('DELETE FROM student_materials WHERE id = ?').run(r1);
|
||||
ctrl.releaseFileForUrl(url);
|
||||
assert.ok(fs.existsSync(fpath), 'file kept while one row still references it');
|
||||
|
||||
db.prepare('DELETE FROM student_materials WHERE id = ?').run(r2);
|
||||
ctrl.releaseFileForUrl(url);
|
||||
assert.ok(!fs.existsSync(fpath), 'file unlinked once orphaned');
|
||||
});
|
||||
|
||||
it('safeUrl / measureBytes behave as documented', () => {
|
||||
assert.equal(ctrl.safeUrl('https://a.b/c'), 'https://a.b/c');
|
||||
assert.equal(ctrl.safeUrl('/textbook/x'), '/textbook/x');
|
||||
assert.equal(ctrl.safeUrl(''), '');
|
||||
assert.equal(ctrl.safeUrl('javascript:x'), undefined);
|
||||
assert.equal(ctrl.safeUrl('//host'), undefined);
|
||||
assert.equal(ctrl.measureBytes('note', null, 'abc'), 3);
|
||||
assert.equal(ctrl.measureBytes('link', 'https://x', null), 0);
|
||||
});
|
||||
|
||||
it('thumb_url: create stores it, list/getOne return it; bad scheme → 400', async () => {
|
||||
const ok = await inject('POST', '/api/materials',
|
||||
{ kind: 'image', url: '/uploads/materials/a.png', thumbUrl: '/uploads/materials/a_thumb.webp' }, studentToken);
|
||||
assert.equal(ok.status, 201, JSON.stringify(ok.body));
|
||||
const id = ok.body.id;
|
||||
const l = await inject('GET', '/api/materials', null, studentToken);
|
||||
assert.equal(l.body.materials.find(m => m.id === id).thumb_url, '/uploads/materials/a_thumb.webp');
|
||||
const one = await inject('GET', `/api/materials/${id}`, null, studentToken);
|
||||
assert.equal(one.body.thumb_url, '/uploads/materials/a_thumb.webp');
|
||||
const bad = await inject('POST', '/api/materials',
|
||||
{ kind: 'image', url: '/uploads/materials/b.png', thumbUrl: 'javascript:alert(1)' }, studentToken);
|
||||
assert.equal(bad.status, 400, JSON.stringify(bad.body));
|
||||
});
|
||||
|
||||
it('releaseFileForUrl ref-counts files referenced as a thumbnail (thumb_url column)', () => {
|
||||
const dir = path.join(__dirname, '..', 'uploads', 'materials');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const fname = 'th_' + Date.now() + '.webp';
|
||||
const fpath = path.join(dir, fname);
|
||||
const url = '/uploads/materials/' + fname;
|
||||
fs.writeFileSync(fpath, Buffer.from([0x52, 0x49, 0x46, 0x46]));
|
||||
const rid = db.prepare('INSERT INTO student_materials (user_id, kind, thumb_url) VALUES (?, ?, ?)').run(studentId, 'image', url).lastInsertRowid;
|
||||
ctrl.releaseFileForUrl(url);
|
||||
assert.ok(fs.existsSync(fpath), 'kept while a row references it as thumb_url');
|
||||
db.prepare('DELETE FROM student_materials WHERE id = ?').run(rid);
|
||||
ctrl.releaseFileForUrl(url);
|
||||
assert.ok(!fs.existsSync(fpath), 'unlinked once orphaned');
|
||||
});
|
||||
|
||||
it('DELETE removes the material\'s full image AND thumbnail files', async () => {
|
||||
const dir = path.join(__dirname, '..', 'uploads', 'materials');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const base = 'del_' + Date.now();
|
||||
const fFull = path.join(dir, base + '.png'), fThumb = path.join(dir, base + '_thumb.webp');
|
||||
fs.writeFileSync(fFull, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
fs.writeFileSync(fThumb, Buffer.from([0x52, 0x49, 0x46, 0x46]));
|
||||
const c = await inject('POST', '/api/materials',
|
||||
{ kind: 'image', url: '/uploads/materials/' + base + '.png', thumbUrl: '/uploads/materials/' + base + '_thumb.webp' }, studentToken);
|
||||
assert.equal(c.status, 201);
|
||||
const d = await inject('DELETE', `/api/materials/${c.body.id}`, null, studentToken);
|
||||
assert.equal(d.status, 200);
|
||||
assert.ok(!fs.existsSync(fFull), 'full image unlinked');
|
||||
assert.ok(!fs.existsSync(fThumb), 'thumbnail unlinked');
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@
|
||||
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
|
||||
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
|
||||
{ key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' },
|
||||
{ key: 'sim_builder', label: 'Конструктор симуляций', desc: 'Создание учителем своих интерактивных симуляций (рабочее поле, формулы, физика, графики)', icon: 'pencil-ruler' },
|
||||
];
|
||||
|
||||
const FS_FEATURES = [
|
||||
|
||||
@@ -20,14 +20,14 @@
|
||||
async function uploadBlob(blob, name) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', blob, name);
|
||||
return await LS.uploadMaterialFile(fd); // { url, thumbUrl }
|
||||
const up = await LS.uploadMaterialFile(fd);
|
||||
return up.url;
|
||||
}
|
||||
|
||||
async function persist(meta, kind, url, thumbUrl) {
|
||||
async function persist(meta, kind, url) {
|
||||
await LS.saveMaterial({
|
||||
kind: kind,
|
||||
url: url,
|
||||
thumbUrl: thumbUrl || null,
|
||||
title: titleFor(meta, kind === 'image' ? ' · фрагмент' : ''),
|
||||
sourceSessionId: meta && meta.sourceSessionId,
|
||||
sourceTitle: meta && meta.sourceTitle,
|
||||
@@ -40,8 +40,8 @@
|
||||
wb.exportBlob(async function (blob) {
|
||||
try {
|
||||
if (!blob) throw new Error('Не удалось снять доску');
|
||||
const up = await uploadBlob(blob, 'board.png');
|
||||
await persist(meta, 'board', up.url, up.thumbUrl);
|
||||
const url = await uploadBlob(blob, 'board.png');
|
||||
await persist(meta, 'board', url);
|
||||
LS.toast('Страница сохранена в «Мои материалы»', 'success');
|
||||
} catch (e) {
|
||||
LS.toast(e.message || 'Ошибка сохранения', 'error');
|
||||
@@ -147,8 +147,8 @@
|
||||
off.getContext('2d').drawImage(img, Math.round(rect.x * sx), Math.round(rect.y * sy), cw, ch, 0, 0, cw, ch);
|
||||
const cblob = await new Promise(function (res) { off.toBlob(res, 'image/png'); });
|
||||
if (!cblob) throw new Error('Не удалось обрезать область');
|
||||
const up = await uploadBlob(cblob, 'board-region.png');
|
||||
await persist(meta, 'image', up.url, up.thumbUrl);
|
||||
const cropUrl = await uploadBlob(cblob, 'board-region.png');
|
||||
await persist(meta, 'image', cropUrl);
|
||||
close();
|
||||
LS.toast('Фрагмент сохранён в «Мои материалы»', 'success');
|
||||
} catch (e) {
|
||||
|
||||
+195
-17
@@ -137,6 +137,26 @@
|
||||
return v < 0 ? 0 : (v > 1 ? 1 : v);
|
||||
}
|
||||
|
||||
/* нормализовать стиль маркера узлов кривой plot: 'dot'|'ring'|'none' (деф. none). */
|
||||
function _markerStyle(v) {
|
||||
return (v === 'dot' || v === 'ring') ? v : 'none';
|
||||
}
|
||||
|
||||
/* полупрозрачная версия цвета для заливки под кривой. #RGB/#RRGGBB -> rgba(...,a);
|
||||
прочие форматы (rgb()/named) оставляем как есть (canvas сам применит globalAlpha). */
|
||||
function _fillAlpha(color, a) {
|
||||
if (typeof color !== 'string') return color;
|
||||
var m = color.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
|
||||
if (!m) return color;
|
||||
var h = m[1], r, g, b;
|
||||
if (h.length === 3) {
|
||||
r = parseInt(h[0] + h[0], 16); g = parseInt(h[1] + h[1], 16); b = parseInt(h[2] + h[2], 16);
|
||||
} else {
|
||||
r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16);
|
||||
}
|
||||
return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
|
||||
}
|
||||
|
||||
/* Компилятор свойства: число/строка -> { ev(env) } (всегда число). */
|
||||
function bind(value, dflt) {
|
||||
if (value === undefined || value === null) {
|
||||
@@ -635,13 +655,52 @@
|
||||
bp('x', 0); bp('y', 0);
|
||||
} else if (type === 'plot') {
|
||||
prep.varName = (typeof o['var'] === 'string' && o['var']) ? o['var'] : 'x';
|
||||
prep.exprFn = bind(o.expr != null ? o.expr : '0', 0);
|
||||
var rng = Array.isArray(o.range) ? o.range : null;
|
||||
prep.rangeA = bind(rng ? rng[0] : null, null);
|
||||
prep.rangeB = bind(rng ? rng[1] : null, null);
|
||||
prep.hasRange = !!rng;
|
||||
prep.samples = Math.max(2, Math.min(2000, num(o.samples, 200) | 0));
|
||||
prep.trace = !!o.trace;
|
||||
// ── P3: несколько кривых на одном plot ──
|
||||
// Источник кривых (приоритет): curves[] -> exprs[] -> одиночный expr (легаси).
|
||||
// Каждой кривой свой нормализованный стиль; цвет — явный или из палитры по индексу.
|
||||
var curveDefs = [];
|
||||
if (Array.isArray(o.curves) && o.curves.length) {
|
||||
curveDefs = o.curves.map(function (cv) {
|
||||
return (cv && typeof cv === 'object') ? cv : { expr: cv };
|
||||
});
|
||||
} else if (Array.isArray(o.exprs) && o.exprs.length) {
|
||||
curveDefs = o.exprs.map(function (ex) { return { expr: ex }; });
|
||||
} else {
|
||||
curveDefs = [{ expr: o.expr != null ? o.expr : '0' }];
|
||||
}
|
||||
var plotMarker = _markerStyle(o.marker);
|
||||
// plot-уровневые дефолты заливки/маркера наследуются кривыми (если у кривой не задано)
|
||||
prep.curves = curveDefs.map(function (cv, ci) {
|
||||
cv = cv || {};
|
||||
var cFill = (cv.fill !== undefined) ? cv.fill : o.fill;
|
||||
return {
|
||||
exprFn: bind(cv.expr != null ? cv.expr : '0', 0),
|
||||
color: cv.color || o.color || DEFAULT_PALETTE[ci % DEFAULT_PALETTE.length],
|
||||
label: (cv.label != null) ? String(cv.label) : '',
|
||||
width: num(cv.width, prep.width),
|
||||
lineStyle: (cv.lineStyle === 'dashed' || cv.lineStyle === 'dotted') ? cv.lineStyle
|
||||
: prep.lineStyle,
|
||||
opacity: (cv.opacity === undefined || cv.opacity === null) ? prep.opacity : _opacity(cv.opacity),
|
||||
// заливка под кривой: true -> полупрозрачный цвет кривой; строка -> явный цвет
|
||||
fill: (cFill === true || (typeof cFill === 'string' && cFill)) ? cFill : false,
|
||||
// маркеры узлов: none|dot|ring (наследует plot-уровень)
|
||||
marker: (cv.marker !== undefined) ? _markerStyle(cv.marker) : plotMarker,
|
||||
glow: prep.glow,
|
||||
glowColor: prep.glowColor,
|
||||
glowBlur: prep.glowBlur
|
||||
};
|
||||
});
|
||||
// легаси: одиночное выражение для trace-режима (накопление по t)
|
||||
prep.exprFn = prep.curves[0].exprFn;
|
||||
// легенда: показывать, если есть хотя бы одна подпись (можно явно legend:false)
|
||||
var anyLabel = prep.curves.some(function (c) { return !!c.label; });
|
||||
prep.legend = (o.legend === false) ? false : anyLabel;
|
||||
} else if (type === 'readout') {
|
||||
// компилируем выражение один раз: храним и fn (быстро), и ast (для evalSafe — мягкая ошибка)
|
||||
var rc = global.SimExpr ? global.SimExpr.compileValue(o.expr != null ? o.expr : '0')
|
||||
@@ -1471,11 +1530,14 @@
|
||||
}
|
||||
};
|
||||
|
||||
/* ── plot: график выражения f(var) на отрезке range (мир-координаты) ── */
|
||||
/* ── plot: график f(var) на отрезке range (мир-координаты) ──
|
||||
P3: несколько кривых (o.curves[]), заливка под кривой (к оси y=0), маркеры узлов,
|
||||
легенда (на canvas). Trace-режим (накопление по t) рисуется отдельно через _drawTrail. */
|
||||
SimEngineInstance.prototype._drawPlot = function (ctx, o, env) {
|
||||
// trace без явного range — только накапливаемый след (статической кривой нет)
|
||||
if (o.trace && !o.hasRange) return;
|
||||
var vp = this._vp();
|
||||
var W = this._cw, H = this._ch;
|
||||
var a = o.rangeA.ev(env), b = o.rangeB.ev(env);
|
||||
if (!o.hasRange || !isFinite(a) || !isFinite(b)) { a = vp.xmin; b = vp.xmax; }
|
||||
if (a === b) return;
|
||||
@@ -1485,25 +1547,141 @@
|
||||
var prev = env[o.varName];
|
||||
var hadPrev = Object.prototype.hasOwnProperty.call(env, o.varName);
|
||||
|
||||
ctx.save();
|
||||
this._applyStroke(ctx, o);
|
||||
ctx.strokeStyle = o.color;
|
||||
ctx.beginPath();
|
||||
var started = false;
|
||||
for (var i = 0; i < n; i++) {
|
||||
var xv = a + step * i;
|
||||
env[o.varName] = xv;
|
||||
var yv = o.exprFn.ev(env);
|
||||
if (typeof yv !== 'number' || !isFinite(yv)) { started = false; continue; }
|
||||
var px = this._toPx(xv, yv);
|
||||
if (!started) { ctx.moveTo(px[0], px[1]); started = true; }
|
||||
else ctx.lineTo(px[0], px[1]);
|
||||
var curves = o.curves || [];
|
||||
var legendItems = [];
|
||||
// y=0 в экранных px (для клипа заливки к оси X) + видимая область для клипа
|
||||
var zeroPy = this._toPx(0, 0)[1];
|
||||
for (var ci = 0; ci < curves.length; ci++) {
|
||||
var cv = curves[ci];
|
||||
// сэмплинг: экранные точки [px,py,worldY], разрывы как null (не-finite y -> пропуск сегмента)
|
||||
var pts = [];
|
||||
for (var i = 0; i < n; i++) {
|
||||
var xv = a + step * i;
|
||||
env[o.varName] = xv;
|
||||
var yv = cv.exprFn.ev(env);
|
||||
if (typeof yv !== 'number' || !isFinite(yv)) { pts.push(null); continue; }
|
||||
var p = this._toPx(xv, yv);
|
||||
pts.push([p[0], p[1], yv]);
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
// заливка под кривой (между кривой и осью y=0), клиппится к видимой высоте
|
||||
if (cv.fill) {
|
||||
var fillCol = (cv.fill === true) ? _fillAlpha(cv.color, 0.18) : cv.fill;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = cv.opacity;
|
||||
ctx.fillStyle = fillCol;
|
||||
ctx.shadowBlur = 0;
|
||||
this._fillUnderCurve(ctx, pts, _clamp(zeroPy, 0, H));
|
||||
ctx.restore();
|
||||
}
|
||||
// линия кривой (через _applyStroke: dash/opacity/glow/width)
|
||||
this._applyStroke(ctx, cv);
|
||||
ctx.strokeStyle = cv.color;
|
||||
ctx.beginPath();
|
||||
var started = false;
|
||||
for (var k = 0; k < pts.length; k++) {
|
||||
if (!pts[k]) { started = false; continue; }
|
||||
if (!started) { ctx.moveTo(pts[k][0], pts[k][1]); started = true; }
|
||||
else ctx.lineTo(pts[k][0], pts[k][1]);
|
||||
}
|
||||
ctx.stroke();
|
||||
// маркеры узлов (с прореживанием: не чаще ~28px по экрану)
|
||||
if (cv.marker && cv.marker !== 'none') {
|
||||
this._drawCurveMarkers(ctx, pts, cv);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
if (o.legend && cv.label) legendItems.push({ color: cv.color, label: cv.label });
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// восстановить env
|
||||
if (hadPrev) env[o.varName] = prev; else delete env[o.varName];
|
||||
|
||||
// легенда (поверх кривых, в углу области plot, на canvas)
|
||||
if (legendItems.length) this._drawLegend(ctx, W, H, legendItems);
|
||||
};
|
||||
|
||||
/* Заливка области под полилинией к базовой линии y=baseY (экранные px). Каждый
|
||||
непрерывный сегмент (между разрывами null) заливается отдельным замкнутым контуром:
|
||||
curve-up -> вдоль кривой -> curve-down к baseY -> закрыть. baseY клиппится к canvas. */
|
||||
SimEngineInstance.prototype._fillUnderCurve = function (ctx, pts, baseY) {
|
||||
var i = 0, n = pts.length;
|
||||
while (i < n) {
|
||||
// найти начало непрерывного сегмента
|
||||
while (i < n && !pts[i]) i++;
|
||||
var startI = i;
|
||||
while (i < n && pts[i]) i++;
|
||||
var endI = i; // [startI, endI)
|
||||
if (endI - startI < 2) continue; // сегмент из <2 точек — заливать нечего
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[startI][0], baseY);
|
||||
for (var k = startI; k < endI; k++) ctx.lineTo(pts[k][0], pts[k][1]);
|
||||
ctx.lineTo(pts[endI - 1][0], baseY);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
/* Маркеры узлов кривой (dot|ring) с прореживанием по экранному расстоянию (~28px),
|
||||
чтобы не рисовать сотни точек. Цвет — цвет кривой; opacity наследуется от ctx. */
|
||||
SimEngineInstance.prototype._drawCurveMarkers = function (ctx, pts, cv) {
|
||||
var MIN_PX = 28; // минимальный шаг между маркерами по экрану
|
||||
var r = Math.max(2.5, (cv.width || 2) + 1.5);
|
||||
var marker = { color: cv.color, fillColor: cv.color, opacity: cv.opacity, glow: false,
|
||||
pointStyle: (cv.marker === 'ring') ? 'hollow' : 'filled', width: cv.width || 2 };
|
||||
var lastX = -1e9, lastY = -1e9;
|
||||
for (var k = 0; k < pts.length; k++) {
|
||||
var p = pts[k];
|
||||
if (!p) continue;
|
||||
var dx = p[0] - lastX, dy = p[1] - lastY;
|
||||
if (dx * dx + dy * dy < MIN_PX * MIN_PX) continue;
|
||||
this._drawPoint(ctx, marker, p[0], p[1], r);
|
||||
lastX = p[0]; lastY = p[1];
|
||||
}
|
||||
};
|
||||
|
||||
/* Компактная легенда в углу области plot (на canvas, без DOM): цветная метка + текст.
|
||||
Позиция: верх-право, со смещением вниз, чтобы не наезжать на ось Y/подписи. */
|
||||
SimEngineInstance.prototype._drawLegend = function (ctx, W, H, items) {
|
||||
if (!items.length) return;
|
||||
ctx.save();
|
||||
ctx.font = '12px Manrope,system-ui,sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'left';
|
||||
var pad = 8, rowH = 18, swatch = 11, gap = 7;
|
||||
// ширина по самой длинной подписи
|
||||
var maxTxt = 0;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var w = ctx.measureText(items[i].label).width;
|
||||
if (w > maxTxt) maxTxt = w;
|
||||
}
|
||||
var boxW = pad * 2 + swatch + gap + Math.ceil(maxTxt);
|
||||
var boxH = pad * 2 + items.length * rowH - (rowH - 14);
|
||||
// верх-право; отступ от края, не наезжает на бар кнопок (right/bottom) и оси
|
||||
var bx = W - boxW - 12, by = 12;
|
||||
if (bx < 6) bx = 6;
|
||||
// фон-плашка (полупрозрачная тёмная, как readout-бейджи)
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = 'rgba(13,13,26,0.78)';
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
|
||||
ctx.lineWidth = 1;
|
||||
if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill(); ctx.stroke(); }
|
||||
else { ctx.fillRect(bx, by, boxW, boxH); ctx.strokeRect(bx, by, boxW, boxH); }
|
||||
for (var j = 0; j < items.length; j++) {
|
||||
var cy = by + pad + 7 + j * rowH;
|
||||
// цветная метка (линия-свотч)
|
||||
ctx.strokeStyle = items[j].color;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx + pad, cy); ctx.lineTo(bx + pad + swatch, cy);
|
||||
ctx.stroke();
|
||||
// текст метки (светлый, без пользовательского цвета в DOM)
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.88)';
|
||||
ctx.fillText(items[j].label, bx + pad + swatch + gap, cy);
|
||||
}
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
/* ── readout: живое значение выражения как бейдж на оверлее ── */
|
||||
|
||||
@@ -39,16 +39,14 @@
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
let url = o.url;
|
||||
let thumbUrl = o.thumbUrl || null;
|
||||
if (o.blob) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', o.blob, o.name || 'image.png');
|
||||
const up = await LS.uploadMaterialFile(fd);
|
||||
url = up.url;
|
||||
thumbUrl = up.thumbUrl || null;
|
||||
}
|
||||
if (!url) throw new Error('Нет изображения');
|
||||
await LS.saveMaterial({ kind: 'image', title: o.title || '', url: url, thumbUrl: thumbUrl, sourceTitle: o.sourceTitle || null });
|
||||
await LS.saveMaterial({ kind: 'image', title: o.title || '', url: url, sourceTitle: o.sourceTitle || null });
|
||||
ok();
|
||||
} catch (e) { err(e); } finally { if (btn) btn.disabled = false; }
|
||||
}
|
||||
|
||||
+797
-76
File diff suppressed because it is too large
Load Diff
@@ -185,7 +185,6 @@
|
||||
kind: 'image',
|
||||
title: input.value.trim() || sectionTitle(),
|
||||
url: up.url,
|
||||
thumbUrl: up.thumbUrl || null,
|
||||
sourceTitle: chapterTitle()
|
||||
});
|
||||
toast('Сохранено в «Мои материалы»', 'success');
|
||||
|
||||
+31
-226
@@ -70,27 +70,6 @@
|
||||
.mm-viewer-note { white-space: pre-wrap; word-break: break-word; line-height: 1.6; font-size: 0.9rem; color: var(--text-2); }
|
||||
.mm-empty { padding: 60px 20px; text-align: center; color: var(--text-3); }
|
||||
.mm-empty svg { width: 38px; height: 38px; opacity: 0.4; margin-bottom: 12px; }
|
||||
.mm-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
|
||||
.mm-tag { font-size: .68rem; font-weight: 600; padding: 2px 8px; border-radius: 99px; background: rgba(6,182,212,0.12); color: #0891b2; cursor: pointer; transition: background .12s; }
|
||||
.mm-tag:hover { background: rgba(6,182,212,0.24); }
|
||||
.mm-src { color: var(--text-3); text-decoration: none; border-bottom: 1px dotted var(--text-3); }
|
||||
.mm-src:hover { color: var(--violet); border-bottom-color: var(--violet); }
|
||||
.mm-tagpill { display: inline-flex; align-items: center; gap: 4px; font-size: .76rem; font-weight: 600; padding: 6px 10px; border-radius: 9px; background: rgba(155,93,229,0.12); color: var(--violet); }
|
||||
.mm-tagpill-x { display: inline-flex; cursor: pointer; }
|
||||
.mm-tagpill-x svg { width: 13px; height: 13px; }
|
||||
.mm-check { position: absolute; top: 10px; left: 10px; z-index: 3; width: 18px; height: 18px; cursor: pointer; accent-color: var(--violet); opacity: 0; transition: opacity .12s; }
|
||||
.mm-card:hover .mm-check, .mm-check:checked { opacity: 1; }
|
||||
.mm-card.mm-selected { border-color: var(--violet); box-shadow: 0 0 0 2px rgba(155,93,229,0.35); }
|
||||
.mm-bulk { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; padding: 10px 12px; border: 1px solid var(--violet); border-radius: 10px; background: rgba(155,93,229,0.06); }
|
||||
.mm-bulk-count { font-weight: 700; font-size: .84rem; color: var(--violet); margin-right: auto; }
|
||||
.mm-swatches { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
|
||||
.mm-swatch { width: 26px; height: 26px; border-radius: 8px; cursor: pointer; border: 2px solid transparent; box-shadow: inset 0 0 0 1px rgba(0,0,0,.08); }
|
||||
.mm-swatch.on { border-color: var(--text); }
|
||||
.mm-swatch-none { background: repeating-linear-gradient(45deg,#fff,#fff 4px,#e2e8f0 4px,#e2e8f0 8px); }
|
||||
.mm-preview { min-height: 22px; padding: 8px 10px; border: 1px dashed var(--border); border-radius: 8px; font-size: .86rem; color: var(--text-2); background: rgba(148,163,184,0.06); white-space: pre-wrap; word-break: break-word; line-height: 1.5; }
|
||||
.mm-preview:empty::before { content: 'Превью формул появится здесь…'; color: var(--text-3); }
|
||||
.mm-more { display: flex; justify-content: center; padding: 8px 0 2px; }
|
||||
@media (max-width: 768px) { .mm-check { opacity: .85; } }
|
||||
@media (max-width: 768px) {
|
||||
.mm-body { flex-direction: column; }
|
||||
.mm-rail { width: auto; position: static; flex-direction: row; overflow-x: auto; gap: 6px; padding-bottom: 4px; }
|
||||
@@ -130,15 +109,7 @@
|
||||
<option value="note">Заметки</option>
|
||||
<option value="link">Ссылки</option>
|
||||
</select>
|
||||
<select class="mm-kind" id="mm-sort" onchange="onSort(this.value)" title="Сортировка">
|
||||
<option value="new">Сначала новые</option>
|
||||
<option value="old">Сначала старые</option>
|
||||
<option value="title">По названию</option>
|
||||
<option value="kind">По типу</option>
|
||||
</select>
|
||||
<span id="mm-tagfilter"></span>
|
||||
</div>
|
||||
<div id="mm-bulk" class="mm-bulk" style="display:none"></div>
|
||||
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,9 +149,6 @@
|
||||
}
|
||||
return tmp.innerHTML;
|
||||
}
|
||||
/* Live formula preview for the note editor (renders $…$ as you type). */
|
||||
function mmPreview(ta, prevId) { const p = document.getElementById(prevId); if (p) p.innerHTML = mathHtml(ta.value); }
|
||||
window.mmPreview = mmPreview;
|
||||
function fmtDate(s) {
|
||||
if (!s) return '';
|
||||
try { const d = new Date(s.replace(' ', 'T') + (s.includes('Z') ? '' : 'Z'));
|
||||
@@ -202,41 +170,9 @@
|
||||
if (u.startsWith('/lab')) return 'Лаборатория';
|
||||
return 'Ссылка';
|
||||
}
|
||||
function parseTags(s) { return String(s || '').split(',').map(t => t.trim()).filter(Boolean); }
|
||||
/* Only trust folder colors that look like a hex value (guards inline-style injection). */
|
||||
function safeColor(c) { return /^#[0-9a-fA-F]{3,8}$/.test(String(c || '')) ? c : ''; }
|
||||
|
||||
/* Meta line: source title links back to the originating lesson when known. */
|
||||
function metaHtml(m) {
|
||||
const date = fmtDate(m.created_at);
|
||||
let src = '';
|
||||
if (m.source_title) {
|
||||
src = m.source_session_id
|
||||
? `<a class="mm-src" href="/my-lessons?session=${Number(m.source_session_id)}" title="Открыть исходный урок">${esc(m.source_title)}</a>`
|
||||
: esc(m.source_title);
|
||||
src += ' · ';
|
||||
}
|
||||
return src + esc(date);
|
||||
}
|
||||
/* Tag chips (click → filter). data-t carries the raw value, dodging JS-string injection. */
|
||||
function tagsHtml(m) {
|
||||
const tg = parseTags(m.tags);
|
||||
if (!tg.length) return '';
|
||||
return `<div class="mm-tags">${tg.map(t => `<span class="mm-tag" data-t="${esc(t)}" onclick="filterTag(this.dataset.t)">${esc(t)}</span>`).join('')}</div>`;
|
||||
}
|
||||
/* Lazy-load the full note body — the list endpoint returns only a 1000-char preview. */
|
||||
async function ensureFullBody(m) {
|
||||
if (!m || !m.body_trunc) return m;
|
||||
try { const full = await LS.getMaterial(m.id); if (full && typeof full.body === 'string') { m.body = full.body; m.body_trunc = 0; } } catch (e) {}
|
||||
return m;
|
||||
}
|
||||
|
||||
let _mats = [];
|
||||
let _cols = [];
|
||||
const _filter = { col: 'all', kind: 'all', q: '', sort: 'new', tag: '' };
|
||||
const _sel = new Set(); // ids selected for bulk actions
|
||||
const PAGE_SIZE = 60; // cards rendered to the DOM at once ("Показать ещё" adds more)
|
||||
let _shown = PAGE_SIZE;
|
||||
const _filter = { col: 'all', kind: 'all', q: '' };
|
||||
|
||||
/* ── Move-to-collection select ── */
|
||||
function moveSelect(m) {
|
||||
@@ -247,10 +183,7 @@
|
||||
|
||||
function card(m) {
|
||||
const kind = KIND_LABEL[m.kind] || m.kind;
|
||||
const meta = metaHtml(m);
|
||||
const tags = tagsHtml(m);
|
||||
const selCls = _sel.has(m.id) ? ' mm-selected' : '';
|
||||
const cb = `<input type="checkbox" class="mm-check" ${_sel.has(m.id) ? 'checked' : ''} onclick="toggleSel(event,${m.id})" title="Выбрать" />`;
|
||||
const meta = `${esc(m.source_title || '')}${m.source_title ? ' · ' : ''}${fmtDate(m.created_at)}`;
|
||||
const chip = `<span class="mm-kind-chip"><i data-lucide="${KIND_ICON[m.kind] || 'tag'}"></i>${kind}</span>`;
|
||||
const del = `<button class="mm-btn danger" onclick="delMaterial(${m.id})" title="Удалить"><i data-lucide="trash-2"></i></button>`;
|
||||
const edit = `<button class="mm-btn" onclick="editMaterial(${m.id})" title="Изменить"><i data-lucide="pencil"></i></button>`;
|
||||
@@ -263,12 +196,12 @@
|
||||
const mv = moveSelect(m);
|
||||
|
||||
if (m.kind === 'board' || m.kind === 'image') {
|
||||
return `<div class="mm-card${selCls}" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">${cb}
|
||||
<a class="mm-card-media" href="${esc(m.url)}" onclick="openViewer(${m.id});return false;"><img src="${esc(m.thumb_url || m.url)}" alt="" loading="lazy" decoding="async" draggable="false"/></a>
|
||||
return `<div class="mm-card" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">
|
||||
<a class="mm-card-media" href="${esc(m.url)}" onclick="openViewer(${m.id});return false;"><img src="${esc(m.url)}" alt="" loading="lazy" draggable="false"/></a>
|
||||
<div class="mm-card-body">
|
||||
${chip}
|
||||
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
||||
<div class="mm-card-meta">${meta}</div>${tags}
|
||||
<div class="mm-card-meta">${meta}</div>
|
||||
<div class="mm-card-actions">
|
||||
${mv}
|
||||
<button class="mm-btn" onclick="openViewer(${m.id})" title="Просмотр"><i data-lucide="eye"></i></button>
|
||||
@@ -280,7 +213,7 @@
|
||||
}
|
||||
|
||||
if (m.kind === 'link') {
|
||||
return `<div class="mm-card${selCls}" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">${cb}
|
||||
return `<div class="mm-card" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">
|
||||
<a class="mm-card-link" href="${esc(m.url)}" target="_blank" rel="noopener" title="${esc(m.url)}">
|
||||
<span class="mm-card-link-ic"><i data-lucide="link"></i></span>
|
||||
<span class="mm-card-link-meta">
|
||||
@@ -291,7 +224,7 @@
|
||||
<div class="mm-card-body">
|
||||
${chip}
|
||||
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
||||
<div class="mm-card-meta">${meta}</div>${tags}
|
||||
<div class="mm-card-meta">${meta}</div>
|
||||
<div class="mm-card-actions">
|
||||
${mv}
|
||||
<a class="mm-btn primary" href="${esc(m.url)}" target="_blank" rel="noopener"><i data-lucide="external-link"></i> Открыть</a>
|
||||
@@ -302,31 +235,29 @@
|
||||
}
|
||||
|
||||
// note
|
||||
return `<div class="mm-card${selCls}" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">${cb}
|
||||
return `<div class="mm-card" draggable="true" ondragstart="mmDragStart(event,${m.id})" ondragend="mmDragEnd(event)">
|
||||
<div class="mm-card-note">${mathHtml(m.body || '')}</div>
|
||||
<div class="mm-card-body">
|
||||
${chip}
|
||||
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
||||
<div class="mm-card-meta">${meta}</div>${tags}
|
||||
<div class="mm-card-meta">${meta}</div>
|
||||
<div class="mm-card-actions">${mv}${fc}${sh}${edit}${del}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ── Folder rail (вертикальный список папок слева) ── */
|
||||
function railItem(key, label, count, editId, droppable, color) {
|
||||
function railItem(key, label, count, editId, droppable) {
|
||||
const active = _filter.col === key ? ' active' : '';
|
||||
const ed = editId
|
||||
? `<span class="mm-rail-edit" onclick="event.stopPropagation();editCollection(${editId})" title="Изменить папку">${PENCIL}</span>`
|
||||
: '';
|
||||
const ic = key === 'all' ? 'inbox' : (key === 'none' ? 'folder-minus' : 'folder');
|
||||
const tint = safeColor(color);
|
||||
const icStyle = (tint && !active) ? ` style="color:${tint}"` : '';
|
||||
const drop = droppable
|
||||
? ` ondragover="mmDragOver(event,this)" ondragleave="mmDragLeave(this)" ondrop="mmDrop(event,'${key}')"`
|
||||
: '';
|
||||
return `<div class="mm-rail-item${active}" onclick="setCol('${key}')"${drop}>
|
||||
<i data-lucide="${ic}"${icStyle}></i>
|
||||
<i data-lucide="${ic}"></i>
|
||||
<span class="mm-rail-label">${esc(label)}</span>
|
||||
<span class="mm-rail-count">${count}</span>${ed}
|
||||
</div>`;
|
||||
@@ -335,7 +266,7 @@
|
||||
const bar = document.getElementById('mm-cols');
|
||||
const noneCount = _mats.filter(m => !m.collection_id).length;
|
||||
let html = railItem('all', 'Все', _mats.length, null, false);
|
||||
_cols.forEach(c => { html += railItem(String(c.id), c.name, c.count, c.id, true, c.color); });
|
||||
_cols.forEach(c => { html += railItem(String(c.id), c.name, c.count, c.id, true); });
|
||||
html += railItem('none', 'Без папки', noneCount, null, true);
|
||||
bar.innerHTML = html;
|
||||
}
|
||||
@@ -368,28 +299,17 @@
|
||||
window.mmDragStart = mmDragStart; window.mmDragEnd = mmDragEnd;
|
||||
window.mmDragOver = mmDragOver; window.mmDragLeave = mmDragLeave; window.mmDrop = mmDrop;
|
||||
|
||||
function sortRows(rows) {
|
||||
const s = _filter.sort || 'new';
|
||||
if (s === 'new') return rows; // server already returns newest-first
|
||||
const a = rows.slice();
|
||||
if (s === 'old') a.reverse();
|
||||
else if (s === 'title') a.sort((x, y) => (x.title || x.body || '').localeCompare(y.title || y.body || '', 'ru'));
|
||||
else if (s === 'kind') a.sort((x, y) => (x.kind || '').localeCompare(y.kind || ''));
|
||||
return a;
|
||||
}
|
||||
function filtered() {
|
||||
const rows = _mats.filter(m => {
|
||||
return _mats.filter(m => {
|
||||
if (_filter.col === 'none' && m.collection_id) return false;
|
||||
if (_filter.col !== 'all' && _filter.col !== 'none' && String(m.collection_id) !== _filter.col) return false;
|
||||
if (_filter.kind !== 'all' && m.kind !== _filter.kind) return false;
|
||||
if (_filter.tag && !parseTags(m.tags).includes(_filter.tag)) return false;
|
||||
if (_filter.q) {
|
||||
const hay = ((m.title || '') + ' ' + (m.body || '') + ' ' + (m.source_title || '') + ' ' + (m.url || '') + ' ' + (m.tags || '')).toLowerCase();
|
||||
if (!hay.includes(_filter.q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return sortRows(rows);
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
@@ -403,20 +323,11 @@
|
||||
return;
|
||||
}
|
||||
const rows = filtered();
|
||||
if (!rows.length) {
|
||||
grid.innerHTML = `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`;
|
||||
lucide.createIcons(); renderBulk(); return;
|
||||
}
|
||||
let html = rows.slice(0, _shown).map(card).join('');
|
||||
if (rows.length > _shown) {
|
||||
html += `<div class="mm-more" style="grid-column:1/-1"><button class="mm-btn" onclick="showMore()"><i data-lucide="chevron-down"></i> Показать ещё (${rows.length - _shown})</button></div>`;
|
||||
}
|
||||
grid.innerHTML = html;
|
||||
grid.innerHTML = rows.length
|
||||
? rows.map(card).join('')
|
||||
: `<div class="mm-empty" style="grid-column:1/-1"><i data-lucide="search-x"></i><p>Ничего не найдено</p></div>`;
|
||||
lucide.createIcons();
|
||||
renderBulk();
|
||||
}
|
||||
function showMore() { _shown += PAGE_SIZE; renderGrid(); }
|
||||
window.showMore = showMore;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
@@ -425,73 +336,16 @@
|
||||
_cols = data.collections || [];
|
||||
renderCols();
|
||||
renderGrid();
|
||||
renderTagFilter();
|
||||
} catch (e) {
|
||||
document.getElementById('mm-grid').innerHTML = `<div class="mm-empty" style="grid-column:1/-1">Ошибка загрузки</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Filters ── */
|
||||
function setCol(key) { _filter.col = key; _shown = PAGE_SIZE; renderCols(); renderGrid(); }
|
||||
function onKind(v) { _filter.kind = v; _shown = PAGE_SIZE; renderGrid(); }
|
||||
function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); _shown = PAGE_SIZE; renderGrid(); }
|
||||
function onSort(v) { _filter.sort = v; _shown = PAGE_SIZE; renderGrid(); }
|
||||
function filterTag(t) { _filter.tag = String(t || ''); _shown = PAGE_SIZE; renderTagFilter(); renderGrid(); }
|
||||
function clearTag() { _filter.tag = ''; _shown = PAGE_SIZE; renderTagFilter(); renderGrid(); }
|
||||
function renderTagFilter() {
|
||||
const el = document.getElementById('mm-tagfilter');
|
||||
if (!el) return;
|
||||
el.innerHTML = _filter.tag
|
||||
? `<span class="mm-tagpill">#${esc(_filter.tag)} <span class="mm-tagpill-x" onclick="clearTag()" title="Сбросить фильтр по тегу"><i data-lucide="x"></i></span></span>`
|
||||
: '';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
function setCol(key) { _filter.col = key; renderCols(); renderGrid(); }
|
||||
function onKind(v) { _filter.kind = v; renderGrid(); }
|
||||
function onSearch(v) { _filter.q = (v || '').trim().toLowerCase(); renderGrid(); }
|
||||
window.setCol = setCol; window.onKind = onKind; window.onSearch = onSearch;
|
||||
window.onSort = onSort; window.filterTag = filterTag; window.clearTag = clearTag;
|
||||
|
||||
/* ── Multi-select + bulk actions (reuse per-item endpoints) ── */
|
||||
function renderBulk() {
|
||||
const bar = document.getElementById('mm-bulk');
|
||||
if (!bar) return;
|
||||
const n = _sel.size;
|
||||
if (!n) { bar.style.display = 'none'; bar.innerHTML = ''; return; }
|
||||
const opts = ['<option value="__none">Без папки</option>']
|
||||
.concat(_cols.map(c => `<option value="${c.id}">${esc(c.name)}</option>`)).join('');
|
||||
bar.style.display = 'flex';
|
||||
bar.innerHTML = `<span class="mm-bulk-count">Выбрано: ${n}</span>
|
||||
<select class="mm-move" onchange="bulkMove(this.value)" title="Переместить выбранные"><option value="">Переместить в…</option>${opts}</select>
|
||||
<button class="mm-btn danger" onclick="bulkDelete()"><i data-lucide="trash-2"></i> Удалить</button>
|
||||
<button class="mm-btn" onclick="clearSel()"><i data-lucide="x"></i> Снять</button>`;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
function toggleSel(e, id) {
|
||||
e.stopPropagation();
|
||||
const cb = e.target;
|
||||
if (cb.checked) _sel.add(id); else _sel.delete(id);
|
||||
const cardEl = cb.closest('.mm-card');
|
||||
if (cardEl) cardEl.classList.toggle('mm-selected', cb.checked);
|
||||
renderBulk();
|
||||
}
|
||||
function clearSel() { _sel.clear(); renderGrid(); }
|
||||
async function bulkMove(v) {
|
||||
if (v === '') return;
|
||||
const cid = v === '__none' ? null : Number(v);
|
||||
const ids = [..._sel];
|
||||
try {
|
||||
for (const id of ids) await LS.updateMaterial(id, { collection_id: cid });
|
||||
_sel.clear(); load(); LS.toast('Перемещено: ' + ids.length, 'success');
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
}
|
||||
async function bulkDelete() {
|
||||
const ids = [..._sel];
|
||||
if (!ids.length) return;
|
||||
if (!await LS.confirm(`Будет удалено материалов: ${ids.length}. Действие необратимо.`, { title: 'Удалить выбранные?', confirmText: 'Удалить' })) return;
|
||||
try {
|
||||
for (const id of ids) await LS.deleteMaterial(id);
|
||||
_sel.clear(); load(); LS.toast('Удалено: ' + ids.length, 'success');
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
}
|
||||
window.toggleSel = toggleSel; window.clearSel = clearSel; window.bulkMove = bulkMove; window.bulkDelete = bulkDelete;
|
||||
|
||||
/* ── Material actions ── */
|
||||
async function moveMaterial(id, cid) {
|
||||
@@ -511,56 +365,46 @@
|
||||
function createNote() {
|
||||
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<input id="mm-nt-title" placeholder="Заголовок (необязательно)" style="${FLD}" />
|
||||
<textarea id="mm-nt-body" rows="7" placeholder="Текст заметки… (поддерживается $формула$)" style="${FLD};resize:vertical" oninput="mmPreview(this,'mm-nt-prev')"></textarea>
|
||||
<div id="mm-nt-prev" class="mm-preview"></div>
|
||||
<input id="mm-nt-tags" placeholder="Теги через запятую (необязательно)" style="${FLD}" />
|
||||
<textarea id="mm-nt-body" rows="7" placeholder="Текст заметки…" style="${FLD};resize:vertical"></textarea>
|
||||
</div>`;
|
||||
const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [
|
||||
{ label: 'Отмена', onClick: () => m.close() },
|
||||
{ label: 'Создать', primary: true, onClick: async () => {
|
||||
const title = m.body.querySelector('#mm-nt-title').value.trim();
|
||||
const text = m.body.querySelector('#mm-nt-body').value.trim();
|
||||
const tags = m.body.querySelector('#mm-nt-tags').value.trim() || null;
|
||||
if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; }
|
||||
const col = _filter.col !== 'all' && _filter.col !== 'none' ? Number(_filter.col) : null;
|
||||
try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col, tags }); m.close(); load(); }
|
||||
try { await LS.saveMaterial({ kind: 'note', title, body: text, collection_id: col }); m.close(); load(); }
|
||||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
} },
|
||||
] });
|
||||
}
|
||||
window.createNote = createNote;
|
||||
|
||||
async function editMaterial(id) {
|
||||
function editMaterial(id) {
|
||||
const mt = _mats.find(x => x.id === id);
|
||||
if (!mt) return;
|
||||
const isNote = mt.kind === 'note';
|
||||
if (isNote) await ensureFullBody(mt);
|
||||
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<input id="mm-ed-title" value="${esc(mt.title || '')}" placeholder="Заголовок" style="${FLD}" />
|
||||
${isNote ? `<textarea id="mm-ed-body" rows="7" style="${FLD};resize:vertical" oninput="mmPreview(this,'mm-ed-prev')">${esc(mt.body || '')}</textarea><div id="mm-ed-prev" class="mm-preview"></div>` : ''}
|
||||
<input id="mm-ed-tags" value="${esc(mt.tags || '')}" placeholder="Теги через запятую" style="${FLD}" />
|
||||
${isNote ? `<textarea id="mm-ed-body" rows="7" style="${FLD};resize:vertical">${esc(mt.body || '')}</textarea>` : ''}
|
||||
</div>`;
|
||||
const m = LS.modal({ title: 'Изменить', content, size: 'sm', actions: [
|
||||
{ label: 'Отмена', onClick: () => m.close() },
|
||||
{ label: 'Сохранить', primary: true, onClick: async () => {
|
||||
const data = {
|
||||
title: m.body.querySelector('#mm-ed-title').value.trim(),
|
||||
tags: m.body.querySelector('#mm-ed-tags').value.trim() || null,
|
||||
};
|
||||
const data = { title: m.body.querySelector('#mm-ed-title').value.trim() };
|
||||
if (isNote) data.body = m.body.querySelector('#mm-ed-body').value;
|
||||
try { await LS.updateMaterial(id, data); m.close(); load(); }
|
||||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
} },
|
||||
] });
|
||||
if (isNote) { const ta = m.body.querySelector('#mm-ed-body'); if (ta) mmPreview(ta, 'mm-ed-prev'); }
|
||||
}
|
||||
window.editMaterial = editMaterial;
|
||||
|
||||
/* ── Просмотр материала в модалке (лайтбокс) ── */
|
||||
async function openViewer(id) {
|
||||
function openViewer(id) {
|
||||
const mt = _mats.find(x => x.id === id);
|
||||
if (!mt) return false;
|
||||
if (mt.kind === 'note') await ensureFullBody(mt);
|
||||
const kind = KIND_LABEL[mt.kind] || mt.kind;
|
||||
let body;
|
||||
if (mt.kind === 'image' || mt.kind === 'board') {
|
||||
@@ -587,61 +431,24 @@
|
||||
window.openViewer = openViewer;
|
||||
|
||||
/* ── Collection CRUD ── */
|
||||
const COL_PALETTE = ['#9b5de5', '#06b6d4', '#f97316', '#10b981', '#ef4444', '#eab308', '#3b82f6', '#ec4899'];
|
||||
function colorPalette(sel) {
|
||||
sel = safeColor(sel);
|
||||
return `<div style="font-size:.78rem;color:var(--text-3)">Цвет</div>
|
||||
<div class="mm-swatches">
|
||||
<span class="mm-swatch mm-swatch-none${!sel ? ' on' : ''}" data-c="" onclick="pickSwatch(this)" title="Без цвета"></span>
|
||||
${COL_PALETTE.map(c => `<span class="mm-swatch${sel === c ? ' on' : ''}" data-c="${c}" style="background:${c}" onclick="pickSwatch(this)"></span>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
function pickSwatch(el) { el.parentNode.querySelectorAll('.mm-swatch').forEach(s => s.classList.remove('on')); el.classList.add('on'); }
|
||||
function pickedColor(body) { const on = body.querySelector('.mm-swatch.on'); return on ? (on.dataset.c || null) : null; }
|
||||
window.pickSwatch = pickSwatch;
|
||||
|
||||
function createCollection() {
|
||||
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<input id="mm-col-name" placeholder="Название папки" style="${FLD}" />
|
||||
${colorPalette(null)}
|
||||
</div>`;
|
||||
const content = `<input id="mm-col-name" placeholder="Название папки" style="${FLD}" />`;
|
||||
const m = LS.modal({ title: 'Новая папка', content, size: 'sm', actions: [
|
||||
{ label: 'Отмена', onClick: () => m.close() },
|
||||
{ label: 'Создать', primary: true, onClick: async () => {
|
||||
const name = m.body.querySelector('#mm-col-name').value.trim();
|
||||
if (!name) { LS.toast('Введите название', 'warn'); return; }
|
||||
try { await LS.createMaterialCollection({ name, color: pickedColor(m.body) }); m.close(); load(); }
|
||||
try { await LS.createMaterialCollection({ name }); m.close(); load(); }
|
||||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
} },
|
||||
] });
|
||||
}
|
||||
window.createCollection = createCollection;
|
||||
|
||||
/* Reorder a folder up/down by normalizing sort_order to the new index order. */
|
||||
async function moveCollection(id, dir) {
|
||||
const arr = _cols.slice();
|
||||
const i = arr.findIndex(c => c.id === id);
|
||||
const j = i + dir;
|
||||
if (i < 0 || j < 0 || j >= arr.length) return;
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
try {
|
||||
await Promise.all(arr.map((c, k) => c.sort_order !== k ? LS.updateMaterialCollection(c.id, { sortOrder: k }) : null).filter(Boolean));
|
||||
load();
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
}
|
||||
window.moveCollection = moveCollection;
|
||||
|
||||
function editCollection(id) {
|
||||
const col = _cols.find(c => c.id === id);
|
||||
if (!col) return;
|
||||
const content = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<input id="mm-col-name" value="${esc(col.name)}" placeholder="Название папки" style="${FLD}" />
|
||||
${colorPalette(col.color)}
|
||||
<div style="display:flex;gap:8px;margin-top:2px">
|
||||
<button class="mm-btn" onclick="moveCollection(${id},-1)"><i data-lucide="arrow-up"></i> Выше</button>
|
||||
<button class="mm-btn" onclick="moveCollection(${id},1)"><i data-lucide="arrow-down"></i> Ниже</button>
|
||||
</div>
|
||||
</div>`;
|
||||
const content = `<input id="mm-col-name" value="${esc(col.name)}" placeholder="Название папки" style="${FLD}" />`;
|
||||
const m = LS.modal({ title: 'Папка', content, size: 'sm', actions: [
|
||||
{ label: 'Удалить', onClick: async () => {
|
||||
if (!await LS.confirm('Материалы из неё останутся и станут «Без папки».', { title: 'Удалить папку?', confirmText: 'Удалить' })) return;
|
||||
@@ -652,11 +459,10 @@
|
||||
{ label: 'Сохранить', primary: true, onClick: async () => {
|
||||
const name = m.body.querySelector('#mm-col-name').value.trim();
|
||||
if (!name) { LS.toast('Введите название', 'warn'); return; }
|
||||
try { await LS.updateMaterialCollection(id, { name, color: pickedColor(m.body) }); m.close(); load(); }
|
||||
try { await LS.updateMaterialCollection(id, { name }); m.close(); load(); }
|
||||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
} },
|
||||
] });
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
window.editCollection = editCollection;
|
||||
|
||||
@@ -696,10 +502,10 @@
|
||||
const up = await LS.uploadMaterialFile(fd);
|
||||
if (o.materialId) {
|
||||
// Аннотация существующего материала — перезаписываем его, а не плодим копии
|
||||
await LS.updateMaterial(o.materialId, { url: up.url, thumbUrl: up.thumbUrl || null });
|
||||
await LS.updateMaterial(o.materialId, { url: up.url });
|
||||
close(); load(); LS.toast('Изменения сохранены', 'success');
|
||||
} else {
|
||||
await LS.saveMaterial({ kind: 'image', title: o.title || 'Рисунок', url: up.url, thumbUrl: up.thumbUrl || null, sourceTitle: o.sourceTitle || null });
|
||||
await LS.saveMaterial({ kind: 'image', title: o.title || 'Рисунок', url: up.url, sourceTitle: o.sourceTitle || null });
|
||||
close(); load(); LS.toast('Сохранено в «Мои материалы»', 'success');
|
||||
}
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); btn.disabled = false; }
|
||||
@@ -719,7 +525,6 @@
|
||||
async function toFlashcard(id) {
|
||||
const mt = _mats.find(x => x.id === id);
|
||||
if (!mt) return;
|
||||
await ensureFullBody(mt);
|
||||
let decks = [];
|
||||
try { const d = await LS.fcListDecks(); decks = d.decks || []; } catch (e) {}
|
||||
const opts = ['<option value="__new">+ Новая колода «Из материалов»</option>']
|
||||
|
||||
@@ -82,10 +82,42 @@
|
||||
|
||||
/* ── объект ── */
|
||||
.sbu-obj.sel { border-color: var(--violet); box-shadow: 0 0 0 2px rgba(155,93,229,0.16); }
|
||||
.sbu-obj-hdr { display: flex; align-items: center; gap: 6px; }
|
||||
.sbu-obj.is-hidden, .sbu-plot.is-hidden { opacity: .62; }
|
||||
.sbu-obj.is-hidden .sbu-obj-fields, .sbu-obj.is-hidden .sbu-obj-style { opacity: .7; }
|
||||
.sbu-obj-hdr { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
|
||||
.sbu-obj-type { font-size: .72rem; font-weight: 800; color: var(--violet); flex-shrink: 0; }
|
||||
.sbu-in-id { flex: 1; max-width: 120px; }
|
||||
.sbu-in-id { flex: 1; min-width: 64px; max-width: 110px; }
|
||||
.sbu-obj-hdr .sbu-icon-btn { width: 26px; height: 26px; }
|
||||
.sbu-icon-btn:disabled { opacity: .32; cursor: default; pointer-events: none; }
|
||||
.sbu-icon-btn.active { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,0.1); }
|
||||
.sbu-zord { color: var(--text-3); }
|
||||
.sbu-obj-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
|
||||
/* ── блок «Стиль» объекта (P4) ── */
|
||||
.sbu-obj-style { border-top: 1px dashed var(--border); padding-top: 7px; margin-top: 1px; display: flex; flex-direction: column; gap: 7px; }
|
||||
.sbu-obj-style .sbu-sub { margin-top: 0; }
|
||||
.sbu-style-row { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; align-items: end; }
|
||||
.sbu-style-row > * { min-width: 0; }
|
||||
.sbu-grad-row { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
|
||||
|
||||
/* ── color-picker контрол (нативный пикер + текст + очистка) ── */
|
||||
.sbu-color-mini { min-width: 0; }
|
||||
.sbu-color-wrap { display: flex; align-items: center; gap: 5px; }
|
||||
.sbu-color-pick { width: 30px; height: 30px; flex-shrink: 0; padding: 0; border: 1px solid var(--border); border-radius: 8px; background: #fff; cursor: pointer; }
|
||||
.sbu-color-pick::-webkit-color-swatch-wrapper { padding: 3px; }
|
||||
.sbu-color-pick::-webkit-color-swatch { border: none; border-radius: 5px; }
|
||||
.sbu-color-wrap .sbu-in-color { flex: 1; min-width: 0; }
|
||||
.sbu-color-clr { width: 26px; height: 26px; flex-shrink: 0; border: 1px solid var(--border); border-radius: 7px; background: #fff; color: var(--text-3); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.sbu-color-clr:hover { border-color: #ef4444; color: #ef4444; }
|
||||
|
||||
/* ── range (opacity) ── */
|
||||
.sbu-range-mini { min-width: 0; }
|
||||
.sbu-range-val { color: var(--violet); font-variant-numeric: tabular-nums; }
|
||||
.sbu-range { width: 100%; accent-color: var(--violet); height: 30px; box-sizing: border-box; }
|
||||
|
||||
/* ── кривые графика ── */
|
||||
.sbu-curves { display: flex; flex-direction: column; gap: 8px; }
|
||||
.sbu-curve { border: 1px solid var(--border); border-radius: 9px; padding: 8px; background: #fafbfd; display: flex; flex-direction: column; gap: 7px; }
|
||||
.sbu-curve-del { width: 24px; height: 24px; }
|
||||
.sbu-of { display: flex; flex-direction: column; gap: 2px; }
|
||||
.sbu-of-lbl { font-size: .66rem; color: var(--text-3); display: flex; align-items: center; justify-content: space-between; gap: 4px; }
|
||||
.sbu-fx { font-size: .62rem; font-weight: 800; font-style: italic; color: var(--violet); background: rgba(155,93,229,0.1); border: none; border-radius: 5px; padding: 1px 6px; cursor: pointer; }
|
||||
@@ -113,6 +145,11 @@
|
||||
.sbu-panels { width: auto; max-height: 50vh; border-right: none; border-bottom: 1px solid var(--border); }
|
||||
.sbu-preview { min-height: 320px; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.sbu-obj-fields { grid-template-columns: 1fr; }
|
||||
.sbu-style-row, .sbu-grad-row { grid-template-columns: 1fr; }
|
||||
.sbu-row4 { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -152,6 +189,13 @@
|
||||
var ip = LS.initPage() || {};
|
||||
if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; }
|
||||
|
||||
// Фича-гейт: «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled).
|
||||
if (LS.loadFeatures) {
|
||||
LS.loadFeatures().then(function (feats) {
|
||||
if (feats && feats.sim_builder === false) { LS.toast && LS.toast('Конструктор симуляций отключён', 'warn'); location.href = '/dashboard'; }
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
if (!window.SimEngine || !window.SimExpr || !window.SimBuilder) {
|
||||
document.getElementById('sbu-preview').innerHTML =
|
||||
'<div style="padding:40px;color:#fff">Движок симуляций не загрузился. Обновите страницу.</div>';
|
||||
|
||||
+131
-2
@@ -416,7 +416,7 @@
|
||||
<div class="tg-nav-title">Содержание</div>
|
||||
<div class="tg-progress-wrap">
|
||||
<div class="tg-progress-bar-outer"><div class="tg-progress-bar-inner" id="tg-prog-bar"></div></div>
|
||||
<div class="tg-progress-text" id="tg-prog-text">0 из 13 глав прочитано</div>
|
||||
<div class="tg-progress-text" id="tg-prog-text">0 из 21 глав прочитано</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tg-nav-search">
|
||||
@@ -1587,7 +1587,7 @@
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Магазин наград</b> — за монеты (начисляются вместе с XP) ученик покупает предметы и награды.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Родительские аккаунты</b> (<a href="/parent">/parent</a>) — родитель привязывается к ученику и видит его прогресс и уведомления.</div></div>
|
||||
</div>
|
||||
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Это вся учительская часть</div>Дальше — главы для администраторов (видны только под ролью admin).</div></div>
|
||||
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Почти всё</div>Дальше — мощный «Конструктор симуляций» (создание своих интерактивных сцен), а за ним — главы для администраторов (видны только под ролью admin).</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tg-chapter-nav">
|
||||
@@ -1595,6 +1595,134 @@
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Флэшкарты</div></div>
|
||||
</div>
|
||||
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-21')" style="text-align:right">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-right"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Следующая глава</div><div class="tg-ch-nav-title">Конструктор симуляций</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ CHAPTER 21 — КОНСТРУКТОР СИМУЛЯЦИЙ ═══ -->
|
||||
<div class="tg-chapter" id="ch-21">
|
||||
<div class="tg-chapter-header">
|
||||
<div class="tg-chapter-icon"><i data-lucide="pencil-ruler"></i></div>
|
||||
<div class="tg-chapter-meta">
|
||||
<div class="tg-chapter-num">Глава 21</div>
|
||||
<div class="tg-chapter-title">Конструктор симуляций</div>
|
||||
</div>
|
||||
<a href="/sim-builder" class="tg-chapter-try" target="_blank"><i data-lucide="external-link"></i> Открыть конструктор</a>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-21-1">
|
||||
<div class="tg-section-title">21.1 Что это и где</div>
|
||||
<p><b>Конструктор симуляций</b> — инструмент, в котором вы сами, без программирования, собираете интерактивную 2D-сцену: параметры-ползунки, объекты (точки, отрезки, векторы, фигуры, подписи), привязанные <b>формулами</b> к параметрам и времени, настоящую физику и графики. Готовую симуляцию можно сохранить, опубликовать в лабораторию, раздать классу и открыть на доске онлайн-урока.</p>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body">Открыть: в боковом меню пункт <b>«Конструктор симуляций»</b> или адрес <a href="/sim-builder">/sim-builder</a>. Доступно учителю и администратору.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body">Симуляция — это <b>данные</b>, а не код. Формулы вычисляются безопасным движком (доступны только математические функции), поэтому готовыми сценами безопасно делиться.</div></div>
|
||||
</div>
|
||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="lightbulb"></i></div><div class="tg-box-body"><div class="tg-box-label">Что реально собрать</div>Кинематику (брошенное тело, равноускоренное движение), колебания и волны, графики функций, геометрические чертежи, а с включённой физикой — маятники, пружины и упругие столкновения.</div></div>
|
||||
<div class="tg-note"><div class="tg-box-icon"><i data-lucide="shield"></i></div><div class="tg-box-body"><div class="tg-box-label">Если пункта меню нет</div>Администратор мог отключить конструктор: <b>Админка → Функции → «Конструктор симуляций»</b>. При выключенном тумблере страница недоступна, но ранее опубликованные симуляции в лаборатории продолжают работать.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-21-2">
|
||||
<div class="tg-section-title">21.2 Рабочее поле</div>
|
||||
<p>В центре — <b>живое превью</b>: всё, что вы добавляете и меняете, сразу видно. Слева — панели настроек, сверху — панель инструментов (Тест, Сброс, Сохранить, Опубликовать, Шаблон, Раздать, отмена/повтор).</p>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Масштаб</b> — колесо мыши (приближает к курсору).</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Перемещение вида (панорама)</b> — перетаскивание мышью по пустому месту сцены.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Кнопки в углу сцены</b> — «Вписать» (показать всю область) и «Сбросить вид».</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Сетка и оси</b> с числовыми делениями и точкой (0,0) рисуются автоматически; границы области задаются в настройках сцены (xmin/xmax/ymin/ymax).</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body">Контролы запущенной симуляции (ползунки, плей/пауза) — плавающая панель в углу, не закрывает сцену; её можно свернуть.</div></div>
|
||||
</div>
|
||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="play"></i></div><div class="tg-box-body"><div class="tg-box-label">Кнопка «Тест»</div>Запускает анимацию (время <code>t</code> идёт), «Сброс» — возвращает в начало. Пока симуляция на паузе, перетаскивание объектов и ползунки служат для настройки сцены.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-21-3">
|
||||
<div class="tg-section-title">21.3 Параметры (ползунки)</div>
|
||||
<p>Параметр — это переменная со ползунком, которой можно управлять прямо в симуляции. На параметры потом ссылаются формулы объектов.</p>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">1</div><div class="tg-step-body">Откройте панель <b>«Параметры»</b> → «Добавить параметр».</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">2</div><div class="tg-step-body">Задайте: <b>имя</b> (латиницей, например <code>v</code>, <code>theta</code>), минимум, максимум, шаг, начальное значение и (необязательно) единицу.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">3</div><div class="tg-step-body">В превью появится ползунок — двигайте его, чтобы видеть, как меняется сцена.</div></div>
|
||||
</div>
|
||||
<div class="tg-note"><div class="tg-box-icon"><i data-lucide="alert-triangle"></i></div><div class="tg-box-body"><div class="tg-box-label">Зарезервированные имена</div>Нельзя называть параметр <code>t</code> (время), <code>e</code>, <code>pi</code>, <code>w</code>, <code>h</code> — это служебные имена в формулах. Конструктор предупредит и не даст сохранить.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-21-4">
|
||||
<div class="tg-section-title">21.4 Объекты и формулы</div>
|
||||
<p>Объекты — это то, что рисуется на сцене. Любое числовое поле объекта (координата, радиус, размер) можно задать <b>числом или формулой</b> от параметров и времени <code>t</code>.</p>
|
||||
<div class="tg-tools-grid">
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="dot"></i></div><div><div class="tg-tool-name">Точка / Отрезок / Вектор</div><div class="tg-tool-desc">Базовая геометрия со стрелками</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="circle"></i></div><div><div class="tg-tool-name">Круг / Прямоугольник</div><div class="tg-tool-desc">Фигуры с заливкой</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="spline"></i></div><div><div class="tg-tool-name">Ломаная / Кривая</div><div class="tg-tool-desc">Набор точек</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="type"></i></div><div><div class="tg-tool-name">Подпись (LaTeX)</div><div class="tg-tool-desc">Текст и формулы KaTeX</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="line-chart"></i></div><div><div class="tg-tool-name">График</div><div class="tg-tool-desc">Кривая y=f(x), см. 21.7</div></div></div>
|
||||
<div class="tg-tool-card"><div class="tg-tool-icon"><i data-lucide="gauge"></i></div><div><div class="tg-tool-name">Индикатор (readout)</div><div class="tg-tool-desc">Живое числовое значение</div></div></div>
|
||||
</div>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">1</div><div class="tg-step-body">Панель <b>«Объекты»</b> → выберите тип → «Добавить».</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">2</div><div class="tg-step-body">В полях координат пишите формулы: например для броска тела <code>x = v*cos(theta)*t</code>, <code>y = v*sin(theta)*t - 5*t^2</code>.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">3</div><div class="tg-step-body">Кнопка <b>fx</b> у поля открывает палитру: параметры, время <code>t</code>, функции (sin, cos, sqrt, abs, exp, ln…), константы (pi, e). Клик вставляет имя в формулу.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">4</div><div class="tg-step-body">Объект можно поставить мышью: нажмите значок <b>«прицел»</b> у объекта и кликните по сцене — координаты подставятся (см. 21.5).</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">5</div><div class="tg-step-body">У объекта можно задать <b>id</b> и затем ссылаться на его координаты в других формулах: <code>id.x</code>, <code>id.y</code>.</div></div>
|
||||
</div>
|
||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="function-square"></i></div><div class="tg-box-body"><div class="tg-box-label">Ошибки в формуле</div>Если выражение записано неверно, поле подсветится и покажет ошибку — симуляция при этом не ломается. Деление на ноль и неопределённости дают 0.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-21-5">
|
||||
<div class="tg-section-title">21.5 Стиль, порядок и прямое редактирование</div>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Стиль объекта</b>: выбор цвета (палитра), прозрачность, толщина и тип линии (сплошная / штрих / пунктир), стиль точки, свечение, градиентная заливка.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Порядок и операции</b>: кнопки «вверх/вниз» меняют порядок отрисовки (что поверх чего), есть дублирование и тумблер видимости (глаз), удаление.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Перетаскивание на сцене</b>: значок «прицел» включает ручки — тяните точку, концы отрезка/вектора, вершины ломаной прямо на превью (на паузе). Поля с формулами при этом не затираются.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Привязка к сетке</b> (тумблер в панели инструментов) — координаты при перетаскивании округляются к узлам сетки.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Отмена/повтор</b>: кнопки в панели инструментов и горячие клавиши <code>Ctrl+Z</code> / <code>Ctrl+Y</code> (или <code>Ctrl+Shift+Z</code>).</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-21-6">
|
||||
<div class="tg-section-title">21.6 Физика</div>
|
||||
<p>Включите тумблер <b>«Физика»</b> — и часть объектов будет двигаться по законам механики, а не по формуле. Движок сам интегрирует движение.</p>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Общие силы</b>: гравитация (g по X и Y), трение, упругость столкновений (0…1).</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Тело</b>: у точки/круга включите «тело» и задайте массу и начальную скорость (vx, vy). Тело падает, сталкивается, отскакивает.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Пружины</b> — между двумя телами или телом и точкой-якорем (жёсткость, длина покоя, демпфирование).</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Стены</b> — границы области (низ/верх/лево/право) или произвольный отрезок: от них тела отражаются.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body">Тело можно <b>тянуть мышью</b> на паузе; при отпускании в запущенной сцене оно полетит.</div></div>
|
||||
</div>
|
||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="info"></i></div><div class="tg-box-body"><div class="tg-box-label">Формулы и физика вместе</div>Физические тела и формульные объекты сосуществуют на одной сцене: например, подпись или вектор скорости можно привязать к координатам тела через <code>id.x</code>, <code>id.y</code>.</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-21-7">
|
||||
<div class="tg-section-title">21.7 Графики и диаграммы</div>
|
||||
<p>Объект <b>«График»</b> рисует кривую функции прямо на сцене в мировых координатах.</p>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body">Задайте выражение (например <code>sin(x)</code>), переменную (по умолчанию <code>x</code>), диапазон и число точек.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Несколько кривых</b> на одном графике — у каждой свои цвет, подпись, толщина и стиль линии.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Заливка под кривой</b>, <b>маркеры точек</b> и <b>легенда</b> (по подписям кривых) включаются переключателями.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body">Режим <b>«след» (trace)</b> — кривая накапливается по времени <code>t</code> (удобно строить график величины в ходе анимации).</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tg-section" id="s-21-8">
|
||||
<div class="tg-section-title">21.8 Сохранение, публикация и раздача</div>
|
||||
<div class="tg-steps">
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Сохранить</b> — симуляция сохраняется как черновик (видна только вам).</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Опубликовать</b> — симуляция появляется в лаборатории для всех; «Снять с публикации» возвращает в черновик.</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Шаблон</b> — начать не с нуля, а с заготовки (пустая, маятник, график, бросок).</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Раздать классу</b> — ученики класса получают уведомление со ссылкой на симуляцию (она автоматически публикуется).</div></div>
|
||||
<div class="tg-step"><div class="tg-step-num">—</div><div class="tg-step-body"><b>Клонировать</b> — сделать свою копию чужой опубликованной симуляции и доработать её.</div></div>
|
||||
</div>
|
||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="flask-conical"></i></div><div class="tg-box-body"><div class="tg-box-label">Где появляются ваши симуляции</div>В <a href="/lab">лаборатории</a> есть раздел <b>«Мои симуляции»</b> — там ваши черновики и опубликованные, с кнопками «Редактировать» и «Удалить». Прямая ссылка вида <code>/lab?sim=custom:НОМЕР</code> открывает конкретную симуляцию.</div></div>
|
||||
<div class="tg-tip"><div class="tg-box-icon"><i data-lucide="presentation"></i></div><div class="tg-box-body"><div class="tg-box-label">На онлайн-уроке</div>Свою симуляцию можно открыть на доске урока — значения ползунков синхронизируются у учеников, а поверх можно рисовать аннотации.</div></div>
|
||||
<div class="tg-success"><div class="tg-box-icon"><i data-lucide="check-circle"></i></div><div class="tg-box-body"><div class="tg-box-label">Готово</div>Это вся учительская часть руководства. Дальше — главы для администраторов (видны только под ролью admin).</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tg-chapter-nav">
|
||||
<div class="tg-ch-nav-btn prev" onclick="scrollToChapter('ch-20')">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="arrow-left"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Предыдущая глава</div><div class="tg-ch-nav-title">Ещё модули платформы</div></div>
|
||||
</div>
|
||||
<div class="tg-ch-nav-btn next" onclick="scrollToChapter('ch-1')" style="text-align:right">
|
||||
<div class="tg-ch-nav-icon"><i data-lucide="rotate-ccw"></i></div>
|
||||
<div><div class="tg-ch-nav-label">Вернуться к началу</div><div class="tg-ch-nav-title">Быстрый старт</div></div>
|
||||
@@ -1928,6 +2056,7 @@
|
||||
{ id:'ch-18', label:'Квантик-ассистент', icon:'sparkles', sections:['s-18-1','s-18-2','s-18-3'], sLabels:['Что умеет','Спроси Квантика','Подсказки на экзамене'] },
|
||||
{ id:'ch-19', label:'Флэшкарты', icon:'copy', sections:['s-19-1','s-19-2','s-19-3'], sLabels:['Колоды и карточки','Картинки и формулы','Импорт и генерация ИИ'] },
|
||||
{ id:'ch-20', label:'Ещё модули платформы', icon:'grid-3x3', sections:['s-20-1','s-20-2','s-20-3','s-20-4'], sLabels:['Карта знаний и Теория','Игры: Кроссворд, Виселица','Красная книга и Коллекция','Материалы, Магазин, Родители'] },
|
||||
{ id:'ch-21', label:'Конструктор симуляций', icon:'pencil-ruler', sections:['s-21-1','s-21-2','s-21-3','s-21-4','s-21-5','s-21-6','s-21-7','s-21-8'], sLabels:['Что это и где','Рабочее поле','Параметры','Объекты и формулы','Стиль и порядок','Физика','Графики','Сохранение и раздача'] },
|
||||
];
|
||||
|
||||
const ADMIN_CHAPTERS = [
|
||||
|
||||
@@ -860,6 +860,7 @@ async function hideDisabledFeatures() {
|
||||
biochem: ['/biochem', '/biochem-library', '/biochem-reactions'],
|
||||
live_quiz: ['/live-quiz'],
|
||||
classroom: ['/classroom'],
|
||||
sim_builder: ['/sim-builder', '/sim-builder.html'],
|
||||
exam9: ['/exam9', '/exam9.html'],
|
||||
textbooks: ['/textbooks', '/textbooks.html', '/textbook'],
|
||||
};
|
||||
@@ -1037,7 +1038,7 @@ window.LS = {
|
||||
crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession,
|
||||
crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory,
|
||||
crAdminGetAllHistory, crAdminGetTeachersList,
|
||||
listMaterials, getMaterial, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
|
||||
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
|
||||
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
|
||||
customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete,
|
||||
customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink,
|
||||
@@ -1253,7 +1254,6 @@ async function uploadMaterialFile(formData) {
|
||||
}
|
||||
function downloadFileUrl(id) { return `${API}/files/${id}/download`; }
|
||||
async function listMaterials() { return req('GET', '/materials'); }
|
||||
async function getMaterial(id) { return req('GET', `/materials/${id}`); }
|
||||
async function saveMaterial(data) { return req('POST', '/materials', data); }
|
||||
async function updateMaterial(id, d) { return req('PATCH', `/materials/${id}`, d); }
|
||||
async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); }
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# «Мои материалы» — v2: харднинг и доводка
|
||||
|
||||
> Составлен Opus 2026-06-13. Базовый план (PLAN.md, Фазы 1–6) **полностью реализован**.
|
||||
> Его раздел «Сквозные риски» отложил ровно то, что закрывает этот план: учёт/лимиты/чистку
|
||||
> хранилища и `materials.test.js`. Источник истины по текущему состоянию — код
|
||||
> (`studentMaterialsController.js`, `materials.js`, `my-materials.html`, `board-clip.js`,
|
||||
> `material-save.js`) и [[reference_student_materials]].
|
||||
|
||||
Готчи проекта: новый `:id`-роут → `// @public-by-design` + проверка владельца; большие HTML — только Edit;
|
||||
без эмодзи (inline SVG `.ic`); коммит поимённо + push; перезапуск сервера при правке backend; ветка
|
||||
`feature/sim-builder` в рабочем дереве — НЕ коммитить чужие правки, только свои файлы.
|
||||
|
||||
---
|
||||
|
||||
## Фаза 1 — Целостность и безопасность (backend, фундамент) ✅ цель этого захода
|
||||
|
||||
1. **Ссылочно-подсчётная чистка файлов.** `DELETE /:id` и смена `url` (аннотация) сейчас оставляют
|
||||
файл в `uploads/materials/` сиротой. `share` копирует `url` дословно → несколько строк ссылаются на
|
||||
ОДИН файл, поэтому `unlink` только когда на `url` не ссылается ни одна строка. Хелпер
|
||||
`releaseFileForUrl(url)` вызывается ПОСЛЕ delete/update.
|
||||
2. **Allowlist схемы URL.** `create`/`update` принимали любой `url` → `link` со схемой `javascript:`
|
||||
рендерится как рабочий `<a href>` (раздача делает это вектором учитель→ученики). Хелпер `safeUrl`:
|
||||
только `http(s)://` или app-relative `/…` (не `//host`); иначе 400.
|
||||
3. **Квота на пользователя.** Колонка `bytes` (мигр. 073), счёт `SUM(bytes)`/`COUNT(*)`. Лимит по числу
|
||||
материалов — в `create()`; лимит по байтам — в `uploadPersonalFile` (до приёма файла). Конфигурируемо
|
||||
через `MATERIALS_MAX_ITEMS` / `MATERIALS_MAX_BYTES` (для тестов — низкий потолок).
|
||||
4. **`backend/tests/materials.test.js`** — CRUD, владелец (403/404), коллекции, share-копия + роль/owner,
|
||||
валидация URL, лимит числа, ссылочная чистка (прямой вызов хелпера на временном файле).
|
||||
|
||||
## Фаза 2 — Производительность ✅
|
||||
- ✅ `GET /api/materials` отдаёт **обрезанный** `body` (первые 1000 симв.) + флаг `body_trunc`; полный текст —
|
||||
ленивый `GET /api/materials/:id` (`getOne`, owner-only). Клиент `ensureFullBody()` подгружает перед
|
||||
просмотром/правкой/флешкартой (иначе правка сохранила бы усечённый текст).
|
||||
- ✅ Пагинация рендера: клиент держит весь список (поиск/фильтр/сортировка в памяти), но в DOM рисует
|
||||
`PAGE_SIZE=60` карточек + «Показать ещё»; `_shown` сбрасывается на смену фильтра. Снимает стоимость
|
||||
рендера тысяч узлов, не ломая клиентский поиск (keyset на сервере не нужен на текущих объёмах).
|
||||
- ✅ Серверные миниатюры `board/image`: `uploadPersonalFile` (sharp → webp ≤480px) возвращает `{url, thumbUrl}`;
|
||||
колонка `thumb_url` (мигр. **074**); грид рисует `<img src=thumb_url||url>`, просмотр/скачивание/аннотация —
|
||||
полный `url`. Чистится по ссылкам (releaseFileForUrl теперь матчит url **и** thumb_url); share копирует thumb;
|
||||
квота считает файл+миниатюру. Клиентские сейверы (board-clip/material-save/textbook-clip/draw) пробрасывают `thumbUrl`.
|
||||
|
||||
## Фаза 3 — Доводка заложенных фич ✅
|
||||
- ✅ UI тегов: ввод в модалках создания/правки + чипы на карточке (клик → фильтр) + пилюля активного фильтра.
|
||||
- ✅ Ссылка «открыть исходный урок» на карточке (`/my-lessons?session=<id>`, есть `source_session_id`).
|
||||
- ✅ Цвет папки (палитра 8 пресетов, тинт иконки в рейле) + сортировка папок «Выше/Ниже» в модалке правки
|
||||
(нормализует `sort_order` к индексам). `safeColor` гейтит inline-style инъекцию (только hex).
|
||||
|
||||
## Фаза 4 — UX ✅
|
||||
- ✅ Варианты сортировки (новые/старые/имя/тип) — селект в тулбаре.
|
||||
- ✅ Множественный выбор (чекбокс на карточке) + панель массовых действий (переместить/удалить, reuse per-item API).
|
||||
- ✅ Живое превью KaTeX в редакторе заметки (oninput → `mmPreview` → `mathHtml`).
|
||||
|
||||
### Статус — ПЛАН V2 ВЫПОЛНЕН
|
||||
**Ф1–Ф4 ✅.** Backend: 19 тестов `materials.test.js` (CRUD/владелец/коллекции/share/URL-allowlist/квота/
|
||||
ссылочная чистка url+thumb/round-trip thumb_url). Frontend: headless-смоук `my-materials.html` (синтаксис +
|
||||
deep-link/теги/чекбокс/bulk/тинт папки + `<img>` на thumb_url + пагинация «Показать ещё»). sharp-пайплайн и
|
||||
client-сейверы (board-clip/material-save/textbook-clip) проверены. Открытого из плана не осталось.
|
||||
|
||||
---
|
||||
|
||||
## Порядок
|
||||
**Ф1 (этот заход) → Ф2 → Ф3 → Ф4.** Ф1 — серверный фундамент (риск-возврат, без него фронт-фичи множат
|
||||
мусор). Дальше преимущественно фронтенд `my-materials.html` + точечные ручки API.
|
||||
@@ -1,6 +1,85 @@
|
||||
# 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) уже умеет рисовать.
|
||||
- **Контролы стиля объекта** (блок «Стиль», `STYLE_FOR[type]`): слайдер непрозр.(`opacity` 0..1),
|
||||
select `lineStyle`(solid/dashed/dotted), `pointStyle`(только point), тумблер `glow`, тумблер градиент-
|
||||
заливки(circle/rect → `gradient:[c0,c1]`). Цвета — `colorCtl`: нативный `<input type=color>` + текст
|
||||
(источник истины, держит rgba/named) + очистка для fill/trailColor. Синхрон — `wireColorControls`,
|
||||
`toHexColor`→`#rrggbb`. Per-объект уже были width/color в OBJ_FIELDS — переведены на color-пикеры.
|
||||
- **Редактор кривых plot** (`plotEditor`/`curveEditor`): UI-модель `{var,range_a/b,samples,trace,legend,
|
||||
plotFill,plotMarker,curves:[{expr,color,label,width,lineStyle,opacity,fill,fillColor,marker}]}`. Список
|
||||
кривых (add/del, минимум 1), на кривую все P3-поля + fx-палитра, plot-уровневые fill/marker/легенда.
|
||||
`loadPlot` (spec→UI: curves[]→exprs[]→legacy expr; легаси plot-level width/lineStyle/opacity → в кривую),
|
||||
`normalizePlotForSpec`+`stripCurve` (UI→spec). Одиночная простая кривая → легаси `{expr,color}`, иначе
|
||||
`curves:[...]`. `legend:false` эмитится только при выкл.
|
||||
- **Список объектов/графиков**: z-order вверх/вниз (порядок массива = порядок отрисовки), видимость
|
||||
(`hidden:true` — чисто билдерский флаг, фильтруется в `buildSpec`, движок не знает), дублировать
|
||||
(deep-clone+новый `_uid`, `id+'_copy'`), удалить. Иконки — новые inline SVG `.ic` (up/down/copy/eye/eyeOff/clearX).
|
||||
- **Минимизация спеки + стабильный round-trip**: `stripObj.isDefaultStyle` выбрасывает дефолты
|
||||
(glow:false, lineStyle:'solid', pointStyle:'filled', opacity:1, trail/closed:false) и `hidden`. Save→load→
|
||||
save идемпотентен (loadFromSim восстанавливает дефолты из контролов).
|
||||
- **Дизайн/мобайл**: новые CSS-классы в ls.css-стиле (`.sbu-obj-style`/`.sbu-style-row`/`.sbu-color-*`/
|
||||
`.sbu-range`/`.sbu-curve(s)`/`.is-hidden`/`.sbu-grad-row`); заголовок объекта flex-wrap + 26px-кнопки;
|
||||
медиа ≤920px (раскладка) + новый ≤560px (поля/стили в один столбец). Пустые состояния дополнены.
|
||||
- **Безопасность**: выражения только через `SimExpr.compile`; цвета попадают лишь в спеку (canvas-стоки
|
||||
движка), DOM-style с польз.цветом не используется; eval/new Function — нет.
|
||||
- Верификация: `node --check` sim-builder.js + извлечённого инлайна html — OK; эмодзи нет (скан кодпойнтов
|
||||
обоих файлов — 0); eval/new Function — 0; headless vm-смоук (DOM/SimExpr-стаб) 27+12 PASS: стили объекта в
|
||||
спеке, round-trip объектов идемпотентен ×2, plot с 2 кривыми (label/marker/lineStyle/opacity/fill-цвет/
|
||||
range/samples) + round-trip ×2, легаси-одиночная кривая → легаси-форма + round-trip, hidden исключает из
|
||||
спеки, z-order=порядок массива, дефолты-стрип; +шаблонные легаси-plot save→load→save стабильны (2 PASS).
|
||||
Temp удалены. git status: тронуты только sim-builder.html и sim-builder.js.
|
||||
- **Следующее (P5):** прямое манипулирование на сцене (drag всех типов + snap-к-сетке + выравнивание) и
|
||||
undo/redo. Потребуются правки `_sim_engine.js` (хит-тесты/ручки) + `sim-builder.js` (стек снапшотов `this.st`).
|
||||
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P3 «Графики/диаграммы» РЕАЛИЗОВАН** (рабочее дерево, не
|
||||
закоммичено; ветка `feature/sim-builder`). Файл: только `frontend/js/labs/_sim_engine.js`. Расширен
|
||||
`_drawPlot` + ветка `type==='plot'` в `_prepareObjects`. Оси/сетка/подписи уже из P1 — не дублировались.
|
||||
- **Несколько кривых**: нормализуются в `prep.curves[]`, приоритет источника `curves:[{...}]` →
|
||||
`exprs:['sin(x)','x^2']` → одиночный `expr` (легаси, обратная совместимость сохранена). Каждой кривой
|
||||
свой цвет (явный `color` или `DEFAULT_PALETTE[i%8]`). `prep.exprFn` = первой кривой (для trace-режима).
|
||||
- **Поля кривой** (`curves[i]`): `expr`, `color`, `label`(→легенда), `width`, `lineStyle`, `opacity`,
|
||||
`fill`(true→полупрозр. цвет / строка), `marker`(none|dot|ring). Не заданные наследуют plot-уровень.
|
||||
**Plot-уровневые `fill`/`marker`** — дефолт для всех кривых.
|
||||
- **Заливка под кривой** `_fillUnderCurve` (между кривой и y=0, посегментно — разрывы у не-finite не
|
||||
сливаются; baseY клиппится к canvas). **Маркеры** `_drawCurveMarkers` (переиспользует `_drawPoint`,
|
||||
прорежены ~28px). **Легенда** `_drawLegend` на canvas (тёмная плашка + свотч + светлый текст, верх-право,
|
||||
авто при `label`, `legend:false` отключает). Новые модульные хелперы `_markerStyle`/`_fillAlpha`.
|
||||
- **Безопасность**: цвета только в canvas-стоки (strokeStyle/fillStyle/fillText фикс-цвет легенды);
|
||||
DOM-style с пользовательским цветом не используется; eval нет. Каждая кривая в своём save/restore,
|
||||
легенда на внешнем уровне.
|
||||
- Верификация: `node --check` OK; headless vm-смоук (canvas-стаб со счётчиком save/restore + РЕАЛЬНЫЕ
|
||||
`_sim_expr`+`_sim_engine`) 10/10: легаси/exprs[]/curves+fill+marker+legend/наследование/не-finite
|
||||
(1/x,tan)/legend:false/trace±range/fillUnder+markers с null/регресс point-vector-circle-rect — все PASS;
|
||||
ctx сбалансирован (depth→0, нет underflow). Эмодзи нет (только пре-существующие → в комментариях); eval=0.
|
||||
Temp-смоук удалён. git status: тронут только `_sim_engine.js`.
|
||||
- **Следующее (P4):** UI билдера + контролы стиля (`sim-builder.html`/`sim-builder.js`) — дать новым полям
|
||||
plot контролы: список кривых (add/del, expr+color+label+width+lineStyle+opacity+fill+marker), plot-fill/
|
||||
marker, тумблер легенды; плюс per-объект color/opacity/width/dash, z-order, дублирование, мобайл.
|
||||
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P2 «Качество графики объектов» РЕАЛИЗОВАН** (рабочее дерево, не
|
||||
закоммичено; ветка `feature/sim-builder`). Файл: только `frontend/js/labs/_sim_engine.js`. Один движок →
|
||||
эффект и в билдере, и в /lab, и на доске.
|
||||
@@ -25,9 +104,7 @@
|
||||
масштаб; все поля прочитаны; палитра применена + явный color сохранён; трасса накоплена; destroy чист.
|
||||
Эмодзи нет (скан кодпойнтов: только пре-существующие →/─/═/∞ в комментариях); eval=0; new Function — только
|
||||
в комментарии стр.15. git status: тронут только `_sim_engine.js`.
|
||||
- **Следующее (P3):** графики/диаграммы (`_drawPlot`): оси-деления plot, несколько кривых, заливка под
|
||||
кривой, маркеры точек (переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/
|
||||
`_drawPoint` готовы к переиспользованию.
|
||||
- **Следующее (P3):** РЕАЛИЗОВАНО (см. блок P3 выше) — несколько кривых, заливка, маркеры, легенда.
|
||||
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md) — P1 «Рабочее поле» РЕАЛИЗОВАН** (рабочее дерево, не закоммичено;
|
||||
ветка `feature/sim-builder`, общая с параллельной сессией materials/quota). Файл: только
|
||||
`frontend/js/labs/_sim_engine.js` (sim-builder.html НЕ потребовался). Один движок → эффект и в билдере, и в /lab, и на доске.
|
||||
@@ -213,6 +290,11 @@
|
||||
- Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`.
|
||||
|
||||
## RESUME STATE
|
||||
- **РАУНД УЛУЧШЕНИЙ (IMPROVEMENTS.md):** P1+P2+P3 закоммичены; **P4 «UI билдера + контролы стиля»
|
||||
РЕАЛИЗОВАН** (рабочее дерево, не закоммичено — ждёт ревьюера/оркестратора). Файлы: только
|
||||
`frontend/sim-builder.html` + `frontend/js/sim-builder.js`. Дальше — независимый ревью P4, затем P5
|
||||
(прямое манипулирование на сцене для всех типов + snap/выравнивание + undo/redo; правки `_sim_engine.js`
|
||||
+ `sim-builder.js`). Контракт стилей/кривых из P2/P3-handoff полностью покрыт контролами билдера.
|
||||
- Последний коммит фичи: — (Ф0..Ф7 ВСЕ реализованы, ещё не закоммичены — ждут оркестратора)
|
||||
- Текущая фаза: Phase 7 — Доска онлайн-урока (✅ Implemented, pending commit) — ФИНАЛЬНАЯ.
|
||||
Дальше: Final Review (final-reviewer + security review) → коммит всех фаз → merge в master.
|
||||
|
||||
@@ -68,20 +68,103 @@
|
||||
кривых). Для P3 расширять `_drawPlot` — оси-делений plot, несколько кривых, заливка под кривой, маркеры
|
||||
точек (можно переиспользовать `_drawPoint`), легенда. Хелперы `_applyStroke`/`_fillStyleFor`/`_drawPoint`
|
||||
готовы к переиспользованию.
|
||||
- [ ] **P3 — Графики/диаграммы (визуал charts).** Для plot: оси с делениями/подписями, несколько кривых,
|
||||
заливка под кривой, маркеры точек, легенда; аккуратный стиль диаграмм. Файл: `_sim_engine.js` (+ билдер
|
||||
поля plot).
|
||||
- [ ] **P4 — UI билдера + контролы стиля.** Дизайн-полировка панелей/тулбара (ls.css), нативные color-
|
||||
- [x] **P3 — Графики/диаграммы (визуал charts).** Для plot: несколько кривых, заливка под кривой,
|
||||
маркеры точек, легенда; аккуратный стиль диаграмм (оси/сетка/подписи — уже из P1). Файл: `_sim_engine.js`.
|
||||
|
||||
**Handoff (P3 → P4): новые поля plot-объекта** (контракт для контролов билдера в P4). Все читаются в
|
||||
`_prepareObjects` (ветка `type==='plot'`), рендерятся ТОЛЬКО на canvas (без DOM-style/eval). Старый
|
||||
одиночный `expr`/`var`/`range`/`samples`/`trace` работает как раньше (обратная совместимость):
|
||||
- **Несколько кривых.** Источник (приоритет): `curves:[{...}]` → `exprs:['sin(x)','x^2']` → `expr`
|
||||
(легаси). Нормализуются в `prep.curves[]`. Каждой кривой свой цвет: явный `color` или
|
||||
`DEFAULT_PALETTE[i%8]`. `prep.exprFn` = первая кривая (для trace-режима).
|
||||
- **Поля кривой** (`curves[i]`): `expr` (строка), `color`, `label` (строка → легенда), `width`,
|
||||
`lineStyle` (`solid|dashed|dotted`), `opacity` (0..1), `fill` (`true` → полупрозр. цвет кривой / строка
|
||||
цвета), `marker` (`none|dot|ring`). Не заданные наследуются от plot-уровня (`width/lineStyle/opacity`)
|
||||
или дефолтов.
|
||||
- **Plot-уровневые** `fill` и `marker` — дефолт для всех кривых (если у кривой не задано).
|
||||
- **Заливка под кривой** — между кривой и осью `y=0`, посегментно (разрывы у не-finite точек не сливаются),
|
||||
`_fillUnderCurve`. Прозрачность через `_fillAlpha(color, 0.18)` для `fill:true`.
|
||||
- **Маркеры узлов** — `_drawCurveMarkers` (переиспользует `_drawPoint`), прорежены ~28px по экрану
|
||||
(не рисуем сотни точек). `dot` → filled, `ring` → hollow.
|
||||
- **Легенда** — `_drawLegend` (на canvas: тёмная плашка + цветной свотч + светлый текст), верх-право,
|
||||
не наезжает на бар кнопок вида. Включается авто при наличии `label`; `legend:false` отключает.
|
||||
- **Качество кривой** — пропуск не-finite (разрывы), переиспользован существующий equidistant sampling
|
||||
(`samples`, деф. 200, макс 2000), `_applyStroke` (dash/opacity/glow/lineJoin/cap).
|
||||
- **На P4 (билдер):** дать этим полям контролы — список кривых (добавить/удалить, expr + color-picker +
|
||||
label + width + lineStyle + opacity + fill toggle/color + marker select), plot-уровневые fill/marker,
|
||||
тумблер легенды. Хелпер `_markerStyle`/`_fillAlpha` — модульного уровня, рядом с `_dashFor`/`_opacity`.
|
||||
- [x] **P4 — UI билдера + контролы стиля.** Дизайн-полировка панелей/тулбара (ls.css), нативные color-
|
||||
пикеры + opacity/width/dash/линестиль на объект, z-order/дублирование/видимость объектов, пустые
|
||||
состояния, мобайл. Файлы: `frontend/sim-builder.html`, `frontend/js/sim-builder.js`.
|
||||
- [ ] **P5 — Прямое манипулирование на сцене + история.** Drag всех типов (не только point/circle),
|
||||
snap-к-сетке, выравнивание; undo/redo в билдере. Файлы: `_sim_engine.js`, `frontend/js/sim-builder.js`.
|
||||
|
||||
**Handoff (P4 → P5):**
|
||||
- **Контролы стиля объекта** (блок «Стиль» в каждом редакторе, `STYLE_FOR[type]` решает набор):
|
||||
`rangeCtl` непрозр. (слайдер 0..1 → `opacity`), `selectCtl` линия (`lineStyle` solid/dashed/dotted),
|
||||
стиль точки (`pointStyle`, только point), тумблер `glow`, тумблер «Градиент-заливка» (circle/rect →
|
||||
`gradient:[c0,c1]`, две пары color-инпутов). Цвета — новый `colorCtl`: нативный `<input type=color>`
|
||||
+ текстовое поле (источник истины, поддерживает rgba/named) + кнопка очистки для fill/trailColor
|
||||
(«нет заливки»). Синхрон пикер↔текст — `Builder.wireColorControls(row)` (текст диспатчит `input`,
|
||||
основной `data-of`/`data-cvf` обработчик ловит). `toHexColor` приводит к `#rrggbb` для нативного пикера.
|
||||
- **Редактор кривых plot** (`plotEditor`/`curveEditor`): UI-модель plot = `{var, range_a/b, samples,
|
||||
trace, legend, plotFill, plotMarker, curves:[...]}`. Кривая = `{_uid, expr, color, label, width,
|
||||
lineStyle, opacity, fill(bool), fillColor, marker}`. Список кривых (добавить `[data-curveadd]` /
|
||||
удалить `[data-curvedel]`, минимум 1), на кривую — expr+fx, color, label, width, lineStyle, marker,
|
||||
opacity, fill+цвет. Plot-уровневые `plotFill`/`plotMarker`/легенда. `loadPlot` нормализует
|
||||
spec→UI (curves[]→exprs[]→legacy expr; легаси plot-level width/lineStyle/opacity наследуются кривой),
|
||||
`normalizePlotForSpec`+`stripCurve` собирают обратно: **одиночная «простая» кривая (только expr+color,
|
||||
нет plot-fill/marker) → легаси-форма** `{expr,color}`; иначе `curves:[...]`. `legend:false` эмитится
|
||||
только при выключенной легенде.
|
||||
- **Список объектов**: в шапке каждого — z-order вверх/вниз (`[data-oup]`/`[data-odown]`, порядок в
|
||||
массиве = порядок отрисовки; крайние disabled), видимость (`[data-ohide]` → `o.hidden=true`),
|
||||
дублировать (`[data-odup]`, deep-clone + новый `_uid`, `id+'_copy'`), удалить. Аналогично у plot.
|
||||
- **hidden — чисто на стороне билдера** (движок не трогали): `buildSpec` фильтрует объекты/plot с
|
||||
`hidden`; `stripObj.isDefaultStyle` гарантирует, что `hidden`/дефолты стиля (glow:false, lineStyle:
|
||||
'solid', pointStyle:'filled', opacity:1, trail/closed:false) НЕ попадают в спеку → спека минимальна,
|
||||
round-trip save→load→save идемпотентен (проверено vm-смоуком 27+12+2 PASS).
|
||||
- **На P5 (прямое манипулирование + история):** в билдере сейчас есть только drag x/y point/circle/label/
|
||||
readout/rect и конца segment/vector (`bindPreviewDrag` через `inst._toWorld`). Расширять до всех типов
|
||||
+ snap-к-сетке + выравнивание (нужны правки `_sim_engine.js` — хит-тесты/ручки). Undo/redo: состояние
|
||||
= `this.st` (сериализуемо JSON); снимать снапшот при `onAdd`/удалении/правке (debounce) — стек в
|
||||
Builder, перерисовка `renderPanels`+`scheduleRemount`. Идентичность спеки между билдами уже гарантирована.
|
||||
- [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 |
|
||||
|-------|--------|--------|-----------|
|
||||
| P1 Working field | Done | ✅ PASS | ✅ |
|
||||
| P2 Object graphics | Done | ✅ PASS | ✅ |
|
||||
| P3 Charts | ⬜ | ⬜ | ⬜ |
|
||||
| P4 Builder UI | ⬜ | ⬜ | ⬜ |
|
||||
| P5 Direct manip + history | ⬜ | ⬜ | ⬜ |
|
||||
| P3 Charts | Done | ✅ PASS | ✅ |
|
||||
| P4 Builder UI | Done | ✅ PASS | ✅ |
|
||||
| P5 Direct manip + history | Done | ✅ PASS | ✅ |
|
||||
|
||||
Reference in New Issue
Block a user