feat(materials): сохранение ЧАСТИ доски (выделение области)
На странице доски в «Мои уроки» кнопка «Область»: снимок страницы → модалка с выделением прямоугольника мышью → обрезка до выбранного фрагмента (таблица, рисунок и т.п.) → загрузка в /api/files → сохранение в «Мои материалы» (kind=image). Координаты выделения масштабируются к натуральному размеру снимка. Бэкенд не менялся. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+122
-1
@@ -585,10 +585,14 @@
|
||||
<span>Загрузка доски...</span>
|
||||
</div>
|
||||
<div class="lh-board-export">
|
||||
<button class="lh-export-btn" onclick="saveBoardToMaterials(this)" title="Сохранить страницу в «Мои материалы»">
|
||||
<button class="lh-export-btn" onclick="saveBoardToMaterials(this)" title="Сохранить всю страницу в «Мои материалы»">
|
||||
<svg width="12" height="12" 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>
|
||||
<button class="lh-export-btn" onclick="saveBoardRegion(this)" title="Выделить и сохранить часть доски">
|
||||
<svg width="12" height="12" 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="lh-export-btn" onclick="exportBoardPage()">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
PNG
|
||||
@@ -1072,6 +1076,123 @@ async function saveNoteToMaterials(btn) {
|
||||
} finally { if (btn) btn.disabled = false; }
|
||||
}
|
||||
|
||||
/* Сохранить ЧАСТЬ доски: снимок страницы → выделение области → обрезка → «Мои материалы». */
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ─── Chat ─── */
|
||||
async function loadChat() {
|
||||
_chatLoaded = true;
|
||||
|
||||
Reference in New Issue
Block a user