/** * CollabOverlay — DOM-слой совместного редактирования (Team Create). * * Рисует поверх canvas студии: * - панель «онлайн» (аватарки-кружки соавторов с цветом и ником); * - 3D-курсоры соавторов (точка с ником, спроецированная из мировых * координат в экранные); * - подсветку чужого выделения (по selectedKey — обводим объект); * - тосты («объект занят другим», «N присоединился»). * * Самодостаточный (как LoadingScreenOverlay): монтируется на parent canvas, * UI обновляется методами update(...). Кнопка «Пригласить» — снаружи (TopRibbon), * здесь только presence + курсоры. */ import { Vector3, Matrix } from '@babylonjs/core'; let _cssInjected = false; function injectCss() { if (_cssInjected || typeof document === 'undefined') return; _cssInjected = true; const s = document.createElement('style'); s.id = 'kbn-collab-css'; s.textContent = '.kbnCollabCursor{position:absolute;transform:translate(-50%,-50%);pointer-events:none;z-index:62;transition:left 0.08s linear,top 0.08s linear;will-change:left,top}' + '.kbnCollabDot{width:14px;height:14px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.5)}' + '.kbnCollabName{position:absolute;left:16px;top:-2px;white-space:nowrap;font:600 11px system-ui;color:#fff;padding:1px 6px;border-radius:6px;text-shadow:0 1px 2px rgba(0,0,0,.6)}' + '.kbnCollabBar{position:absolute;top:10px;left:50%;transform:translateX(-50%);z-index:63;display:flex;gap:6px;align-items:center;background:rgba(20,24,38,.78);padding:5px 10px;border-radius:20px;backdrop-filter:blur(6px);font:600 12px system-ui;color:#cdd6e6;pointer-events:auto}' + '.kbnCollabAva{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font:700 12px system-ui;color:#10131c;border:2px solid rgba(255,255,255,.25)}' + '.kbnCollabToast{position:absolute;bottom:80px;left:50%;transform:translateX(-50%);z-index:64;background:rgba(20,24,38,.92);color:#fff;padding:10px 18px;border-radius:10px;font:600 14px system-ui;box-shadow:0 8px 24px rgba(0,0,0,.5);opacity:0;transition:opacity .25s}'; document.head.appendChild(s); } export class CollabOverlay { constructor(scene) { this.scene = scene; this.root = null; this.barEl = null; this.cursorsEl = null; this.toastEl = null; this._cursors = new Map(); // sessionId → {wrap, dot, name} this._presence = []; this._raf = null; } mount() { injectCss(); const parent = (this.scene.canvas && this.scene.canvas.parentElement) || document.body; try { if (getComputedStyle(parent).position === 'static') parent.style.position = 'relative'; } catch (e) { /* ignore */ } const root = document.createElement('div'); root.className = 'kbn-collab-root'; root.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:60;overflow:hidden'; const bar = document.createElement('div'); bar.className = 'kbnCollabBar'; bar.innerHTML = '👥 Соавторы:'; const cursors = document.createElement('div'); cursors.style.cssText = 'position:absolute;inset:0'; const toast = document.createElement('div'); toast.className = 'kbnCollabToast'; root.appendChild(bar); root.appendChild(cursors); root.appendChild(toast); parent.appendChild(root); this.root = root; this.barEl = bar; this.cursorsEl = cursors; this.toastEl = toast; this._startLoop(); } /** Обновить список соавторов (из StudioCollab.onPresenceChange). */ updatePresence(list) { this._presence = list || []; if (!this.barEl) return; // Перерисовать аватарки. const others = this._presence; let html = '👥'; for (const c of others) { const initial = (c.username || '?').trim().charAt(0).toUpperCase() || '?'; const ring = c.me ? 'box-shadow:0 0 0 2px #fff' : ''; html += `
${escapeHtml(initial)}
`; } html += `${others.length}`; this.barEl.innerHTML = html; // Курсоры пересоберём в loop. this._syncCursorEls(); } _syncCursorEls() { const present = new Set(this._presence.filter(c => !c.me).map(c => c.sessionId)); // удалить ушедших for (const [sid, el] of this._cursors) { if (!present.has(sid)) { try { el.wrap.remove(); } catch (e) { /* ignore */ } this._cursors.delete(sid); } } // добавить новых for (const c of this._presence) { if (c.me) continue; if (!this._cursors.has(c.sessionId)) { const wrap = document.createElement('div'); wrap.className = 'kbnCollabCursor'; const dot = document.createElement('div'); dot.className = 'kbnCollabDot'; const name = document.createElement('div'); name.className = 'kbnCollabName'; wrap.appendChild(dot); wrap.appendChild(name); this.cursorsEl.appendChild(wrap); this._cursors.set(c.sessionId, { wrap, dot, name }); } const el = this._cursors.get(c.sessionId); el.dot.style.background = c.color; el.name.style.background = c.color; el.name.textContent = c.username; } } /** Каждый кадр проецируем 3D-курсоры соавторов в экранные координаты. */ _startLoop() { const tick = () => { this._raf = requestAnimationFrame(tick); if (!this.cursorsEl || !this._presence.length) return; const scene = this.scene.scene; const cam = this.scene.camera; const eng = this.scene.engine; if (!scene || !cam || !eng) return; for (const c of this._presence) { if (c.me) continue; const el = this._cursors.get(c.sessionId); if (!el) continue; const cur = c.cursor; if (!cur || (cur.x === 0 && cur.y === 0 && cur.z === 0)) { el.wrap.style.display = 'none'; continue; } const sp = this._project(cur.x, cur.y, cur.z, scene, cam, eng); if (!sp) { el.wrap.style.display = 'none'; continue; } el.wrap.style.display = 'block'; el.wrap.style.left = sp.x + 'px'; el.wrap.style.top = sp.y + 'px'; } }; this._raf = requestAnimationFrame(tick); } /** Спроецировать мировую точку в экранные координаты canvas. */ _project(x, y, z, scene, cam, eng) { try { const w = eng.getRenderWidth(); const h = eng.getRenderHeight(); const p = Vector3.Project( new Vector3(x, y, z), Matrix.Identity(), scene.getTransformMatrix(), cam.viewport.toGlobal(w, h) ); if (p.z < 0 || p.z > 1) return null; // за камерой // переводим из render-пикселей в css-пиксели canvas const canvas = eng.getRenderingCanvas(); const rect = canvas.getBoundingClientRect(); const sx = rect.width / w, sy = rect.height / h; return { x: p.x * sx, y: p.y * sy }; } catch (e) { return null; } } toast(text) { if (!this.toastEl) return; this.toastEl.textContent = text; this.toastEl.style.opacity = '1'; clearTimeout(this._toastT); this._toastT = setTimeout(() => { if (this.toastEl) this.toastEl.style.opacity = '0'; }, 2600); } dispose() { if (this._raf) cancelAnimationFrame(this._raf); try { this.root?.remove(); } catch (e) { /* ignore */ } this.root = null; this._cursors.clear(); } } function escapeHtml(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m])); }