Files
Learn_System/frontend/classroom.html
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 10:10:37 +03:00

4255 lines
221 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Онлайн-урок — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
.sb-content { background: #0e1117; overflow: hidden; display: flex; flex-direction: column; height: 100vh; }
/* ── header ── */
.cr-header {
background: linear-gradient(140deg, #0a0220 0%, #1a0a3c 60%, #080818 100%);
padding: 16px 20px; display: flex; align-items: center; gap: 12px;
flex-shrink: 0; position: relative; overflow: hidden;
}
.cr-header-dots {
position: absolute; inset: 0;
background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px);
background-size: 22px 22px; pointer-events: none;
}
.cr-header-inner { display: flex; align-items: center; gap: 12px; width: 100%; position: relative; z-index: 1; }
.cr-title {
font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; color: #fff;
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
}
.cr-title span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cr-session-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 12px; border-radius: 999px;
background: rgba(6,214,160,0.15); border: 1.5px solid rgba(6,214,160,0.3);
font-size: 0.72rem; font-weight: 700; color: #06D6A0;
font-family: 'Unbounded', sans-serif;
}
.cr-session-chip .dot {
width: 6px; height: 6px; border-radius: 50%; background: #06D6A0;
animation: pulse-dot 1.5s ease infinite;
}
@keyframes pulse-dot { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.5;transform:scale(.7)} }
.cr-header-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 99px;
border: 1.5px solid rgba(255,255,255,0.15); background: transparent;
color: rgba(255,255,255,0.7); font-family: 'Manrope',sans-serif;
font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: all .15s;
white-space: nowrap;
}
.cr-header-btn:hover { border-color: rgba(255,255,255,0.4); color: #fff; }
.cr-header-btn.danger { border-color: rgba(241,91,181,0.4); color: #F15BB5; }
.cr-header-btn.danger:hover { background: rgba(241,91,181,0.12); }
.cr-header-btn svg { width: 14px; height: 14px; }
/* ── body ── */
.cr-body { flex: 1; display: flex; min-height: 0; }
/* ── main area (whiteboard placeholder) ── */
.cr-main {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
background: #0e1117; min-width: 0;
}
.cr-idle {
display: flex; flex-direction: column; align-items: center; gap: 20px;
text-align: center; padding: 40px;
}
.cr-idle-icon {
width: 72px; height: 72px; border-radius: 20px;
background: linear-gradient(135deg, rgba(155,93,229,0.2), rgba(6,214,224,0.1));
border: 1.5px solid rgba(155,93,229,0.3);
display: flex; align-items: center; justify-content: center;
}
.cr-idle-icon svg { width: 36px; height: 36px; stroke: #9B5DE5; }
.cr-idle h2 {
font-family: 'Unbounded',sans-serif; font-size: 1.1rem; font-weight: 800;
color: #fff; margin: 0;
}
.cr-idle p { color: #8898AA; font-size: 0.85rem; margin: 0; max-width: 340px; line-height: 1.6; }
.cr-start-btn {
display: flex; align-items: center; gap: 8px;
padding: 12px 28px; border-radius: 99px;
background: linear-gradient(135deg, #9B5DE5, #7B3FC5);
border: none; color: #fff; font-family: 'Manrope',sans-serif;
font-size: 0.88rem; font-weight: 700; cursor: pointer;
transition: all .2s; box-shadow: 0 4px 20px rgba(155,93,229,0.35);
}
.cr-start-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 28px rgba(155,93,229,0.5); }
.cr-start-btn svg { width: 16px; height: 16px; }
/* active session main area — whiteboard */
.cr-active-main {
flex: 1; align-self: stretch; display: flex; flex-direction: column;
overflow: hidden; min-height: 0;
}
.cr-board-area {
flex: 1; display: flex; flex-direction: row;
overflow: hidden; min-height: 0;
}
.cr-board-wrap {
flex: 1; position: relative; overflow: hidden;
background: #213d26;
/* Chalkboard wooden frame */
border: 10px solid #3a2210;
border-top: 12px solid #3a2210;
border-bottom: 14px solid #2c1a0d;
box-shadow:
inset 0 0 0 2px rgba(0,0,0,0.55),
inset 0 0 0 3px rgba(255,200,100,0.07),
0 4px 18px rgba(0,0,0,0.6),
0 1px 0 rgba(255,200,80,0.08);
/* Wood grain via gradient */
background-image: none;
}
/* Chalk-dust highlight at inner bottom edge of frame */
.cr-board-wrap::before {
content: '';
position: absolute;
bottom: 0; left: 0; right: 0;
height: 3px;
background: rgba(255,255,255,0.06);
pointer-events: none; z-index: 10;
}
/* Wood highlight — top inner edge */
.cr-board-wrap::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: rgba(255,200,100,0.08);
pointer-events: none; z-index: 10;
}
#cr-canvas {
display: block; cursor: crosshair;
touch-action: none;
}
/* Thumbnail panel (pages sidebar) */
.wb-thumbs-panel {
width: 110px; flex-shrink: 0;
background: #120d1e;
border-right: 1px solid rgba(155,93,229,0.2);
display: flex; flex-direction: column;
overflow-y: auto; overflow-x: hidden;
padding: 6px 4px 40px;
gap: 6px; position: relative;
scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.3) transparent;
}
.wb-thumb-item {
position: relative; cursor: pointer; flex-shrink: 0;
border-radius: 4px; overflow: hidden;
border: 1.5px solid transparent;
transition: border-color .12s;
}
.wb-thumb-item:hover { border-color: rgba(155,93,229,0.5); }
.wb-thumb-item.active { border-color: #9B5DE5; }
.wb-thumb-item canvas { display: block; width: 100%; height: auto; }
.wb-thumb-num {
position: absolute; bottom: 2px; right: 4px;
font-size: 9px; color: rgba(255,255,255,0.4);
font-family: 'Manrope', sans-serif;
}
.wb-thumbs-add {
position: sticky; bottom: 0;
width: 100%; padding: 4px; background: #120d1e;
border-top: 1px solid rgba(155,93,229,0.15);
}
.wb-thumbs-add-btn {
width: 100%; height: 28px; border-radius: 4px;
background: rgba(155,93,229,0.12); border: 1px solid rgba(155,93,229,0.25);
color: rgba(255,255,255,0.5); font-size: 16px; cursor: pointer;
transition: all .12s;
}
.wb-thumbs-add-btn:hover { background: rgba(155,93,229,0.22); color: #fff; }
/* Template picker */
.wb-tpl-picker {
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
}
.wb-tpl-picker label { font-size: 10px; color: rgba(255,230,180,0.45); white-space: nowrap; }
.wb-tpl-picker select {
font-size: 11px; background: #2a1a0e;
border: 1px solid rgba(255,180,60,0.3); border-radius: 4px;
color: rgba(255,230,180,0.9); padding: 2px 4px; cursor: pointer;
max-width: 90px;
}
.wb-tpl-picker select option {
background: #2a1a0e;
color: #ffe0b0;
}
/* toolbar — wood-tray look beneath the board */
.cr-toolbar {
flex-shrink: 0;
background: linear-gradient(to bottom, #2c1e10, #1e1509);
border-top: 2px solid #4a2e18;
border-bottom: 1px solid #110e08;
display: flex; flex-direction: column; align-items: stretch;
box-shadow: 0 3px 10px rgba(0,0,0,0.5);
}
.cr-tb-row {
height: 44px; flex-shrink: 0;
display: flex; align-items: center; gap: 3px; padding: 0 8px;
overflow-x: auto; overflow-y: hidden;
scrollbar-width: thin; scrollbar-color: rgba(255,180,60,0.18) transparent;
}
.cr-tb-row + .cr-tb-row {
border-top: 1px solid rgba(255,180,60,0.1);
}
.cr-tb-row::-webkit-scrollbar { height: 2px; }
.cr-tb-row::-webkit-scrollbar-thumb { background: rgba(255,180,60,0.2); border-radius:2px; }
.cr-tool-sep {
width: 1px; height: 20px; background: rgba(255,255,255,0.12);
margin: 0 4px; flex-shrink: 0;
}
.cr-tool-btn {
display: flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border-radius: 7px; border: 1.5px solid transparent;
background: transparent; cursor: pointer; color: rgba(255,230,180,0.55);
transition: all .12s; flex-shrink: 0;
}
.cr-tool-btn:hover { background: rgba(255,200,100,0.1); color: rgba(255,230,180,0.95); }
.cr-tool-btn.active {
background: rgba(255,180,60,0.15);
border-color: rgba(255,180,60,0.45);
color: #f5b942;
}
.cr-tool-btn svg { width: 15px; height: 15px; }
.cr-color-btn {
width: 20px; height: 20px; border-radius: 50%;
border: 2px solid transparent; cursor: pointer;
flex-shrink: 0; transition: transform .12s;
outline: none;
}
.cr-color-btn:hover { transform: scale(1.15); }
.cr-color-btn.active { border-color: #fff; transform: scale(1.15); }
.cr-width-btn {
display: flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border-radius: 8px; border: 1.5px solid transparent;
background: transparent; cursor: pointer; flex-shrink: 0;
transition: all .12s;
}
.cr-width-btn:hover { background: rgba(255,200,100,0.1); }
.cr-width-btn.active { background: rgba(255,180,60,0.15); border-color: rgba(255,180,60,0.45); }
.cr-width-dot {
border-radius: 50%; background: rgba(255,230,180,0.6);
pointer-events: none;
}
.cr-width-btn.active .cr-width-dot { background: #f5b942; }
.cr-linestyle-btn {
display: flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border-radius: 8px; border: 1.5px solid transparent;
background: transparent; cursor: pointer; flex-shrink: 0;
transition: all .12s; color: rgba(255,230,180,0.55);
font-size: 13px; font-weight: bold; letter-spacing: 1px;
}
.cr-linestyle-btn:hover { background: rgba(255,200,100,0.1); }
.cr-linestyle-btn.active { background: rgba(255,180,60,0.15); border-color: rgba(255,180,60,0.45); color: #f5b942; }
.cr-opacity-row {
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
}
.cr-opacity-row label { font-size: 10px; color: rgba(255,230,180,0.45); white-space: nowrap; }
#wb-opacity-slider {
width: 60px; height: 4px; accent-color: #f5b942; cursor: pointer;
}
.wb-zoom-row {
display: flex; align-items: center; gap: 2px; flex-shrink: 0;
}
.wb-zoom-label {
font-family: 'Manrope', sans-serif; font-size: 0.7rem; font-weight: 700;
color: rgba(255,210,140,0.6); min-width: 36px; text-align: center;
white-space: nowrap;
}
.wb-zoom-label:hover { color: #f5b942; }
/* overlay properties panel */
.wb-ov-props {
display: none; align-items: center; gap: 6px; flex-shrink: 0;
background: rgba(28,20,44,0.92); border: 1px solid rgba(155,93,229,0.3);
border-radius: 8px; padding: 3px 10px; font-family: 'Manrope',sans-serif;
}
.wb-ov-props.visible { display: flex; }
.wb-ov-props label { font-size: 0.68rem; color: rgba(255,255,255,0.55); white-space: nowrap; }
.wb-ov-props input[type=number] {
width: 52px; background: rgba(255,255,255,0.07); border: 1px solid rgba(155,93,229,0.3);
border-radius: 5px; color: #e8e0f7; font-size: 0.72rem; padding: 2px 4px;
text-align: center; -moz-appearance: textfield;
}
.wb-ov-props input[type=number]::-webkit-inner-spin-button { opacity: 0.4; }
.wb-ov-props-type { font-size: 0.68rem; font-weight: 700; color: rgba(155,93,229,0.8); }
/* read-only overlay label */
.cr-board-readonly {
position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.1);
border-radius: 99px; padding: 4px 14px;
font-family: 'Manrope',sans-serif; font-size: 0.72rem; color: rgba(255,255,255,0.4);
pointer-events: none; white-space: nowrap;
}
/* page nav */
.cr-page-nav {
display: flex; align-items: center; gap: 4px; margin-left: auto; flex-shrink: 0;
}
.cr-page-label {
font-family: 'Manrope',sans-serif; font-size: 0.72rem; font-weight: 700;
color: rgba(255,210,140,0.5); min-width: 42px; text-align: center;
}
/* hand raise */
.cr-hand-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 99px;
border: 1.5px solid rgba(255,255,255,0.15); background: transparent;
color: rgba(255,255,255,0.7); font-family: 'Manrope',sans-serif;
font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: all .15s;
}
.cr-hand-btn:hover { border-color: #FFB347; color: #FFB347; }
.cr-hand-btn.raised { background: rgba(255,179,71,0.15); border-color: #FFB347; color: #FFB347; }
.cr-hand-btn svg { width: 14px; height: 14px; }
/* raised hands section in participants panel */
.cr-hands-section {
padding: 8px 8px 0; flex-shrink: 0;
}
.cr-hands-title {
font-family: 'Manrope',sans-serif; font-size: 0.7rem; font-weight: 700;
color: #FFB347; text-transform: uppercase; letter-spacing: .05em;
padding: 4px 2px 6px; display: flex; align-items: center; gap: 6px;
}
.cr-hands-title svg { width: 12px; height: 12px; }
.cr-hand-item {
display: flex; align-items: center; gap: 8px;
padding: 5px 10px; border-radius: 8px; background: rgba(255,179,71,0.08);
font-family: 'Manrope',sans-serif; font-size: 0.78rem; color: #FFB347;
font-weight: 600; margin-bottom: 3px;
}
.cr-hand-item svg { width: 13px; height: 13px; flex-shrink: 0; }
/* ── right panel ── */
.cr-right {
width: 300px; flex-shrink: 0;
background: #12161f; border-left: 1.5px solid rgba(255,255,255,0.06);
display: flex; flex-direction: column;
}
/* participants */
.cr-panel-tabs {
display: flex; border-bottom: 1.5px solid rgba(255,255,255,0.06); flex-shrink: 0;
}
.cr-tab {
flex: 1; padding: 11px 8px; font-family: 'Manrope',sans-serif;
font-size: 0.75rem; font-weight: 700; color: #8898AA;
background: transparent; border: none; cursor: pointer;
border-bottom: 2px solid transparent; transition: all .15s;
display: flex; align-items: center; justify-content: center; gap: 5px;
}
.cr-tab.active { color: #9B5DE5; border-bottom-color: #9B5DE5; }
.cr-tab svg { width: 13px; height: 13px; }
.cr-tab-badge {
background: #F15BB5; color: #fff; border-radius: 99px;
font-size: 0.65rem; font-weight: 800; padding: 1px 5px; min-width: 16px; text-align: center;
}
/* participants list */
.cr-participants {
flex: 1; overflow-y: auto; padding: 8px;
display: flex; flex-direction: column; gap: 4px;
}
.cr-participants::-webkit-scrollbar { width: 4px; }
.cr-participants::-webkit-scrollbar-track { background: transparent; }
.cr-participants::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
.cr-participant {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-radius: 10px;
transition: background .15s;
}
.cr-participant:hover { background: rgba(255,255,255,0.04); }
.cr-p-avatar {
width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0;
background: linear-gradient(135deg, #9B5DE5, #06D6E0);
display: flex; align-items: center; justify-content: center;
font-size: 0.7rem; font-weight: 800; color: #fff;
font-family: 'Unbounded', sans-serif;
transition: box-shadow .15s;
}
.cr-p-avatar.speaking {
box-shadow: 0 0 0 3px #06D6A0, 0 0 10px rgba(6,214,160,.5);
animation: speaking-pulse 0.8s ease-in-out infinite;
}
@keyframes speaking-pulse {
0%, 100% { box-shadow: 0 0 0 2px #06D6A0, 0 0 8px rgba(6,214,160,.4); }
50% { box-shadow: 0 0 0 4px #06D6A0, 0 0 14px rgba(6,214,160,.7); }
}
.cr-p-name { flex: 1; font-size: 0.8rem; font-weight: 600; color: #d1d5db; }
.cr-p-you { font-size: 0.65rem; color: #9B5DE5; font-weight: 700; }
.cr-p-status { display: flex; gap: 5px; align-items: center; }
.cr-p-status svg { width: 13px; height: 13px; }
.mic-on { color: #06D6A0; }
.mic-off { color: #8898AA; }
.cr-p-mute-btn {
background: none; border: none; cursor: pointer; padding: 2px; border-radius: 4px;
color: #8898AA; display: flex; align-items: center; opacity: 0; transition: opacity .15s, color .15s;
}
.cr-participant:hover .cr-p-mute-btn { opacity: 1; }
/* Draw permission toggle — always visible for teacher */
.cr-p-draw-toggle {
display: flex; align-items: center; gap: 4px;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px; padding: 2px 7px 2px 5px;
cursor: pointer; font-size: 0.67rem; font-weight: 600; color: #8898AA;
transition: background .15s, border-color .15s, color .15s;
white-space: nowrap;
}
.cr-p-draw-toggle:hover { background: rgba(6,214,160,.12); border-color: rgba(6,214,160,.3); color: #06D6A0; }
.cr-p-draw-toggle.granted {
background: rgba(6,214,160,.15); border-color: rgba(6,214,160,.5); color: #06D6A0;
}
.cr-p-draw-toggle svg { flex-shrink: 0; }
/* Badge for student who can draw */
.cr-p-draw-badge {
display: inline-flex; align-items: center;
background: rgba(6,214,160,.15); border: 1px solid rgba(6,214,160,.4);
border-radius: 4px; padding: 1px 5px; font-size: 0.6rem; font-weight: 700; color: #06D6A0;
gap: 3px;
}
/* chat area */
.cr-chat-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.cr-messages {
flex: 1; overflow-y: auto; padding: 12px 10px;
display: flex; flex-direction: column; gap: 8px;
}
.cr-messages::-webkit-scrollbar { width: 4px; }
.cr-messages::-webkit-scrollbar-track { background: transparent; }
.cr-messages::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
.cr-msg { display: flex; flex-direction: column; gap: 2px; padding: 4px 8px; border-radius: 8px; }
.cr-msg-pinned { background: rgba(155,93,229,.08); border-left: 2px solid #9B5DE5; }
.cr-msg-header { display: flex; align-items: center; gap: 5px; }
.cr-msg-name { font-size: 0.72rem; font-weight: 700; color: #9B5DE5; }
.cr-msg-name.teacher-name { color: #06D6A0; }
.cr-msg-time { font-size: 0.65rem; color: #8898AA; flex: 1; }
.cr-msg-pin-badge { color: #9B5DE5; opacity: 0.8; display: flex; align-items: center; }
.cr-msg-pin-btn {
background: none; border: none; cursor: pointer; color: #8898AA;
padding: 1px; border-radius: 4px; display: flex; align-items: center;
opacity: 0; transition: opacity .15s, color .15s;
}
.cr-msg:hover .cr-msg-pin-btn { opacity: 1; }
.cr-msg-pin-btn.active { color: #9B5DE5; opacity: 1; }
.cr-msg-pin-btn:hover { color: #9B5DE5; }
.cr-msg-text { font-size: 0.82rem; color: #d1d5db; line-height: 1.5; word-break: break-word; }
/* chat attachment */
.cr-msg-img { max-width: 100%; border-radius: 8px; margin-top: 4px; cursor: zoom-in; display: block; max-height: 200px; object-fit: contain; }
/* chat reactions */
.cr-msg-reactions { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 3px; }
.cr-msg-react-bar {
display: none; gap: 3px;
position: absolute; right: 4px; top: 4px;
background: rgba(20,14,40,0.95); border: 1px solid rgba(155,93,229,0.3);
border-radius: 8px; padding: 3px 5px;
}
.cr-msg:hover .cr-msg-react-bar { display: flex; }
.cr-msg { position: relative; }
.cr-react-pick {
background: none; border: none; cursor: pointer; padding: 3px 4px; border-radius: 5px;
color: rgba(255,255,255,0.45); font-size: 13px; line-height: 1;
transition: all .12s; display: flex; align-items: center; justify-content: center;
}
.cr-react-pick:hover { background: rgba(155,93,229,0.2); color: #c4b5fd; }
.cr-react-chip {
display: inline-flex; align-items: center; gap: 3px;
padding: 1px 7px; border-radius: 99px; cursor: pointer;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
font-size: 0.72rem; color: rgba(255,255,255,0.6); transition: all .12s;
}
.cr-react-chip.mine { background: rgba(155,93,229,0.2); border-color: rgba(155,93,229,0.5); color: #c4b5fd; }
.cr-react-chip:hover { background: rgba(155,93,229,0.25); }
.cr-react-chip svg { flex-shrink: 0; }
/* chat attach preview */
.cr-chat-attach-preview {
display: none; align-items: center; gap: 8px;
padding: 6px 10px; background: rgba(155,93,229,0.1);
border: 1px solid rgba(155,93,229,0.3); border-radius: 8px; margin: 0 10px 4px;
}
.cr-chat-attach-preview.show { display: flex; }
.cr-attach-thumb { width: 40px; height: 40px; border-radius: 5px; object-fit: cover; }
.cr-attach-name { flex: 1; font-size: 0.72rem; color: rgba(255,255,255,0.6); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cr-attach-rm { background: none; border: none; cursor: pointer; color: rgba(255,255,255,0.4); padding: 2px; border-radius: 4px; flex-shrink: 0; }
.cr-attach-rm:hover { color: #F15BB5; }
.cr-attach-btn {
width: 32px; height: 32px; border-radius: 8px; border: 1.5px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.45); cursor: pointer;
display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all .12s;
}
.cr-attach-btn:hover { background: rgba(155,93,229,0.18); color: #c4b5fd; border-color: rgba(155,93,229,0.4); }
/* notes panel */
.cr-notes-panel { flex: 1; display: flex; flex-direction: column; padding: 10px; gap: 6px; overflow: hidden; }
.cr-notes-header { display: flex; align-items: center; justify-content: space-between; }
.cr-notes-label { font-size: 0.72rem; color: rgba(255,255,255,0.35); }
.cr-notes-status { font-size: 0.68rem; color: rgba(6,214,224,0.5); }
.cr-notes-ta {
flex: 1; resize: none; outline: none;
background: rgba(255,255,255,0.04); border: 1.5px solid rgba(255,255,255,0.08);
border-radius: 10px; padding: 10px 12px; color: #e8e0f7;
font-size: 0.82rem; font-family: 'Manrope',sans-serif; line-height: 1.55;
caret-color: #9B5DE5; transition: border-color .15s;
}
.cr-notes-ta:focus { border-color: rgba(155,93,229,0.5); }
.cr-notes-ta::placeholder { color: rgba(255,255,255,0.2); }
/* templates modal */
.cr-tpl-modal-overlay {
display: none; position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,0.7);
align-items: center; justify-content: center;
}
.cr-tpl-modal-overlay.open { display: flex; }
.cr-tpl-modal {
background: #15102a; border: 1.5px solid rgba(155,93,229,0.35);
border-radius: 16px; padding: 20px; width: min(480px, calc(100vw - 24px));
max-height: 80vh; display: flex; flex-direction: column; gap: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.7);
}
.cr-tpl-hdr { display: flex; align-items: center; justify-content: space-between; }
.cr-tpl-title { font-family:'Unbounded',sans-serif; font-size:0.82rem; font-weight:800; color:#fff; }
.cr-tpl-close { background:none;border:none;cursor:pointer;color:rgba(255,255,255,0.4);font-size:18px;padding:0 4px; }
.cr-tpl-list { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:6px; min-height:80px; }
.cr-tpl-item {
display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:8px;
background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);cursor:pointer;
transition:all .12s;
}
.cr-tpl-item:hover { background:rgba(155,93,229,0.12);border-color:rgba(155,93,229,0.35); }
.cr-tpl-item-name { flex:1;font-size:0.82rem;color:#e8e0f7; }
.cr-tpl-item-meta { font-size:0.68rem;color:rgba(255,255,255,0.3); }
.cr-tpl-item-del { background:none;border:none;cursor:pointer;color:rgba(255,255,255,0.2);padding:2px;border-radius:4px; }
.cr-tpl-item-del:hover { color:#F15BB5; }
.cr-tpl-save-area { display:flex;gap:8px;border-top:1px solid rgba(255,255,255,0.08);padding-top:12px; }
.cr-tpl-name-input {
flex:1;background:rgba(255,255,255,0.06);border:1.5px solid rgba(255,255,255,0.12);border-radius:8px;
padding:7px 10px;color:#e8e0f7;font-size:0.82rem;font-family:'Manrope',sans-serif;outline:none;
}
.cr-tpl-name-input:focus { border-color:rgba(155,93,229,0.6); }
.cr-tpl-save-btn {
padding:7px 16px;border-radius:8px;border:none;background:linear-gradient(135deg,#9B5DE5,#7B3FC5);
color:#fff;font-size:0.8rem;font-weight:700;font-family:'Manrope',sans-serif;cursor:pointer;
white-space:nowrap;transition:all .15s;
}
.cr-tpl-save-btn:hover { transform:translateY(-1px);box-shadow:0 4px 14px rgba(155,93,229,0.4); }
.cr-tpl-empty { font-size:0.8rem;color:rgba(255,255,255,0.25);text-align:center;padding:16px; }
.cr-chat-input-wrap {
padding: 10px; border-top: 1.5px solid rgba(255,255,255,0.06);
display: flex; gap: 7px; align-items: flex-end; flex-shrink: 0;
}
.cr-chat-input {
flex: 1; background: rgba(255,255,255,0.06); border: 1.5px solid rgba(255,255,255,0.1);
border-radius: 10px; padding: 8px 12px; color: #fff; font-family: 'Manrope',sans-serif;
font-size: 0.82rem; resize: none; outline: none; min-height: 36px; max-height: 100px;
transition: border-color .15s;
}
.cr-chat-input:focus { border-color: rgba(155,93,229,0.5); }
.cr-chat-input::placeholder { color: #8898AA; }
.cr-chat-send {
width: 34px; height: 34px; border-radius: 10px;
background: #9B5DE5; border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: #fff; transition: all .15s; flex-shrink: 0;
}
.cr-chat-send:hover { background: #7B3FC5; }
.cr-chat-send svg { width: 14px; height: 14px; }
/* no session placeholder in chat */
.cr-no-session {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 10px;
padding: 20px; text-align: center;
}
.cr-no-session svg { width: 32px; height: 32px; stroke: #8898AA; }
.cr-no-session p { font-size: 0.78rem; color: #8898AA; line-height: 1.5; margin: 0; }
/* ── Modal: start session ── */
.cr-modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center;
z-index: 1000; opacity: 0; pointer-events: none; transition: opacity .2s;
}
.cr-modal-overlay.open { opacity: 1; pointer-events: all; }
.cr-modal {
background: #1a1f2e; border-radius: 18px;
border: 1.5px solid rgba(255,255,255,0.1);
padding: 28px; width: 440px; max-width: 90vw;
transform: translateY(10px); transition: transform .2s;
}
.cr-modal-overlay.open .cr-modal { transform: translateY(0); }
.cr-modal-title {
font-family: 'Unbounded',sans-serif; font-size: 1rem; font-weight: 800;
color: #fff; margin-bottom: 6px;
}
.cr-modal-sub { font-size: 0.8rem; color: #8898AA; margin-bottom: 20px; }
.cr-mode-tabs { display: flex; gap: 8px; margin-bottom: 16px; }
.cr-mode-tab {
flex: 1; padding: 9px; border-radius: 10px;
border: 1.5px solid rgba(255,255,255,0.1); background: transparent;
color: #8898AA; font-family: 'Manrope',sans-serif; font-size: 0.78rem;
font-weight: 700; cursor: pointer; transition: all .15s;
}
.cr-mode-tab.active {
border-color: #9B5DE5; background: rgba(155,93,229,0.12); color: #c4b5fd;
}
.cr-field { margin-bottom: 14px; }
.cr-label { font-size: 0.75rem; font-weight: 700; color: #8898AA; margin-bottom: 6px; display: block; }
.cr-input {
width: 100%; background: rgba(255,255,255,0.06);
border: 1.5px solid rgba(255,255,255,0.1); border-radius: 10px;
padding: 10px 12px; color: #fff; font-family: 'Manrope',sans-serif;
font-size: 0.85rem; outline: none; transition: border-color .15s; box-sizing: border-box;
}
.cr-input:focus { border-color: rgba(155,93,229,0.5); }
.cr-select {
width: 100%; background: rgba(255,255,255,0.06);
border: 1.5px solid rgba(255,255,255,0.1); border-radius: 10px;
padding: 10px 12px; color: #fff; font-family: 'Manrope',sans-serif;
font-size: 0.85rem; outline: none; cursor: pointer; appearance: none;
transition: border-color .15s;
}
.cr-select:focus { border-color: rgba(155,93,229,0.5); }
.cr-select option { background: #1a1f2e; }
/* online students list */
.cr-online-list {
max-height: 200px; overflow-y: auto; border: 1.5px solid rgba(255,255,255,0.1);
border-radius: 10px; background: rgba(255,255,255,0.03);
}
.cr-online-item {
display: flex; align-items: center; gap: 10px; padding: 9px 12px;
cursor: pointer; transition: background .12s;
}
.cr-online-item:hover { background: rgba(155,93,229,0.12); }
.cr-online-item.selected { background: rgba(155,93,229,0.18); }
.cr-online-dot {
width: 7px; height: 7px; border-radius: 50%; background: #22c55e; flex-shrink: 0;
}
.cr-online-avatar {
width: 28px; height: 28px; border-radius: 50%;
background: linear-gradient(135deg, #9B5DE5, #06D6E0);
display: flex; align-items: center; justify-content: center;
font-size: 0.62rem; font-weight: 800; color: #fff; flex-shrink: 0;
}
.cr-online-name { font-size: 0.83rem; color: #d1d5db; flex: 1; }
.cr-online-check {
width: 16px; height: 16px; border-radius: 4px;
border: 1.5px solid rgba(155,93,229,0.4); flex-shrink: 0; transition: all .12s;
}
.cr-online-item.selected .cr-online-check {
background: #9B5DE5; border-color: #9B5DE5;
}
.cr-online-empty { padding: 16px 12px; text-align: center; font-size: .8rem; color: #475569; }
.cr-online-refresh {
display: flex; align-items: center; gap: 6px; margin-bottom: 8px;
font-size: .72rem; font-weight: 700; color: #8898AA; cursor: pointer;
background: none; border: none; padding: 0; font-family: 'Manrope',sans-serif;
}
.cr-online-refresh:hover { color: #c4b5fd; }
.cr-selected-users { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.cr-selected-user {
display: flex; align-items: center; gap: 5px;
padding: 4px 10px; border-radius: 99px;
background: rgba(155,93,229,0.15); border: 1.5px solid rgba(155,93,229,0.3);
font-size: 0.75rem; font-weight: 600; color: #c4b5fd;
}
.cr-selected-user button {
background: none; border: none; cursor: pointer; color: #c4b5fd;
padding: 0; display: flex; align-items: center; opacity: .7;
}
.cr-selected-user button:hover { opacity: 1; }
.cr-selected-user button svg { width: 11px; height: 11px; }
.cr-modal-actions { display: flex; gap: 10px; margin-top: 20px; }
.cr-modal-cancel {
flex: 1; padding: 11px; border-radius: 10px;
border: 1.5px solid rgba(255,255,255,0.1); background: transparent;
color: #8898AA; font-family: 'Manrope',sans-serif; font-size: 0.85rem;
font-weight: 700; cursor: pointer; transition: all .15s;
}
.cr-modal-cancel:hover { border-color: rgba(255,255,255,0.25); color: #fff; }
.cr-modal-confirm {
flex: 2; padding: 11px; border-radius: 10px;
background: linear-gradient(135deg, #9B5DE5, #7B3FC5);
border: none; color: #fff; font-family: 'Manrope',sans-serif;
font-size: 0.85rem; font-weight: 700; cursor: pointer; transition: all .15s;
}
.cr-modal-confirm:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(155,93,229,0.4); }
.cr-modal-confirm:disabled { opacity: .5; cursor: not-allowed; transform: none; }
/* join banner (student) */
.cr-join-banner {
background: linear-gradient(135deg, rgba(155,93,229,0.15), rgba(6,214,224,0.08));
border: 1.5px solid rgba(155,93,229,0.3); border-radius: 14px;
padding: 24px 28px; max-width: 380px; text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 14px;
}
.cr-join-banner h3 {
font-family: 'Unbounded',sans-serif; font-size: 1rem; font-weight: 800;
color: #fff; margin: 0;
}
.cr-join-banner p { font-size: 0.82rem; color: #8898AA; margin: 0; line-height: 1.6; }
.cr-join-btn {
padding: 11px 32px; border-radius: 99px;
background: linear-gradient(135deg, #9B5DE5, #7B3FC5);
border: none; color: #fff; font-family: 'Manrope',sans-serif;
font-size: 0.88rem; font-weight: 700; cursor: pointer; transition: all .2s;
}
.cr-join-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(155,93,229,0.4); }
/* ── Session timer ── */
.cr-timer {
font-family: 'Manrope', monospace; font-size: 0.72rem; font-weight: 700;
color: rgba(255,255,255,0.35); letter-spacing: .04em; padding: 0 2px;
}
/* ── Teacher badge in participants ── */
.cr-p-teacher {
font-size: 0.6rem; font-weight: 700; color: #06D6A0;
background: rgba(6,214,160,0.12); border-radius: 4px; padding: 1px 5px;
white-space: nowrap; flex-shrink: 0;
}
/* ── Follow teacher toggle (student) ── */
.cr-follow-btn {
display: flex; align-items: center; gap: 5px;
padding: 3px 10px; border-radius: 99px; font-family: 'Manrope',sans-serif;
font-size: 0.7rem; font-weight: 700; white-space: nowrap;
border: 1.5px solid rgba(255,255,255,0.1); background: transparent;
color: rgba(255,255,255,0.35); cursor: pointer; transition: all .15s; flex-shrink: 0;
}
.cr-follow-btn.active {
border-color: rgba(6,214,224,0.4); background: rgba(6,214,224,0.08); color: #06D6E0;
}
.cr-follow-btn svg { width: 11px; height: 11px; }
/* ── Custom color input ── */
.cr-color-custom {
width: 20px; height: 20px; border-radius: 50%; cursor: pointer; flex-shrink: 0;
border: 2px solid rgba(255,255,255,0.2); padding: 0; overflow: hidden;
background: none; transition: transform .12s;
}
.cr-color-custom:hover { transform: scale(1.15); }
.cr-color-custom::-webkit-color-swatch-wrapper { padding: 0; }
.cr-color-custom::-webkit-color-swatch { border: none; border-radius: 50%; }
/* ── Student minimal nav ── */
.cr-student-nav {
flex-shrink: 0; height: 44px; background: #12161f;
border-top: 1.5px solid rgba(255,255,255,0.06);
display: flex; align-items: center; gap: 4px; padding: 0 12px;
}
/* ── Phase 4: screen share video overlay ── */
#cr-screen-video {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: contain; background: #0e1117; display: none; z-index: 2;
}
.cr-screen-label {
position: absolute; top: 10px; left: 50%; transform: translateX(-50%);
background: rgba(155,93,229,0.25); border: 1.5px solid rgba(155,93,229,0.4);
border-radius: 99px; padding: 4px 14px;
font-family: 'Manrope',sans-serif; font-size: 0.72rem; color: #c4b5fd;
pointer-events: none; white-space: nowrap; z-index: 3; display: none;
}
/* header button states */
.cr-header-btn.cr-btn-sharing {
background: rgba(155,93,229,0.18); border-color: rgba(155,93,229,0.5); color: #c4b5fd;
}
/* ── student status notification bar ── */
#cr-status-bar {
position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%);
z-index: 30; pointer-events: none;
display: flex; flex-direction: column; align-items: center; gap: 6px;
}
.cr-status-msg {
display: flex; align-items: center; gap: 8px;
padding: 8px 16px; border-radius: 99px;
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700;
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
animation: cr-status-in .25s cubic-bezier(.34,1.4,.64,1) forwards;
white-space: nowrap;
}
.cr-status-msg.cr-status-out {
animation: cr-status-out .2s ease-in forwards;
}
.cr-status-msg svg { flex-shrink: 0; }
.cr-status-msg.type-success { background: rgba(6,214,160,.2); border: 1.5px solid rgba(6,214,160,.5); color: #06D6A0; }
.cr-status-msg.type-warn { background: rgba(241,91,181,.18); border: 1.5px solid rgba(241,91,181,.5); color: #F15BB5; }
.cr-status-msg.type-info { background: rgba(155,93,229,.2); border: 1.5px solid rgba(155,93,229,.5); color: #c4b5fd; }
@keyframes cr-status-in {
from { opacity: 0; transform: translateY(10px) scale(.92); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes cr-status-out {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(-6px) scale(.94); }
}
/* mic ON: green */
.cr-header-btn.cr-btn-mic-on {
background: rgba(6,214,160,.12); border-color: rgba(6,214,160,.5); color: #06D6A0;
}
.cr-header-btn.cr-btn-mic-on:hover {
background: rgba(6,214,160,.22); border-color: #06D6A0;
}
/* mic OFF: red */
.cr-header-btn.cr-btn-mic-off {
background: rgba(241,91,181,.12); border-color: rgba(241,91,181,.5); color: #F15BB5;
}
.cr-header-btn.cr-btn-mic-off:hover {
background: rgba(241,91,181,.22); border-color: #F15BB5;
}
/* per-participant mute button (teacher view) — pill style, always visible */
.cr-p-mute-btn {
display: flex; align-items: center; gap: 3px;
padding: 2px 7px 2px 5px; border-radius: 6px; border: 1px solid transparent;
font-family: 'Manrope',sans-serif; font-size: 0.65rem; font-weight: 700;
cursor: pointer; transition: all .15s; white-space: nowrap; flex-shrink: 0;
}
/* mic ON state for participant button */
.cr-p-mute-btn.mic-active {
background: rgba(6,214,160,.1); border-color: rgba(6,214,160,.35); color: #06D6A0;
}
.cr-p-mute-btn.mic-active:hover {
background: rgba(241,91,181,.15); border-color: rgba(241,91,181,.5); color: #F15BB5;
}
/* mic OFF state for participant button */
.cr-p-mute-btn.mic-muted {
background: rgba(241,91,181,.1); border-color: rgba(241,91,181,.35); color: #F15BB5;
opacity: 1 !important;
}
.cr-p-mute-btn.mic-muted:hover {
background: rgba(6,214,160,.1); border-color: rgba(6,214,160,.4); color: #06D6A0;
}
@media (max-width: 768px) {
.cr-right { width: 100%; border-left: none; border-top: 1.5px solid rgba(255,255,255,0.06); max-height: 260px; }
.cr-body { flex-direction: column; }
.cr-main { min-height: 200px; }
}
/* ── Formula visual editor modal ────────────────────────────────────── */
.wbfm-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.75);
z-index: 190; cursor: pointer;
}
.wbfm-card {
position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%);
z-index: 191;
width: min(700px, calc(100vw - 24px));
background: #15102a;
border: 1.5px solid rgba(155,93,229,0.38);
border-radius: 18px;
box-shadow: 0 24px 70px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.04);
font-family: 'Manrope', sans-serif;
display: flex; flex-direction: column;
max-height: calc(100vh - 32px);
overflow: hidden;
}
/* header */
.wbfm-hdr {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px 12px;
border-bottom: 1px solid rgba(255,255,255,0.07);
flex-shrink: 0;
}
.wbfm-title {
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
color: #fff; display: flex; align-items: center; gap: 8px;
}
.wbfm-close {
width: 28px; height: 28px; border-radius: 7px; border: none;
background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5);
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all .12s;
}
.wbfm-close:hover { background: rgba(241,91,181,0.15); color: #F15BB5; }
/* big preview */
.wbfm-preview {
min-height: 72px; max-height: 110px; overflow: auto;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.08);
margin: 10px 16px 0;
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
padding: 10px 20px;
color: rgba(255,255,255,0.85);
font-size: 1.15rem;
flex-shrink: 0;
position: relative;
}
.wbfm-preview .katex { color: #fff; }
.wbfm-prev-hint {
position: absolute; bottom: 4px; right: 10px;
font-size: 0.67rem; color: rgba(255,255,255,0.18);
pointer-events: none;
}
/* category tabs */
.wbfm-tabs {
display: flex; gap: 2px;
padding: 8px 16px 0;
overflow-x: auto; flex-shrink: 0;
scrollbar-width: none;
}
.wbfm-tabs::-webkit-scrollbar { display: none; }
.wbfm-tab {
padding: 5px 13px; border-radius: 8px 8px 0 0;
border: 1px solid rgba(255,255,255,0.08); border-bottom: none;
background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.45);
font-size: 0.75rem; font-family: 'Manrope',sans-serif;
cursor: pointer; transition: all .13s; white-space: nowrap;
flex-shrink: 0;
}
.wbfm-tab:hover { background: rgba(155,93,229,0.14); color: rgba(255,255,255,0.75); }
.wbfm-tab.active {
background: rgba(155,93,229,0.22); color: #c4b5fd;
border-color: rgba(155,93,229,0.45); font-weight: 700;
}
/* button grid */
.wbfm-grid-wrap {
flex: 0 0 auto;
height: 210px;
overflow-y: auto;
padding: 8px 16px 6px;
border-top: 1px solid rgba(155,93,229,0.2);
scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.2) transparent;
}
.wbfm-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 5px;
}
.wbfm-vbtn {
height: 56px; border-radius: 9px;
border: 1.5px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.04);
color: #e8e0f7; cursor: pointer;
transition: all .13s;
display: flex; align-items: center; justify-content: center;
font-size: 0.95rem; padding: 4px 2px;
overflow: hidden; position: relative;
}
.wbfm-vbtn:hover {
background: rgba(155,93,229,0.2); border-color: rgba(155,93,229,0.55);
transform: translateY(-2px);
box-shadow: 0 4px 14px rgba(155,93,229,0.25);
}
.wbfm-vbtn:active { transform: translateY(0); }
.wbfm-vbtn .katex { color: #e8e0f7; font-size: 1em; pointer-events: none; }
.wbfm-vbtn .katex-display { margin: 0 !important; }
/* LaTeX code toggle */
.wbfm-latex-toggle {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 16px 4px; flex-shrink: 0;
}
.wbfm-lt-btn {
background: none; border: none;
color: rgba(255,255,255,0.32); font-size: 0.73rem;
font-family: 'Manrope',sans-serif; cursor: pointer;
display: flex; align-items: center; gap: 5px;
padding: 2px 4px; border-radius: 5px; transition: color .13s;
}
.wbfm-lt-btn:hover { color: rgba(255,255,255,0.6); }
.wbfm-lt-arrow { font-size: 0.65rem; transition: transform .2s; }
.wbfm-lt-arrow.open { transform: rotate(180deg); }
.wbfm-tab-hint {
font-size: 0.67rem; color: rgba(6,214,224,0.45); pointer-events: none;
}
.wbfm-latex-area {
padding: 0 16px 4px; flex-shrink: 0;
}
.wbfm-input {
background: rgba(255,255,255,0.05);
border: 1.5px solid rgba(155,93,229,0.4);
border-radius: 9px; outline: none;
color: #e8e0f7; caret-color: #9B5DE5;
font-size: 0.88rem; font-family: 'JetBrains Mono', 'Courier New', monospace;
padding: 8px 12px; resize: none; width: 100%;
line-height: 1.55; box-sizing: border-box;
transition: border-color .15s;
}
.wbfm-input:focus { border-color: rgba(155,93,229,0.8); }
/* bottom bar */
.wbfm-bottom {
display: flex; align-items: center; gap: 0;
padding: 8px 16px 13px; flex-shrink: 0;
border-top: 1px solid rgba(255,255,255,0.07);
}
.wbfm-size-row {
display: flex; align-items: center; gap: 6px; flex: 1;
font-size: 0.75rem; color: rgba(255,255,255,0.35);
}
.wbfm-sz {
padding: 3px 10px; border-radius: 6px; border: 1.5px solid rgba(255,255,255,0.12);
background: transparent; color: rgba(255,255,255,0.4);
font-size: 0.75rem; cursor: pointer; transition: all .12s;
}
.wbfm-sz.active { border-color: rgba(155,93,229,0.6); background: rgba(155,93,229,0.15); color: #c084fc; }
.wbfm-actions { display: flex; gap: 8px; }
.wbfm-cancel {
padding: 8px 18px; border-radius: 8px; border: 1.5px solid rgba(255,255,255,0.12);
background: transparent; color: rgba(255,255,255,0.5);
font-size: 0.82rem; font-family: 'Manrope',sans-serif;
cursor: pointer; transition: all .12s;
}
.wbfm-cancel:hover { border-color: rgba(255,255,255,0.3); color: #fff; }
.wbfm-insert {
padding: 8px 22px; border-radius: 8px; border: none;
background: linear-gradient(135deg, #9B5DE5, #7B3FC5);
color: #fff; font-size: 0.85rem; font-weight: 700;
font-family: 'Manrope',sans-serif; cursor: pointer;
transition: all .15s; box-shadow: 0 3px 14px rgba(155,93,229,0.4);
}
.wbfm-insert:hover { transform: translateY(-1px); box-shadow: 0 5px 20px rgba(155,93,229,0.55); }
.wbfm-insert:disabled { opacity: 0.4; pointer-events: none; }
/* ── Confirm/Alert dialog ───────────────────────────────────────────── */
.cr-dlg-overlay {
position: fixed; inset: 0; z-index: 9000;
background: rgba(10,7,20,0.72); backdrop-filter: blur(6px);
display: flex; align-items: center; justify-content: center;
padding: 20px;
animation: crDlgFadeIn .15s ease;
}
@keyframes crDlgFadeIn { from { opacity:0 } to { opacity:1 } }
.cr-dlg {
background: linear-gradient(155deg, rgba(30,22,48,0.98), rgba(22,15,38,0.98));
border: 1.5px solid rgba(155,93,229,0.25); border-radius: 18px;
padding: 28px 28px 24px; max-width: 400px; width: 100%;
box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04);
animation: crDlgSlideIn .18s cubic-bezier(.34,1.56,.64,1);
display: flex; flex-direction: column; gap: 0;
}
@keyframes crDlgSlideIn { from { transform: scale(.92) translateY(10px); opacity:0 } to { transform: none; opacity:1 } }
.cr-dlg-icon {
width: 48px; height: 48px; border-radius: 14px; margin-bottom: 16px;
display: flex; align-items: center; justify-content: center;
}
.cr-dlg-icon.danger { background: rgba(241,91,181,0.15); border: 1.5px solid rgba(241,91,181,0.25); }
.cr-dlg-icon.warn { background: rgba(255,159,67,0.15); border: 1.5px solid rgba(255,159,67,0.25); }
.cr-dlg-icon.info { background: rgba(6,214,224,0.12); border: 1.5px solid rgba(6,214,224,0.22); }
.cr-dlg-title {
font-family: 'Unbounded',sans-serif; font-size: 0.95rem; font-weight: 800;
color: #fff; margin: 0 0 8px;
}
.cr-dlg-msg {
font-size: 0.83rem; color: #8898AA; margin: 0 0 24px; line-height: 1.65;
}
.cr-dlg-actions { display: flex; gap: 10px; }
.cr-dlg-cancel {
flex: 1; padding: 10px; border-radius: 10px;
border: 1.5px solid rgba(255,255,255,0.1); background: transparent;
color: #8898AA; font-family: 'Manrope',sans-serif; font-size: 0.85rem;
font-weight: 700; cursor: pointer; transition: all .15s;
}
.cr-dlg-cancel:hover { border-color: rgba(255,255,255,0.28); color: #fff; }
.cr-dlg-ok {
flex: 2; padding: 10px; border-radius: 10px; border: none;
color: #fff; font-family: 'Manrope',sans-serif;
font-size: 0.85rem; font-weight: 700; cursor: pointer; transition: all .15s;
}
.cr-dlg-ok.danger { background: linear-gradient(135deg, #F15BB5, #c43a94); }
.cr-dlg-ok.danger:hover { box-shadow: 0 4px 20px rgba(241,91,181,0.45); transform: translateY(-1px); }
.cr-dlg-ok.warn { background: linear-gradient(135deg, #FF9F43, #e07a20); }
.cr-dlg-ok.warn:hover { box-shadow: 0 4px 20px rgba(255,159,67,0.45); transform: translateY(-1px); }
.cr-dlg-ok.info { background: linear-gradient(135deg, #9B5DE5, #7B3FC5); }
.cr-dlg-ok.info:hover { box-shadow: 0 4px 20px rgba(155,93,229,0.45); transform: translateY(-1px); }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar">
<div class="sb-brand">
<a href="/dashboard" class="sb-logo"><span class="sb-lbl">Learn<span>Space</span></span></a>
<button class="sb-toggle" onclick="toggleSidebar()" title="Свернуть меню"><i data-lucide="chevron-left" class="sb-icon"></i></button>
</div>
<nav class="sb-nav">
<button class="sb-link" onclick="lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
<a href="/dashboard" class="sb-link"><i data-lucide="home" class="sb-icon"></i><span class="sb-lbl">Дашборд</span></a>
<a href="/board" class="sb-link"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></a>
<a href="/classes" class="sb-link" id="btn-classes" style="display:none"><i data-lucide="graduation-cap" class="sb-icon"></i><span class="sb-lbl">Классы</span></a>
<a href="/library" class="sb-link"><i data-lucide="book-open" class="sb-icon"></i><span class="sb-lbl">Библиотека</span></a>
<a href="/theory" class="sb-link"><i data-lucide="brain" class="sb-icon"></i><span class="sb-lbl">Теория</span></a>
<a href="/lab" class="sb-link"><i data-lucide="atom" class="sb-icon"></i><span class="sb-lbl">Лаборатория</span></a>
<a href="/biochem" class="sb-link"><i data-lucide="flask-conical" class="sb-icon"></i><span class="sb-lbl">Биохимия</span></a>
<a href="/hangman" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Виселица</span></a>
<a href="/crossword" class="sb-link"><i data-lucide="grid-3x3" class="sb-icon"></i><span class="sb-lbl">Кроссворд</span></a>
<a href="/pet" class="sb-link"><i data-lucide="heart" class="sb-icon"></i><span class="sb-lbl">Питомец</span></a>
<a href="/collection" class="sb-link"><i data-lucide="layers" class="sb-icon"></i><span class="sb-lbl">Коллекция</span></a>
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link nav-active"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
<a href="/live-quiz" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="radio" class="sb-icon"></i><span class="sb-lbl">Live-квиз</span></a>
<a href="/gradebook" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="table" class="sb-icon"></i><span class="sb-lbl">Журнал</span></a>
<a href="/admin" class="sb-link" id="btn-admin" style="display:none"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></a>
</nav>
<div style="padding: 4px 2px">
<div id="notif-wrap">
<button class="sb-link" id="notif-btn" onclick="toggleNotifDrop()">
<i data-lucide="bell" class="sb-icon"></i><span class="sb-lbl">Уведомления</span>
<span class="sb-badge" id="notif-badge" style="display:none"></span>
</button>
</div>
</div>
<div class="sb-foot">
<a href="/profile" class="sb-user-row" style="text-decoration:none">
<div class="sb-avatar" id="nav-avatar">?</div>
<div class="sb-user-info">
<div class="sb-user-name" id="nav-user"></div>
<span class="sb-logout" style="pointer-events:none">Мой профиль</span>
</div>
</a>
</div>
</aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<!-- Header -->
<div class="cr-header">
<div class="cr-header-dots"></div>
<div class="cr-header-inner">
<div class="cr-title">
<i data-lucide="presentation" style="width:18px;height:18px;opacity:.6;flex-shrink:0"></i>
<span id="cr-session-title">Онлайн-урок</span>
</div>
<div id="cr-session-chip" style="display:none" class="cr-session-chip">
<span class="dot"></span> Идёт урок
</div>
<span class="cr-timer" id="cr-timer" style="display:none">00:00</span>
<div id="cr-header-actions" style="display:none; align-items:center; gap:8px;">
<button class="cr-header-btn" id="cr-mute-btn" onclick="crToggleMute()" style="display:none">
</button>
<button class="cr-header-btn" id="cr-screen-btn" onclick="crToggleScreen()" style="display:none">
<i data-lucide="monitor" style="width:14px;height:14px"></i>
<span>Экран</span>
</button>
<!-- hand raise: student only -->
<button class="cr-hand-btn" id="cr-hand-btn" onclick="crToggleHand()" style="display:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M18 11V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0"/><path d="M14 10V4a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>
<span id="cr-hand-label">Поднять руку</span>
</button>
<button class="cr-header-btn danger" id="cr-end-btn" onclick="crEndSession()" style="display:none">
<i data-lucide="x" style="width:14px;height:14px"></i>
<span>Завершить</span>
</button>
<button class="cr-header-btn" id="cr-leave-btn" onclick="crLeaveSession()" style="display:none">
<i data-lucide="log-out" style="width:14px;height:14px"></i>
<span>Покинуть</span>
</button>
</div>
</div>
</div>
<!-- Body -->
<div class="cr-body">
<!-- Main area -->
<div class="cr-main" id="cr-main">
<!-- idle state (teacher) -->
<div class="cr-idle" id="cr-idle-teacher" style="display:none">
<div class="cr-idle-icon">
<i data-lucide="presentation" style="width:36px;height:36px;stroke:#9B5DE5"></i>
</div>
<h2>Начните онлайн-урок</h2>
<p>Интерактивная доска, голосовой чат и трансляция экрана для вашего класса</p>
<button class="cr-start-btn" onclick="crOpenStartModal()">
<i data-lucide="play" style="width:16px;height:16px"></i>
Начать урок
</button>
</div>
<!-- idle state (student) -->
<div class="cr-idle" id="cr-idle-student" style="display:none">
<div class="cr-idle-icon">
<i data-lucide="clock" style="width:36px;height:36px;stroke:#06D6E0"></i>
</div>
<h2>Ожидание урока</h2>
<p>Когда учитель начнёт урок — вы получите уведомление</p>
</div>
<!-- join banner (student got notification) -->
<div id="cr-join-banner" style="display:none">
<div class="cr-join-banner">
<i data-lucide="presentation" style="width:32px;height:32px;stroke:#9B5DE5"></i>
<h3 id="cr-join-title">Урок начался!</h3>
<p id="cr-join-sub">Нажмите кнопку чтобы войти</p>
<button class="cr-join-btn" onclick="crJoinSession()">Войти в урок</button>
</div>
</div>
<!-- active session main — whiteboard -->
<div class="cr-active-main" id="cr-active-main" style="display:none">
<!-- board area: thumbs panel + board wrap side by side -->
<div class="cr-board-area" id="cr-board-area">
<!-- thumbnail panel (teacher only) -->
<div class="wb-thumbs-panel" id="wb-thumbs-panel" style="display:none">
<div id="wb-thumbs-list"></div>
<div class="wb-thumbs-add">
<button class="wb-thumbs-add-btn" onclick="wbAddPage()" title="Добавить страницу">+</button>
</div>
</div>
<div class="cr-board-wrap" id="cr-board-wrap">
<canvas id="cr-canvas"></canvas>
<video id="cr-screen-video" autoplay playsinline></video>
<div class="cr-screen-label" id="cr-screen-label">Трансляция экрана</div>
<div class="cr-board-readonly" id="cr-board-readonly" style="display:none">Только просмотр</div>
<!-- Student status notification bar -->
<div id="cr-status-bar"></div>
<!-- Teacher cursor overlay (students see teacher's mouse) -->
<div id="cr-teacher-cursor" style="display:none;"></div>
</div>
</div><!-- /.cr-board-area -->
<!-- student nav: page nav + follow toggle -->
<div class="cr-student-nav" id="cr-student-nav" style="display:none">
<button class="cr-tool-btn" onclick="wbStudentPrevPage()" title="Предыдущая страница">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<span class="cr-page-label" id="wb-student-page-label">1/1</span>
<button class="cr-tool-btn" onclick="wbStudentNextPage()" title="Следующая страница">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
<button class="cr-follow-btn active" id="cr-follow-btn" onclick="crToggleFollow()" title="Следовать за учителем">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
<span id="cr-follow-label">Авто</span>
</button>
</div>
<!-- toolbar: teacher only -->
<div class="cr-toolbar" id="cr-toolbar" style="display:none">
<!-- ── ROW 1: drawing tools ─────────────────────────────────── -->
<div class="cr-tb-row">
<!-- select -->
<button class="cr-tool-btn" id="wb-tool-select" onclick="wbSetTool('select')" title="Выделить / переместить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3l14 9-7 1-4 7L5 3z"/></svg>
</button>
<div class="cr-tool-sep"></div>
<!-- draw -->
<button class="cr-tool-btn active" id="wb-tool-pencil" onclick="wbSetTool('pencil')" title="Карандаш">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
</button>
<button class="cr-tool-btn" id="wb-tool-highlighter" onclick="wbSetTool('highlighter')" title="Маркер">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l-6 6v3h3l6-6"/><path d="m22 2-3 3-4-4 3-3 4 4zm-7 3 4 4"/></svg>
</button>
<button class="cr-tool-btn" id="wb-tool-laser" onclick="wbSetTool('laser')" title="Лазер">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/></svg>
</button>
<button class="cr-tool-btn" id="wb-tool-eraser" onclick="wbSetTool('eraser')" title="Ластик">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20H7L3 16l11-11 7 7-1 8z"/><path d="M6.0001 13.9999 10 18"/></svg>
</button>
<div class="cr-tool-sep"></div>
<!-- shapes -->
<button class="cr-tool-btn" id="wb-tool-rect" onclick="wbSetTool('rect')" title="Прямоугольник"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-ellipse" onclick="wbSetTool('ellipse')" title="Эллипс"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="12" rx="10" ry="6"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-line" onclick="wbSetTool('line')" title="Линия"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="20" x2="20" y2="4"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-arrow" onclick="wbSetTool('arrow')" title="Стрелка"><svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M3 11h12.5l-4-4 1.5-1.5 6 6-6 6L11.5 16 15.5 12H3z"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-triangle" onclick="wbSetTool('triangle')" title="Треугольник"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 3 22 21 2 21"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-diamond" onclick="wbSetTool('diamond')" title="Ромб"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 22 12 12 22 2 12"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-hexagon" onclick="wbSetTool('hexagon')" title="Шестиугольник"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 20.66 7 20.66 17 12 22 3.34 17 3.34 7"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-star" onclick="wbSetTool('star')" title="Звезда"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-roundedrect" onclick="wbSetTool('roundedrect')" title="Скруглённый прямоугольник"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="5"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-callout" onclick="wbSetTool('callout')" title="Облачко"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
<button class="cr-tool-btn" id="wb-fill-btn" onclick="wbToggleFill(this)" title="Заливка"><svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M16.56 8.94L7.62 0 6.21 1.41l2.38 2.38-5.15 5.15a1.49 1.49 0 0 0 0 2.12l5.5 5.5c.29.29.68.44 1.06.44s.77-.15 1.06-.44l5.5-5.5c.59-.58.59-1.53 0-2.12zM5.21 10 10 5.21 14.79 10H5.21zM19 11.5s-2 2.17-2 3.5c0 1.1.9 2 2 2s2-.9 2-2c0-1.33-2-3.5-2-3.5z"/></svg></button>
<div class="cr-tool-sep"></div>
<!-- objects -->
<button class="cr-tool-btn" id="wb-tool-connector" onclick="wbSetTool('connector')" title="Коннектор"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-sticky" onclick="wbSetTool('sticky')" title="Стикер"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8z"/><polyline points="16 3 16 8 21 8"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-formula" onclick="wbSetTool('formula')" title="Формула LaTeX"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19c2 0 3-1 3-3V8c0-2 1-3 3-3"/><path d="M20 19c-2 0-3-1-3-3V8c0-2-1-3-3-3"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-table" onclick="wbSetTool('table')" title="Таблица"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-coordinate" onclick="wbSetTool('coordinate')" title="Система координат"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="3" x2="12" y2="21"/><polyline points="18 8 21 12 18 16"/><polyline points="8 18 12 21 16 18"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-numberline" onclick="wbSetTool('numberline')" title="Числовая ось"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="22" y2="12"/><polyline points="18 8 22 12 18 16"/><line x1="6" y1="9" x2="6" y2="15"/><line x1="10" y1="10" x2="10" y2="14"/><line x1="14" y1="10" x2="14" y2="14"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-compass" onclick="wbSetTool('compass')" title="Циркуль"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v8"/><path d="m8.93 12.36-5.2 9"/><path d="m15.07 12.36 5.2 9"/><circle cx="12" cy="10" r="2"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-text" onclick="wbSetTool('text')" title="Текст"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg></button>
<button class="cr-tool-btn" id="wb-tool-image" onclick="wbOpenImagePicker()" title="Изображение"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg></button>
<input type="file" id="wb-image-input" accept="image/*" style="display:none" onchange="wbImageSelected(this)">
</div>
<!-- ── ROW 2: options + actions ─────────────────────────────── -->
<div class="cr-tb-row">
<!-- colors (12 + custom) -->
<button class="cr-color-btn active" data-color="#ffffff" style="background:#ffffff" onclick="wbSetColor(this)" title="Белый"></button>
<button class="cr-color-btn" data-color="#e8e0f7" style="background:#e8e0f7" onclick="wbSetColor(this)" title="Лаванда"></button>
<button class="cr-color-btn" data-color="#FFE066" style="background:#FFE066" onclick="wbSetColor(this)" title="Жёлтый"></button>
<button class="cr-color-btn" data-color="#06D6E0" style="background:#06D6E0" onclick="wbSetColor(this)" title="Голубой"></button>
<button class="cr-color-btn" data-color="#FF6B6B" style="background:#FF6B6B" onclick="wbSetColor(this)" title="Красный"></button>
<button class="cr-color-btn" data-color="#4361EE" style="background:#4361EE" onclick="wbSetColor(this)" title="Синий"></button>
<button class="cr-color-btn" data-color="#A8E063" style="background:#A8E063" onclick="wbSetColor(this)" title="Зелёный"></button>
<button class="cr-color-btn" data-color="#F15BB5" style="background:#F15BB5" onclick="wbSetColor(this)" title="Розовый"></button>
<button class="cr-color-btn" data-color="#9B5DE5" style="background:#9B5DE5" onclick="wbSetColor(this)" title="Фиолетовый"></button>
<button class="cr-color-btn" data-color="#FF9F43" style="background:#FF9F43" onclick="wbSetColor(this)" title="Оранжевый"></button>
<button class="cr-color-btn" data-color="#8B6F47" style="background:#8B6F47" onclick="wbSetColor(this)" title="Коричневый"></button>
<button class="cr-color-btn" data-color="#9CA3AF" style="background:#9CA3AF" onclick="wbSetColor(this)" title="Серый"></button>
<input type="color" class="cr-color-custom" id="wb-color-custom" value="#ffffff"
onchange="wbSetCustomColor(this)" title="Свой цвет" />
<div class="cr-tool-sep"></div>
<!-- widths -->
<button class="cr-width-btn" id="wb-w1" onclick="wbSetWidth(1,this)" title="1px"><span class="cr-width-dot" style="width:2px;height:2px"></span></button>
<button class="cr-width-btn" id="wb-w2" onclick="wbSetWidth(2,this)" title="2px"><span class="cr-width-dot" style="width:3px;height:3px"></span></button>
<button class="cr-width-btn active" id="wb-w4" onclick="wbSetWidth(4,this)" title="4px"><span class="cr-width-dot" style="width:6px;height:6px"></span></button>
<button class="cr-width-btn" id="wb-w8" onclick="wbSetWidth(8,this)" title="8px"><span class="cr-width-dot" style="width:11px;height:11px"></span></button>
<button class="cr-width-btn" id="wb-w16" onclick="wbSetWidth(16,this)" title="16px"><span class="cr-width-dot" style="width:16px;height:16px"></span></button>
<div class="cr-tool-sep"></div>
<!-- line styles -->
<button class="cr-linestyle-btn active" id="wb-ls-solid" onclick="wbSetLineStyle('solid',this)" title="Сплошная"></button>
<button class="cr-linestyle-btn" id="wb-ls-dashed" onclick="wbSetLineStyle('dashed',this)" title="Пунктир"></button>
<button class="cr-linestyle-btn" id="wb-ls-dotted" onclick="wbSetLineStyle('dotted',this)" title="Точки">···</button>
<div class="cr-tool-sep"></div>
<!-- opacity -->
<div class="cr-opacity-row">
<label>α</label>
<input type="range" id="wb-opacity-slider" min="10" max="100" value="100"
oninput="wbSetOpacity(this.value)" title="Непрозрачность">
</div>
<div class="cr-tool-sep"></div>
<!-- object actions -->
<button class="cr-tool-btn" onclick="wbCopySelected()" title="Копировать (Ctrl+C)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
<button class="cr-tool-btn" onclick="wbPasteSelected()" title="Вставить (Ctrl+V)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/></svg></button>
<button class="cr-tool-btn" onclick="wbDeleteSelected()" title="Удалить (Del)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
<button class="cr-tool-btn" onclick="wbBringToFront()" title="На перед"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M4 16V4h12" opacity=".4"/></svg></button>
<button class="cr-tool-btn" onclick="wbSendToBack()" title="Назад"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="12" height="12" rx="2"/><path d="M8 16v4h12V8h-4" opacity=".4"/></svg></button>
<div class="cr-tool-sep"></div>
<!-- undo / redo / clear / fullscreen / export / overlays -->
<button class="cr-tool-btn" onclick="wbUndo()" title="Отменить (Ctrl+Z)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg></button>
<button class="cr-tool-btn" onclick="wbRedo()" title="Повторить (Ctrl+Y)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"/></svg></button>
<button class="cr-tool-btn" onclick="wbClear()" title="Очистить страницу"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
<button class="cr-tool-btn" onclick="wbToggleFullscreen()" title="Полный экран"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg></button>
<button class="cr-tool-btn" id="wb-btn-measurements" onclick="wbToggleMeasurements(this)" title="Авто-измерения фигур"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 6H3"/><path d="M10 12H3"/><path d="M10 18H3"/><polyline points="15 12 18 15 21 12"/></svg></button>
<button class="cr-tool-btn" id="wb-btn-ruler" onclick="wbToggleRuler()" title="Линейка"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="8" width="20" height="8" rx="1"/><line x1="6" y1="12" x2="6" y2="16"/><line x1="10" y1="12" x2="10" y2="16"/><line x1="14" y1="12" x2="14" y2="16"/><line x1="18" y1="12" x2="18" y2="16"/></svg></button>
<button class="cr-tool-btn" id="wb-btn-protractor" onclick="wbToggleProtractor()" title="Транспортир"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 20 A9 9 0 0 1 21 20"/><line x1="12" y1="11" x2="12" y2="20"/><line x1="3" y1="20" x2="21" y2="20"/></svg></button>
<!-- overlay properties (shown when ruler/protractor is active & clicked) -->
<div class="wb-ov-props" id="wb-ov-props">
<span class="wb-ov-props-type" id="wb-ov-type">Линейка</span>
<label>Угол°<input type="number" id="wb-ov-angle" min="-360" max="360" step="1" oninput="wbOvApply()"></label>
<label id="wb-ov-size-lbl">Длина<input type="number" id="wb-ov-size" min="50" max="1800" step="10" oninput="wbOvApply()"></label>
<button class="cr-tool-btn" onclick="wbOvHide()" title="Скрыть свойства" style="width:20px;height:20px;font-size:10px;padding:0"></button>
</div>
<button class="cr-tool-btn" onclick="_wb && _wb.exportPNG()" title="Экспорт PNG"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
<button class="cr-tool-btn" id="btn-templates" onclick="crShowTemplates()" title="Шаблоны урока" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg></button>
<div class="cr-tool-sep"></div>
<!-- zoom controls -->
<div class="wb-zoom-row">
<button class="cr-tool-btn" onclick="wbZoomIn()" title="Приблизить (Ctrl+)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg></button>
<span class="wb-zoom-label" id="wb-zoom-label" onclick="wbResetZoom()" title="Сбросить масштаб (Ctrl+0)" style="cursor:pointer">100%</span>
<button class="cr-tool-btn" onclick="wbZoomOut()" title="Отдалить (Ctrl-)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg></button>
</div>
<div class="cr-tool-sep"></div>
<!-- template picker -->
<div class="wb-tpl-picker">
<label>Шаблон:</label>
<select id="wb-tpl-select" onchange="wbSetPageTemplate(this.value)" title="Шаблон страницы">
<option value="blank">Чистая</option>
<option value="grid">Сетка</option>
<option value="lined">Линейки</option>
<option value="dots">Точки</option>
<option value="coordinate">Оси</option>
</select>
</div>
<!-- page nav pushed to right -->
<div class="cr-page-nav" style="margin-left:auto">
<button class="cr-tool-btn" onclick="wbPrevPage()" title="Предыдущая страница"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg></button>
<span class="cr-page-label" id="wb-page-label">1/1</span>
<button class="cr-tool-btn" onclick="wbNextPage()" title="Следующая страница"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></button>
<button class="cr-tool-btn" onclick="wbAddPage()" title="Добавить страницу"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
</div>
</div>
</div>
</div>
</div>
<!-- Right panel -->
<div class="cr-right">
<div class="cr-panel-tabs">
<button class="cr-tab active" id="tab-participants" onclick="crSwitchTab('participants')">
<i data-lucide="users" style="width:13px;height:13px"></i>
Участники
<span class="cr-tab-badge" id="participants-count">0</span>
</button>
<button class="cr-tab" id="tab-chat" onclick="crSwitchTab('chat')">
<i data-lucide="message-circle" style="width:13px;height:13px"></i>
Чат
<span class="cr-tab-badge" id="chat-unread" style="display:none">0</span>
</button>
<button class="cr-tab" id="tab-notes" onclick="crSwitchTab('notes')">
<i data-lucide="notebook-pen" style="width:13px;height:13px"></i>
Заметки
</button>
</div>
<!-- Participants panel -->
<div id="panel-participants" style="flex:1;overflow-y:auto;display:flex;flex-direction:column;">
<!-- raised hands (teacher only) -->
<div id="cr-hands-section" class="cr-hands-section" style="display:none">
<div class="cr-hands-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0"/><path d="M14 10V4a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>
Поднятые руки
</div>
<div id="cr-hands-list"></div>
</div>
<div class="cr-participants" id="cr-participants-list">
<div class="cr-no-session" id="participants-no-session">
<i data-lucide="users" style="width:32px;height:32px;stroke:#8898AA"></i>
<p>Участники появятся когда урок начнётся</p>
</div>
</div>
</div>
<!-- Notes panel -->
<div id="panel-notes" style="display:none; flex:1; flex-direction:column; overflow:hidden;">
<div class="cr-notes-panel">
<div class="cr-notes-header">
<span class="cr-notes-label">Личные заметки (видны только вам)</span>
<span class="cr-notes-status" id="notes-status"></span>
</div>
<textarea class="cr-notes-ta" id="cr-notes-ta"
placeholder="Пишите заметки по уроку…"
oninput="crNotesOnInput()"></textarea>
</div>
</div>
<!-- Chat panel -->
<div id="panel-chat" style="display:none; flex:1; flex-direction:column; overflow:hidden;">
<div class="cr-no-session" id="chat-no-session">
<i data-lucide="message-circle" style="width:32px;height:32px;stroke:#8898AA"></i>
<p>Чат доступен во время урока</p>
</div>
<div class="cr-chat-wrap" id="chat-active" style="display:none">
<div class="cr-messages" id="cr-messages"></div>
<!-- attach preview -->
<div class="cr-chat-attach-preview" id="cr-attach-preview">
<img id="cr-attach-thumb" class="cr-attach-thumb" src="" alt="">
<span class="cr-attach-name" id="cr-attach-name"></span>
<button class="cr-attach-rm" onclick="crClearAttach()" title="Удалить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="cr-chat-input-wrap">
<!-- file attach button -->
<button class="cr-attach-btn" onclick="document.getElementById('cr-file-input').click()" title="Прикрепить изображение">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 0 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</button>
<input type="file" id="cr-file-input" accept="image/*" style="display:none" onchange="crAttachFile(this)">
<textarea class="cr-chat-input" id="cr-chat-input" placeholder="Сообщение..." rows="1"
onkeydown="crChatKeyDown(event)"></textarea>
<button class="cr-chat-send" onclick="crSendChat()">
<i data-lucide="send" style="width:14px;height:14px"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Start Session Modal -->
<div class="cr-modal-overlay" id="cr-modal">
<div class="cr-modal">
<div class="cr-modal-title">Начать онлайн-урок</div>
<div class="cr-modal-sub">Выберите аудиторию и введите тему</div>
<div class="cr-mode-tabs">
<button class="cr-mode-tab active" id="mode-class" onclick="crSetMode('class')">
<i data-lucide="graduation-cap" style="width:13px;height:13px;display:inline;vertical-align:middle;margin-right:4px"></i>
Класс
</button>
<button class="cr-mode-tab" id="mode-personal" onclick="crSetMode('personal')">
<i data-lucide="user" style="width:13px;height:13px;display:inline;vertical-align:middle;margin-right:4px"></i>
Личная сессия
</button>
</div>
<div class="cr-field">
<label class="cr-label">Тема урока (необязательно)</label>
<input type="text" class="cr-input" id="cr-title-input" placeholder="Например: Тема 5 — Фотосинтез" maxlength="100" />
</div>
<!-- class mode -->
<div id="field-class" class="cr-field">
<label class="cr-label">Класс</label>
<select class="cr-select" id="cr-class-select">
<option value="">Выберите класс...</option>
</select>
</div>
<!-- personal mode -->
<div id="field-personal" class="cr-field" style="display:none">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<label class="cr-label" style="margin:0">Онлайн-ученики</label>
<button class="cr-online-refresh" onclick="crLoadOnlineStudents()">
<i data-lucide="refresh-cw" style="width:11px;height:11px"></i>Обновить
</button>
</div>
<div class="cr-online-list" id="cr-online-list">
<div class="cr-online-empty">Загрузка...</div>
</div>
<div class="cr-selected-users" id="cr-selected-users"></div>
</div>
<div class="cr-modal-actions">
<button class="cr-modal-cancel" onclick="crCloseStartModal()">Отмена</button>
<button class="cr-modal-confirm" id="cr-confirm-btn" onclick="crStartSession()">
<i data-lucide="play" style="width:14px;height:14px;display:inline;vertical-align:middle;margin-right:4px"></i>
Начать урок
</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" crossorigin="anonymous" />
<script src="/js/whiteboard.js"></script>
<script src="/js/classroom-rtc.js"></script>
<script src="/js/api.js"></script>
<script>
/* ── state ── */
let _me = null;
let _session = null;
let _sessionId = null;
let _sseHandle = null;
let _participants = {}; // userId -> {name, micMuted}
let _raisedHands = {}; // userId -> userName
let _handRaised = false;// student: own hand state
let _totalPages = 1; // total page count for current session
let _followTeacher = true; // student: follow teacher's page
let _mode = 'class';
let _selectedUsers = []; // [{id, name}] for personal session
/* ── whiteboard state ── */
let _wb = null; // Whiteboard instance
let _wbBatch = []; // strokes pending send
let _wbBatchTimer = null; // flush timer
let _wbCurrentPage = 1;
let _wbInitializing = false; // true while loading initial strokes from server
let _wbPendingSSE = []; // SSE strokes buffered during initialization
const _wbOwnIds = new Set(); // server-assigned IDs of our own sent strokes (to skip in SSE)
/* ── WebRTC state ── */
let _rtc = null; // ClassroomRTC instance
/* ── UI state ── */
let _chatUnread = 0; // unread messages (when chat tab not active)
let _activeTab = 'participants'; // current right panel tab
let _timerHandle = null; // session timer interval
let _sessionStartTime = null; // Date when session was created
/* ── polling ── */
let _pollPart = null; // setInterval handle (participants backup)
let _pollChatTimer = null; // setInterval handle (chat backup poll)
let _lastChatId = 0; // last rendered message id
const _renderedMsgIds = new Set(); // dedup
/* ── live drawing ── */
let _wbLiveTimer = null; // throttle timer for stroke-preview POST
let _wbMaxSeq = 0; // highest seq received (for polling fallback)
let _wbClearGen = 0; // incremented on clear/page-switch to discard stale poll responses
let _strokePollTimer = null;// polling interval for student stroke fallback
let _teacherPollTimer = null;// incremental stroke poll for teacher (SSE fallback)
let _drawPermPollTimer = null;// periodic draw permission check for student
/* ── teacher cursor ── */
let _cursorThrottle = null; // setTimeout handle for cursor POST throttle
let _cursorHideTimer = null;// setTimeout to hide cursor after inactivity
/* ── collaborative drawing ── */
let _canDraw = false; // student: has teacher granted draw permission?
const _permittedStudents = new Set(); // teacher: set of student user IDs with draw permission
/** Enable draw permission for the current student (shared logic for SSE + REST) */
function enableDrawPermission(silent) {
if (_canDraw) return; // already enabled
_canDraw = true;
if (_wb) {
_wb.setReadOnly(false);
_wb._onStrokeDone = wbOnLocalStroke;
_wb._onStrokeUndo = wbOnLocalUndo;
_wb._onStrokeProgress = wbOnStrokeProgress;
_wb._onStrokeUpdated = wbOnLocalStrokeUpdated;
_wb._onFormulaInsert = showFormulaModal;
_wb._onCoordEdit = showCoordModal;
_wb._onNumberLineEdit = showNumLineModal;
_wb._onObjectCreated = () => wbSetTool('select');
}
document.getElementById('cr-toolbar').style.display = 'flex';
document.getElementById('cr-board-readonly').style.display = 'none';
if (!_wbBatchTimer) _wbBatchTimer = setInterval(wbFlushBatch, 80);
// Stop the 2s full-replace poll, switch to full-sync-with-local-preservation poll.
wbStopPoll();
if (!_teacherPollTimer) _teacherPollTimer = setInterval(_drawingStudentSyncPoll, 2000);
if (!silent) crStatusNotify('success',
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>`,
'Вам разрешено рисовать');
}
/** Disable draw permission for the current student */
function disableDrawPermission() {
if (!_canDraw) return; // already disabled
_canDraw = false;
if (_wb) {
_wb.setReadOnly(true);
_wb._onStrokeDone = null;
_wb._onStrokeUndo = null;
_wb._onStrokeProgress = null;
_wb._onStrokeUpdated = null;
_wb._onFormulaInsert = null;
_wb._onCoordEdit = null;
_wb._onObjectCreated = null;
}
document.getElementById('cr-toolbar').style.display = 'none';
document.getElementById('cr-board-readonly').style.display = 'block';
if (_wbBatchTimer) { clearInterval(_wbBatchTimer); _wbBatchTimer = null; }
if (_teacherPollTimer){ clearInterval(_teacherPollTimer);_teacherPollTimer= null; }
// Resume the 2s full-replace poll so the student sees teacher strokes
wbStartPoll();
crStatusNotify('warn',
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>`,
'Рисование запрещено');
}
/* ── Student status notification bar ── */
const _statusTimers = new Map();
function crStatusNotify(type, iconSvg, text, duration = 4000) {
const isStudent = _me?.role !== 'teacher' && _me?.role !== 'admin';
if (!isStudent) return;
const bar = document.getElementById('cr-status-bar');
if (!bar) return;
const id = type + '_' + text; // deduplicate same message
// remove old same message if exists
const old = bar.querySelector(`[data-sid="${CSS.escape(id)}"]`);
if (old) { clearTimeout(_statusTimers.get(id)); old.remove(); }
const el = document.createElement('div');
el.className = `cr-status-msg type-${type}`;
el.dataset.sid = id;
el.innerHTML = iconSvg + `<span>${text}</span>`;
bar.appendChild(el);
const timer = setTimeout(() => {
el.classList.add('cr-status-out');
el.addEventListener('animationend', () => el.remove(), { once: true });
_statusTimers.delete(id);
}, duration);
_statusTimers.set(id, timer);
}
function startPolling() {
stopPolling();
_pollPart = setInterval(pollParticipants, 10_000); // participants backup (10s)
_pollChatTimer = setInterval(_pollChat, 15_000); // chat backup (15s)
}
function stopPolling() {
clearInterval(_pollPart); _pollPart = null;
clearInterval(_pollChatTimer); _pollChatTimer = null;
}
async function _pollChat() {
if (!_sessionId) return;
try {
const data = await LS.get(`/api/classroom/${_sessionId}/chat?since_id=${_lastChatId}`);
(data.messages || []).forEach(m => appendChatMessage({
id: m.id, userId: m.user_id, userName: m.user_name,
message: m.message, createdAt: m.created_at, pinned: !!m.pinned,
attachmentUrl: m.attachment_url, attachmentType: m.attachment_type,
reactions: m.reactions || {},
}));
} catch {}
}
async function pollParticipants() {
if (!_sessionId) return;
try {
const data = await LS.get(`/api/classroom/${_sessionId}/participants`);
const fresh = {};
for (const p of (data.participants || [])) {
fresh[p.user_id] = { name: p.name, micMuted: _participants[p.user_id]?.micMuted || false };
}
_participants = fresh;
updateParticipantsList();
} catch {}
}
/* ── cursor colors per user ── */
const _CURSOR_COLORS = ['#06D6E0','#FF6B6B','#A8E063','#FF9F43','#F15BB5','#4361EE','#FFE066','#9B5DE5'];
function _getUserColor(userId) {
return _CURSOR_COLORS[Math.abs(userId) % _CURSOR_COLORS.length];
}
/* ── init ── */
/* ── Universal confirm dialog ─────────────────────────────────────── */
let _dlgResolve = null;
function crConfirm(title, msg, { okText = 'Подтвердить', type = 'danger', iconSvg = null } = {}) {
return new Promise(resolve => {
const overlay = document.getElementById('cr-dlg');
const iconEl = document.getElementById('cr-dlg-icon');
const titleEl = document.getElementById('cr-dlg-title');
const msgEl = document.getElementById('cr-dlg-msg');
const okBtn = document.getElementById('cr-dlg-ok');
const cancelBtn = document.getElementById('cr-dlg-cancel');
titleEl.textContent = title;
msgEl.textContent = msg;
okBtn.textContent = okText;
okBtn.className = `cr-dlg-ok ${type}`;
iconEl.className = `cr-dlg-icon ${type}`;
const icons = {
danger: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:22px;height:22px;stroke:#F15BB5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><circle cx="12" cy="16" r=".5" fill="currentColor"/></svg>`,
warn: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:22px;height:22px;stroke:#FF9F43"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><circle cx="12" cy="17" r=".5" fill="currentColor"/></svg>`,
info: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:22px;height:22px;stroke:#06D6E0"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><circle cx="12" cy="8" r=".5" fill="currentColor"/></svg>`,
};
iconEl.innerHTML = iconSvg || icons[type] || icons.info;
if (_dlgResolve) _dlgResolve(false); // dismiss any previous dialog
_dlgResolve = resolve;
overlay.style.display = 'flex';
const onOk = () => { overlay.style.display = 'none'; _dlgResolve = null; resolve(true); cleanup(); };
const onCancel = () => { overlay.style.display = 'none'; _dlgResolve = null; resolve(false); cleanup(); };
const onKey = (e) => { if (e.key === 'Escape') onCancel(); if (e.key === 'Enter') onOk(); };
function cleanup() {
okBtn.removeEventListener('click', onOk);
cancelBtn.removeEventListener('click', onCancel);
document.removeEventListener('keydown', onKey);
}
okBtn.addEventListener('click', onOk, { once: true });
cancelBtn.addEventListener('click', onCancel, { once: true });
document.addEventListener('keydown', onKey, { once: true });
});
}
async function init() {
if (!LS.isLoggedIn()) { window.location.href = '/login'; return; }
_me = LS.getUser();
if (!_me) {
try { _me = await LS.get('/api/auth/me'); LS.setUser(_me); } catch { window.location.href = '/login'; return; }
}
// setup nav
const initials = (_me.name || 'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('') || 'LS';
document.getElementById('nav-avatar').textContent = initials;
document.getElementById('nav-user').textContent = _me.name || _me.email;
if (_me.role === 'admin') document.getElementById('btn-admin').style.display = '';
if (['teacher', 'admin'].includes(_me.role)) {
document.querySelectorAll('.sb-teacher-only').forEach(el => el.style.display = '');
document.getElementById('btn-classes').style.display = '';
}
if (window.lucide) lucide.createIcons();
// sidebar collapse
if (localStorage.getItem('ls_sb_collapsed') === '1') {
document.querySelector('.app-layout')?.classList.add('sb-collapsed');
}
// SSE for classroom events
_sseHandle = LS.connectSSE(handleSSE);
// check for active session
await checkActiveSession();
// load classes for modal (teacher only)
if (_me.role === 'teacher' || _me.role === 'admin') {
loadClassesForModal();
}
}
async function checkActiveSession() {
const isTeacher = _me.role === 'teacher' || _me.role === 'admin';
try {
const data = await LS.get('/api/classroom/my/session');
if (data.session) {
_session = data.session;
_sessionId = data.session.id;
if (isTeacher) {
// Teacher: always reconnect — rejoin to refresh attendance
await LS.post(`/api/classroom/${_sessionId}/join`).catch(() => {});
enterActiveState(data.session);
loadChat();
return;
} else {
// Student: auto-reconnect if was already in session, else show banner
if (data.wasJoined) {
const joinRes = await LS.post(`/api/classroom/${_sessionId}/join`).catch(() => ({}));
const fresh = await LS.get(`/api/classroom/${_sessionId}`).catch(() => data.session);
_session = fresh;
// Set _canDraw BEFORE enterActiveState so initWhiteboard sees it
if (fresh.canDraw || joinRes.canDraw) _canDraw = true;
enterActiveState(fresh);
loadChat();
// Apply toolbar/readonly state if draw was pre-set
if (_canDraw) enableDrawPermission(true);
} else {
showJoinBanner(data.session);
}
return;
}
}
} catch {}
// No active session — show idle state
if (isTeacher) {
document.getElementById('cr-idle-teacher').style.display = 'flex';
} else {
document.getElementById('cr-idle-student').style.display = 'flex';
}
}
function showJoinBanner(session) {
_session = session;
_sessionId = session.id;
// Hide board area if it was left visible from a previous session
document.getElementById('cr-active-main').style.display = 'none';
document.getElementById('cr-idle-teacher').style.display = 'none';
document.getElementById('cr-idle-student').style.display = 'none';
document.getElementById('cr-join-banner').style.display = 'flex';
document.getElementById('cr-join-title').textContent = session.title || 'Урок начался!';
document.getElementById('cr-join-sub').textContent = `Нажмите чтобы войти`;
}
/* ── SSE handler ── */
/* eslint-disable eqeqeq */
function handleSSE(data) {
// Use == for sessionId comparison throughout — guards against string/number mismatch
// when _sessionId comes from JSON (number) vs SSE data (could be string in edge cases).
if (data.type === 'classroom_started') {
onClassroomStarted(data);
} else if (data.type === 'classroom_ended') {
if (_sessionId == data.sessionId) onClassroomEnded();
} else if (data.type === 'classroom_user_joined') {
if (_sessionId == data.sessionId) onUserJoined(data);
} else if (data.type === 'classroom_user_left') {
if (_sessionId == data.sessionId) onUserLeft(data);
} else if (data.type === 'classroom_chat') {
if (_sessionId == data.sessionId) appendChatMessage(data);
} else if (data.type === 'classroom_strokes') {
if (_sessionId == data.sessionId && _wb && data.pageNum == _wbCurrentPage) {
// Skip our own strokes — server now includes userId.
// _wbOwnIds is a backup for strokes already confirmed locally.
if (data.userId == _me?.id) return;
const incoming = (data.strokes || []).filter(s => !_wbOwnIds.has(s.id));
// Confirmed strokes replace live preview
if (incoming.length > 0) _wb.clearAllLiveStrokes();
if (incoming.length === 0) return;
if (_wbInitializing) {
// Buffer until loadStrokes completes to avoid race condition
_wbPendingSSE.push(...incoming);
} else {
_wbUpdateMaxSeq(incoming);
_wb.addStrokes(incoming);
}
}
} else if (data.type === 'classroom_stroke_preview') {
if (_sessionId == data.sessionId && _wb && data.pageNum == _wbCurrentPage) {
// Skip our own preview echo — we already render it locally via _curPts/_shapeEnd
if (data.userId == _me?.id) return;
if (data.cancel) {
_wb.removeLiveStroke(data.liveId);
} else {
// Assign stable color per userId
const color = _getUserColor(data.userId);
_wb.setLiveStroke(data.liveId, data.tool, data.data, data.userName, color);
}
}
} else if (data.type === 'classroom_stroke_deleted') {
if (_sessionId == data.sessionId && _wb) _wb.removeStroke(data.strokeId);
} else if (data.type === 'classroom_stroke_updated') {
if (_sessionId == data.sessionId && _wb && data.pageNum == _wbCurrentPage)
_wb.updateStroke(data.strokeId, data.data);
} else if (data.type === 'classroom_page_added') {
if (_sessionId == data.sessionId) onPageAdded(data.pageNum);
} else if (data.type === 'classroom_page_changed') {
if (_sessionId == data.sessionId) onPageChanged(data.pageNum);
} else if (data.type === 'classroom_template_changed') {
if (_sessionId == data.sessionId && _wb && data.pageNum == _wbCurrentPage) {
_wb.setTemplate(data.template);
const sel = document.getElementById('wb-tpl-select');
if (sel) sel.value = data.template;
wbUpdateThumbnail(_wbCurrentPage);
}
} else if (data.type === 'classroom_hand_raised') {
if (_sessionId == data.sessionId) onHandRaised(data.userId, data.userName);
} else if (data.type === 'classroom_hand_lowered') {
if (_sessionId == data.sessionId) onHandLowered(data.userId);
} else if (data.type === 'classroom_signal') {
if (_rtc && _sessionId == data.sessionId) _rtc.handleSignal(data.from, data.payload);
} else if (data.type === 'classroom_screen_started') {
if (_sessionId == data.sessionId) {
document.getElementById('cr-screen-label').style.display = 'block';
}
} else if (data.type === 'classroom_screen_stopped') {
if (_sessionId == data.sessionId) {
const video = document.getElementById('cr-screen-video');
video.srcObject = null; video.style.display = 'none';
document.getElementById('cr-screen-label').style.display = 'none';
document.getElementById('cr-screen-btn').classList.remove('cr-btn-sharing');
}
} else if (data.type === 'classroom_muted') {
if (_sessionId == data.sessionId && _rtc) {
_rtc.forceMute();
if (_participants[_me.id]) _participants[_me.id].micMuted = true;
updateParticipantsList();
updateMuteBtn();
crStatusNotify('warn',
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`,
'Учитель выключил ваш микрофон');
}
} else if (data.type === 'classroom_draw_permitted') {
if (_sessionId == data.sessionId) enableDrawPermission();
} else if (data.type === 'classroom_draw_revoked') {
if (_sessionId == data.sessionId) disableDrawPermission();
} else if (data.type === 'classroom_reaction') {
if (_sessionId == data.sessionId) {
const el = document.getElementById(`msg-reactions-${data.msgId}`);
if (el) el.innerHTML = _renderReactions(data.reactions || {}, data.msgId);
}
} else if (data.type === 'classroom_template_loaded') {
if (_sessionId == data.sessionId && _wb) {
_totalPages = data.pages || 1;
_wbCurrentPage = 1;
wbRebuildThumbnails();
_wbOwnIds.clear();
_wbMaxSeq = 0;
LS.get(`/api/classroom/${_sessionId}/strokes?page_num=1`).then(strokesData => {
_wbUpdateMaxSeq(strokesData.strokes || []);
_wb.loadStrokes(strokesData.strokes || []);
wbUpdateThumbnail(1);
}).catch(() => {});
}
} else if (data.type === 'classroom_message_pinned') {
if (_sessionId == data.sessionId) _updatePinnedMsg(data.msgId, data.pinned);
} else if (data.type === 'classroom_cursor') {
if (_sessionId == data.sessionId && data.pageNum == _wbCurrentPage && data.userId != _me?.id) {
_showRemoteCursor(data.userId, data.userName, data.x, data.y);
}
} else if (data.type === 'classroom_page_cleared') {
if (_sessionId == data.sessionId && _wb && data.pageNum == _wbCurrentPage) {
_wbClearGen++; // discard any in-flight poll response that has pre-clear strokes
_wbMaxSeq = 0; // seq restarts from 0 after DB delete, so reset polling cursor
_wb.clearPage();
}
} else if (data.type === '_sse_reconnect') {
// SSE reconnected after a drop — re-sync all real-time state to fill the gap
if (_sessionId) resyncAfterReconnect();
}
}
/* eslint-enable eqeqeq */
function onClassroomStarted(data) {
const isTeacher = _me.role === 'teacher' || _me.role === 'admin';
if (!isTeacher) {
showJoinBanner({ id: data.sessionId, title: data.title });
}
}
function onClassroomEnded() {
stopPolling();
wbStopBatch();
// Fully destroy whiteboard (not just clear) so the board area is gone
if (_wb) { _wb.destroy(); _wb = null; }
if (_rtc) { _rtc.destroy(); _rtc = null; }
if (_timerHandle) { clearInterval(_timerHandle); _timerHandle = null; }
_session = null;
_sessionId = null;
_sessionStartTime = null;
_participants = {};
_raisedHands = {};
_handRaised = false;
_wbCurrentPage = 1;
_totalPages = 1;
_canDraw = false;
_permittedStudents.clear();
_lastChatId = 0;
_chatUnread = 0;
_renderedMsgIds.clear();
_notesLoaded = false;
document.getElementById('cr-hands-section').style.display = 'none';
document.getElementById('cr-hand-btn').style.display = 'none';
document.getElementById('cr-mute-btn').style.display = 'none';
document.getElementById('cr-student-nav').style.display = 'none';
document.getElementById('cr-timer').style.display = 'none';
document.getElementById('cr-timer').textContent = '00:00';
document.getElementById('chat-unread').style.display = 'none';
// reset screen video
const video = document.getElementById('cr-screen-video');
video.srcObject = null; video.style.display = 'none';
document.getElementById('cr-screen-label').style.display = 'none';
setActiveState(false);
document.getElementById('cr-session-title').textContent = 'Онлайн-урок';
document.getElementById('cr-session-chip').style.display = 'none';
document.getElementById('cr-header-actions').style.display = 'none';
_cleanupRemoteCursors();
// Hide active board area immediately so the empty board doesn't stay on screen
document.getElementById('cr-active-main').style.display = 'none';
document.getElementById('cr-join-banner').style.display = 'none';
// Show correct idle state right away (don't wait for checkActiveSession)
const isTeacher = _me?.role === 'teacher' || _me?.role === 'admin';
document.getElementById('cr-idle-teacher').style.display = isTeacher ? 'flex' : 'none';
document.getElementById('cr-idle-student').style.display = isTeacher ? 'none' : 'flex';
document.getElementById('cr-toolbar').style.display = 'none';
document.getElementById('wb-thumbs-panel').style.display = 'none';
updateParticipantsList();
LS.toast('Урок завершён', 'info');
}
function onUserJoined(data) {
_participants[data.userId] = { name: data.userName, micMuted: false };
updateParticipantsList();
// If teacher is sharing screen, add the new peer
if (_rtc) _rtc.addPeerForScreenShare(data.userId);
}
function onUserLeft(data) {
delete _participants[data.userId];
updateParticipantsList();
if (_rtc) _rtc.removePeer(data.userId);
}
/* ── start session modal ── */
function crOpenStartModal() {
_selectedUsers = [];
renderSelectedUsers();
document.getElementById('cr-modal').classList.add('open');
if (window.lucide) lucide.createIcons();
}
function crCloseStartModal() {
document.getElementById('cr-modal').classList.remove('open');
}
function crSetMode(mode) {
_mode = mode;
document.getElementById('mode-class').classList.toggle('active', mode === 'class');
document.getElementById('mode-personal').classList.toggle('active', mode === 'personal');
document.getElementById('field-class').style.display = mode === 'class' ? '' : 'none';
document.getElementById('field-personal').style.display = mode === 'personal' ? '' : 'none';
if (mode === 'personal') crLoadOnlineStudents();
}
async function loadClassesForModal() {
try {
const classes = await LS.get('/api/classes');
const sel = document.getElementById('cr-class-select');
sel.innerHTML = '<option value="">Выберите класс...</option>';
(classes || []).forEach(c => {
sel.innerHTML += `<option value="${c.id}">${c.name}</option>`;
});
} catch {}
}
async function crLoadOnlineStudents() {
const list = document.getElementById('cr-online-list');
list.innerHTML = '<div class="cr-online-empty">Загрузка...</div>';
try {
const data = await LS.get('/api/classroom/online-students');
const students = data.students || [];
if (!students.length) {
list.innerHTML = '<div class="cr-online-empty">Нет учеников онлайн</div>';
return;
}
list.innerHTML = students.map(u => {
const initials = (u.name || '?').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('') || '?';
const sel = _selectedUsers.find(s => s.id === u.id) ? 'selected' : '';
return `<div class="cr-online-item ${sel}" id="cr-oi-${u.id}" onclick="crToggleOnlineUser(${u.id}, '${(u.name||'').replace(/'/g,"\\'")}')">
<span class="cr-online-dot"></span>
<div class="cr-online-avatar">${initials}</div>
<span class="cr-online-name">${LS.esc(u.name || u.email)}</span>
<span class="cr-online-check"></span>
</div>`;
}).join('');
if (window.lucide) lucide.createIcons();
} catch { list.innerHTML = '<div class="cr-online-empty">Ошибка загрузки</div>'; }
}
function crToggleOnlineUser(id, name) {
const idx = _selectedUsers.findIndex(u => u.id === id);
if (idx >= 0) {
_selectedUsers.splice(idx, 1);
} else {
_selectedUsers.push({ id, name });
}
// toggle selected class on item
const item = document.getElementById(`cr-oi-${id}`);
if (item) item.classList.toggle('selected', _selectedUsers.some(u => u.id === id));
renderSelectedUsers();
}
function crRemoveUser(id) {
_selectedUsers = _selectedUsers.filter(u => u.id !== id);
// un-select in online list if visible
const item = document.getElementById(`cr-oi-${id}`);
if (item) item.classList.remove('selected');
renderSelectedUsers();
}
function renderSelectedUsers() {
const wrap = document.getElementById('cr-selected-users');
wrap.innerHTML = _selectedUsers.map(u =>
`<div class="cr-selected-user">
${u.name}
<button onclick="crRemoveUser(${u.id})"><i data-lucide="x" style="width:11px;height:11px"></i></button>
</div>`
).join('');
if (window.lucide) lucide.createIcons();
}
async function crStartSession() {
const title = document.getElementById('cr-title-input').value.trim();
const btn = document.getElementById('cr-confirm-btn');
btn.disabled = true;
try {
let body;
if (_mode === 'class') {
const classId = Number(document.getElementById('cr-class-select').value);
if (!classId) { LS.toast('Выберите класс', 'error'); btn.disabled = false; return; }
body = { class_id: classId, title };
} else {
if (!_selectedUsers.length) { LS.toast('Добавьте хотя бы одного ученика', 'error'); btn.disabled = false; return; }
body = { user_ids: _selectedUsers.map(u => u.id), title };
}
const session = await LS.post('/api/classroom', body);
_session = session;
_sessionId = session.id;
crCloseStartModal();
// teacher joins their own session (adds to attendance + fires classroom_user_joined SSE)
await LS.post(`/api/classroom/${session.id}/join`).catch(() => {});
const fullSession = await LS.get(`/api/classroom/${session.id}`).catch(() => session);
_session = fullSession;
enterActiveState(fullSession);
loadChat();
} catch (e) {
LS.toast(e.message || 'Ошибка', 'error');
} finally {
btn.disabled = false;
}
}
/* ── join session (student) ── */
async function crJoinSession() {
if (!_sessionId) return;
try {
const joinRes = await LS.post(`/api/classroom/${_sessionId}/join`);
const session = await LS.get(`/api/classroom/${_sessionId}`);
_session = session;
// Set _canDraw BEFORE enterActiveState so initWhiteboard sees it
if (session.canDraw || joinRes.canDraw) _canDraw = true;
enterActiveState(session);
// load existing chat
loadChat();
// Apply toolbar/readonly state if draw was pre-set
if (_canDraw) enableDrawPermission();
} catch (e) {
LS.toast(e.message || 'Ошибка входа', 'error');
}
}
/* ── active state ── */
function enterActiveState(session) {
// reset dedup state for fresh session
_lastChatId = 0;
_renderedMsgIds.clear();
_notesLoaded = false;
document.getElementById('cr-messages').innerHTML = '';
document.getElementById('cr-notes-ta').value = '';
startPolling();
setActiveState(true);
document.getElementById('cr-idle-teacher').style.display = 'none';
document.getElementById('cr-idle-student').style.display = 'none';
document.getElementById('cr-join-banner').style.display = 'none';
document.getElementById('cr-active-main').style.display = 'flex';
document.getElementById('cr-session-title').textContent = session.title || 'Онлайн-урок';
document.getElementById('cr-session-chip').style.display = 'inline-flex';
document.getElementById('cr-header-actions').style.display = 'flex';
const isTeacher = _me.role === 'teacher' || _me.role === 'admin';
document.getElementById('cr-end-btn').style.display = isTeacher ? 'flex' : 'none';
document.getElementById('cr-leave-btn').style.display = isTeacher ? 'none' : 'flex';
document.getElementById('cr-screen-btn').style.display = isTeacher ? 'flex' : 'none';
document.getElementById('cr-hand-btn').style.display = isTeacher ? 'none' : 'flex';
document.getElementById('btn-templates').style.display = isTeacher ? 'flex' : 'none';
// Teacher: show raised hands panel
document.getElementById('cr-hands-section').style.display = isTeacher ? 'block' : 'none';
// page state
_wbCurrentPage = session.current_page || 1;
_totalPages = session.pageCount || 1;
_raisedHands = {};
_handRaised = false;
_followTeacher = true;
updatePageLabel();
// populate participants from attendance
if (session.attendance) {
session.attendance.forEach(a => {
if (!a.left_at) _participants[a.user_id] = { name: a.name || a.user_name, micMuted: false };
});
}
if (_me?.id) _participants[_me.id] = { name: _me.name, micMuted: false };
updateParticipantsList();
if (window.lucide) lucide.createIcons();
// session timer
_sessionStartTime = session.created_at ? new Date(session.created_at) : new Date();
if (_timerHandle) clearInterval(_timerHandle);
_timerHandle = setInterval(updateTimer, 1000);
document.getElementById('cr-timer').style.display = 'inline';
updateTimer();
// show correct nav (teacher toolbar vs student nav)
document.getElementById('cr-student-nav').style.display = isTeacher ? 'none' : 'flex';
// load raised hands (non-blocking)
_loadHandsAsync(session.id);
// init whiteboard
initWhiteboard(isTeacher, session.id);
// init WebRTC (async - don't block UI)
initRTC(session);
// Welcome status for student
if (!isTeacher) {
setTimeout(() => crStatusNotify('info',
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
`Урок начался: ${session.title || 'Онлайн-урок'}`, 5000), 600);
}
}
function setActiveState(active) {
document.getElementById('chat-no-session').style.display = active ? 'none' : 'flex';
document.getElementById('chat-active').style.display = active ? 'flex' : 'none';
document.getElementById('participants-no-session').style.display = active ? 'none' : 'flex';
}
/* ── whiteboard ── */
async function initWhiteboard(isTeacher, sessionId) {
const canvas = document.getElementById('cr-canvas');
if (!canvas) return;
// destroy previous instance
if (_wb) { _wb.destroy(); _wb = null; }
wbStopBatch();
_wbPendingSSE = [];
_wbOwnIds.clear();
// Mark as initializing — SSE strokes will be buffered into _wbPendingSSE
_wbInitializing = true;
const canEdit = isTeacher || _canDraw;
_wb = new Whiteboard(canvas, {
readOnly: !canEdit,
onStrokeDone: canEdit ? wbOnLocalStroke : null,
onStrokeUndo: canEdit ? wbOnLocalUndo : null,
onStrokeProgress: canEdit ? wbOnStrokeProgress : null,
onStrokeUpdated: canEdit ? wbOnLocalStrokeUpdated : null,
onCursorMove: wbOnCursorMove,
// Formula: open modal editor instead of inline input
onFormulaInsert: canEdit ? showFormulaModal : null,
onCoordEdit: canEdit ? showCoordModal : null,
onNumberLineEdit: canEdit ? showNumLineModal : null,
// After creating an object (text/sticky/formula/table), auto-switch to select
onObjectCreated: canEdit ? () => wbSetTool('select') : null,
// Zoom change: update zoom label
onZoomChange: z => { const el = document.getElementById('wb-zoom-label'); if (el) el.textContent = Math.round(z * 100) + '%'; },
// Overlay change: update props panel
onOverlayChange: wbOvChange,
});
// show/hide toolbar, thumbs panel, and readonly label
document.getElementById('cr-toolbar').style.display = canEdit ? 'flex' : 'none';
document.getElementById('wb-thumbs-panel').style.display = isTeacher ? 'flex' : 'none';
document.getElementById('cr-board-readonly').style.display = canEdit ? 'none' : 'block';
// load existing strokes — done before unblocking SSE to avoid duplicates
try {
const res = await LS.get(`/api/classroom/${sessionId}/strokes?page_num=1`);
const initialStrokes = res.strokes || [];
_wbUpdateMaxSeq(initialStrokes);
_wb.loadStrokes(initialStrokes);
if (res.template) { _wb.setTemplate(res.template); const sel = document.getElementById('wb-tpl-select'); if (sel) sel.value = res.template; }
wbUpdateThumbnail(1);
} catch {}
// Unlock SSE processing and apply any buffered strokes
_wbInitializing = false;
if (_wbPendingSSE.length > 0) {
_wbUpdateMaxSeq(_wbPendingSSE);
_wb.addStrokes(_wbPendingSSE);
_wbPendingSSE = [];
}
if (isTeacher) {
// start batch flush every 80ms
_wbBatchTimer = setInterval(wbFlushBatch, 80);
// Incremental poll every 4s as SSE fallback — fetches only strokes newer than _wbMaxSeq
_teacherPollTimer = setInterval(_wbTeacherIncrementalPoll, 4000);
// cursor is now broadcast via whiteboard's onCursorMove callback
} else {
// Start batch flush if student has draw permission
if (_canDraw) {
if (!_wbBatchTimer) _wbBatchTimer = setInterval(wbFlushBatch, 80);
// Full-sync every 2s (preserves local unconfirmed strokes, guarantees teacher strokes appear)
if (!_teacherPollTimer) _teacherPollTimer = setInterval(_drawingStudentSyncPoll, 2000);
} else {
// Read-only student: poll every 2s as a fallback in case SSE events are missed
wbStartPoll();
}
// Periodic draw permission check — catches missed SSE draw_permitted events
if (!_drawPermPollTimer) _drawPermPollTimer = setInterval(_checkDrawPermission, 5000);
}
}
/** Teacher: fetch strokes newer than _wbMaxSeq to catch any missed SSE events */
async function _wbTeacherIncrementalPoll() {
if (!_sessionId || !_wb || _wbInitializing) return;
const since = _wbMaxSeq;
const page = _wbCurrentPage;
try {
const res = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${page}&since_seq=${since}`);
if (_wbCurrentPage !== page) return; // page switched while fetching
const strokes = res.strokes || [];
if (strokes.length === 0) return;
const newStrokes = strokes.filter(s => !_wbOwnIds.has(s.id));
if (newStrokes.length === 0) return;
_wbUpdateMaxSeq(newStrokes);
_wb.addStrokes(newStrokes);
} catch {}
}
/** Drawing student: full-sync poll that preserves locally-drawn (unconfirmed) strokes.
* Guarantees teacher strokes appear even if SSE is missed. Runs every 2s. */
async function _drawingStudentSyncPoll() {
if (!_sessionId || !_wb || _wbInitializing) return;
const page = _wbCurrentPage;
const gen = _wbClearGen;
try {
const res = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${page}`);
if (_wbClearGen !== gen || _wbCurrentPage !== page) return;
const serverStrokes = res.strokes || [];
// Collect strokes with negative IDs (locally drawn, not yet saved to server)
// Snapshot taken AFTER fetch so we don't re-add strokes that were confirmed during await
const localUnconfirmed = _wb.getLocalStrokes();
_wbMaxSeq = 0;
_wbUpdateMaxSeq(serverStrokes);
_wb.loadStrokes(serverStrokes); // full replace — includes all teacher strokes
// Re-add unconfirmed local strokes on top (addStrokes deduplicates by ID)
if (localUnconfirmed.length > 0) _wb.addStrokes(localUnconfirmed);
if (res.template) _wb.setTemplate(res.template);
wbUpdateThumbnail(page);
} catch {}
}
/** Student: check draw permission state from server (SSE fallback) */
async function _checkDrawPermission() {
if (!_sessionId || !_wb) return;
const isTeacher = _me?.role === 'teacher' || _me?.role === 'admin';
if (isTeacher) return;
try {
const session = await LS.get(`/api/classroom/${_sessionId}`);
if (session.canDraw && !_canDraw) {
enableDrawPermission(true); // silent — catching up with missed SSE event
} else if (!session.canDraw && _canDraw) {
disableDrawPermission();
}
} catch {}
}
/* Remote cursors: one DOM element per userId */
const _remoteCursorEls = new Map(); // userId → {el, hideTimer}
const _CURSOR_COLORS_DOM = ['#9B5DE5','#06D6E0','#FF6B6B','#A8E063','#FF9F43','#F15BB5','#4361EE','#FFE066'];
function _getOrCreateCursorEl(userId, userName, color) {
if (_remoteCursorEls.has(userId)) return _remoteCursorEls.get(userId);
const wrap = document.getElementById('cr-board-wrap');
if (!wrap) return null;
const el = document.createElement('div');
el.style.cssText = 'position:absolute;pointer-events:none;z-index:20;display:none;transform:translate(-4px,-4px);transition:left 100ms linear,top 100ms linear;';
el.innerHTML = `
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 2L12 6L7 7.5L5.5 12L2 2Z" fill="${color}" stroke="#fff" stroke-width="1"/>
</svg>
<span style="position:absolute;left:14px;top:0;background:${color};color:#fff;font-size:0.6rem;font-weight:700;padding:1px 5px;border-radius:4px;white-space:nowrap;">${userName || ''}</span>`;
wrap.appendChild(el);
_remoteCursorEls.set(userId, { el, hideTimer: null });
return _remoteCursorEls.get(userId);
}
function _showRemoteCursor(userId, userName, vx, vy) {
const wrap = document.getElementById('cr-board-wrap');
if (!wrap) return;
const color = _CURSOR_COLORS_DOM[Math.abs(userId) % _CURSOR_COLORS_DOM.length];
const entry = _getOrCreateCursorEl(userId, userName, color);
if (!entry) return;
const { el } = entry;
const cssW = wrap.clientWidth || 1;
const cssH = wrap.clientHeight || 1;
el.style.left = ((vx / 1920) * cssW) + 'px';
el.style.top = ((vy / 1080) * cssH) + 'px';
el.style.display = 'block';
if (entry.hideTimer) clearTimeout(entry.hideTimer);
entry.hideTimer = setTimeout(() => { el.style.display = 'none'; }, 3000);
}
function _cleanupRemoteCursors() {
for (const { el } of _remoteCursorEls.values()) el.remove();
_remoteCursorEls.clear();
}
// Keep legacy function name for backward compat with old SSE events
function _showTeacherCursor(vx, vy) {
if (_session?.teacher_id) _showRemoteCursor(_session.teacher_id, 'Учитель', vx, vy);
}
/* Teacher cursor: throttled broadcast */
function _onTeacherMouseMove(e) {
if (!_sessionId || !_wb) return;
if (_cursorThrottle) return; // already scheduled
_cursorThrottle = setTimeout(() => {
_cursorThrottle = null;
const rect = e.target.getBoundingClientRect();
const cssW = e.target.clientWidth || 1;
const cssH = e.target.clientHeight || 1;
// Normalize to virtual 1920x1080
const vx = ((e.clientX - rect.left) / cssW) * 1920;
const vy = ((e.clientY - rect.top) / cssH) * 1080;
LS.post(`/api/classroom/${_sessionId}/cursor`, { x: vx, y: vy, page_num: _wbCurrentPage }).catch(() => {});
}, 150); // ~6fps throttle — enough for cursor indicator, stays within rate limits
}
function _onTeacherMouseLeave() {
// Send off-screen cursor position to hide it on student side
if (_sessionId) {
LS.post(`/api/classroom/${_sessionId}/cursor`, { x: -1, y: -1, page_num: _wbCurrentPage }).catch(() => {});
}
}
function wbOnLocalStroke(stroke) {
_wbBatch.push(stroke);
wbUpdateThumbnail(_wbCurrentPage);
}
/* Called when teacher moves/resizes an image object on the board */
function wbOnLocalStrokeUpdated(stroke) {
if (!_sessionId || stroke.id < 0) return; // not yet saved to server — skip
LS.patch(`/api/classroom/${_sessionId}/strokes/${stroke.id}`, { data: stroke.data }).catch(() => {});
}
async function wbOnLocalUndo(id) {
// if it's a server-confirmed id (positive), delete from server
if (id > 0) {
try { await LS.del(`/api/classroom/${_sessionId}/strokes/${id}`); } catch {}
}
// if negative (local only, not yet sent), remove from batch queue
_wbBatch = _wbBatch.filter(s => s.id !== id);
}
/* Called by Whiteboard during drawing (throttled to 50ms inside the class).
Posts current stroke state so students can see it in real-time. */
function wbOnStrokeProgress(p) {
if (!_sessionId) return;
// Cancel case: drawing ended with no valid stroke
if (p.cancel) {
LS.post(`/api/classroom/${_sessionId}/stroke-preview`, {
live_id: p.liveId, cancel: true, page_num: _wbCurrentPage,
}).catch(() => {});
return;
}
// Normal progress — Whiteboard already throttles at 50ms, so post directly
LS.post(`/api/classroom/${_sessionId}/stroke-preview`, {
live_id: p.liveId,
tool: p.tool,
data: p.data,
page_num: _wbCurrentPage,
}).catch(() => {});
}
function wbOnCursorMove(vx, vy) {
if (!_sessionId) return;
LS.post(`/api/classroom/${_sessionId}/cursor`, {
x: vx, y: vy, page_num: _wbCurrentPage,
}).catch(() => {});
}
async function wbFlushBatch() {
if (!_sessionId || _wbBatch.length === 0) return;
const toSend = _wbBatch.splice(0, _wbBatch.length); // drain queue
try {
const res = await LS.post(`/api/classroom/${_sessionId}/strokes`, {
page_num: _wbCurrentPage,
strokes: toSend.map(s => ({ tool: s.tool, data: s.data })),
});
// update local (negative) ids to server-assigned ids; track own ids to skip in SSE
if (res.strokes && _wb) {
res.strokes.forEach((saved, i) => {
if (toSend[i]) {
_wbOwnIds.add(saved.id); // register BEFORE confirmStroke calls render
_wb.confirmStroke(toSend[i].id, saved.id);
}
});
}
} catch {
// put back on failure
_wbBatch.unshift(...toSend);
}
}
function wbStopBatch() {
if (_wbBatchTimer) { clearInterval(_wbBatchTimer); _wbBatchTimer = null; }
if (_wbLiveTimer) { clearTimeout(_wbLiveTimer); _wbLiveTimer = null; }
if (_teacherPollTimer){ clearInterval(_teacherPollTimer);_teacherPollTimer= null; }
if (_drawPermPollTimer){ clearInterval(_drawPermPollTimer);_drawPermPollTimer= null; }
wbStopPoll();
_wbBatch = [];
_wbOwnIds.clear();
_wbInitializing = false;
_wbPendingSSE = [];
_wbMaxSeq = 0;
}
/* Track max stroke seq (for efficient polling with since_seq) */
function _wbUpdateMaxSeq(strokes) {
for (const s of strokes) {
if (s.seq != null && s.seq > _wbMaxSeq) _wbMaxSeq = s.seq;
}
}
/* Student-side full-sync poll: replaces board state every 2s.
Handles missed SSE events for strokes AND clears — no since_seq tricks needed. */
function wbStartPoll() {
wbStopPoll();
_strokePollTimer = setInterval(async () => {
if (!_sessionId || !_wb || _wbInitializing) return;
const page = _wbCurrentPage;
const gen = _wbClearGen;
try {
const res = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${page}`);
// Discard if board was cleared or page switched while fetching
if (_wbClearGen !== gen || _wbCurrentPage !== page) return;
const strokes = res.strokes || [];
// Update seq cursor so SSE dedup still works
_wbMaxSeq = 0;
_wbUpdateMaxSeq(strokes);
// Full replace — correctly reflects clears, undos, and any missed events
_wb.loadStrokes(strokes);
if (res.template) _wb.setTemplate(res.template);
wbUpdateThumbnail(page);
} catch {}
}, 2000);
}
function wbStopPoll() {
if (_strokePollTimer) { clearInterval(_strokePollTimer); _strokePollTimer = null; }
}
// zoom controls
function wbZoomIn() { if (_wb) _wb.zoomTo(_wb._zoom * 1.25); }
function wbZoomOut() { if (_wb) _wb.zoomTo(_wb._zoom / 1.25); }
function wbResetZoom() { if (_wb) _wb.resetView(); }
// toolbar handlers
const WB_TOOLS = ['pencil','eraser','highlighter','laser','select',
'rect','ellipse','line','arrow',
'triangle','diamond','hexagon','star','roundedrect','callout',
'text','sticky','formula','table','connector','coordinate','numberline','compass'];
function wbSetTool(tool) {
if (!_wb) return;
_wb.setTool(tool);
WB_TOOLS.forEach(t => {
const el = document.getElementById(`wb-tool-${t}`);
if (el) el.classList.toggle('active', t === tool);
});
const cursors = { eraser: 'cell', text: 'text', pencil: 'crosshair', select: 'default',
sticky: 'copy', formula: 'copy', table: 'crosshair', connector: 'crosshair',
highlighter: 'crosshair', laser: 'none', coordinate: 'copy',
numberline: 'copy', compass: 'copy' };
document.getElementById('cr-canvas').style.cursor = cursors[tool] || 'crosshair';
}
function wbSetColor(btn) {
if (!_wb) return;
const color = btn.dataset.color;
_wb.setColor(color);
document.querySelectorAll('.cr-color-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// switch to pencil if eraser or laser is active when color changes
const activeTools = ['eraser','laser'];
if (activeTools.some(t => document.getElementById(`wb-tool-${t}`)?.classList.contains('active')))
wbSetTool('pencil');
}
function wbSetWidth(px, btn) {
if (!_wb) return;
_wb.setWidth(px);
document.querySelectorAll('.cr-width-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}
function wbSetLineStyle(style, btn) {
if (!_wb) return;
_wb.setLineStyle(style);
document.querySelectorAll('.cr-linestyle-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
}
function wbSetOpacity(val) {
if (!_wb) return;
const v = Number(val) / 100;
_wb.setOpacity(v);
// also update selected stroke opacity live
if (_wb._selectedId != null) _wb.updateOpacity(v);
}
function wbToggleRuler() {
if (!_wb) return;
_wb.toggleRuler();
const on = _wb._overlays.some(o => o.type === 'ruler');
document.getElementById('wb-btn-ruler')?.classList.toggle('active', on);
if (!on && _wbOvCurrent?.type === 'ruler') wbOvHide();
}
function wbToggleProtractor() {
if (!_wb) return;
_wb.toggleProtractor();
const on = _wb._overlays.some(o => o.type === 'protractor');
document.getElementById('wb-btn-protractor')?.classList.toggle('active', on);
if (!on && _wbOvCurrent?.type === 'protractor') wbOvHide();
}
function wbToggleMeasurements(btn) {
if (!_wb) return;
const on = _wb.toggleMeasurements();
btn.classList.toggle('active', on);
}
/* ── Overlay properties panel ─────────────────────────────────────── */
let _wbOvCurrent = null; // currently shown overlay object
function wbOvChange(ov) {
_wbOvCurrent = ov;
const panel = document.getElementById('wb-ov-props');
if (!panel) return;
const isRuler = ov.type === 'ruler';
document.getElementById('wb-ov-type').textContent = isRuler ? 'Линейка' : 'Транспортир';
document.getElementById('wb-ov-size-lbl').firstChild.textContent = isRuler ? 'Длина' : 'Радиус';
const angleDeg = Math.round(((ov.angle || 0) * 180 / Math.PI) * 10) / 10;
document.getElementById('wb-ov-angle').value = angleDeg;
const sizeVal = isRuler ? Math.round(ov.width || 400) : Math.round(ov.radius || 80);
document.getElementById('wb-ov-size').value = sizeVal;
panel.classList.add('visible');
}
function wbOvApply() {
if (!_wbOvCurrent || !_wb) return;
const ov = _wbOvCurrent;
const angleDeg = parseFloat(document.getElementById('wb-ov-angle').value) || 0;
ov.angle = angleDeg * Math.PI / 180;
const sizeVal = parseFloat(document.getElementById('wb-ov-size').value) || 100;
if (ov.type === 'ruler') ov.width = Math.max(50, sizeVal);
else ov.radius = Math.max(20, sizeVal);
_wb.render();
}
function wbOvHide() {
_wbOvCurrent = null;
document.getElementById('wb-ov-props')?.classList.remove('visible');
}
/* Coordinate system edit modal */
let _coordEditStroke = null;
function showCoordModal(stroke) {
_coordEditStroke = stroke;
const d = stroke.data;
document.getElementById('coord-xmin').value = d.xMin ?? -10;
document.getElementById('coord-xmax').value = d.xMax ?? 10;
document.getElementById('coord-ymin').value = d.yMin ?? -10;
document.getElementById('coord-ymax').value = d.yMax ?? 10;
document.getElementById('coord-step').value = d.gridStep ?? 1;
renderCoordFnList();
document.getElementById('wb-coord-modal').style.display = 'flex';
}
function closeCoordModal() { document.getElementById('wb-coord-modal').style.display = 'none'; }
function applyCoordSettings() {
if (!_coordEditStroke || !_wb) return;
const d = _coordEditStroke.data;
d.xMin = parseFloat(document.getElementById('coord-xmin').value) || -10;
d.xMax = parseFloat(document.getElementById('coord-xmax').value) || 10;
d.yMin = parseFloat(document.getElementById('coord-ymin').value) || -10;
d.yMax = parseFloat(document.getElementById('coord-ymax').value) || 10;
d.gridStep = parseFloat(document.getElementById('coord-step').value) || 1;
_wb._staticDirty = true; _wb.render();
if (_wb._onStrokeUpdated) _wb._onStrokeUpdated(_coordEditStroke);
}
function renderCoordFnList() {
if (!_coordEditStroke) return;
const list = document.getElementById('coord-fn-list');
list.innerHTML = '';
(_coordEditStroke.data.functions || []).forEach((fn, i) => {
const row = document.createElement('div');
row.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:6px;';
row.innerHTML = `<input type="color" value="${fn.color||'#06D6E0'}" style="width:28px;height:28px;border:none;border-radius:4px;cursor:pointer;padding:0" onchange="_coordEditStroke.data.functions[${i}].color=this.value; _wb&&(_wb._staticDirty=true,_wb.render())">
<input type="text" value="${fn.expr||''}" placeholder="y = sin(x)" style="flex:1;font-size:12px;background:rgba(255,255,255,0.07);border:1px solid rgba(155,93,229,0.3);border-radius:4px;color:#e8e0f7;padding:4px 8px" onchange="_coordEditStroke.data.functions[${i}].expr=this.value;_wb&&(_wb._staticDirty=true,_wb.render())">
<button onclick="coordRemoveFn(${i})" style="background:rgba(255,80,80,0.15);border:1px solid rgba(255,80,80,0.3);color:#ff6b6b;border-radius:4px;cursor:pointer;padding:3px 8px;font-size:11px">✕</button>`;
list.appendChild(row);
});
}
function coordAddFn() {
if (!_coordEditStroke) return;
_coordEditStroke.data.functions = _coordEditStroke.data.functions || [];
_coordEditStroke.data.functions.push({ expr: 'x', color: '#06D6E0' });
renderCoordFnList();
if (_wb) { _wb._staticDirty = true; _wb.render(); }
}
function coordRemoveFn(i) {
if (!_coordEditStroke) return;
_coordEditStroke.data.functions.splice(i, 1);
renderCoordFnList();
if (_wb) { _wb._staticDirty = true; _wb.render(); }
}
/* Number line edit modal */
let _nlEditStroke = null;
function showNumLineModal(stroke) {
_nlEditStroke = stroke;
const d = stroke.data;
document.getElementById('nl-min').value = d.min ?? -10;
document.getElementById('nl-max').value = d.max ?? 10;
document.getElementById('nl-step').value = d.step ?? 1;
nlRenderPoints(); nlRenderIntervals();
document.getElementById('wb-numline-modal').style.display = 'flex';
}
function closeNumLineModal() { document.getElementById('wb-numline-modal').style.display = 'none'; }
function applyNumLineSettings() {
if (!_nlEditStroke || !_wb) return;
const d = _nlEditStroke.data;
d.min = parseFloat(document.getElementById('nl-min').value) || -10;
d.max = parseFloat(document.getElementById('nl-max').value) || 10;
d.step = parseFloat(document.getElementById('nl-step').value) || 1;
_wb._staticDirty = true; _wb.render();
}
const _nlStyle = 'width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(6,214,224,0.3);border-radius:4px;color:#e8e0f7;padding:4px 7px;font-size:11px;';
function nlRenderPoints() {
const list = document.getElementById('nl-points-list');
if (!list || !_nlEditStroke) return;
list.innerHTML = '';
(_nlEditStroke.data.points || []).forEach((pt, i) => {
const row = document.createElement('div');
row.style.cssText = 'display:flex;gap:5px;align-items:center;margin-bottom:5px;';
row.innerHTML = `<input type="number" value="${pt.val??0}" placeholder="Значение" style="${_nlStyle}flex:1" oninput="_nlEditStroke.data.points[${i}].val=+this.value;_wb&&(_wb._staticDirty=true,_wb.render())">
<input type="text" value="${pt.label||''}" placeholder="Метка" style="${_nlStyle}width:60px" oninput="_nlEditStroke.data.points[${i}].label=this.value;_wb&&(_wb._staticDirty=true,_wb.render())">
<input type="color" value="${pt.color||'#06D6E0'}" style="width:24px;height:24px;border:none;border-radius:3px;cursor:pointer;padding:0" onchange="_nlEditStroke.data.points[${i}].color=this.value;_wb&&(_wb._staticDirty=true,_wb.render())">
<label style="font-size:10px;color:#aaa;white-space:nowrap;"><input type="checkbox" ${pt.open?'checked':''} onchange="_nlEditStroke.data.points[${i}].open=this.checked;_wb&&(_wb._staticDirty=true,_wb.render())"> ○</label>
<button onclick="nlRemovePoint(${i})" style="background:rgba(255,80,80,0.15);border:1px solid rgba(255,80,80,0.3);color:#ff6b6b;border-radius:3px;cursor:pointer;padding:2px 6px;font-size:10px">✕</button>`;
list.appendChild(row);
});
}
function nlRenderIntervals() {
const list = document.getElementById('nl-intervals-list');
if (!list || !_nlEditStroke) return;
list.innerHTML = '';
(_nlEditStroke.data.intervals || []).forEach((iv, i) => {
const row = document.createElement('div');
row.style.cssText = 'display:flex;gap:5px;align-items:center;margin-bottom:5px;';
row.innerHTML = `<input type="number" value="${iv.from??0}" placeholder="От" style="${_nlStyle}flex:1" oninput="_nlEditStroke.data.intervals[${i}].from=+this.value;_wb&&(_wb._staticDirty=true,_wb.render())">
<input type="number" value="${iv.to??5}" placeholder="До" style="${_nlStyle}flex:1" oninput="_nlEditStroke.data.intervals[${i}].to=+this.value;_wb&&(_wb._staticDirty=true,_wb.render())">
<input type="color" value="${iv.color||'#9B5DE5'}" style="width:24px;height:24px;border:none;border-radius:3px;cursor:pointer;padding:0" onchange="_nlEditStroke.data.intervals[${i}].color=this.value;_wb&&(_wb._staticDirty=true,_wb.render())">
<button onclick="nlRemoveInterval(${i})" style="background:rgba(255,80,80,0.15);border:1px solid rgba(255,80,80,0.3);color:#ff6b6b;border-radius:3px;cursor:pointer;padding:2px 6px;font-size:10px">✕</button>`;
list.appendChild(row);
});
}
function nlAddPoint() {
if (!_nlEditStroke) return;
_nlEditStroke.data.points = _nlEditStroke.data.points || [];
_nlEditStroke.data.points.push({ val: 0, color: '#06D6E0', open: false });
nlRenderPoints();
if (_wb) { _wb._staticDirty = true; _wb.render(); }
}
function nlRemovePoint(i) {
if (!_nlEditStroke) return;
_nlEditStroke.data.points.splice(i, 1); nlRenderPoints();
if (_wb) { _wb._staticDirty = true; _wb.render(); }
}
function nlAddInterval() {
if (!_nlEditStroke) return;
_nlEditStroke.data.intervals = _nlEditStroke.data.intervals || [];
_nlEditStroke.data.intervals.push({ from: 0, to: 5, color: '#9B5DE5' });
nlRenderIntervals();
if (_wb) { _wb._staticDirty = true; _wb.render(); }
}
function nlRemoveInterval(i) {
if (!_nlEditStroke) return;
_nlEditStroke.data.intervals.splice(i, 1); nlRenderIntervals();
if (_wb) { _wb._staticDirty = true; _wb.render(); }
}
function wbUndo() {
if (!_wb) return;
_wb.undo();
}
async function wbClear() {
if (!_wb || !_sessionId) return;
const ok = await crConfirm('Очистить страницу?', 'Все рисунки на текущей странице будут удалены для всех участников урока.', { okText: 'Очистить', type: 'warn' });
if (!ok) return;
_wb.clearPage();
_wbBatch = [];
try { await LS.post(`/api/classroom/${_sessionId}/clear-page`, { page_num: _wbCurrentPage }); } catch {}
wbUpdateThumbnail(_wbCurrentPage);
}
/* ── end / leave session ── */
async function crEndSession() {
if (!_sessionId) return;
const ok = await crConfirm('Завершить урок?', 'Урок будет завершён для всех участников. Это действие нельзя отменить.', { okText: 'Завершить урок', type: 'danger' });
if (!ok) return;
try {
await LS.del(`/api/classroom/${_sessionId}`);
onClassroomEnded();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function crLeaveSession() {
if (!_sessionId) return;
try {
await LS.post(`/api/classroom/${_sessionId}/leave`);
onClassroomEnded();
} catch {}
}
/* ── participants ── */
function updateParticipantsList() {
const list = document.getElementById('cr-participants-list');
const noSession = document.getElementById('participants-no-session');
const ids = Object.keys(_participants);
document.getElementById('participants-count').textContent = ids.length;
if (!_sessionId || !ids.length) {
noSession.style.display = 'flex';
return;
}
noSession.style.display = 'none';
const existing = list.querySelectorAll('.cr-participant');
existing.forEach(el => el.remove());
const isTeacherView = _me?.role === 'teacher' || _me?.role === 'admin';
ids.forEach(uid => {
const p = _participants[uid];
const initials = (p.name || '?').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('') || '?';
const isMe = String(uid) === String(_me?.id);
const isSessionTeacher = _session && String(uid) === String(_session.teacher_id);
const hasHand = !!_raisedHands[uid];
const div = document.createElement('div');
div.className = 'cr-participant';
div.dataset.uid = uid;
const hasDrawPerm = _permittedStudents.has(Number(uid));
const svgPencil = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:10px;height:10px"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>`;
const svgMicOn = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:10px;height:10px"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`;
const svgMicOff = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:10px;height:10px"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`;
// Mute button for teacher: shows current state, action on hover
const muteBtnHtml = isTeacherView && !isMe && !isSessionTeacher ? (
p.micMuted
? `<button class="cr-p-mute-btn mic-muted" onclick="crMutePeer(${uid})" title="Включить микрофон">${svgMicOff} Выкл</button>`
: `<button class="cr-p-mute-btn mic-active" onclick="crMutePeer(${uid})" title="Заглушить">${svgMicOn} Вкл</button>`
) : '';
div.innerHTML = `
<div class="cr-p-avatar${p.speaking ? ' speaking' : ''}">${initials}</div>
<div class="cr-p-name">${LS.escapeHtml(p.name)}</div>
${isSessionTeacher ? '<span class="cr-p-teacher">Учитель</span>' : ''}
${isMe && !isSessionTeacher ? '<span class="cr-p-you">Вы</span>' : ''}
${!isTeacherView && hasDrawPerm ? `<span class="cr-p-draw-badge">${svgPencil} рисует</span>` : ''}
<div class="cr-p-status">
${hasHand ? `<svg viewBox="0 0 24 24" fill="none" stroke="#FFB347" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><path d="M18 11V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0"/><path d="M14 10V4a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>` : ''}
${muteBtnHtml}
</div>
${isTeacherView && !isMe && !isSessionTeacher ? `
<button class="cr-p-draw-toggle${hasDrawPerm ? ' granted' : ''}" onclick="crToggleDrawPermission(${uid})">
${svgPencil}
${hasDrawPerm ? 'Запретить' : 'Рисовать'}
</button>` : ''}`;
list.appendChild(div);
});
if (window.lucide) lucide.createIcons();
}
/* ── hand raise ── */
async function crToggleHand() {
if (!_sessionId) return;
_handRaised = !_handRaised;
const btn = document.getElementById('cr-hand-btn');
const lbl = document.getElementById('cr-hand-label');
btn.classList.toggle('raised', _handRaised);
lbl.textContent = _handRaised ? 'Опустить руку' : 'Поднять руку';
try {
if (_handRaised) await LS.post(`/api/classroom/${_sessionId}/hand`);
else await LS.del(`/api/classroom/${_sessionId}/hand`);
} catch {
// revert on error
_handRaised = !_handRaised;
btn.classList.toggle('raised', _handRaised);
lbl.textContent = _handRaised ? 'Опустить руку' : 'Поднять руку';
}
}
function onHandRaised(userId, userName) {
_raisedHands[userId] = userName;
updateHandsList();
updateParticipantsList();
}
function onHandLowered(userId) {
delete _raisedHands[userId];
updateHandsList();
updateParticipantsList();
}
function updateHandsList() {
const list = document.getElementById('cr-hands-list');
if (!list) return;
const ids = Object.keys(_raisedHands);
list.innerHTML = ids.map(uid => `
<div class="cr-hand-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0"/><path d="M14 10V4a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>
${LS.escapeHtml(_raisedHands[uid])}
</div>`).join('');
}
/* ── page navigation ── */
function updatePageLabel() {
const text = `${_wbCurrentPage}/${_totalPages}`;
const lbl = document.getElementById('wb-page-label');
const slbl = document.getElementById('wb-student-page-label');
if (lbl) lbl.textContent = text;
if (slbl) slbl.textContent = text;
}
function onPageAdded(pageNum) {
_totalPages = Math.max(_totalPages, pageNum);
updatePageLabel();
const isTeacher = _me.role === 'teacher' || _me.role === 'admin';
if (!isTeacher && _followTeacher) wbGoToPage(pageNum);
}
function onPageChanged(pageNum) {
const isTeacher = _me.role === 'teacher' || _me.role === 'admin';
if (!isTeacher && _followTeacher) wbGoToPage(pageNum);
else if (isTeacher) {
_wbCurrentPage = pageNum;
updatePageLabel();
}
}
async function wbGoToPage(pageNum) {
if (!_wb || pageNum === _wbCurrentPage) return;
_wbCurrentPage = pageNum;
_wbMaxSeq = 0;
_wbClearGen++;
updatePageLabel();
_wb.clearPage();
_wbBatch = [];
try {
const res = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${pageNum}`);
const strokes = res.strokes || [];
_wbUpdateMaxSeq(strokes);
_wb.loadStrokes(strokes);
if (res.template) _wb.setTemplate(res.template);
_wb.setPageNum(pageNum);
wbRebuildThumbnails();
} catch {}
}
async function wbPrevPage() {
if (_wbCurrentPage <= 1) return;
await _wbChangePage(_wbCurrentPage - 1);
}
async function wbNextPage() {
if (_wbCurrentPage >= _totalPages) return;
await _wbChangePage(_wbCurrentPage + 1);
}
// Thumbnail rendering helpers
function wbUpdateThumbnail(pageNum) {
if (!_wb) return;
const list = document.getElementById('wb-thumbs-list');
if (!list) return;
// Rebuild if count changed
if (list.children.length !== _totalPages) { wbRebuildThumbnails(); return; }
// Update only the target page canvas
const item = list.querySelector(`.wb-thumb-item[data-page="${pageNum}"]`);
if (!item) return;
const cvs = item.querySelector('canvas');
if (!cvs) return;
if (pageNum === _wbCurrentPage) {
_wb.renderThumbnail(cvs);
}
// Highlight active
list.querySelectorAll('.wb-thumb-item').forEach(el => {
el.classList.toggle('active', parseInt(el.dataset.page) === _wbCurrentPage);
});
}
function wbRebuildThumbnails() {
const list = document.getElementById('wb-thumbs-list');
if (!list || !_wb) return;
list.innerHTML = '';
for (let i = 1; i <= _totalPages; i++) {
const item = document.createElement('div');
item.className = 'wb-thumb-item' + (i === _wbCurrentPage ? ' active' : '');
item.dataset.page = i;
item.title = `Страница ${i}`;
item.onclick = () => { if (i !== _wbCurrentPage) _wbChangePage(i); };
const cvs = document.createElement('canvas');
cvs.width = 192; cvs.height = 108;
item.appendChild(cvs);
const num = document.createElement('span');
num.className = 'wb-thumb-num';
num.textContent = i;
item.appendChild(num);
list.appendChild(item);
if (i === _wbCurrentPage) _wb.renderThumbnail(cvs);
}
}
async function wbAddPage(template = 'blank') {
if (!_sessionId) return;
try {
const res = await LS.post(`/api/classroom/${_sessionId}/pages`, { template });
_totalPages = res.pageNum;
await _wbChangePage(res.pageNum);
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function wbSetPageTemplate(template) {
if (!_sessionId || !_wb) return;
_wb.setTemplate(template);
try { await LS.patch(`/api/classroom/${_sessionId}/page-template`, { template }); } catch {}
wbUpdateThumbnail(_wbCurrentPage);
}
async function _wbChangePage(pageNum) {
if (!_sessionId) return;
_wbBatch = []; // discard unsent strokes from old page
try {
await LS.put(`/api/classroom/${_sessionId}/page`, { page_num: pageNum });
// SSE will echo back page_changed; teacher handles locally too
_wbCurrentPage = pageNum;
_wbMaxSeq = 0;
_wbClearGen++;
updatePageLabel();
if (_wb) {
_wb.clearPage();
const res = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${pageNum}`);
const strokes = res.strokes || [];
_wbUpdateMaxSeq(strokes);
_wb.loadStrokes(strokes);
if (res.template) {
_wb.setTemplate(res.template);
const sel = document.getElementById('wb-tpl-select');
if (sel) sel.value = res.template;
}
wbRebuildThumbnails();
}
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ── tabs ── */
function crSwitchTab(tab) {
_activeTab = tab;
document.getElementById('tab-participants').classList.toggle('active', tab === 'participants');
document.getElementById('tab-chat').classList.toggle('active', tab === 'chat');
document.getElementById('tab-notes').classList.toggle('active', tab === 'notes');
document.getElementById('panel-participants').style.display = tab === 'participants' ? 'flex' : 'none';
document.getElementById('panel-chat').style.display = tab === 'chat' ? 'flex' : 'none';
document.getElementById('panel-notes').style.display = tab === 'notes' ? 'flex' : 'none';
if (tab === 'chat') {
_chatUnread = 0;
document.getElementById('chat-unread').style.display = 'none';
}
if (tab === 'notes' && _sessionId) crNotesLoad();
}
/* ── chat ── */
async function loadChat() {
if (!_sessionId) return;
try {
const data = await LS.get(`/api/classroom/${_sessionId}/chat`);
(data.messages || []).forEach(m => appendChatMessage({
id: m.id, userId: m.user_id, userName: m.user_name,
message: m.message, createdAt: m.created_at, pinned: !!m.pinned,
attachmentUrl: m.attachment_url, attachmentType: m.attachment_type,
reactions: m.reactions || {},
}));
} catch {}
}
function appendChatMessage(data) {
// dedup
if (data.id && _renderedMsgIds.has(data.id)) return;
if (data.id) {
_renderedMsgIds.add(data.id);
if (data.id > _lastChatId) _lastChatId = data.id;
}
// track unread when chat tab not active
if (_activeTab !== 'chat' && data.userId !== _me?.id) {
_chatUnread++;
const badge = document.getElementById('chat-unread');
if (badge) { badge.textContent = _chatUnread; badge.style.display = 'inline'; }
}
const wrap = document.getElementById('cr-messages');
const isMsgTeacher = data.userId === _session?.teacher_id;
const isTeacherView = _me?.role === 'teacher' || _me?.role === 'admin';
const time = new Date(data.createdAt).toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
const div = document.createElement('div');
div.className = 'cr-msg' + (data.pinned ? ' cr-msg-pinned' : '');
if (data.id) div.dataset.msgid = data.id;
const pinIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17H19V15L17 7V3H7V7L5 15V17Z"/><line x1="12" y1="3" x2="12" y2="7"/></svg>`;
// reaction icons (SVG, no emoji)
const REACT_ICONS = {
like: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>`,
heart: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/></svg>`,
question: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>`,
idea: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/></svg>`,
wow: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
};
const REACT_LABELS = { like:'Понятно', heart:'Класс!', question:'Вопрос', idea:'Интересно', wow:'Вау' };
const reactBar = `<div class="cr-msg-react-bar">${
Object.entries(REACT_ICONS).map(([k,ico]) =>
`<button class="cr-react-pick" onclick="crReact(${data.id},'${k}')" title="${REACT_LABELS[k]}">${ico}</button>`
).join('')
}</div>`;
const attachHtml = data.attachmentUrl && data.attachmentType === 'image'
? `<img class="cr-msg-img" src="${LS.esc(data.attachmentUrl)}" alt="изображение" onclick="window.open('${LS.esc(data.attachmentUrl)}','_blank')">`
: data.attachmentUrl ? `<a href="${LS.esc(data.attachmentUrl)}" target="_blank" style="color:#9B5DE5;font-size:0.78rem;">Файл</a>` : '';
const reactionsHtml = _renderReactions(data.reactions || {}, data.id);
div.innerHTML = `
${isTeacherView && data.id ? reactBar : ''}
<div class="cr-msg-header">
<span class="cr-msg-name${isMsgTeacher ? ' teacher-name' : ''}">${LS.esc(data.userName || '')}</span>
<span class="cr-msg-time">${time}</span>
${data.pinned ? `<span class="cr-msg-pin-badge">${pinIcon}</span>` : ''}
${isTeacherView && data.id ? `<button class="cr-msg-pin-btn${data.pinned ? ' active' : ''}" onclick="crPinMessage(${data.id}, this)" title="${data.pinned ? 'Открепить' : 'Закрепить'}">${pinIcon}</button>` : ''}
</div>
${data.message ? `<div class="cr-msg-text">${LS.esc(data.message)}</div>` : ''}
${attachHtml}
<div class="cr-msg-reactions" id="msg-reactions-${data.id}">${reactionsHtml}</div>`;
wrap.appendChild(div);
wrap.scrollTop = wrap.scrollHeight;
}
async function crPinMessage(msgId, btn) {
if (!_sessionId) return;
try {
const res = await LS.post(`/api/classroom/${_sessionId}/chat/${msgId}/pin`);
// SSE will update all clients; update local immediately
_updatePinnedMsg(msgId, res.pinned);
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
function _updatePinnedMsg(msgId, pinned) {
const div = document.querySelector(`.cr-msg[data-msgid="${msgId}"]`);
if (!div) return;
div.classList.toggle('cr-msg-pinned', pinned);
const badge = div.querySelector('.cr-msg-pin-badge');
const btn = div.querySelector('.cr-msg-pin-btn');
const pinIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17H19V15L17 7V3H7V7L5 15V17Z"/><line x1="12" y1="3" x2="12" y2="7"/></svg>`;
if (pinned && !badge) {
const s = document.createElement('span');
s.className = 'cr-msg-pin-badge';
s.innerHTML = pinIcon;
div.querySelector('.cr-msg-header')?.insertBefore(s, btn || null);
} else if (!pinned && badge) {
badge.remove();
}
if (btn) {
btn.classList.toggle('active', pinned);
btn.title = pinned ? 'Открепить' : 'Закрепить';
}
}
/* ── chat attach state ── */
let _pendingAttachUrl = null;
let _pendingAttachType = null;
let _pendingAttachName = null;
async function crAttachFile(input) {
const file = input.files[0];
if (!file || !_sessionId) return;
input.value = '';
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(`/api/classroom/${_sessionId}/chat/upload`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${LS.getToken()}` },
body: fd,
});
if (!res.ok) throw new Error('Ошибка загрузки');
const data = await res.json();
_pendingAttachUrl = data.url;
_pendingAttachType = data.type;
_pendingAttachName = data.name;
const prev = document.getElementById('cr-attach-preview');
const thumb = document.getElementById('cr-attach-thumb');
const name = document.getElementById('cr-attach-name');
if (data.type === 'image') {
thumb.src = data.url;
thumb.style.display = 'block';
} else {
thumb.style.display = 'none';
}
name.textContent = data.name;
prev.style.display = 'flex';
} catch (e) { LS.toast(e.message || 'Ошибка загрузки', 'error'); }
}
function crClearAttach() {
_pendingAttachUrl = null;
_pendingAttachType = null;
_pendingAttachName = null;
document.getElementById('cr-attach-preview').style.display = 'none';
document.getElementById('cr-attach-thumb').src = '';
document.getElementById('cr-attach-name').textContent = '';
}
async function crSendChat() {
const input = document.getElementById('cr-chat-input');
const message = input.value.trim();
const hasAttach = !!_pendingAttachUrl;
if (!message && !hasAttach) return;
if (!_sessionId) return;
input.value = '';
const body = { message };
if (hasAttach) {
body.attachment_url = _pendingAttachUrl;
body.attachment_type = _pendingAttachType;
crClearAttach();
}
try {
await LS.post(`/api/classroom/${_sessionId}/chat`, body);
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
function crChatKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); crSendChat(); }
}
/* ── reactions ── */
function _renderReactions(reactions, msgId) {
if (!reactions || !Object.keys(reactions).length) return '';
const ICONS = { like:'👍', heart:'❤', question:'❓', idea:'💡', wow:'⭐' };
const myId = String(_me?.id || '');
return Object.entries(reactions).map(([k, info]) => {
// getChat provides `mine` boolean; SSE/reactToMessage provides `uids` string
let isMine = !!info.mine;
if (!isMine && info.uids) isMine = myId && String(info.uids).split(',').includes(myId);
const mine = isMine ? ' cr-react-chip-mine' : '';
const label = ICONS[k] || k;
const count = info.count || info.cnt || 0;
return `<button class="cr-react-chip${mine}" onclick="crReact(${msgId},'${k}')" title="${k}">${label} <span>${count}</span></button>`;
}).join('');
}
async function crReact(msgId, reaction) {
if (!_sessionId || !msgId) return;
try {
const res = await LS.post(`/api/classroom/${_sessionId}/chat/${msgId}/react`, { reaction });
// SSE will broadcast; update locally immediately
const el = document.getElementById(`msg-reactions-${msgId}`);
if (el) el.innerHTML = _renderReactions(res.reactions || {}, msgId);
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ── notes ── */
let _notesSaveTimer = null;
let _notesLoaded = false;
async function crNotesLoad() {
if (!_sessionId || _notesLoaded) return;
_notesLoaded = true;
try {
const data = await LS.get(`/api/classroom/${_sessionId}/notes`);
const ta = document.getElementById('cr-notes-ta');
if (ta) ta.value = data.content || '';
const st = document.getElementById('notes-status');
if (st) st.textContent = '';
} catch {}
}
function crNotesOnInput() {
const st = document.getElementById('notes-status');
if (st) st.textContent = 'Изменено…';
clearTimeout(_notesSaveTimer);
_notesSaveTimer = setTimeout(crNotesSave, 1500);
}
async function crNotesSave() {
if (!_sessionId) return;
const ta = document.getElementById('cr-notes-ta');
const content = ta ? ta.value : '';
try {
await LS.put(`/api/classroom/${_sessionId}/notes`, { content });
const st = document.getElementById('notes-status');
if (st) st.textContent = 'Сохранено';
setTimeout(() => { const s = document.getElementById('notes-status'); if (s) s.textContent = ''; }, 2000);
} catch {}
}
/* ── lesson templates ── */
function crShowTemplates() {
document.getElementById('cr-tpl-modal').classList.add('open');
crLoadTemplatesList();
}
function crHideTemplates() {
document.getElementById('cr-tpl-modal').classList.remove('open');
}
async function crLoadTemplatesList() {
try {
const data = await LS.get('/api/classroom/templates');
crRenderTemplates(data.templates || []);
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
function crRenderTemplates(list) {
const el = document.getElementById('cr-tpl-list');
if (!list.length) {
el.innerHTML = '<div class="cr-tpl-empty">Нет сохранённых шаблонов</div>';
return;
}
el.innerHTML = list.map(t => `
<div class="cr-tpl-item">
<div style="flex:1;min-width:0">
<div class="cr-tpl-item-name">${LS.esc(t.title)}</div>
${t.description ? `<div class="cr-tpl-item-meta">${LS.esc(t.description)}</div>` : ''}
</div>
<button class="cr-tpl-save-btn" style="padding:5px 10px;font-size:0.74rem;flex-shrink:0" onclick="crLoadTemplate(${t.id})">Загрузить</button>
<button class="cr-tpl-item-del" onclick="crDeleteTemplate(${t.id})" title="Удалить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
</div>`).join('');
}
async function crSaveTemplate() {
if (!_sessionId) return;
const name = document.getElementById('cr-tpl-name').value.trim();
if (!name) { LS.toast('Введите название шаблона', 'error'); return; }
try {
await LS.post(`/api/classroom/${_sessionId}/save-template`, { title: name });
document.getElementById('cr-tpl-name').value = '';
LS.toast('Шаблон сохранён', 'success');
crLoadTemplatesList();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function crLoadTemplate(id) {
if (!_sessionId || !id) return;
const ok = await crConfirm('Загрузить шаблон?', 'Текущее содержимое всех страниц урока будет заменено содержимым шаблона.', { okText: 'Загрузить', type: 'warn' });
if (!ok) return;
try {
await LS.post(`/api/classroom/${_sessionId}/load-template`, { template_id: id });
crHideTemplates();
LS.toast('Шаблон загружен', 'success');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function crDeleteTemplate(id) {
if (!id) return;
const ok = await crConfirm('Удалить шаблон?', 'Шаблон будет удалён без возможности восстановления.', { okText: 'Удалить', type: 'danger' });
if (!ok) return;
try {
await LS.del(`/api/classroom/templates/${id}`);
crLoadTemplatesList();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ── Helpers ── */
function _loadHandsAsync(sessionId) {
LS.get(`/api/classroom/${sessionId}/hands`)
.then(data => {
(data.hands || []).forEach(h => { _raisedHands[h.userId] = h.userName; });
updateHandsList();
updateParticipantsList();
}).catch(() => {});
}
/* ── SSE reconnect: re-sync all state missed during the gap ── */
async function resyncAfterReconnect() {
if (!_sessionId) return;
try {
// 1. Verify session is still active
const session = await LS.get(`/api/classroom/${_sessionId}`).catch(() => null);
if (!session || session.status !== 'active') {
onClassroomEnded();
return;
}
_session = session;
// 2. Re-sync participants
const partData = await LS.get(`/api/classroom/${_sessionId}/participants`).catch(() => null);
if (partData) {
const fresh = {};
for (const p of (partData.participants || [])) {
fresh[p.user_id] = {
name: p.name,
micMuted: _participants[p.user_id]?.micMuted || false,
};
}
// Keep our own entry with current mute state
if (_me?.id) fresh[_me.id] = { name: _me.name, micMuted: _rtc?.isMuted() || false };
_participants = fresh;
updateParticipantsList();
}
// 3. Re-sync raised hands
const handsData = await LS.get(`/api/classroom/${_sessionId}/hands`).catch(() => null);
if (handsData) {
_raisedHands = {};
(handsData.hands || []).forEach(h => { _raisedHands[h.userId] = h.userName; });
updateHandsList();
updateParticipantsList();
}
// 4. Re-sync whiteboard strokes for current page
if (_wb) {
// Clear own-id filter — strokes will be reloaded from server, so echoes no longer needed
_wbOwnIds.clear();
_wbMaxSeq = 0;
const strokesData = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${_wbCurrentPage}`).catch(() => null);
if (strokesData) {
_wbUpdateMaxSeq(strokesData.strokes || []);
_wb.loadStrokes(strokesData.strokes || []);
}
}
// 5. Catch up on missed chat messages using since_id for efficiency
const chatData = await LS.get(`/api/classroom/${_sessionId}/chat?since_id=${_lastChatId}`).catch(() => null);
if (chatData) {
(chatData.messages || []).forEach(m => appendChatMessage({
id: m.id, userId: m.user_id, userName: m.user_name,
message: m.message, createdAt: m.created_at,
}));
}
// 6. Recover WebRTC peers that failed during the SSE gap
if (_rtc) {
const peerIds = Object.keys(_participants).map(Number).filter(id => id !== _me?.id);
_rtc.recoverPeers(peerIds).catch(() => {});
}
// 7. Follow teacher's current page if follow mode is on
const isTeacher = _me?.role === 'teacher' || _me?.role === 'admin';
if (!isTeacher && _followTeacher && session.current_page && session.current_page !== _wbCurrentPage) {
wbGoToPage(session.current_page);
}
// 8. Re-check draw permission (SSE event may have been missed during the gap)
if (!isTeacher && session.canDraw && !_canDraw) {
enableDrawPermission(true); // silent — just re-syncing after SSE gap
} else if (!isTeacher && !session.canDraw && _canDraw) {
disableDrawPermission();
}
} catch (e) {
console.warn('[Classroom] resync error', e);
}
}
function updateTimer() {
if (!_sessionStartTime) return;
const s = Math.floor((Date.now() - _sessionStartTime.getTime()) / 1000);
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
const t = h > 0
? `${h}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`
: `${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
const el = document.getElementById('cr-timer');
if (el) el.textContent = t;
}
/* ── Whiteboard extra ── */
function wbRedo() { if (_wb) _wb.redo(); }
let _wbFill = false;
function wbToggleFill(btn) {
if (!_wb) return;
_wbFill = !_wbFill;
_wb.setFill(_wbFill);
btn.classList.toggle('active', _wbFill);
}
function wbCopySelected() { if (_wb) _wb.copy(); }
function wbPasteSelected() { if (_wb) _wb.paste(); }
function wbDeleteSelected() { if (_wb) _wb.deleteSelected(); }
function wbBringToFront() { if (_wb) _wb.bringToFront(); }
function wbSendToBack() { if (_wb) _wb.sendToBack(); }
/* ── Formula visual editor ── */
let _wbFmVx = 960, _wbFmVy = 540, _wbFmFontSize = 32, _wbFmPrevTimer = null;
let _wbFmCurrentTab = 'basic';
/* ---- button data for all categories ---- */
const WB_FM_CATS = {
basic: { name: 'Основные', items: [
{ label:'Плюс-минус ±', latex:'\\pm', display:'\\pm' },
{ label:'Умножить ×', latex:'\\times', display:'\\times' },
{ label:'Делить ÷', latex:'\\div', display:'\\div' },
{ label:'Точка · (умножение)',latex:'\\cdot', display:'\\cdot' },
{ label:'Не равно ≠', latex:'\\neq', display:'\\neq' },
{ label:'Меньше или равно ≤', latex:'\\leq', display:'\\leq' },
{ label:'Больше или равно ≥', latex:'\\geq', display:'\\geq' },
{ label:'Примерно равно ≈', latex:'\\approx', display:'\\approx' },
{ label:'Бесконечность ∞', latex:'\\infty', display:'\\infty' },
{ label:'Многоточие …', latex:'\\ldots', display:'\\ldots' },
{ label:'Процент %', latex:'\\%', display:'\\%' },
{ label:'Градус °', latex:'^{\\circ}', display:'90^{\\circ}' },
{ label:'Модуль числа |x|', latex:'\\left|{}\\right|', display:'|x|', cursorBack:7 },
{ label:'Скобки ( )', latex:'\\left({}\\right)', display:'(a+b)', cursorBack:7 },
{ label:'Квадратные скобки', latex:'\\left[{}\\right]', display:'[x]', cursorBack:7 },
{ label:'Стрелка вправо →', latex:'\\rightarrow', display:'\\rightarrow' },
{ label:'Следовательно ⇒', latex:'\\Rightarrow', display:'\\Rightarrow' },
{ label:'Равносильно ⟺', latex:'\\Leftrightarrow', display:'\\Leftrightarrow' },
]},
frac: { name: 'Дроби · √', items: [
{ label:'Дробь a/b', latex:'\\frac{}{}', display:'\\frac{a}{b}', cursorBack:2 },
{ label:'Квадратный корень √', latex:'\\sqrt{}', display:'\\sqrt{a}', cursorBack:1 },
{ label:'Кубический корень ∛', latex:'\\sqrt[3]{}', display:'\\sqrt[3]{a}', cursorBack:1 },
{ label:'Корень n-й степени', latex:'\\sqrt[n]{}', display:'\\sqrt[n]{a}', cursorBack:1 },
{ label:'Дробь со скобками', latex:'\\frac{\\left({}\\right)}{}', display:'\\frac{(a+b)}{c}', cursorBack:9 },
{ label:'Корень из дроби', latex:'\\sqrt{\\frac{}{}}', display:'\\sqrt{\\frac{a}{b}}', cursorBack:3 },
{ label:'Дробь с корнем вверху',latex:'\\frac{\\sqrt{}}{{}}' ,display:'\\frac{\\sqrt{a}}{b}',cursorBack:2 },
{ label:'Дробь с корнем внизу', latex:'\\frac{{}}{\\sqrt{}}', display:'\\frac{a}{\\sqrt{b}}', cursorBack:8 },
]},
powers: { name: 'Степени', items: [
{ label:'В квадрате x²', latex:'^{2}', display:'x^{2}' },
{ label:'В кубе x³', latex:'^{3}', display:'x^{3}' },
{ label:'В степени n', latex:'^{}', display:'x^{n}', cursorBack:1 },
{ label:'Нижний индекс', latex:'_{}', display:'x_{n}', cursorBack:1 },
{ label:'Степень и индекс', latex:'_{}^{}', display:'x_{i}^{n}', cursorBack:1 },
{ label:'e в степени x', latex:'e^{}', display:'e^{x}', cursorBack:1 },
{ label:'10 в степени n', latex:'10^{}', display:'10^{n}', cursorBack:1 },
{ label:'Логарифм log_a(b)', latex:'\\log_{}{}' ,display:'\\log_{a}b', cursorBack:2 },
{ label:'Натуральный ln', latex:'\\ln{}', display:'\\ln x', cursorBack:1 },
{ label:'Десятичный lg', latex:'\\lg{}', display:'\\lg x', cursorBack:1 },
{ label:'Логарифм log₂', latex:'\\log_{2}{}', display:'\\log_{2}x', cursorBack:1 },
{ label:'Логарифм log₁₀', latex:'\\log_{10}{}',display:'\\log_{10}x', cursorBack:1 },
]},
sums: { name: 'Суммы · ∫', items: [
{ label:'Сумма Σ с пределами', latex:'\\sum_{i=1}^{n}{}', display:'\\sum_{i=1}^{n}a_i', cursorBack:2 },
{ label:'Интеграл от a до b', latex:'\\int_{a}^{b}{}\\,dx', display:'\\int_{a}^{b}f(x)\\,dx', cursorBack:4 },
{ label:'Неопределённый интеграл', latex:'\\int {}\\,dx', display:'\\int f(x)\\,dx', cursorBack:4 },
{ label:'Двойной интеграл', latex:'\\iint', display:'\\iint' },
{ label:'Криволинейный интеграл', latex:'\\oint', display:'\\oint' },
{ label:'Произведение Π', latex:'\\prod_{i=1}^{n}{}', display:'\\prod_{i=1}^{n}a_i', cursorBack:2 },
{ label:'Предел x → 0', latex:'\\lim_{x \\to {}}{}', display:'\\lim_{x\\to 0}f(x)', cursorBack:4 },
{ label:'Предел x → ∞', latex:'\\lim_{x \\to \\infty}{}', display:'\\lim_{x\\to\\infty}f(x)', cursorBack:2 },
{ label:'Производная dy/dx', latex:'\\frac{dy}{dx}', display:'\\frac{dy}{dx}' },
{ label:'Частная производная', latex:'\\frac{\\partial {}}{\\partial {}}',display:'\\frac{\\partial f}{\\partial x}',cursorBack:11 },
{ label:'Вторая производная d²y/dx²',latex:"\\frac{d^2y}{dx^2}", display:"\\frac{d^{2}y}{dx^{2}}" },
{ label:'f штрих f\'(x)', latex:"f'(x)", display:"f'(x)" },
{ label:'Вектор над буквой', latex:'\\vec{}', display:'\\vec{a}', cursorBack:1 },
{ label:'Вектор AB →', latex:'\\overrightarrow{}', display:'\\overrightarrow{AB}', cursorBack:1 },
]},
greek: { name: 'α β γ …', items: [
{ label:'Альфа α', latex:'\\alpha', display:'\\alpha' },
{ label:'Бета β', latex:'\\beta', display:'\\beta' },
{ label:'Гамма γ', latex:'\\gamma', display:'\\gamma' },
{ label:'Гамма Γ', latex:'\\Gamma', display:'\\Gamma' },
{ label:'Дельта δ', latex:'\\delta', display:'\\delta' },
{ label:'Дельта Δ', latex:'\\Delta', display:'\\Delta' },
{ label:'Эпсилон ε', latex:'\\varepsilon',display:'\\varepsilon' },
{ label:'Дзета ζ', latex:'\\zeta', display:'\\zeta' },
{ label:'Эта η', latex:'\\eta', display:'\\eta' },
{ label:'Тета θ', latex:'\\theta', display:'\\theta' },
{ label:'Тета Θ', latex:'\\Theta', display:'\\Theta' },
{ label:'Каппа κ', latex:'\\kappa', display:'\\kappa' },
{ label:'Лямбда λ', latex:'\\lambda', display:'\\lambda' },
{ label:'Лямбда Λ', latex:'\\Lambda', display:'\\Lambda' },
{ label:'Мю μ', latex:'\\mu', display:'\\mu' },
{ label:'Ню ν', latex:'\\nu', display:'\\nu' },
{ label:'Кси ξ', latex:'\\xi', display:'\\xi' },
{ label:'Пи π', latex:'\\pi', display:'\\pi' },
{ label:'Пи Π', latex:'\\Pi', display:'\\Pi' },
{ label:'Ро ρ', latex:'\\rho', display:'\\rho' },
{ label:'Сигма σ', latex:'\\sigma', display:'\\sigma' },
{ label:'Сигма Σ', latex:'\\Sigma', display:'\\Sigma' },
{ label:'Тау τ', latex:'\\tau', display:'\\tau' },
{ label:'Фи φ', latex:'\\varphi', display:'\\varphi' },
{ label:'Фи Φ', latex:'\\Phi', display:'\\Phi' },
{ label:'Хи χ', latex:'\\chi', display:'\\chi' },
{ label:'Пси ψ', latex:'\\psi', display:'\\psi' },
{ label:'Пси Ψ', latex:'\\Psi', display:'\\Psi' },
{ label:'Омега ω', latex:'\\omega', display:'\\omega' },
{ label:'Омега Ω', latex:'\\Omega', display:'\\Omega' },
]},
trig: { name: 'sin · cos', items: [
{ label:'Синус sin(x)', latex:'\\sin{}', display:'\\sin(x)', cursorBack:1 },
{ label:'Косинус cos(x)', latex:'\\cos{}', display:'\\cos(x)', cursorBack:1 },
{ label:'Тангенс tan(x)', latex:'\\tan{}', display:'\\tan(x)', cursorBack:1 },
{ label:'Котангенс cot(x)', latex:'\\cot{}', display:'\\cot(x)', cursorBack:1 },
{ label:'Арксинус arcsin', latex:'\\arcsin{}', display:'\\arcsin(x)', cursorBack:1 },
{ label:'Арккосинус arccos', latex:'\\arccos{}', display:'\\arccos(x)', cursorBack:1 },
{ label:'Арктангенс arctan', latex:'\\arctan{}', display:'\\arctan(x)', cursorBack:1 },
{ label:'sin² x', latex:'\\sin^{2}{}', display:'\\sin^{2}x', cursorBack:1 },
{ label:'cos² x', latex:'\\cos^{2}{}', display:'\\cos^{2}x', cursorBack:1 },
{ label:'sin²+cos²=1', latex:'\\sin^{2}x+\\cos^{2}x=1', display:'\\sin^{2}x+\\cos^{2}x=1' },
{ label:'sin(α+β)', latex:'\\sin(\\alpha+\\beta)', display:'\\sin(\\alpha+\\beta)' },
{ label:'cos(α+β)', latex:'\\cos(\\alpha+\\beta)', display:'\\cos(\\alpha+\\beta)' },
{ label:'sin(2α) = 2sin·cos', latex:'\\sin(2\\alpha)=2\\sin\\alpha\\cos\\alpha', display:'\\sin 2\\alpha' },
]},
geo: { name: 'Геометрия', items: [
{ label:'Угол ∠', latex:'\\angle', display:'\\angle' },
{ label:'Треугольник △', latex:'\\triangle', display:'\\triangle' },
{ label:'Перпендикулярно ⊥', latex:'\\perp', display:'\\perp' },
{ label:'Параллельно ∥', latex:'\\parallel', display:'\\parallel' },
{ label:'Отрезок AB с чёрточкой', latex:'\\overline{}', display:'\\overline{AB}', cursorBack:1 },
{ label:'Вектор AB →', latex:'\\overrightarrow{}', display:'\\overrightarrow{AB}',cursorBack:1 },
{ label:'Длина отрезка |AB|', latex:'|{}|', display:'|AB|', cursorBack:2 },
{ label:'Координаты (x; y)', latex:'({};\\ {})', display:'(x;\\ y)', cursorBack:5 },
{ label:'Подобие ~', latex:'\\sim', display:'\\sim' },
{ label:'Конгруэнтность ≅', latex:'\\cong', display:'\\cong' },
{ label:'Площадь S = ...', latex:'S={}', display:'S=\\ldots', cursorBack:2 },
{ label:'Периметр P = ...', latex:'P={}', display:'P=\\ldots', cursorBack:2 },
{ label:'Теорема Пифагора a²+b²=c²',latex:'a^{2}+b^{2}=c^{2}', display:'a^{2}+b^{2}=c^{2}' },
{ label:'Формула расстояния', latex:'\\sqrt{(x_2-x_1)^{2}+(y_2-y_1)^{2}}', display:'\\sqrt{\\Delta x^{2}+\\Delta y^{2}}' },
]},
matrix: { name: 'Матрицы', items: [
{ label:'Матрица 2×2', latex:'\\begin{pmatrix} {} & {} \\\\ {} & {} \\end{pmatrix}', display:'\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}', cursorBack:30 },
{ label:'Матрица 3×3', latex:'\\begin{pmatrix} {} & {} & {} \\\\ {} & {} & {} \\\\ {} & {} & {} \\end{pmatrix}', display:'\\begin{pmatrix}a&b&c\\\\d&e&f\\\\g&h&k\\end{pmatrix}', cursorBack:50 },
{ label:'Столбец-вектор', latex:'\\begin{pmatrix} {} \\\\ {} \\end{pmatrix}', display:'\\begin{pmatrix}x\\\\y\\end{pmatrix}', cursorBack:18 },
{ label:'Система 2 уравнений', latex:'\\begin{cases} {} \\\\ {} \\end{cases}', display:'\\begin{cases}ax=b\\\\cx=d\\end{cases}', cursorBack:18 },
{ label:'Система 3 уравнений', latex:'\\begin{cases} {} \\\\ {} \\\\ {} \\end{cases}', display:'\\begin{cases}a\\\\b\\\\c\\end{cases}', cursorBack:18 },
{ label:'Определитель |M| 2×2',latex:'\\begin{vmatrix} {} & {} \\\\ {} & {} \\end{vmatrix}', display:'\\begin{vmatrix}a&b\\\\c&d\\end{vmatrix}', cursorBack:30 },
]},
};
/* ---- render buttons for current tab ---- */
function wbFmRenderTab(tabId) {
const grid = document.getElementById('wbfm-grid');
grid.innerHTML = '';
const cat = WB_FM_CATS[tabId];
if (!cat) return;
cat.items.forEach(item => {
const btn = document.createElement('button');
btn.className = 'wbfm-vbtn';
btn.title = item.label;
if (window.katex) {
try {
btn.innerHTML = window.katex.renderToString(item.display, { throwOnError: false, displayMode: false });
} catch(e) { btn.textContent = item.label.slice(0,6); }
} else {
btn.textContent = item.label.slice(0,8);
}
btn.addEventListener('click', () => wbFmInsertItem(item));
grid.appendChild(btn);
});
}
function wbFmTab(btn, tabId) {
_wbFmCurrentTab = tabId;
document.querySelectorAll('.wbfm-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
wbFmRenderTab(tabId);
}
/* ---- insert item into textarea and show LaTeX area ---- */
function wbFmInsertItem(item) {
// Auto-show LaTeX area so teacher sees what was generated
const ltArea = document.getElementById('wbfm-latex-area');
if (ltArea.style.display === 'none') {
ltArea.style.display = 'block';
document.getElementById('wbfm-lt-arrow').classList.add('open');
}
const ta = document.getElementById('wbfm-input');
const s = ta.selectionStart, e = ta.selectionEnd;
ta.value = ta.value.slice(0, s) + item.latex + ta.value.slice(e);
// place cursor inside first {} if template
let newPos = s + item.latex.length - (item.cursorBack || 0);
const braceIn = item.latex.indexOf('{}');
if (braceIn >= 0 && item.cursorBack > 0) {
newPos = s + braceIn + 1;
}
ta.focus();
ta.setSelectionRange(newPos, newPos);
// show hint about Tab key if template has holes
const holes = (item.latex.match(/\{\}/g) || []).length;
const hint = document.getElementById('wbfm-tab-hint');
hint.textContent = holes > 1 ? 'Tab — следующее поле' : holes === 1 ? 'Допишите значение' : '';
wbFmUpdatePreview();
}
/* ---- Tab key jumps between {} holes ---- */
function wbFmKeydown(event) {
if (event.key === 'Tab') {
event.preventDefault();
const ta = event.target;
const pos = ta.selectionStart;
const val = ta.value;
// find next {} after cursor
let next = val.indexOf('{}', pos);
if (next < 0) next = val.indexOf('{}'); // wrap around
if (next >= 0) { ta.setSelectionRange(next + 1, next + 1); return; }
}
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault(); wbFmInsert();
}
if (event.key === 'Escape') { event.stopPropagation(); wbFmClose(); }
}
function wbFmToggleLatex() {
const area = document.getElementById('wbfm-latex-area');
const arrow = document.getElementById('wbfm-lt-arrow');
const open = area.style.display !== 'none';
area.style.display = open ? 'none' : 'block';
arrow.classList.toggle('open', !open);
if (!open) setTimeout(() => document.getElementById('wbfm-input').focus(), 50);
}
function showFormulaModal(vx, vy, existingLatex) {
_wbFmVx = vx; _wbFmVy = vy;
const modal = document.getElementById('wb-formula-modal');
modal.style.display = 'block';
// Reset tab to basic
wbFmRenderTab(_wbFmCurrentTab);
// Reset/load latex
const ta = document.getElementById('wbfm-input');
ta.value = existingLatex || '';
if (existingLatex) {
// Editing existing — show LaTeX field
document.getElementById('wbfm-latex-area').style.display = 'block';
document.getElementById('wbfm-lt-arrow').classList.add('open');
} else {
document.getElementById('wbfm-latex-area').style.display = 'none';
document.getElementById('wbfm-lt-arrow').classList.remove('open');
}
document.getElementById('wbfm-insert-btn').disabled = !ta.value.trim();
wbFmUpdatePreview();
}
function wbFmClose() {
document.getElementById('wb-formula-modal').style.display = 'none';
if (_wbFmPrevTimer) { clearTimeout(_wbFmPrevTimer); _wbFmPrevTimer = null; }
if (_wb) _wb._editingFormulaStroke = null;
document.getElementById('wbfm-tab-hint').textContent = '';
}
function wbFmUpdatePreview() {
if (_wbFmPrevTimer) clearTimeout(_wbFmPrevTimer);
_wbFmPrevTimer = setTimeout(() => {
const latex = document.getElementById('wbfm-input').value.trim();
const prev = document.getElementById('wbfm-preview');
const btn = document.getElementById('wbfm-insert-btn');
btn.disabled = !latex;
if (!latex) {
prev.innerHTML = '<span style="color:rgba(255,255,255,0.28);font-size:0.85rem">Нажмите любую кнопку — здесь появится формула</span><span class="wbfm-prev-hint">Ctrl+Enter — вставить</span>';
return;
}
if (!window.katex) { prev.textContent = latex; return; }
try {
prev.innerHTML = window.katex.renderToString(latex, { throwOnError: false, displayMode: true });
prev.innerHTML += '<span class="wbfm-prev-hint">Ctrl+Enter — вставить</span>';
} catch (e) {
prev.innerHTML = `<span style="color:#F15BB5;font-size:0.8rem">${e.message || 'Ошибка'}</span>`;
}
}, 160);
}
function wbFmSz(btn, fs) {
_wbFmFontSize = fs;
document.querySelectorAll('.wbfm-sz').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}
function wbFmInsert() {
if (!_wb) return wbFmClose();
const latex = document.getElementById('wbfm-input').value.trim();
if (!latex) return;
const editStroke = _wb._editingFormulaStroke;
if (editStroke) {
_wb.updateFormula(editStroke, latex, _wbFmFontSize);
} else {
_wb.insertFormula(_wbFmVx, _wbFmVy, latex, _wbFmFontSize);
}
wbFmClose();
}
/* ── Image upload ── */
function wbOpenImagePicker() {
document.getElementById('wb-image-input')?.click();
}
function wbImageSelected(input) {
const file = input.files?.[0];
if (!file || !_wb || !_sessionId) return;
input.value = '';
const maxPx = 800;
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
// Resize to max 800px on longest side
let w = img.naturalWidth, h = img.naturalHeight;
if (w > maxPx || h > maxPx) {
if (w >= h) { h = Math.round(h * maxPx / w); w = maxPx; }
else { w = Math.round(w * maxPx / h); h = maxPx; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
const src = canvas.toDataURL('image/jpeg', 0.8);
// Place image centered on whiteboard in virtual coords
const vw = (w / (img.naturalWidth || w)) * 800;
const vh = (h / (img.naturalHeight || h)) * 450;
const vx = (1920 - vw) / 2;
const vy = (1080 - vh) / 2;
const stroke = {
id: _wb._localIdCounter--,
tool: 'image',
data: { src, x: vx, y: vy, w: vw, h: vh },
};
_wb._strokes.push(stroke);
_wb._undoStack.push(stroke.id);
_wb.render();
if (_wb._onStrokeDone) _wb._onStrokeDone(stroke);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function wbSetCustomColor(input) {
if (!_wb) return;
_wb.setColor(input.value);
document.querySelectorAll('.cr-color-btn').forEach(b => b.classList.remove('active'));
input.style.outline = '2px solid #fff';
setTimeout(() => { if (input) input.style.outline = ''; }, 300);
if (['eraser','laser'].some(t => document.getElementById(`wb-tool-${t}`)?.classList.contains('active')))
wbSetTool('pencil');
}
function wbToggleFullscreen() {
const el = document.getElementById('cr-board-wrap');
if (!document.fullscreenElement) {
el.requestFullscreen?.() || el.webkitRequestFullscreen?.();
} else {
document.exitFullscreen?.() || document.webkitExitFullscreen?.();
}
}
/* ── Student page nav / follow ── */
async function wbStudentPrevPage() {
if (_wbCurrentPage <= 1) return;
_followTeacher = false; updateFollowBtn();
await wbGoToPage(_wbCurrentPage - 1);
}
async function wbStudentNextPage() {
if (_wbCurrentPage >= _totalPages) return;
_followTeacher = false; updateFollowBtn();
await wbGoToPage(_wbCurrentPage + 1);
}
function crToggleFollow() {
_followTeacher = !_followTeacher;
updateFollowBtn();
if (_followTeacher && _session?.current_page) wbGoToPage(_session.current_page);
}
function updateFollowBtn() {
const btn = document.getElementById('cr-follow-btn');
const lbl = document.getElementById('cr-follow-label');
if (!btn) return;
btn.classList.toggle('active', _followTeacher);
if (lbl) lbl.textContent = _followTeacher ? 'Авто' : 'Ручной';
}
/* ── Keyboard shortcuts (non-whiteboard-class shortcuts only) ── */
// Note: Ctrl+Z/Y, Ctrl+C/V, Delete, Escape are handled inside Whiteboard class.
document.addEventListener('keydown', e => {
if (!_wb || !_sessionId) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const isTeacher = _me?.role === 'teacher' || _me?.role === 'admin';
if (!isTeacher) return;
const toolMap = { '1': 'pencil', '2': 'eraser', '3': 'rect', '4': 'ellipse', '5': 'line', '6': 'text',
'7': 'connector', '8': 'sticky', '9': 'formula' };
if (toolMap[e.key] && !e.ctrlKey && !e.metaKey) { wbSetTool(toolMap[e.key]); return; }
});
/* ── WebRTC ── */
async function initRTC(session) {
if (_rtc) { _rtc.destroy(); _rtc = null; }
// peers already in session (excluding self)
const peerIds = Object.keys(_participants).map(Number).filter(id => id !== _me.id);
_rtc = new ClassroomRTC({
sessionId: session.id,
userId: _me.id,
onSignal: async (targetId, payload) => {
try {
await LS.post(`/api/classroom/${session.id}/signal`, { target_user_id: targetId, payload });
} catch {}
},
onScreenStream: (stream) => {
const video = document.getElementById('cr-screen-video');
const label = document.getElementById('cr-screen-label');
if (stream) {
video.srcObject = stream;
video.style.display = 'block';
label.style.display = 'block';
} else {
video.srcObject = null;
video.style.display = 'none';
label.style.display = 'none';
}
},
onMicActive: (uid, speaking) => {
if (_participants[uid]) {
_participants[uid].speaking = speaking;
// Update only the avatar element to avoid full re-render on every VAD tick
const avatar = document.querySelector(`.cr-participant[data-uid="${uid}"] .cr-p-avatar`);
if (avatar) avatar.classList.toggle('speaking', speaking);
}
},
});
const micOk = await _rtc.startAudio();
if (!micOk) LS.toast('Нет доступа к микрофону', 'warning');
document.getElementById('cr-mute-btn').style.display = 'flex';
updateMuteBtn();
if (peerIds.length > 0) await _rtc.connectTo(peerIds);
}
function crToggleMute() {
if (!_rtc) return;
const muted = _rtc.toggleMute();
if (_participants[_me?.id]) _participants[_me.id].micMuted = muted;
updateParticipantsList();
updateMuteBtn();
}
function updateMuteBtn() {
const btn = document.getElementById('cr-mute-btn');
if (!btn) return;
const muted = _rtc?.isMuted() ?? false;
btn.classList.toggle('cr-btn-mic-on', !muted);
btn.classList.toggle('cr-btn-mic-off', muted);
// Update icon via data attribute on a child span (no innerHTML overwrite — keeps onclick)
let ico = btn.querySelector('.cr-mute-ico');
let lbl = btn.querySelector('.cr-mute-lbl');
if (!ico) {
btn.innerHTML = '<span class="cr-mute-ico"></span><span class="cr-mute-lbl"></span>';
ico = btn.querySelector('.cr-mute-ico');
lbl = btn.querySelector('.cr-mute-lbl');
}
lbl.textContent = muted ? 'Выключен' : 'Включён';
ico.innerHTML = muted
? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;display:block"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;display:block"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`;
}
async function crToggleScreen() {
if (!_rtc || !_sessionId) return;
const btn = document.getElementById('cr-screen-btn');
if (_rtc.isSharing()) {
await _rtc.stopScreenShare();
btn.classList.remove('cr-btn-sharing');
try { await LS.del(`/api/classroom/${_sessionId}/screen`); } catch {}
} else {
const stream = await _rtc.startScreenShare();
if (!stream) return; // user cancelled
// intercept track ended (browser stop button) to clean up page state
const vt = stream.getVideoTracks()[0];
if (vt) {
vt.onended = () => {
_rtc.stopScreenShare();
onScreenShareStopped();
};
}
btn.classList.add('cr-btn-sharing');
try { await LS.post(`/api/classroom/${_sessionId}/screen`); } catch {}
}
}
async function onScreenShareStopped() {
document.getElementById('cr-screen-btn').classList.remove('cr-btn-sharing');
if (_sessionId) {
try { await LS.del(`/api/classroom/${_sessionId}/screen`); } catch {}
}
}
async function crToggleDrawPermission(uid) {
if (!_sessionId) return;
const numUid = Number(uid);
const hasPermit = _permittedStudents.has(numUid);
try {
if (hasPermit) {
await LS.del(`/api/classroom/${_sessionId}/allow-draw/${numUid}`);
_permittedStudents.delete(numUid);
} else {
await LS.post(`/api/classroom/${_sessionId}/allow-draw/${numUid}`);
_permittedStudents.add(numUid);
}
updateParticipantsList();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function crMutePeer(uid) {
if (!_sessionId) return;
try {
await LS.post(`/api/classroom/${_sessionId}/mute`, { user_id: uid });
if (_participants[uid]) {
_participants[uid].micMuted = true;
updateParticipantsList();
}
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ── notifications ── */
function toggleNotifDrop() {
const drop = document.getElementById('notif-drop');
if (!drop.classList.contains('open')) {
const r = document.getElementById('notif-btn').getBoundingClientRect();
drop.style.left = (r.right + 8) + 'px';
drop.style.top = r.top + 'px';
}
drop.classList.toggle('open');
if (drop.classList.contains('open')) LS.loadNotifs();
}
document.addEventListener('click', e => {
const drop = document.getElementById('notif-drop');
if (drop.classList.contains('open') &&
!drop.contains(e.target) &&
!document.getElementById('notif-btn').contains(e.target))
drop.classList.remove('open');
});
/* ── sidebar ── */
function toggleSidebar() {
const layout = document.querySelector('.app-layout');
layout.classList.toggle('sb-collapsed');
localStorage.setItem('ls_sb_collapsed', layout.classList.contains('sb-collapsed') ? '1' : '0');
if (window.lucide) lucide.createIcons();
}
function lsSearchOpen() {
if (typeof LS !== 'undefined' && LS.searchOpen) LS.searchOpen();
}
/* ── stop polling + leave on navigate away ── */
window.addEventListener('pagehide', () => {
stopPolling();
if (_timerHandle) { clearInterval(_timerHandle); _timerHandle = null; }
if (_rtc) { _rtc.destroy(); _rtc = null; }
if (_sessionId) LS.post(`/api/classroom/${_sessionId}/leave`).catch(() => {});
});
/* ── run ── */
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
</script>
<script src="/js/mobile.js"></script>
<!-- ── Formula visual editor modal ──────────────────────────────────── -->
<div id="wb-formula-modal" style="display:none; position:fixed; inset:0; z-index:190;">
<div class="wbfm-overlay" onclick="wbFmClose()"></div>
<div class="wbfm-card">
<!-- header -->
<div class="wbfm-hdr">
<span class="wbfm-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9B5DE5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h7l1 9 2-6 2 6 1-9h7"/><path d="M5 21h14"/></svg>
Вставить формулу
</span>
<button class="wbfm-close" onclick="wbFmClose()" title="Закрыть (Esc)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<!-- live preview (big) -->
<div class="wbfm-preview" id="wbfm-preview">
<span style="color:rgba(255,255,255,0.28);font-size:0.85rem">Нажмите любую кнопку — здесь появится формула</span>
<span class="wbfm-prev-hint">Ctrl+Enter — вставить</span>
</div>
<!-- category tabs -->
<div class="wbfm-tabs" id="wbfm-tabs">
<button class="wbfm-tab active" onclick="wbFmTab(this,'basic')">Основные</button>
<button class="wbfm-tab" onclick="wbFmTab(this,'frac')">Дроби · √</button>
<button class="wbfm-tab" onclick="wbFmTab(this,'powers')">Степени</button>
<button class="wbfm-tab" onclick="wbFmTab(this,'sums')">Суммы · ∫</button>
<button class="wbfm-tab" onclick="wbFmTab(this,'greek')">α β γ</button>
<button class="wbfm-tab" onclick="wbFmTab(this,'trig')">sin · cos</button>
<button class="wbfm-tab" onclick="wbFmTab(this,'geo')">Геометрия</button>
<button class="wbfm-tab" onclick="wbFmTab(this,'matrix')">Матрицы</button>
</div>
<!-- visual button grid -->
<div class="wbfm-grid-wrap">
<div class="wbfm-grid" id="wbfm-grid"></div>
</div>
<!-- LaTeX code toggle (hidden by default) -->
<div class="wbfm-latex-toggle">
<button class="wbfm-lt-btn" onclick="wbFmToggleLatex()" id="wbfm-lt-btn">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
LaTeX-код (для опытных)
<span class="wbfm-lt-arrow" id="wbfm-lt-arrow"></span>
</button>
<span class="wbfm-tab-hint" id="wbfm-tab-hint"></span>
</div>
<div class="wbfm-latex-area" id="wbfm-latex-area" style="display:none">
<textarea id="wbfm-input" class="wbfm-input" rows="2"
oninput="wbFmUpdatePreview()"
onkeydown="wbFmKeydown(event)"
placeholder="\frac{a}{b} \sqrt{x} \int_0^\infty e^{-x}\,dx"></textarea>
</div>
<!-- bottom bar: size + actions -->
<div class="wbfm-bottom">
<div class="wbfm-size-row">
Размер:
<button class="wbfm-sz" onclick="wbFmSz(this,24)">S</button>
<button class="wbfm-sz active" onclick="wbFmSz(this,32)">M</button>
<button class="wbfm-sz" onclick="wbFmSz(this,48)">L</button>
<button class="wbfm-sz" onclick="wbFmSz(this,72)">XL</button>
</div>
<div class="wbfm-actions">
<button class="wbfm-cancel" onclick="wbFmClose()">Отмена</button>
<button class="wbfm-insert" id="wbfm-insert-btn" onclick="wbFmInsert()" disabled>Вставить на доску</button>
</div>
</div>
</div>
</div>
<!-- Templates modal -->
<div class="cr-tpl-modal-overlay" id="cr-tpl-modal">
<div class="cr-tpl-modal">
<div class="cr-tpl-hdr">
<span class="cr-tpl-title">Шаблоны уроков</span>
<button class="cr-tpl-close" onclick="crHideTemplates()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="cr-tpl-list" id="cr-tpl-list">
<div class="cr-tpl-empty">Нет сохранённых шаблонов</div>
</div>
<div class="cr-tpl-save-area">
<input class="cr-tpl-name-input" id="cr-tpl-name" placeholder="Название шаблона…" maxlength="80">
<button class="cr-tpl-save-btn" onclick="crSaveTemplate()">Сохранить текущий урок</button>
</div>
</div>
</div>
<!-- Coordinate system edit modal -->
<div id="wb-coord-modal" style="display:none; position:fixed; inset:0; z-index:190; align-items:center; justify-content:center; background:rgba(0,0,0,0.6);">
<div style="background:#1a1028; border:1px solid rgba(155,93,229,0.4); border-radius:12px; padding:20px; width:380px; max-width:96vw;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;">
<span style="font-size:14px;font-weight:700;color:#e8e0f7;">Система координат</span>
<button onclick="closeCoordModal()" style="background:none;border:none;cursor:pointer;color:#9B5DE5;font-size:18px;padding:0 4px;"></button>
</div>
<!-- Axis range -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px;">
<label style="font-size:11px;color:rgba(255,230,180,0.5);">X min
<input id="coord-xmin" type="number" value="-10" oninput="applyCoordSettings()" style="width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(155,93,229,0.3);border-radius:4px;color:#e8e0f7;padding:5px 8px;font-size:12px;">
</label>
<label style="font-size:11px;color:rgba(255,230,180,0.5);">X max
<input id="coord-xmax" type="number" value="10" oninput="applyCoordSettings()" style="width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(155,93,229,0.3);border-radius:4px;color:#e8e0f7;padding:5px 8px;font-size:12px;">
</label>
<label style="font-size:11px;color:rgba(255,230,180,0.5);">Y min
<input id="coord-ymin" type="number" value="-10" oninput="applyCoordSettings()" style="width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(155,93,229,0.3);border-radius:4px;color:#e8e0f7;padding:5px 8px;font-size:12px;">
</label>
<label style="font-size:11px;color:rgba(255,230,180,0.5);">Y max
<input id="coord-ymax" type="number" value="10" oninput="applyCoordSettings()" style="width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(155,93,229,0.3);border-radius:4px;color:#e8e0f7;padding:5px 8px;font-size:12px;">
</label>
</div>
<label style="font-size:11px;color:rgba(255,230,180,0.5);">Шаг сетки
<input id="coord-step" type="number" value="1" min="0.1" step="0.1" oninput="applyCoordSettings()" style="width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(155,93,229,0.3);border-radius:4px;color:#e8e0f7;padding:5px 8px;font-size:12px;">
</label>
<!-- Functions -->
<div style="margin-top:14px;">
<div style="font-size:11px;color:rgba(255,230,180,0.5);margin-bottom:8px;">Функции (y = ...)</div>
<div id="coord-fn-list"></div>
<button onclick="coordAddFn()" style="width:100%;padding:6px;background:rgba(155,93,229,0.12);border:1px solid rgba(155,93,229,0.3);border-radius:6px;color:#9B5DE5;cursor:pointer;font-size:12px;margin-top:4px;">+ Добавить функцию</button>
</div>
<div style="margin-top:16px;display:flex;justify-content:flex-end;">
<button onclick="closeCoordModal();if(_wb&&_wb._onStrokeUpdated&&_coordEditStroke)_wb._onStrokeUpdated(_coordEditStroke);" style="padding:7px 18px;background:linear-gradient(135deg,#9B5DE5,#4361EE);border:none;border-radius:7px;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">Готово</button>
</div>
</div>
</div>
<!-- ── Number Line edit modal ───────────────────────────────────────────── -->
<div id="wb-numline-modal" style="display:none; position:fixed; inset:0; z-index:190; align-items:center; justify-content:center; background:rgba(0,0,0,0.6);">
<div style="background:#1a1028; border:1px solid rgba(6,214,224,0.35); border-radius:12px; padding:20px; width:380px; max-width:96vw;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;">
<span style="font-size:14px;font-weight:700;color:#e8e0f7;">Числовая ось</span>
<button onclick="closeNumLineModal()" style="background:none;border:none;cursor:pointer;color:#06D6E0;font-size:18px;padding:0 4px;"></button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px;">
<label style="font-size:11px;color:rgba(255,230,180,0.5);">Мин
<input id="nl-min" type="number" value="-10" oninput="applyNumLineSettings()" style="width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(6,214,224,0.3);border-radius:4px;color:#e8e0f7;padding:5px 8px;font-size:12px;">
</label>
<label style="font-size:11px;color:rgba(255,230,180,0.5);">Макс
<input id="nl-max" type="number" value="10" oninput="applyNumLineSettings()" style="width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(6,214,224,0.3);border-radius:4px;color:#e8e0f7;padding:5px 8px;font-size:12px;">
</label>
<label style="font-size:11px;color:rgba(255,230,180,0.5);">Шаг
<input id="nl-step" type="number" value="1" min="0.1" step="0.1" oninput="applyNumLineSettings()" style="width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(6,214,224,0.3);border-radius:4px;color:#e8e0f7;padding:5px 8px;font-size:12px;">
</label>
</div>
<!-- Points -->
<div style="margin-bottom:12px;">
<div style="font-size:11px;color:rgba(255,230,180,0.5);margin-bottom:6px;">Точки</div>
<div id="nl-points-list"></div>
<button onclick="nlAddPoint()" style="width:100%;padding:5px;background:rgba(6,214,224,0.1);border:1px solid rgba(6,214,224,0.25);border-radius:5px;color:#06D6E0;cursor:pointer;font-size:11px;margin-top:4px;">+ Добавить точку</button>
</div>
<!-- Intervals -->
<div style="margin-bottom:14px;">
<div style="font-size:11px;color:rgba(255,230,180,0.5);margin-bottom:6px;">Интервалы (закрашенные области)</div>
<div id="nl-intervals-list"></div>
<button onclick="nlAddInterval()" style="width:100%;padding:5px;background:rgba(155,93,229,0.1);border:1px solid rgba(155,93,229,0.25);border-radius:5px;color:#9B5DE5;cursor:pointer;font-size:11px;margin-top:4px;">+ Добавить интервал</button>
</div>
<div style="display:flex;justify-content:flex-end;">
<button onclick="closeNumLineModal();if(_wb&&_wb._onStrokeUpdated&&_nlEditStroke)_wb._onStrokeUpdated(_nlEditStroke);" style="padding:7px 18px;background:linear-gradient(135deg,#06D6E0,#4361EE);border:none;border-radius:7px;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">Готово</button>
</div>
</div>
</div>
<!-- ── Universal Confirm Dialog ────────────────────────────────────────── -->
<div id="cr-dlg" style="display:none" class="cr-dlg-overlay" role="dialog" aria-modal="true">
<div class="cr-dlg">
<div class="cr-dlg-icon danger" id="cr-dlg-icon"></div>
<h3 class="cr-dlg-title" id="cr-dlg-title"></h3>
<p class="cr-dlg-msg" id="cr-dlg-msg"></p>
<div class="cr-dlg-actions">
<button class="cr-dlg-cancel" id="cr-dlg-cancel">Отмена</button>
<button class="cr-dlg-ok danger" id="cr-dlg-ok"></button>
</div>
</div>
</div>
</body>
</html>