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
+55 -1
View File
@@ -53,6 +53,7 @@
<div class="mm-head">
<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" onclick="openDrawModal({})"><i data-lucide="pen-tool"></i> Рисунок</button>
</div>
<div class="mm-sub">Сохранённые с уроков: страницы доски, заметки и вложения. Хранятся у вас и не пропадают, даже если урок удалят.</div>
<div class="mm-toolbar">
@@ -75,6 +76,8 @@
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/mobile.js"></script>
<script src="/js/svg-sanitize.js"></script>
<script src="/js/svg-draw.js"></script>
<script>
LS.initPage();
@@ -103,6 +106,8 @@
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 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);
if (m.kind === 'board' || m.kind === 'image') {
return `<div class="mm-card">
@@ -115,7 +120,7 @@
${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)}" download title="Скачать"><i data-lucide="download"></i></a>
${edit}${del}
${ann}${edit}${del}
</div>
</div>
</div>`;
@@ -297,6 +302,55 @@
}
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();
</script>
</body>