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:
+41
-1
@@ -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 };
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user