diff --git a/frontend/classroom.html b/frontend/classroom.html
index 3c58a55..6a49da7 100644
--- a/frontend/classroom.html
+++ b/frontend/classroom.html
@@ -2382,6 +2382,12 @@
На весь экран
+
+
@@ -3084,6 +3090,7 @@
+
@@ -6617,6 +6624,17 @@
document.addEventListener('fullscreenchange', _updateStudentFsBtn);
document.addEventListener('webkitfullscreenchange', _updateStudentFsBtn);
+ /* ── Сохранить доску/фрагмент в «Мои материалы» (ученик) ── */
+ function _crClipMeta() {
+ return {
+ sourceSessionId: _sessionId,
+ sourceTitle: (_session && _session.title) || 'Онлайн-урок',
+ pageNum: (typeof _wbCurrentPage !== 'undefined' ? _wbCurrentPage : null),
+ };
+ }
+ function crSaveBoardPage(btn) { if (window.BoardClip) BoardClip.savePage(_wb, _crClipMeta(), btn); }
+ function crSaveBoardRegion(btn) { if (window.BoardClip) BoardClip.saveRegion(_wb, _crClipMeta(), btn); }
+
/* ── Student page nav / follow ── */
async function wbStudentPrevPage() {
if (_wbCurrentPage <= 1) return;
diff --git a/frontend/js/board-clip.js b/frontend/js/board-clip.js
new file mode 100644
index 0000000..a7d5dda
--- /dev/null
+++ b/frontend/js/board-clip.js
@@ -0,0 +1,162 @@
+'use strict';
+/* board-clip.js — save a whiteboard page or a selected region into the
+ * student's personal materials ("Мои материалы"). Shared by the live
+ * classroom (classroom.html) and the past-session review (my-lessons.html).
+ *
+ * Requires window.LS (api.js: uploadFile, downloadFileUrl, saveMaterial, toast)
+ * and a Whiteboard instance exposing exportBlob(cb) (whiteboard.js).
+ *
+ * BoardClip.savePage(wb, meta, btn?) // whole current page -> kind 'board'
+ * BoardClip.saveRegion(wb, meta, btn?) // crop a rectangle -> kind 'image'
+ * meta: { sourceSessionId, sourceTitle, pageNum? }
+ */
+(function () {
+ function titleFor(meta, suffix) {
+ const base = (meta && meta.sourceTitle) || 'Доска';
+ const pg = (meta && meta.pageNum) ? ' · стр. ' + meta.pageNum : '';
+ return base + pg + (suffix || '');
+ }
+
+ async function uploadBlob(blob, name) {
+ const fd = new FormData();
+ fd.append('file', blob, name);
+ const up = await LS.uploadFile(fd);
+ return LS.downloadFileUrl(up.id);
+ }
+
+ async function persist(meta, kind, url) {
+ await LS.saveMaterial({
+ kind: kind,
+ url: url,
+ title: titleFor(meta, kind === 'image' ? ' · фрагмент' : ''),
+ sourceSessionId: meta && meta.sourceSessionId,
+ sourceTitle: meta && meta.sourceTitle,
+ });
+ }
+
+ function savePage(wb, meta, btn) {
+ if (!wb || typeof wb.exportBlob !== 'function') { LS.toast('Доска не готова', 'warn'); return; }
+ if (btn) btn.disabled = true;
+ wb.exportBlob(async function (blob) {
+ try {
+ if (!blob) throw new Error('Не удалось снять доску');
+ const url = await uploadBlob(blob, 'board.png');
+ await persist(meta, 'board', url);
+ LS.toast('Страница сохранена в «Мои материалы»', 'success');
+ } catch (e) {
+ LS.toast(e.message || 'Ошибка сохранения', 'error');
+ } finally { if (btn) btn.disabled = false; }
+ });
+ }
+
+ function saveRegion(wb, meta, btn) {
+ if (!wb || typeof wb.exportBlob !== 'function') { LS.toast('Доска не готова', 'warn'); return; }
+ if (btn) btn.disabled = true;
+ wb.exportBlob(function (blob) {
+ if (btn) btn.disabled = false;
+ if (!blob) { LS.toast('Не удалось снять доску', 'error'); return; }
+ openCropOverlay(blob, meta);
+ });
+ }
+
+ function ensureCropStyles() {
+ if (document.getElementById('bclip-style')) return;
+ const s = document.createElement('style');
+ s.id = 'bclip-style';
+ s.textContent = `
+ .bclip-ov { position: fixed; inset: 0; z-index: 2147483600; background: rgba(15,12,30,.72); display: flex; align-items: center; justify-content: center; padding: 16px; }
+ .bclip-box { background: #fff; border-radius: 14px; max-width: 92vw; max-height: 92vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,.4); }
+ .bclip-hint { padding: 12px 16px; font-weight: 700; font-size: .9rem; color: #1a1433; border-bottom: 1px solid #eee; font-family: 'Manrope', system-ui, sans-serif; }
+ .bclip-stage { position: relative; background: #f1f5f9; line-height: 0; }
+ .bclip-img { display: block; max-width: 84vw; max-height: 64vh; width: auto; height: auto; user-select: none; -webkit-user-drag: none; touch-action: none; cursor: crosshair; }
+ .bclip-sel { position: absolute; border: 2px dashed #8b5cf6; background: rgba(139,92,246,.14); pointer-events: none; }
+ .bclip-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: 1px solid #eee; }
+ .bclip-actions button { padding: 8px 16px; border-radius: 9px; border: 1px solid #e2e8f0; background: #fff; font-weight: 700; font-size: .85rem; cursor: pointer; color: #475569; font-family: 'Manrope', system-ui, sans-serif; }
+ .bclip-actions button.primary { background: #8b5cf6; border-color: #8b5cf6; color: #fff; }
+ .bclip-actions button:disabled { opacity: .5; cursor: default; }
+ `;
+ document.head.appendChild(s);
+ }
+
+ function openCropOverlay(blob, meta) {
+ ensureCropStyles();
+ const url = URL.createObjectURL(blob);
+ const ov = document.createElement('div');
+ ov.className = 'bclip-ov';
+ ov.innerHTML = `
+
+
Выделите мышью нужную область (таблицу, рисунок и т.п.)
+
+
![]()
+
+
+
+
+
+
+
`;
+ document.body.appendChild(ov);
+ const img = ov.querySelector('.bclip-img');
+ const sel = ov.querySelector('.bclip-sel');
+ const saveBtn = ov.querySelector('[data-act="save"]');
+ img.src = url;
+
+ let dragging = false, start = null, rect = null;
+ function close() { ov.remove(); URL.revokeObjectURL(url); }
+ ov.querySelector('[data-act="cancel"]').onclick = close;
+
+ function rel(e) {
+ const r = img.getBoundingClientRect();
+ return {
+ x: Math.max(0, Math.min(r.width, e.clientX - r.left)),
+ y: Math.max(0, Math.min(r.height, e.clientY - r.top)),
+ };
+ }
+ function onMove(e) {
+ if (!dragging) return;
+ const p = rel(e);
+ const x = Math.min(p.x, start.x), y = Math.min(p.y, start.y);
+ const w = Math.abs(p.x - start.x), h = Math.abs(p.y - start.y);
+ rect = { x, y, w, h };
+ sel.style.display = 'block';
+ sel.style.left = x + 'px'; sel.style.top = y + 'px';
+ sel.style.width = w + 'px'; sel.style.height = h + 'px';
+ saveBtn.disabled = (w < 6 || h < 6);
+ }
+ function onUp() {
+ dragging = false;
+ document.removeEventListener('pointermove', onMove);
+ document.removeEventListener('pointerup', onUp);
+ }
+ img.addEventListener('pointerdown', function (e) {
+ e.preventDefault();
+ dragging = true; start = rel(e); rect = null; saveBtn.disabled = true;
+ sel.style.display = 'none';
+ document.addEventListener('pointermove', onMove);
+ document.addEventListener('pointerup', onUp);
+ });
+
+ saveBtn.onclick = async function () {
+ if (!rect || rect.w < 6 || rect.h < 6) return;
+ saveBtn.disabled = true;
+ try {
+ const r = img.getBoundingClientRect();
+ const sx = img.naturalWidth / r.width, sy = img.naturalHeight / r.height;
+ const cw = Math.max(1, Math.round(rect.w * sx)), ch = Math.max(1, Math.round(rect.h * sy));
+ const off = document.createElement('canvas'); off.width = cw; off.height = ch;
+ 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 cropUrl = await uploadBlob(cblob, 'board-region.png');
+ await persist(meta, 'image', cropUrl);
+ close();
+ LS.toast('Фрагмент сохранён в «Мои материалы»', 'success');
+ } catch (e) {
+ LS.toast(e.message || 'Ошибка сохранения', 'error');
+ saveBtn.disabled = false;
+ }
+ };
+ }
+
+ window.BoardClip = { savePage: savePage, saveRegion: saveRegion };
+})();
diff --git a/frontend/my-lessons.html b/frontend/my-lessons.html
index ecdcdcb..65dc792 100644
--- a/frontend/my-lessons.html
+++ b/frontend/my-lessons.html
@@ -632,6 +632,7 @@
+