53e996e2e0
/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>
235 lines
12 KiB
JavaScript
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);
|
|
})();
|