Files
Maxim Dolgolyov 53e996e2e0 fix(materials): картинки материалов отдаются публично (рендер/открытие/скачивание)
/api/files/:id/download требует Bearer-заголовок, поэтому <img>, переход по
ссылке и «Скачать» для сохранённых картинок ломались (битое изображение,
клик не открывал). Теперь личные картинки складываются в uploads/materials и
отдаются статикой (как flashcards): POST /api/files/personal возвращает
{ url:'/uploads/materials/<file>' }. board-clip, material-save, textbook-clip
и рисовалка в my-materials сохраняют этот публичный url.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:30:47 +03:00

235 lines
12 KiB
JavaScript

'use strict';
/* textbook-clip.js — floating actions on textbook pages (/textbook/<slug>).
* Injected by the server. Two actions for logged-in students:
* • «В мои материалы» — save the current paragraph as a LINK
* • «Вырезать область» — drag a rectangle on the page, capture it as an
* IMAGE (html2canvas, lazy-loaded) and save it.
* Reuses MaterialSave (material-save.js) + LS (api.js). Hidden in the
* classroom embed (iframe) to avoid clutter. */
(function () {
if (window.parent !== window) return; // skip in classroom embed
if (!window.LS || !LS.getToken || !LS.getToken()) return; // logged-in users only
var H2C_SRC = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
var CROP_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>';
var BOOKMARK_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
function chapterTitle() {
return (document.title || 'Учебник').replace(/\s*[—|].*$/, '').replace(/\s*·\s*LearnSpace.*$/i, '').trim() || 'Учебник';
}
function sectionTitle() {
var h = document.querySelector('.sec.active .sec-h');
if (h && h.textContent.trim()) return h.textContent.trim();
var n = document.querySelector('.psel-card.active .psel-name');
if (n && n.textContent.trim()) return n.textContent.trim();
return (document.title || 'Тема').split('·').pop().trim() || 'Тема';
}
function activeId() {
var c = document.querySelector('.psel-card.active');
return c && c.dataset ? c.dataset.id : null;
}
function toast(msg, type) { if (LS.toast) LS.toast(msg, type); }
/* ── Save current paragraph as a link ── */
function saveLink(btn) {
if (!window.MaterialSave) { toast('Модуль не загружен', 'error'); return; }
var slug = location.pathname.replace(/^\/textbook\//, '').replace(/\/+$/, '');
if (!slug) return;
var id = activeId();
var hash = id ? '#sec-' + id : (location.hash || '');
MaterialSave.link({ title: sectionTitle(), url: '/textbook/' + slug + hash, sourceTitle: chapterTitle() }, btn);
}
/* ── Lazy html2canvas loader ── */
var _h2c = null;
function ensureH2C() {
if (window.html2canvas) return Promise.resolve(window.html2canvas);
if (_h2c) return _h2c;
_h2c = new Promise(function (res, rej) {
var s = document.createElement('script');
s.src = H2C_SRC; s.async = true;
s.onload = function () { res(window.html2canvas); };
s.onerror = function () { _h2c = null; rej(new Error('Не удалось загрузить модуль снимков')); };
document.head.appendChild(s);
});
return _h2c;
}
/* ── Styles for selection + preview overlays ── */
function ensureStyles() {
if (document.getElementById('__lsclip-style')) return;
var s = document.createElement('style');
s.id = '__lsclip-style';
s.textContent = [
'.__lsclip-sel-ov{position:fixed;inset:0;z-index:2147483600;cursor:crosshair;background:rgba(15,12,30,.22);touch-action:none}',
'.__lsclip-box{position:absolute;border:2px dashed #fff;background:rgba(139,92,246,.18);box-shadow:0 0 0 1px rgba(139,92,246,.9);pointer-events:none}',
'.__lsclip-hint{position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:2147483601;background:rgba(15,12,30,.92);color:#fff;padding:8px 16px;border-radius:99px;font:600 13px/1 Manrope,system-ui,sans-serif;box-shadow:0 6px 20px rgba(0,0,0,.3);pointer-events:none}',
'.__lsclip-pv-ov{position:fixed;inset:0;z-index:2147483600;background:rgba(15,12,30,.72);display:flex;align-items:center;justify-content:center;padding:16px}',
'.__lsclip-pv{background:#fff;border-radius:14px;width:440px;max-width:92vw;max-height:92vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 20px 60px rgba(0,0,0,.4)}',
'.__lsclip-pv-h{padding:14px 16px;font:700 .95rem/1.2 Manrope,system-ui,sans-serif;color:#1a1433;border-bottom:1px solid #eee}',
'.__lsclip-pv-body{padding:14px 16px;display:flex;flex-direction:column;gap:12px;overflow:auto}',
'.__lsclip-pv-img{max-width:100%;max-height:46vh;object-fit:contain;border:1px solid #eee;border-radius:8px;background:#f8fafc;align-self:center}',
'.__lsclip-pv-input{padding:9px 12px;border:1px solid #e2e8f0;border-radius:9px;font:inherit;width:100%;box-sizing:border-box}',
'.__lsclip-pv-actions{display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid #eee}',
'.__lsclip-pv-actions button{padding:8px 16px;border-radius:9px;border:1px solid #e2e8f0;background:#fff;font:700 .85rem Manrope,system-ui,sans-serif;cursor:pointer;color:#475569}',
'.__lsclip-pv-actions button.primary{background:#8b5cf6;border-color:#8b5cf6;color:#fff}',
'.__lsclip-pv-actions button:disabled{opacity:.5;cursor:default}'
].join('');
document.head.appendChild(s);
}
/* ── Drag a rectangle on the live page ── */
function startRegion() {
ensureStyles();
var ov = document.createElement('div'); ov.className = '__lsclip-sel-ov'; ov.setAttribute('data-h2c-ignore', '');
var box = document.createElement('div'); box.className = '__lsclip-box'; box.style.display = 'none'; box.setAttribute('data-h2c-ignore', '');
var hint = document.createElement('div'); hint.className = '__lsclip-hint'; hint.setAttribute('data-h2c-ignore', '');
hint.textContent = 'Выделите область мышью — она сохранится картинкой. Esc — отмена';
ov.appendChild(box);
document.body.appendChild(ov);
document.body.appendChild(hint);
var dragging = false, sx = 0, sy = 0, rect = null;
function cleanup() { ov.remove(); hint.remove(); document.removeEventListener('keydown', onKey); }
function onKey(e) { if (e.key === 'Escape') cleanup(); }
document.addEventListener('keydown', onKey);
ov.addEventListener('pointerdown', function (e) {
e.preventDefault();
dragging = true; sx = e.clientX; sy = e.clientY; rect = null;
box.style.display = 'block';
box.style.left = sx + 'px'; box.style.top = sy + 'px'; box.style.width = '0'; box.style.height = '0';
try { ov.setPointerCapture(e.pointerId); } catch (err) {}
});
ov.addEventListener('pointermove', function (e) {
if (!dragging) return;
var x = Math.min(e.clientX, sx), y = Math.min(e.clientY, sy);
var w = Math.abs(e.clientX - sx), h = Math.abs(e.clientY - sy);
rect = { left: x, top: y, width: w, height: h };
box.style.left = x + 'px'; box.style.top = y + 'px'; box.style.width = w + 'px'; box.style.height = h + 'px';
});
ov.addEventListener('pointerup', function () {
dragging = false;
var r = rect;
cleanup(); // remove overlay BEFORE rasterizing
if (!r || r.width < 8 || r.height < 8) return;
capture(r);
});
}
/* ── Rasterize the selected region → PNG blob ── */
function capture(r) {
var scrollX = window.scrollX || window.pageXOffset || 0;
var scrollY = window.scrollY || window.pageYOffset || 0;
var br = document.body.getBoundingClientRect();
var ox = br.left + scrollX, oy = br.top + scrollY; // body origin in document coords
document.body.style.cursor = 'progress';
ensureH2C().then(function (h2c) {
return h2c(document.body, {
x: Math.round((r.left + scrollX) - ox),
y: Math.round((r.top + scrollY) - oy),
width: Math.round(r.width),
height: Math.round(r.height),
scale: Math.min(2, window.devicePixelRatio || 1),
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
ignoreElements: function (el) { return !!(el && el.hasAttribute && el.hasAttribute('data-h2c-ignore')); }
});
}).then(function (canvas) {
document.body.style.cursor = '';
canvas.toBlob(function (blob) {
if (!blob) { toast('Не удалось снять область', 'error'); return; }
preview(blob);
}, 'image/png');
}).catch(function (e) {
document.body.style.cursor = '';
toast((e && e.message) || 'Ошибка снимка', 'error');
});
}
/* ── Preview + title, then save as image material ── */
function preview(blob) {
ensureStyles();
var url = URL.createObjectURL(blob);
var ov = document.createElement('div'); ov.className = '__lsclip-pv-ov';
ov.innerHTML =
'<div class="__lsclip-pv">' +
'<div class="__lsclip-pv-h">Сохранить область в «Мои материалы»</div>' +
'<div class="__lsclip-pv-body">' +
'<img class="__lsclip-pv-img" alt="" />' +
'<input class="__lsclip-pv-input" type="text" maxlength="200" placeholder="Название (необязательно)" />' +
'</div>' +
'<div class="__lsclip-pv-actions">' +
'<button data-a="cancel">Отмена</button>' +
'<button data-a="save" class="primary">Сохранить</button>' +
'</div>' +
'</div>';
document.body.appendChild(ov);
ov.querySelector('.__lsclip-pv-img').src = url;
var input = ov.querySelector('.__lsclip-pv-input');
input.value = sectionTitle();
var saveBtn = ov.querySelector('[data-a="save"]');
function close() { ov.remove(); URL.revokeObjectURL(url); }
ov.querySelector('[data-a="cancel"]').onclick = close;
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
saveBtn.onclick = async function () {
saveBtn.disabled = true;
try {
var fd = new FormData();
fd.append('file', blob, 'textbook-region.png');
var up = await LS.uploadMaterialFile(fd);
await LS.saveMaterial({
kind: 'image',
title: input.value.trim() || sectionTitle(),
url: up.url,
sourceTitle: chapterTitle()
});
toast('Сохранено в «Мои материалы»', 'success');
close();
} catch (e) {
toast((e && e.message) || 'Ошибка сохранения', 'error');
saveBtn.disabled = false;
}
};
setTimeout(function () { try { input.focus(); input.select(); } catch (e) {} }, 30);
}
/* ── Floating buttons ── */
function makeBtn(id, label, svg, solid) {
var b = document.createElement('button');
b.id = id; b.type = 'button';
b.innerHTML = svg + '<span>' + label + '</span>';
var base = 'display:inline-flex;align-items:center;gap:7px;padding:10px 14px;border-radius:99px;font:600 13px/1 Manrope,system-ui,sans-serif;cursor:pointer;box-shadow:0 6px 20px rgba(139,92,246,.30);';
b.style.cssText = base + (solid
? 'border:none;background:#8b5cf6;color:#fff;'
: 'border:1px solid rgba(139,92,246,.45);background:#fff;color:#7c3aed;');
return b;
}
function build() {
if (document.getElementById('__ls_clip') || !document.body) return;
var grp = document.createElement('div');
grp.id = '__ls_clip';
grp.setAttribute('data-h2c-ignore', '');
grp.style.cssText = 'position:fixed;right:18px;bottom:18px;z-index:9000;display:flex;flex-direction:column;gap:10px;align-items:flex-end';
var bRegion = makeBtn('__ls_clip_region', 'Вырезать область', CROP_SVG, false);
bRegion.title = 'Выделить часть страницы и сохранить картинкой';
bRegion.addEventListener('click', function () { startRegion(); });
var bLink = makeBtn('__ls_clip_link', 'В мои материалы', BOOKMARK_SVG, true);
bLink.title = 'Сохранить эту тему ссылкой в «Мои материалы»';
bLink.addEventListener('click', function () { saveLink(bLink); });
grp.appendChild(bRegion);
grp.appendChild(bLink);
document.body.appendChild(grp);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { setTimeout(build, 300); });
else setTimeout(build, 300);
})();