feat(materials): Фаза 4 — аннотации и рисунки

- svg-draw.js: opts.bgImage (рисунок-подложка) + exportFlatBlob() — растеризация подложки и
  вектора в плоский PNG.
- /my-materials: кнопка «Рисунок» (создать с нуля) и «Аннотировать» на карточках доски/изображения
  (рисовать поверх). Модалка с SVG-рисовалкой → сохранение в «Мои материалы» как image.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 12:20:56 +03:00
parent 43fe90d601
commit d3a64ac682
2 changed files with 96 additions and 2 deletions
+41 -1
View File
@@ -153,6 +153,18 @@
const content = document.createElementNS(SVGNS, 'g'); // user drawing lives here const content = document.createElementNS(SVGNS, 'g'); // user drawing lives here
content.setAttribute('class', 'svgd-content'); content.setAttribute('class', 'svgd-content');
svg.appendChild(content); svg.appendChild(content);
// optional background image to draw over (annotation mode)
const bgUrl = opts.bgImage || null;
if (bgUrl) {
const im = document.createElementNS(SVGNS, 'image');
im.setAttribute('href', bgUrl);
try { im.setAttributeNS('http://www.w3.org/1999/xlink', 'href', bgUrl); } catch (e) {}
im.setAttribute('x', '0'); im.setAttribute('y', '0');
im.setAttribute('width', String(W)); im.setAttribute('height', String(H));
im.setAttribute('preserveAspectRatio', 'xMidYMid meet');
im.style.pointerEvents = 'none';
svg.insertBefore(im, content);
}
wrap.appendChild(svg); wrap.appendChild(svg);
root.appendChild(toolbar); root.appendChild(toolbar);
@@ -480,13 +492,41 @@
return '#' + [m[1], m[2], m[3]].map(function (x) { return ('0' + parseInt(x, 10).toString(16)).slice(-2); }).join(''); return '#' + [m[1], m[2], m[3]].map(function (x) { return ('0' + parseInt(x, 10).toString(16)).slice(-2); }).join('');
} }
/* Rasterize to a flattened PNG: optional background image + the vector
drawing on top. Used to save an annotated image into «Мои материалы». */
function exportFlatBlob(cb) {
const out = document.createElement('canvas');
out.width = W; out.height = H;
const ctx = out.getContext('2d');
function drawVector() {
const v = new Image();
v.onload = function () { ctx.drawImage(v, 0, 0, W, H); out.toBlob(cb, 'image/png'); };
v.onerror = function () { out.toBlob(cb, 'image/png'); };
v.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(getSVG());
}
ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, W, H);
if (bgUrl) {
const bg = new Image();
bg.onload = function () {
const s = Math.min(W / bg.naturalWidth, H / bg.naturalHeight) || 1;
const dw = bg.naturalWidth * s, dh = bg.naturalHeight * s;
ctx.drawImage(bg, (W - dw) / 2, (H - dh) / 2, dw, dh);
drawVector();
};
bg.onerror = drawVector;
bg.src = bgUrl;
} else {
drawVector();
}
}
function destroy() { function destroy() {
document.removeEventListener('keydown', onKey); document.removeEventListener('keydown', onKey);
document.removeEventListener('pointerup', restoreAncestorDrag); document.removeEventListener('pointerup', restoreAncestorDrag);
if (root.parentNode) root.parentNode.removeChild(root); if (root.parentNode) root.parentNode.removeChild(root);
} }
return { getSVG: getSVG, destroy: destroy, el: root }; return { getSVG: getSVG, exportFlatBlob: exportFlatBlob, destroy: destroy, el: root };
} }
window.SvgDraw = { mount: mount }; window.SvgDraw = { mount: mount };
+55 -1
View File
@@ -53,6 +53,7 @@
<div class="mm-head"> <div class="mm-head">
<span class="mm-title">Мои материалы</span> <span class="mm-title">Мои материалы</span>
<button class="mm-btn" style="margin-left:auto" onclick="createNote()"><i data-lucide="plus"></i> Заметка</button> <button class="mm-btn" style="margin-left:auto" onclick="createNote()"><i data-lucide="plus"></i> Заметка</button>
<button class="mm-btn" onclick="openDrawModal({})"><i data-lucide="pen-tool"></i> Рисунок</button>
</div> </div>
<div class="mm-sub">Сохранённые с уроков: страницы доски, заметки и вложения. Хранятся у вас и не пропадают, даже если урок удалят.</div> <div class="mm-sub">Сохранённые с уроков: страницы доски, заметки и вложения. Хранятся у вас и не пропадают, даже если урок удалят.</div>
<div class="mm-toolbar"> <div class="mm-toolbar">
@@ -75,6 +76,8 @@
<script src="/js/sidebar.js"></script> <script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script> <script src="/js/notifications.js"></script>
<script src="/js/mobile.js"></script> <script src="/js/mobile.js"></script>
<script src="/js/svg-sanitize.js"></script>
<script src="/js/svg-draw.js"></script>
<script> <script>
LS.initPage(); LS.initPage();
@@ -103,6 +106,8 @@
const meta = `${esc(m.source_title || '')}${m.source_title ? ' · ' : ''}${fmtDate(m.created_at)}`; const meta = `${esc(m.source_title || '')}${m.source_title ? ' · ' : ''}${fmtDate(m.created_at)}`;
const del = `<button class="mm-btn danger" onclick="delMaterial(${m.id})" title="Удалить"><i data-lucide="trash-2"></i></button>`; const del = `<button class="mm-btn danger" onclick="delMaterial(${m.id})" title="Удалить"><i data-lucide="trash-2"></i></button>`;
const edit = `<button class="mm-btn" onclick="editMaterial(${m.id})" title="Изменить"><i data-lucide="pencil"></i></button>`; const edit = `<button class="mm-btn" onclick="editMaterial(${m.id})" title="Изменить"><i data-lucide="pencil"></i></button>`;
const ann = (m.kind === 'board' || m.kind === 'image')
? `<button class="mm-btn" onclick="annotate(${m.id})" title="Аннотировать (рисовать поверх)"><i data-lucide="pencil-ruler"></i></button>` : '';
const mv = moveSelect(m); const mv = moveSelect(m);
if (m.kind === 'board' || m.kind === 'image') { if (m.kind === 'board' || m.kind === 'image') {
return `<div class="mm-card"> return `<div class="mm-card">
@@ -115,7 +120,7 @@
${mv} ${mv}
<a class="mm-btn" href="${esc(m.url)}" target="_blank" rel="noopener" title="Открыть"><i data-lucide="external-link"></i></a> <a class="mm-btn" href="${esc(m.url)}" target="_blank" rel="noopener" title="Открыть"><i data-lucide="external-link"></i></a>
<a class="mm-btn" href="${esc(m.url)}" download title="Скачать"><i data-lucide="download"></i></a> <a class="mm-btn" href="${esc(m.url)}" download title="Скачать"><i data-lucide="download"></i></a>
${edit}${del} ${ann}${edit}${del}
</div> </div>
</div> </div>
</div>`; </div>`;
@@ -297,6 +302,55 @@
} }
window.editCollection = editCollection; window.editCollection = editCollection;
/* ── Рисование / аннотации (SVG-рисовалка) ── */
function ensureDrawStyles() {
if (document.getElementById('mmdraw-style')) return;
const s = document.createElement('style');
s.id = 'mmdraw-style';
s.textContent = `
.mmdraw-ov { position:fixed; inset:0; z-index:99999; background:rgba(15,12,30,.72); display:flex; align-items:center; justify-content:center; padding:16px; }
.mmdraw-box { background:#fff; border-radius:14px; max-width:94vw; max-height:94vh; display:flex; flex-direction:column; overflow:hidden; box-shadow:0 20px 60px rgba(0,0,0,.4); }
.mmdraw-host { padding:10px; overflow:auto; }
.mmdraw-actions { display:flex; justify-content:flex-end; gap:8px; padding:12px 16px; border-top:1px solid #eee; }
.mmdraw-actions button { padding:8px 16px; border-radius:9px; border:1px solid #e2e8f0; background:#fff; font-weight:700; font-size:.85rem; cursor:pointer; color:#475569; }
.mmdraw-actions button.primary { background:#8b5cf6; border-color:#8b5cf6; color:#fff; }
`;
document.head.appendChild(s);
}
function openDrawModal(o) {
o = o || {};
if (!window.SvgDraw) { LS.toast('Редактор не загружен', 'error'); return; }
ensureDrawStyles();
const ov = document.createElement('div');
ov.className = 'mmdraw-ov';
ov.innerHTML = '<div class="mmdraw-box"><div class="mmdraw-host"></div><div class="mmdraw-actions"><button data-a="cancel">Отмена</button><button data-a="save" class="primary">Сохранить</button></div></div>';
document.body.appendChild(ov);
const host = ov.querySelector('.mmdraw-host');
const ed = SvgDraw.mount(host, { bgImage: o.bgImage || null, width: 800, height: 500, onChange: function () {} });
function close() { try { ed.destroy(); } catch (e) {} ov.remove(); }
ov.querySelector('[data-a="cancel"]').onclick = close;
ov.querySelector('[data-a="save"]').onclick = function () {
const btn = this; btn.disabled = true;
ed.exportFlatBlob(async function (blob) {
try {
if (!blob) throw new Error('Не удалось сохранить рисунок');
const fd = new FormData(); fd.append('file', blob, 'drawing.png');
const up = await LS.uploadFile(fd);
await LS.saveMaterial({ kind: 'image', title: o.title || 'Рисунок', url: LS.downloadFileUrl(up.id), sourceTitle: o.sourceTitle || null });
close(); load(); LS.toast('Сохранено в «Мои материалы»', 'success');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); btn.disabled = false; }
});
};
}
window.openDrawModal = openDrawModal;
function annotate(id) {
const mt = _mats.find(x => x.id === id);
if (!mt) return;
openDrawModal({ bgImage: mt.url, title: (mt.title || 'Рисунок') + ' (разметка)', sourceTitle: mt.source_title });
}
window.annotate = annotate;
load(); load();
</script> </script>
</body> </body>