feat(materials): сохранять доску/фрагмент прямо на онлайн-уроке

Выделение области и сохранение страницы доски теперь доступны ученику ВО ВРЕМЯ живого урока
(classroom.html), не только в просмотре прошлых уроков.

- Вынес общий модуль /js/board-clip.js (BoardClip.savePage / saveRegion + кроп-оверлей),
  переиспользуется в classroom.html и my-lessons.html (убрал дубль ~120 строк из my-lessons).
- classroom.html: кнопки «Область» и «К себе» в ученической панели (#cr-student-nav),
  обёртки crSaveBoardPage/crSaveBoardRegion над живым _wb + контекст сессии.
- Бэкенд без изменений (используется существующий /api/files + /api/materials).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 11:48:51 +03:00
parent 116876d8ec
commit fcb8ef77bd
3 changed files with 188 additions and 132 deletions
+18
View File
@@ -2382,6 +2382,12 @@
<svg class="fs-exit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/></svg>
<span id="cr-fs-label">На весь экран</span>
</button>
<button class="cr-tool-btn" onclick="crSaveBoardRegion(this)" title="Сохранить часть доски в «Мои материалы»">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>
</button>
<button class="cr-tool-btn" onclick="crSaveBoardPage(this)" title="Сохранить страницу доски в «Мои материалы»">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
</button>
</div>
<!-- toolbar: teacher only -->
<div class="cr-toolbar" id="cr-toolbar" style="display:none">
@@ -3084,6 +3090,7 @@
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" crossorigin="anonymous" />
<script src="/js/whiteboard.js"></script>
<script src="/js/board-clip.js"></script>
<script src="/js/classroom-rtc.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sound.js"></script>
@@ -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;
+162
View File
@@ -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 = `
<div class="bclip-box">
<div class="bclip-hint">Выделите мышью нужную область (таблицу, рисунок и т.п.)</div>
<div class="bclip-stage">
<img class="bclip-img" alt="" draggable="false" />
<div class="bclip-sel" style="display:none"></div>
</div>
<div class="bclip-actions">
<button data-act="cancel">Отмена</button>
<button data-act="save" class="primary" disabled>Сохранить область</button>
</div>
</div>`;
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 };
})();
+8 -132
View File
@@ -632,6 +632,7 @@
<script src="/js/notifications.js"></script>
<script src="/js/mobile.js"></script>
<script src="/js/whiteboard.js"></script>
<script src="/js/board-clip.js"></script>
<script>
let _me = null;
let _sessions = [];
@@ -1037,26 +1038,10 @@ function _matSource() {
const s = _activeSession && _activeSession.session;
return { sourceSessionId: s ? s.id : null, sourceTitle: s ? (s.title || 'Урок') : null };
}
async function saveBoardToMaterials(btn) {
function saveBoardToMaterials(btn) {
if (!_wb) { LS.toast('Откройте страницу доски', 'warn'); return; }
if (btn) btn.disabled = true;
try {
const blob = await new Promise(res => _wb.exportBlob(res));
if (!blob) throw new Error('Не удалось сделать снимок страницы');
const fd = new FormData();
fd.append('file', blob, 'board-p' + (_wbCurrentPage || 1) + '.png');
const up = await LS.uploadFile(fd);
const src = _matSource();
await LS.saveMaterial({
kind: 'board',
title: (src.sourceTitle || 'Доска') + ' · стр. ' + (_wbCurrentPage || 1),
url: LS.downloadFileUrl(up.id),
sourceSessionId: src.sourceSessionId, sourceTitle: src.sourceTitle,
});
LS.toast('Страница сохранена в «Мои материалы»', 'success');
} catch (e) {
LS.toast(e.message || 'Ошибка сохранения', 'error');
} finally { if (btn) btn.disabled = false; }
const s = _matSource();
BoardClip.savePage(_wb, { sourceSessionId: s.sourceSessionId, sourceTitle: s.sourceTitle, pageNum: (_wbCurrentPage || 1) }, btn);
}
async function saveNoteToMaterials(btn) {
const text = (_myNoteText || '').trim();
@@ -1076,121 +1061,12 @@ async function saveNoteToMaterials(btn) {
} finally { if (btn) btn.disabled = false; }
}
/* Сохранить ЧАСТЬ доски: снимок страницы → выделение области → обрезка → «Мои материалы». */
/* Сохранить ЧАСТЬ доски: снимок страницы → выделение области → обрезка → «Мои материалы».
Логика вынесена в /js/board-clip.js (общая с онлайн-уроком). */
function saveBoardRegion(btn) {
if (!_wb) { LS.toast('Откройте страницу доски', 'warn'); return; }
if (btn) btn.disabled = true;
_wb.exportBlob(blob => {
if (btn) btn.disabled = false;
if (!blob) { LS.toast('Не удалось получить снимок доски', 'error'); return; }
openCropOverlay(blob);
});
}
function ensureCropStyles() {
if (document.getElementById('crop-style')) return;
const s = document.createElement('style');
s.id = 'crop-style';
s.textContent = `
.crop-ov { position: fixed; inset: 0; z-index: 99999; background: rgba(15,12,30,.72); display: flex; align-items: center; justify-content: center; padding: 16px; }
.crop-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); }
.crop-hint { padding: 12px 16px; font-weight: 700; font-size: .9rem; color: #1a1433; border-bottom: 1px solid #eee; }
.crop-stage { position: relative; background: #f1f5f9; line-height: 0; }
.crop-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; }
.crop-sel { position: absolute; border: 2px dashed #8b5cf6; background: rgba(139,92,246,.14); pointer-events: none; }
.crop-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: 1px solid #eee; }
.crop-actions button { padding: 8px 16px; border-radius: 9px; border: 1px solid #e2e8f0; background: #fff; font-weight: 700; font-size: .85rem; cursor: pointer; color: #475569; }
.crop-actions button.primary { background: #8b5cf6; border-color: #8b5cf6; color: #fff; }
.crop-actions button:disabled { opacity: .5; cursor: default; }
`;
document.head.appendChild(s);
}
function openCropOverlay(blob) {
ensureCropStyles();
const url = URL.createObjectURL(blob);
const ov = document.createElement('div');
ov.className = 'crop-ov';
ov.innerHTML = `
<div class="crop-box">
<div class="crop-hint">Выделите мышью нужную область (таблицу, рисунок и т.п.)</div>
<div class="crop-stage" id="crop-stage">
<img class="crop-img" id="crop-img" alt="" draggable="false" />
<div class="crop-sel" id="crop-sel" style="display:none"></div>
</div>
<div class="crop-actions">
<button id="crop-cancel">Отмена</button>
<button id="crop-save" class="primary" disabled>Сохранить область</button>
</div>
</div>`;
document.body.appendChild(ov);
const img = ov.querySelector('#crop-img');
const sel = ov.querySelector('#crop-sel');
const saveBtn = ov.querySelector('#crop-save');
img.src = url;
let dragging = false, start = null, rect = null;
function close() { ov.remove(); URL.revokeObjectURL(url); }
ov.querySelector('#crop-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', 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 () => {
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(res => off.toBlob(res, 'image/png'));
if (!cblob) throw new Error('Не удалось обрезать область');
const fd = new FormData(); fd.append('file', cblob, 'board-region.png');
const up = await LS.uploadFile(fd);
const src = _matSource();
await LS.saveMaterial({
kind: 'image',
title: (src.sourceTitle || 'Доска') + ' · фрагмент',
url: LS.downloadFileUrl(up.id),
sourceSessionId: src.sourceSessionId, sourceTitle: src.sourceTitle,
});
close();
LS.toast('Фрагмент сохранён в «Мои материалы»', 'success');
} catch (e) {
LS.toast(e.message || 'Ошибка сохранения', 'error');
saveBtn.disabled = false;
}
};
const s = _matSource();
BoardClip.saveRegion(_wb, { sourceSessionId: s.sourceSessionId, sourceTitle: s.sourceTitle, pageNum: (_wbCurrentPage || 1) }, btn);
}
/* ─── Chat ─── */