From ac1857c931071dee2ce7f5d8b69d30437eac62b4 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 4 Jun 2026 14:11:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(textbook):=20=D0=B2=D1=8B=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=B1=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B?= =?UTF-8?q?=20=D0=B2=20=C2=AB=D0=9C=D0=BE=D0=B8=20=D0=BC=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B8=D0=B0=D0=BB=D1=8B=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Плавающая кнопка «Вырезать область» на странице учебника: выделяешь прямоугольник прямо на живой странице → регион растеризуется в PNG (html2canvas, грузится лениво только при первом использовании) → превью с редактируемым названием → сохраняется как материал-картинка. Рядом сохранена прежняя кнопка «В мои материалы» (ссылка). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/textbook-clip.js | 231 +++++++++++++++++++++++++++++++---- 1 file changed, 208 insertions(+), 23 deletions(-) diff --git a/frontend/js/textbook-clip.js b/frontend/js/textbook-clip.js index 12ca674..e9e7616 100644 --- a/frontend/js/textbook-clip.js +++ b/frontend/js/textbook-clip.js @@ -1,47 +1,232 @@ 'use strict'; -/* textbook-clip.js — floating «В мои материалы» button on textbook pages. - * Injected by the server into /textbook/. Saves the currently open - * paragraph as a link (/textbook/#sec-) into the student's - * personal materials. Reuses MaterialSave (material-save.js) + LS (api.js). - * Hidden inside the classroom embed (iframe) to avoid clutter. */ +/* textbook-clip.js — floating actions on textbook pages (/textbook/). + * 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; // only for logged-in users + 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 = ''; + var BOOKMARK_SVG = ''; function chapterTitle() { return (document.title || 'Учебник').replace(/\s*[—|].*$/, '').replace(/\s*·\s*LearnSpace.*$/i, '').trim() || 'Учебник'; } function sectionTitle() { - const h = document.querySelector('.sec.active .sec-h'); + var h = document.querySelector('.sec.active .sec-h'); if (h && h.textContent.trim()) return h.textContent.trim(); - const n = document.querySelector('.psel-card.active .psel-name'); + 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() { - const c = document.querySelector('.psel-card.active'); + 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); } - function save(btn) { - if (!window.MaterialSave) { if (LS.toast) LS.toast('Модуль не загружен', 'error'); return; } - const slug = location.pathname.replace(/^\/textbook\//, '').replace(/\/+$/, ''); + /* ── 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; - const id = activeId(); - const hash = id ? '#sec-' + id : (location.hash || ''); + 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 = + '
' + + '
Сохранить область в «Мои материалы»
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
'; + 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.uploadFile(fd); + await LS.saveMaterial({ + kind: 'image', + title: input.value.trim() || sectionTitle(), + url: LS.downloadFileUrl(up.id), + 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 + '' + label + ''; + 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; - const btn = document.createElement('button'); - btn.id = '__ls_clip'; - btn.type = 'button'; - btn.title = 'Сохранить эту тему в «Мои материалы»'; - btn.innerHTML = 'В мои материалы'; - btn.style.cssText = 'position:fixed;right:18px;bottom:18px;z-index:9000;display:inline-flex;align-items:center;gap:7px;padding:10px 14px;border-radius:99px;border:none;background:#8b5cf6;color:#fff;font:600 13px/1 Inter,system-ui,sans-serif;cursor:pointer;box-shadow:0 6px 20px rgba(139,92,246,.4)'; - btn.addEventListener('click', function () { save(btn); }); - document.body.appendChild(btn); + 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); });