Files

8262 lines
436 KiB
HTML
Raw Permalink 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&family=Caveat:wght@400;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: var(--text-3); 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; position: relative;
}
.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; }
.wb-thumb-label {
position: absolute; bottom: 14px; left: 0; right: 0;
font-size: 9px; color: rgba(255,255,255,0.55);
font-family: 'Manrope', sans-serif; text-align: center;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
padding: 0 3px; pointer-events: none;
}
.wb-thumb-rename {
position: absolute; bottom: 10px; left: 3px; right: 3px; z-index: 10;
font-size: 9px; font-family: 'Manrope', sans-serif;
background: #1a1030; border: 1px solid #9B5DE5;
color: #fff; border-radius: 3px; padding: 1px 3px;
outline: none; width: calc(100% - 6px);
}
/* Page context menu */
.wb-page-menu {
position: fixed; z-index: 9999;
background: #1c1030; border: 1px solid rgba(155,93,229,0.35);
border-radius: 6px; padding: 4px 0;
box-shadow: 0 6px 20px rgba(0,0,0,0.55);
min-width: 140px; display: none;
}
.wb-page-menu.open { display: block; }
.wb-page-menu button {
display: block; width: 100%; padding: 6px 12px;
background: none; border: none; color: rgba(255,255,255,0.8);
font-size: 11px; font-family: 'Manrope', sans-serif;
text-align: left; cursor: pointer; transition: background .1s;
}
.wb-page-menu button:hover { background: rgba(155,93,229,0.2); color: #fff; }
.wb-page-menu button.danger:hover { background: rgba(255,80,80,0.18); color: #ff8080; }
.wb-page-menu hr { border: none; border-top: 1px solid rgba(255,255,255,0.07); margin: 3px 0; }
/* 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); }
/* ── toolbar dropdown popups (shapes / utils) ──────────────────────── */
.cr-drop { position: relative; display: inline-flex; flex-shrink: 0; }
.cr-drop > .cr-tool-btn { position: relative; }
.cr-drop-chevron {
position: absolute; bottom: 3px; right: 2px; pointer-events: none;
width: 6px; height: 4px; fill: rgba(255,200,100,0.32);
}
.cr-drop-popup {
position: fixed;
background: linear-gradient(180deg, #2c1e10 0%, #1e1509 100%);
border: 1.5px solid #4a2e18; border-radius: 10px; padding: 5px;
z-index: 9999; display: none; gap: 3px;
box-shadow: 0 -6px 28px rgba(0,0,0,0.65);
}
.cr-drop-popup.open { display: grid; }
.cr-shape-popup-grid { grid-template-columns: repeat(5, 32px); }
.cr-utils-popup-grid { grid-template-columns: repeat(3, 32px); }
.cr-pop-sep {
grid-column: 1 / -1; height: 1px;
background: rgba(255,180,60,0.12); margin: 2px 0;
}
.cr-pop-full { grid-column: 1 / -1; display: flex; justify-content: center; }
.cr-pop-fill-btn {
display: flex; align-items: center; gap: 5px; padding: 0 10px;
height: 28px; border-radius: 7px; border: 1.5px solid transparent;
background: transparent; cursor: pointer; color: rgba(255,230,180,0.6);
font-family: 'Manrope',sans-serif; font-size: 11px; font-weight: 600;
transition: all .12s; white-space: nowrap;
}
.cr-pop-fill-btn:hover { background: rgba(255,200,100,0.1); color: rgba(255,230,180,0.95); }
.cr-pop-fill-btn.active { background: rgba(255,180,60,0.15); border-color: rgba(255,180,60,0.45); color: #f5b942; }
.cr-pop-fill-btn svg { width: 13px; height: 13px; flex-shrink: 0; }
/* ── text options row ────────────────────────────────────────────────── */
.cr-text-row {
height: 38px; flex-shrink: 0;
display: none; align-items: center; gap: 4px; padding: 0 10px;
border-top: 1px solid rgba(255,180,60,0.12);
background: rgba(0,0,0,0.18);
}
.cr-text-row.visible { display: flex; }
.cr-text-row label {
font-family: 'Manrope',sans-serif; font-size: 10px;
color: rgba(255,230,180,0.4); white-space: nowrap;
}
.cr-text-font-sel {
height: 26px; padding: 0 6px;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,180,60,0.2);
border-radius: 6px; color: rgba(255,230,180,0.85);
font-family: 'Manrope',sans-serif; font-size: 11px; cursor: pointer;
outline: none;
}
.cr-text-size-inp {
width: 44px; height: 26px; text-align: center;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,180,60,0.2);
border-radius: 6px; color: rgba(255,230,180,0.85);
font-family: 'Manrope',sans-serif; font-size: 12px; font-weight: 700;
outline: none; -moz-appearance: textfield;
}
.cr-text-size-inp::-webkit-inner-spin-button { opacity: 0.4; }
.cr-text-fmt-btn {
width: 28px; height: 26px; border-radius: 6px; border: 1.5px solid transparent;
background: transparent; cursor: pointer; color: rgba(255,230,180,0.55);
font-family: Georgia,serif; font-size: 14px; font-weight: bold;
display: flex; align-items: center; justify-content: center;
transition: all .12s; flex-shrink: 0;
}
.cr-text-fmt-btn:hover { background: rgba(255,200,100,0.1); color: rgba(255,230,180,0.95); }
.cr-text-fmt-btn.active { background: rgba(255,180,60,0.15); border-color: rgba(255,180,60,0.45); color: #f5b942; }
/* ── context rows (connector / sticky / multi-align) ──────────────── */
.wb-ctx-row {
height: 38px; flex-shrink: 0;
display: none; align-items: center; gap: 4px; padding: 0 10px;
border-top: 1px solid rgba(255,180,60,0.12);
background: rgba(0,0,0,0.18);
font-family: 'Manrope',sans-serif;
}
.wb-ctx-row.visible { display: flex; }
.wb-ctx-lbl {
font-size: 10px; color: rgba(255,230,180,0.4);
white-space: nowrap; margin-right: 2px;
}
/* sticky color swatches */
.wb-sticky-col {
width: 22px; height: 22px; border-radius: 50%;
border: 2.5px solid transparent; cursor: pointer;
transition: transform .12s; outline: none; flex-shrink: 0;
}
.wb-sticky-col:hover { transform: scale(1.15); }
.wb-sticky-col.active { border-color: #fff; transform: scale(1.15); }
/* table grid picker popup */
.wb-tbl-drop { position: relative; display: inline-flex; flex-shrink: 0; }
.wb-tbl-popup {
position: fixed; z-index: 9999;
background: linear-gradient(180deg, #2c1e10 0%, #1e1509 100%);
border: 1.5px solid #4a2e18; border-radius: 10px; padding: 8px 8px 6px;
display: none; flex-direction: column; gap: 2px; align-items: center;
box-shadow: 0 -6px 28px rgba(0,0,0,0.65);
}
.wb-tbl-popup.open { display: flex; }
.wb-tbl-grid { display: flex; flex-direction: column; gap: 2px; }
.wb-tbl-row { display: flex; gap: 2px; }
.wb-tbl-cell {
width: 20px; height: 20px; border: 1px solid rgba(255,180,60,0.18);
border-radius: 3px; background: transparent; cursor: pointer;
transition: background 0.08s, border-color 0.08s;
}
.wb-tbl-cell.hl { background: rgba(255,180,60,0.38); border-color: rgba(255,180,60,0.7); }
.wb-tbl-label {
font-family: 'Manrope',sans-serif; font-size: 10px;
color: rgba(255,230,180,0.5); margin-top: 2px;
}
/* template & bg-image picker popup */
.wb-tpl-popup-grid {
position: fixed; z-index: 9999;
background: linear-gradient(180deg, #1e1530 0%, #140e26 100%);
border: 1.5px solid rgba(155,93,229,0.35); border-radius: 10px;
padding: 8px; display: none; flex-direction: column; gap: 2px;
box-shadow: 0 -6px 28px rgba(0,0,0,0.65);
}
.wb-tpl-popup-grid.open { display: flex; }
.wb-tpl-popup-grid .tpl-grid { display: grid; grid-template-columns: repeat(3, 68px); gap: 5px; }
.wb-tpl-item {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 6px 4px; border-radius: 7px; cursor: pointer;
border: 1.5px solid rgba(155,93,229,0.2); background: rgba(155,93,229,0.06);
font-family: 'Manrope',sans-serif; font-size: 10px; color: rgba(230,220,255,0.7);
transition: all .15s; white-space: nowrap;
}
.wb-tpl-item:hover { background: rgba(155,93,229,0.18); border-color: rgba(155,93,229,0.5); color: #e8e0f7; }
.wb-tpl-item svg { width: 40px; height: 28px; }
.wb-bg-section { display: flex; flex-direction: column; gap: 4px; padding: 4px 0 0; border-top: 1px solid rgba(155,93,229,0.15); }
.wb-bg-row { display: flex; gap: 4px; }
.wb-bg-btn { flex: 1; padding: 5px 6px; border-radius: 5px; font-size: 10px; font-family: 'Manrope',sans-serif; cursor: pointer; border: 1px solid rgba(155,93,229,0.3); background: rgba(155,93,229,0.08); color: rgba(200,185,240,0.85); transition: all .15s; }
.wb-bg-btn:hover { background: rgba(155,93,229,0.2); color: #e8e0f7; }
.wb-bg-btn.danger { border-color: rgba(241,91,181,0.3); background: rgba(241,91,181,0.06); color: rgba(241,91,181,0.8); }
.wb-bg-btn.danger:hover { background: rgba(241,91,181,0.18); color: #F15BB5; }
/* 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; }
/* student fullscreen button */
.cr-fullscreen-btn {
display: flex; align-items: center; gap: 7px;
padding: 6px 18px; border-radius: 99px; cursor: pointer;
border: 1.5px solid rgba(155,93,229,0.5);
background: rgba(155,93,229,0.12);
color: rgba(200,185,255,0.9);
font-family: 'Manrope',sans-serif; font-size: 0.8rem; font-weight: 700;
transition: all .15s; white-space: nowrap; margin-left: auto;
}
.cr-fullscreen-btn:hover {
background: rgba(155,93,229,0.28); border-color: #9B5DE5;
color: #fff; box-shadow: 0 0 12px rgba(155,93,229,0.35);
}
.cr-fullscreen-btn svg { width: 15px; height: 15px; flex-shrink: 0; }
/* nav fullscreen btn: update icon in fullscreen */
:fullscreen #cr-student-fs-btn svg.fs-exit { display: inline; }
:fullscreen #cr-student-fs-btn svg.fs-enter { display: none; }
#cr-student-fs-btn svg.fs-exit { display: none; }
/* floating exit-fullscreen button (inside board, touch-friendly) */
#cr-fs-exit-overlay {
position: absolute; top: 14px; right: 14px; z-index: 500;
display: none;
align-items: center; gap: 7px;
padding: 9px 16px; border-radius: 99px;
background: rgba(20,14,30,0.72); backdrop-filter: blur(8px);
border: 1.5px solid rgba(255,255,255,0.18); color: rgba(255,255,255,0.75);
font-family: 'Manrope',sans-serif; font-size: 0.75rem; font-weight: 700;
cursor: pointer; transition: all .15s; user-select: none;
min-width: 44px; min-height: 44px; box-shadow: 0 4px 20px rgba(0,0,0,0.45);
}
#cr-fs-exit-overlay:hover,
#cr-fs-exit-overlay:active { background: rgba(155,93,229,0.35); border-color: #9B5DE5; color: #fff; }
:fullscreen #cr-fs-exit-overlay { display: flex; }
:-webkit-full-screen #cr-fs-exit-overlay { display: flex; }
/* 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: 310px; flex-shrink: 0;
background: linear-gradient(180deg, #0f1220 0%, #0c0e1a 100%);
border-left: 1px solid rgba(155,93,229,0.13);
display: flex; flex-direction: column;
transition: width 0.22s cubic-bezier(0.4,0,0.2,1);
}
.cr-right.collapsed { width: 36px; }
.cr-panel-content { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
.cr-right.collapsed .cr-panel-tabs-inner,
.cr-right.collapsed .cr-panel-content { display: none; }
/* ── Panel header with tabs ── */
.cr-panel-tabs {
display: flex; flex-direction: row; align-items: center;
padding: 10px 10px 0; gap: 4px; flex-shrink: 0;
background: transparent;
}
.cr-panel-collapse-btn {
width: 26px; height: 26px; flex-shrink: 0; align-self: flex-start; margin-left: 2px; margin-top: 2px;
display: flex; align-items: center; justify-content: center;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
border-radius: 7px; cursor: pointer; color: rgba(255,255,255,0.4);
transition: color .15s, background .15s, border-color .15s;
}
.cr-panel-collapse-btn:hover { color: #9B5DE5; background: rgba(155,93,229,0.12); border-color: rgba(155,93,229,0.35); }
.cr-panel-collapse-btn svg { width: 12px; height: 12px; transition: transform 0.22s; transform: rotate(180deg); }
.cr-right.collapsed .cr-panel-collapse-btn { margin: 8px auto 0; }
.cr-right.collapsed .cr-panel-collapse-btn svg { transform: rotate(0deg); }
/* ── Pill tabs ── */
.cr-panel-tabs-inner {
display: flex; flex: 1; min-width: 0;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 10px; padding: 3px; gap: 2px;
}
.cr-tab {
flex: 1; padding: 6px 6px; font-family: 'Manrope',sans-serif;
font-size: 0.72rem; font-weight: 700; color: rgba(255,255,255,0.4);
background: transparent; border: none; cursor: pointer; border-radius: 7px;
transition: all .15s; display: flex; align-items: center; justify-content: center; gap: 4px;
white-space: nowrap;
}
.cr-tab:hover { color: rgba(255,255,255,0.75); background: rgba(255,255,255,0.05); }
.cr-tab.active {
color: #e8e0f7; background: rgba(155,93,229,0.22);
box-shadow: 0 1px 6px rgba(155,93,229,0.18);
}
.cr-tab svg, .cr-tab i { width: 12px; height: 12px; flex-shrink: 0; }
.cr-tab-badge {
background: #F15BB5; color: #fff; border-radius: 99px;
font-size: 0.6rem; font-weight: 800; padding: 1px 4px; min-width: 15px; text-align: center;
line-height: 1.4;
}
/* 4-tab compact mode: hide labels, icons only */
.cr-panel-tabs-inner.tabs-4 .cr-tab-label { display: none; }
.cr-panel-tabs-inner.tabs-4 .cr-tab { padding: 7px 6px; }
.cr-panel-tabs-inner.tabs-4 .cr-tab svg,
.cr-panel-tabs-inner.tabs-4 .cr-tab i { width: 15px; height: 15px; }
/* divider below tabs */
.cr-panel-tabs::after {
content: ''; display: block; position: absolute; left: 0; right: 0;
height: 1px; background: rgba(255,255,255,0.05);
}
.cr-panel-tabs { position: relative; padding-bottom: 10px; }
/* ── Participants list ── */
.cr-participants {
flex: 1; overflow-y: auto; padding: 6px 8px;
display: flex; flex-direction: column; gap: 2px;
}
.cr-participants::-webkit-scrollbar { width: 3px; }
.cr-participants::-webkit-scrollbar-track { background: transparent; }
.cr-participants::-webkit-scrollbar-thumb { background: rgba(155,93,229,0.2); border-radius: 2px; }
.cr-participant {
display: flex; align-items: center; gap: 9px;
padding: 7px 10px; border-radius: 10px;
border: 1px solid transparent;
transition: background .15s, border-color .15s;
position: relative;
}
.cr-participant:hover { background: rgba(255,255,255,0.03); border-color: rgba(255,255,255,0.05); }
.cr-participant.speaking { background: rgba(6,214,160,0.05); border-color: rgba(6,214,160,0.18); }
/* avatar with online ring */
.cr-p-avatar-wrap { position: relative; flex-shrink: 0; }
.cr-p-avatar {
width: 32px; height: 32px; border-radius: 50%;
background: linear-gradient(135deg, #9B5DE5, #06D6E0);
display: flex; align-items: center; justify-content: center;
font-size: 0.68rem; font-weight: 800; color: #fff;
font-family: 'Unbounded', sans-serif;
transition: box-shadow .15s;
}
.cr-p-online-dot {
position: absolute; bottom: 1px; right: 1px;
width: 8px; height: 8px; border-radius: 50%;
background: #22c55e; border: 2px solid #0c0e1a;
}
.cr-p-avatar.speaking {
box-shadow: 0 0 0 2.5px #06D6A0, 0 0 10px rgba(6,214,160,.4);
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,.35); }
50% { box-shadow: 0 0 0 3.5px #06D6A0, 0 0 14px rgba(6,214,160,.6); }
}
.cr-p-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.cr-p-name {
font-size: 0.8rem; font-weight: 600; color: #d1d5db;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cr-p-sub { display: flex; align-items: center; gap: 4px; }
.cr-p-you {
font-size: 0.62rem; color: rgba(155,93,229,0.8); font-weight: 700;
background: rgba(155,93,229,0.1); border: 1px solid rgba(155,93,229,0.25);
border-radius: 4px; padding: 0px 4px;
}
.cr-p-role-tag {
font-size: 0.6rem; font-weight: 700; border-radius: 4px; padding: 0px 4px;
}
.cr-p-role-tag.teacher { color: #06D6A0; background: rgba(6,214,160,0.1); border: 1px solid rgba(6,214,160,0.2); }
.cr-p-role-tag.student { color: rgba(255,255,255,0.35); background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); }
.cr-p-status { display: flex; gap: 4px; align-items: center; flex-shrink: 0; }
.cr-p-status svg { width: 13px; height: 13px; }
.mic-on { color: #06D6A0; }
.mic-off { color: rgba(255,255,255,0.18); }
.cr-p-mute-btn {
background: none; border: none; cursor: pointer; padding: 2px; border-radius: 4px;
color: var(--text-3); display: flex; align-items: center; opacity: 0; transition: opacity .15s, color .15s;
}
.cr-participant:hover .cr-p-mute-btn { opacity: 1; }
/* ── Audio level bars ─────────────────────────────────────────────────── */
.cr-audio-bars {
display: none; align-items: flex-end; gap: 2px;
height: 14px; width: 16px; flex-shrink: 0;
}
.cr-participant.speaking .cr-audio-bars { display: flex; }
.cr-audio-bars span {
width: 3px; background: rgba(136,152,170,0.35); border-radius: 2px;
height: 2px; transition: height 0.1s ease, background 0.1s ease;
}
.cr-participant.speaking .cr-audio-bars span { background: #06D6A0; }
/* ── Floating speaker chip on board ──────────────────────────────────── */
.cr-speaker-chip {
position: absolute; bottom: 14px; left: 14px; z-index: 20;
display: flex; align-items: center; gap: 8px;
background: rgba(10,6,25,0.82); backdrop-filter: blur(12px);
border: 1px solid rgba(6,214,160,0.35); border-radius: 22px;
padding: 5px 13px 5px 5px;
pointer-events: none;
opacity: 0; transform: translateY(6px);
transition: opacity .22s ease, transform .22s ease;
}
.cr-speaker-chip.visible { opacity: 1; transform: translateY(0); }
.cr-speaker-chip-avatar {
width: 26px; height: 26px; border-radius: 50%; flex-shrink: 0;
background: linear-gradient(135deg, #9B5DE5, #06D6E0);
display: flex; align-items: center; justify-content: center;
font-size: 0.6rem; font-weight: 800; color: #fff;
font-family: 'Unbounded', sans-serif;
box-shadow: 0 0 0 2px #06D6A0, 0 0 10px rgba(6,214,160,0.5);
animation: speaking-pulse 0.8s ease-in-out infinite;
}
.cr-speaker-chip-name {
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 600;
color: #e2e8f0; max-width: 160px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
.cr-speaker-chip-wave {
display: flex; align-items: flex-end; gap: 2px; height: 14px;
}
.cr-speaker-chip-wave span {
width: 3px; background: #06D6A0; border-radius: 2px;
animation: wave-bar 0.7s ease-in-out infinite;
}
.cr-speaker-chip-wave span:nth-child(1) { animation-delay: 0s; }
.cr-speaker-chip-wave span:nth-child(2) { animation-delay: 0.12s; }
.cr-speaker-chip-wave span:nth-child(3) { animation-delay: 0.24s; }
@keyframes wave-bar {
0%, 100% { height: 3px; }
50% { height: 12px; }
}
/* 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: var(--text-3);
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: 10px 10px 6px;
display: flex; flex-direction: column; gap: 4px;
}
.cr-messages::-webkit-scrollbar { width: 3px; }
.cr-messages::-webkit-scrollbar-track { background: transparent; }
.cr-messages::-webkit-scrollbar-thumb { background: rgba(155,93,229,0.2); border-radius: 2px; }
/* ── message row (wrapper for alignment) ── */
.cr-msg-row { display: flex; flex-direction: column; gap: 1px; }
.cr-msg-row.mine { align-items: flex-end; }
.cr-msg-row.theirs { align-items: flex-start; }
/* ── bubble ── */
.cr-msg {
display: flex; flex-direction: column; gap: 3px;
padding: 7px 10px; border-radius: 12px;
max-width: 88%; position: relative;
}
.cr-msg-row.mine .cr-msg {
background: rgba(155,93,229,0.22);
border: 1px solid rgba(155,93,229,0.3);
border-bottom-right-radius: 4px;
}
.cr-msg-row.theirs .cr-msg {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.07);
border-bottom-left-radius: 4px;
}
.cr-msg-pinned {
background: rgba(155,93,229,.12) !important;
border-color: rgba(155,93,229,0.4) !important;
}
.cr-msg-header { display: flex; align-items: center; gap: 5px; }
.cr-msg-name { font-size: 0.7rem; font-weight: 700; color: #9B5DE5; }
.cr-msg-name.teacher-name { color: #06D6A0; }
.cr-msg-time { font-size: 0.63rem; color: rgba(255,255,255,0.28); flex: 1; }
.cr-msg-row.mine .cr-msg-time { text-align: right; }
.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: var(--text-3);
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.81rem; color: #d1d5db; line-height: 1.5; word-break: break-word; }
.cr-msg-row.mine .cr-msg-text { color: #e8e0f7; }
/* 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); }
/* ── share library file button ── */
.cr-share-lib-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: none; align-items: center; justify-content: center; flex-shrink: 0; transition: all .12s;
}
.cr-share-lib-btn:hover { background: rgba(6,214,224,0.14); color: #06D6E0; border-color: rgba(6,214,224,0.35); }
/* ── file picker modal ── */
.cr-file-picker-overlay {
position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,0.65);
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity .18s;
}
.cr-file-picker-overlay.open { opacity: 1; pointer-events: all; }
.cr-file-picker {
background: #1a1628; border: 1px solid rgba(155,93,229,0.25); border-radius: 16px;
padding: 0; width: 480px; max-width: calc(100vw - 24px); max-height: 70vh;
display: flex; flex-direction: column; overflow: hidden;
transform: translateY(14px); transition: transform .18s;
}
.cr-file-picker-overlay.open .cr-file-picker { transform: translateY(0); }
.cr-file-picker-head {
padding: 18px 20px 0; display: flex; align-items: center; gap: 10px; flex-shrink: 0;
}
.cr-file-picker-title { flex: 1; font-size: 0.92rem; font-weight: 700; color: #fff; }
.cr-file-picker-close {
background: none; border: none; cursor: pointer; color: rgba(255,255,255,0.4);
padding: 4px; border-radius: 6px; transition: color .12s; display: flex;
}
.cr-file-picker-close:hover { color: #fff; }
.cr-file-picker-search {
margin: 12px 20px 0; padding: 8px 12px; border-radius: 10px;
border: 1.5px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.04);
color: #fff; font-size: 0.78rem; width: calc(100% - 40px); box-sizing: border-box;
outline: none; transition: border-color .12s;
}
.cr-file-picker-search::placeholder { color: rgba(255,255,255,0.25); }
.cr-file-picker-search:focus { border-color: rgba(155,93,229,0.45); }
.cr-file-picker-list {
flex: 1; overflow-y: auto; padding: 10px 12px 14px;
display: flex; flex-direction: column; gap: 4px;
}
.cr-file-picker-list::-webkit-scrollbar { width: 4px; }
.cr-file-picker-list::-webkit-scrollbar-track { background: transparent; }
.cr-file-picker-list::-webkit-scrollbar-thumb { background: rgba(155,93,229,0.3); border-radius: 2px; }
.cr-file-picker-empty { text-align: center; color: rgba(255,255,255,0.3); font-size: 0.75rem; padding: 24px 0; }
.cr-file-item {
display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: 10px;
cursor: default; transition: background .12s;
}
.cr-file-item:hover { background: rgba(255,255,255,0.05); }
.cr-file-icon {
width: 34px; height: 34px; border-radius: 8px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 0.58rem; font-weight: 900; letter-spacing: 0.02em; text-transform: uppercase;
}
.cr-file-icon.pdf { background: rgba(239,71,111,0.18); color: #EF476F; }
.cr-file-icon.img { background: rgba(6,214,160,0.18); color: #06D6A0; }
.cr-file-icon.doc { background: rgba(67,97,238,0.2); color: #7B9FFF; }
.cr-file-icon.xls { background: rgba(6,214,160,0.15); color: #06D6A0; }
.cr-file-icon.ppt { background: rgba(255,159,67,0.2); color: #FF9F43; }
.cr-file-icon.vid { background: rgba(155,93,229,0.2); color: #c4b5fd; }
.cr-file-icon.other { background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.45); }
.cr-file-info { flex: 1; min-width: 0; }
.cr-file-name { font-size: 0.78rem; font-weight: 600; color: rgba(255,255,255,0.88); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cr-file-meta { font-size: 0.66rem; color: rgba(255,255,255,0.3); margin-top: 1px; }
.cr-file-pick-btn {
flex-shrink: 0; padding: 5px 12px; border-radius: 8px; font-size: 0.7rem; font-weight: 700;
border: 1.5px solid rgba(6,214,224,0.35); background: rgba(6,214,224,0.08); color: #06D6E0;
cursor: pointer; transition: all .12s; white-space: nowrap;
}
.cr-file-pick-btn:hover { background: rgba(6,214,224,0.18); }
/* ── library file message card ── */
.cr-msg-file-card {
display: flex; align-items: center; gap: 10px; margin-top: 4px;
padding: 9px 12px; border-radius: 10px; background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.09); cursor: pointer; transition: background .12s;
max-width: 260px;
}
.cr-msg-file-card:hover { background: rgba(6,214,224,0.1); border-color: rgba(6,214,224,0.25); }
.cr-msg-file-card .cr-file-icon { width: 30px; height: 30px; border-radius: 7px; }
.cr-msg-file-card .cr-file-name { font-size: 0.74rem; }
.cr-msg-file-card .cr-file-meta { font-size: 0.62rem; }
.cr-msg-shared-label {
font-size: 0.6rem; color: rgba(6,214,224,0.55); font-weight: 600;
letter-spacing: 0.04em; text-transform: uppercase; margin-top: 3px;
}
/* ── notes panel ── */
.cr-notes-panel { flex: 1; display: flex; flex-direction: column; padding: 10px; gap: 8px; overflow: hidden; }
.cr-notes-header { display: flex; align-items: center; justify-content: space-between; padding: 0 2px; }
.cr-notes-label {
font-size: 0.7rem; color: rgba(255,255,255,0.3); display: flex; align-items: center; gap: 5px;
}
.cr-notes-label svg { width: 12px; height: 12px; color: rgba(155,93,229,0.5); }
.cr-notes-status { font-size: 0.65rem; color: rgba(6,214,224,0.45); display: flex; align-items: center; gap: 3px; }
.cr-notes-wordcount { font-size: 0.63rem; color: rgba(255,255,255,0.2); }
.cr-notes-ta {
flex: 1; resize: none; outline: none;
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
border-radius: 12px; padding: 12px 13px; color: #e8e0f7;
font-size: 0.82rem; font-family: 'Manrope',sans-serif; line-height: 1.6;
caret-color: #9B5DE5; transition: border-color .15s, background .15s;
scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.2) transparent;
}
.cr-notes-ta:focus {
border-color: rgba(155,93,229,0.35);
background: rgba(155,93,229,0.04);
}
.cr-notes-ta::placeholder { color: rgba(255,255,255,0.15); }
/* 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: 8px 10px 10px; border-top: 1px solid rgba(255,255,255,0.05);
background: rgba(0,0,0,0.15);
display: flex; flex-direction: column; gap: 6px; flex-shrink: 0;
}
.cr-chat-input-row { display: flex; gap: 6px; align-items: flex-end; }
.cr-chat-input {
flex: 1; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09);
border-radius: 12px; padding: 8px 12px; color: #fff; font-family: 'Manrope',sans-serif;
font-size: 0.82rem; resize: none; outline: none; min-height: 36px; max-height: 90px;
transition: border-color .15s, background .15s; line-height: 1.45;
}
.cr-chat-input:focus {
border-color: rgba(155,93,229,0.45);
background: rgba(155,93,229,0.04);
}
.cr-chat-input::placeholder { color: rgba(255,255,255,0.25); }
.cr-chat-send {
width: 34px; height: 34px; border-radius: 10px;
background: linear-gradient(135deg, #9B5DE5, #7B3FC5);
border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: #fff; transition: all .15s; flex-shrink: 0;
box-shadow: 0 2px 10px rgba(155,93,229,0.3);
}
.cr-chat-send:hover { transform: translateY(-1px); box-shadow: 0 4px 14px rgba(155,93,229,0.5); }
.cr-chat-send:active { transform: translateY(0); }
.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: var(--text-3); 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: var(--text-3); 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: var(--text-3); 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: var(--text-3); 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: var(--text-3); 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: var(--text-3); 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; }
/* ── guest link modal ── */
.cr-guest-link-wrap {
display: flex; align-items: center; gap: 8px;
background: rgba(255,255,255,0.04); border: 1px solid rgba(155,93,229,0.2);
border-radius: 10px; padding: 10px 12px; margin: 16px 0 8px;
}
.cr-guest-link-url {
flex: 1; font-size: 0.72rem; color: rgba(232,224,247,0.75);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
font-family: 'Manrope', monospace;
}
.cr-guest-copy-btn {
flex-shrink: 0; padding: 5px 12px; border: 1px solid rgba(155,93,229,0.35);
border-radius: 7px; background: none; color: #c4a8f5; font-size: 0.7rem;
font-weight: 700; cursor: pointer; transition: all .15s; white-space: nowrap;
}
.cr-guest-copy-btn:hover { background: rgba(155,93,229,0.12); color: #fff; }
.cr-guest-copy-btn.copied { color: #06D6A0; border-color: rgba(6,214,160,0.4); }
.cr-guest-note { font-size: 0.7rem; color: rgba(255,255,255,0.3); line-height: 1.6; }
.cr-guest-actions { display: flex; gap: 8px; margin-top: 16px; }
.cr-guest-create-btn {
flex: 1; padding: 10px; border: none; border-radius: 10px;
background: linear-gradient(135deg, #9B5DE5, #5e2fb5); color: #fff;
font-family: 'Unbounded', sans-serif; font-size: 0.7rem; font-weight: 800;
cursor: pointer; transition: opacity .15s;
}
.cr-guest-create-btn:hover { opacity: .88; }
.cr-guest-revoke-btn {
padding: 10px 14px; border: 1px solid rgba(239,71,111,0.3); border-radius: 10px;
background: none; color: #EF476F; font-size: 0.72rem; font-weight: 700;
cursor: pointer; transition: all .15s; white-space: nowrap;
}
.cr-guest-revoke-btn:hover { background: rgba(239,71,111,0.08); border-color: rgba(239,71,111,0.55); }
/* guest badge in participants list */
.cr-p-guest-badge {
font-size: 0.55rem; font-weight: 800; padding: 1px 5px; border-radius: 4px;
background: rgba(155,93,229,0.15); color: #c4a8f5; border: 1px solid rgba(155,93,229,0.25);
text-transform: uppercase; letter-spacing: 0.04em; flex-shrink: 0;
}
/* 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: var(--text-3); 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;
}
/* ── screen picker modal v2 (Discord-style with live preview) ── */
.cr-screen-picker-overlay {
position: fixed; inset: 0; z-index: 10000;
background: rgba(0,0,0,0.75); backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity .18s;
}
.cr-screen-picker-overlay.open { opacity: 1; pointer-events: all; }
.cr-screen-picker {
background: #18131f; border: 1px solid rgba(155,93,229,0.22);
border-radius: 20px; width: 580px; max-width: calc(100vw - 24px);
max-height: 92vh; overflow-y: auto; overflow-x: hidden;
transform: scale(.95) translateY(16px);
transition: transform .24s cubic-bezier(.34,1.35,.64,1);
box-shadow: 0 40px 100px rgba(0,0,0,0.65), 0 0 0 1px rgba(255,255,255,0.04);
}
.cr-screen-picker::-webkit-scrollbar { width: 4px; }
.cr-screen-picker::-webkit-scrollbar-track { background: transparent; }
.cr-screen-picker::-webkit-scrollbar-thumb { background: rgba(155,93,229,0.3); border-radius: 2px; }
.cr-screen-picker-overlay.open .cr-screen-picker { transform: scale(1) translateY(0); }
/* header */
.cr-sp-head { padding: 20px 22px 0; display: flex; align-items: center; gap: 12px; }
.cr-sp-icon {
width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0;
background: rgba(155,93,229,0.2); display: flex; align-items: center; justify-content: center; color: #c4b5fd;
}
.cr-sp-titles { flex: 1; }
.cr-sp-title { font-size: 0.96rem; font-weight: 800; color: #fff; line-height: 1.2; }
.cr-sp-sub { font-size: 0.68rem; color: rgba(255,255,255,0.32); margin-top: 2px; }
.cr-sp-close {
background: rgba(255,255,255,0.06); border: none; cursor: pointer;
color: rgba(255,255,255,0.4); width: 30px; height: 30px; border-radius: 8px;
display: flex; align-items: center; justify-content: center; transition: all .12s; flex-shrink: 0;
}
.cr-sp-close:hover { background: rgba(239,71,111,0.15); color: #EF476F; }
/* ── tab bar ── */
.cr-sp-tabs {
display: flex; margin: 16px 22px 0; gap: 4px;
background: rgba(255,255,255,0.04); border-radius: 12px; padding: 4px;
}
.cr-sp-tab {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px 10px; border-radius: 9px; border: none; cursor: pointer;
font-size: 0.72rem; font-weight: 700; color: rgba(255,255,255,0.4);
background: none; transition: all .14s; white-space: nowrap;
}
.cr-sp-tab svg { flex-shrink: 0; transition: color .14s; }
.cr-sp-tab:hover { color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.05); }
.cr-sp-tab.active { background: rgba(155,93,229,0.2); color: #c4b5fd; }
.cr-sp-tab.active svg { color: #9B5DE5; }
/* ── preview area ── */
.cr-sp-preview-wrap {
margin: 14px 22px 0; border-radius: 14px; overflow: hidden;
background: #0d0a12; border: 1px solid rgba(255,255,255,0.08);
position: relative;
}
.cr-sp-preview-wrap::before { content: ''; display: block; padding-top: 56.25%; } /* 16:9 */
.cr-sp-preview-inner {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
}
/* empty state */
.cr-sp-empty {
display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px;
animation: crSpFadeIn .2s ease;
}
@keyframes crSpFadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.cr-sp-empty-icon {
width: 60px; height: 60px; border-radius: 16px;
background: rgba(155,93,229,0.1); border: 1px solid rgba(155,93,229,0.2);
display: flex; align-items: center; justify-content: center; color: rgba(155,93,229,0.6);
}
.cr-sp-empty-title { font-size: 0.82rem; font-weight: 700; color: rgba(255,255,255,0.5); }
.cr-sp-empty-hint { font-size: 0.66rem; color: rgba(255,255,255,0.25); text-align: center; max-width: 220px; line-height: 1.5; }
.cr-sp-pick-btn {
margin-top: 4px; padding: 9px 20px; border-radius: 10px; font-size: 0.76rem; font-weight: 800;
border: 1.5px solid rgba(155,93,229,0.45); background: rgba(155,93,229,0.12); color: #c4b5fd;
cursor: pointer; transition: all .14s; display: flex; align-items: center; gap: 7px;
}
.cr-sp-pick-btn:hover { background: rgba(155,93,229,0.22); border-color: rgba(155,93,229,0.7); color: #fff; }
/* live video preview */
.cr-sp-video-wrap {
position: absolute; inset: 0; display: none;
flex-direction: column; animation: crSpFadeIn .2s ease;
}
.cr-sp-video-wrap.visible { display: flex; }
.cr-sp-video {
width: 100%; height: 100%; object-fit: contain;
background: #000;
}
.cr-sp-preview-bar {
position: absolute; bottom: 0; left: 0; right: 0;
padding: 20px 14px 12px;
background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%);
display: flex; align-items: center; gap: 8px;
}
.cr-sp-src-icon-sm {
width: 22px; height: 22px; border-radius: 6px;
background: rgba(155,93,229,0.25); display: flex; align-items: center; justify-content: center;
color: #c4b5fd; flex-shrink: 0;
}
.cr-sp-preview-name {
flex: 1; font-size: 0.72rem; font-weight: 700; color: #fff;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cr-sp-res-badge {
font-size: 0.58rem; font-weight: 800; padding: 2px 7px; border-radius: 5px;
background: rgba(155,93,229,0.3); color: #c4b5fd; flex-shrink: 0; letter-spacing: 0.02em;
}
.cr-sp-change-btn {
padding: 5px 12px; border-radius: 7px; font-size: 0.66rem; font-weight: 700;
border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.8);
cursor: pointer; flex-shrink: 0; transition: all .12s;
}
.cr-sp-change-btn:hover { background: rgba(255,255,255,0.18); color: #fff; }
/* ── divider ── */
.cr-sp-divider { margin: 14px 22px 0; border: none; border-top: 1px solid rgba(255,255,255,0.07); }
/* ── settings section ── */
.cr-sp-settings { padding: 14px 22px; display: flex; flex-direction: column; gap: 12px; }
.cr-sp-q-row { display: flex; align-items: center; gap: 10px; }
.cr-sp-q-label {
font-size: 0.64rem; font-weight: 800; color: rgba(255,255,255,0.35);
text-transform: uppercase; letter-spacing: 0.07em; width: 108px; flex-shrink: 0;
}
.cr-sp-q-opts { display: flex; gap: 5px; flex-wrap: wrap; }
.cr-sp-qbtn {
padding: 5px 13px; border-radius: 8px; font-size: 0.7rem; font-weight: 700;
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; transition: all .12s;
}
.cr-sp-qbtn:hover { border-color: rgba(155,93,229,0.4); color: rgba(255,255,255,0.75); }
.cr-sp-qbtn.active { border-color: rgba(155,93,229,0.65); background: rgba(155,93,229,0.16); color: #c4b5fd; }
/* ── option cards (2-col grid) ── */
.cr-sp-opts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.cr-sp-opt {
display: flex; align-items: center; gap: 9px; padding: 10px 12px; border-radius: 11px;
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
cursor: pointer; transition: all .12s; user-select: none;
}
.cr-sp-opt:hover { background: rgba(255,255,255,0.06); border-color: rgba(255,255,255,0.12); }
.cr-sp-opt.on { background: rgba(6,214,224,0.07); border-color: rgba(6,214,224,0.25); }
.cr-sp-opt-ic { color: rgba(255,255,255,0.3); flex-shrink: 0; transition: color .12s; }
.cr-sp-opt.on .cr-sp-opt-ic { color: #06D6E0; }
.cr-sp-opt-text { flex: 1; min-width: 0; }
.cr-sp-opt-title { font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.6); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cr-sp-opt.on .cr-sp-opt-title { color: rgba(255,255,255,0.85); }
.cr-sp-opt-hint { font-size: 0.58rem; color: rgba(255,255,255,0.25); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cr-sp-toggle {
width: 34px; height: 18px; border-radius: 9px; border: none; cursor: pointer; flex-shrink: 0;
background: rgba(255,255,255,0.1); position: relative; transition: background .15s;
}
.cr-sp-toggle::after {
content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px;
border-radius: 50%; background: rgba(255,255,255,0.5); transition: transform .15s, background .15s;
}
.cr-sp-opt.on .cr-sp-toggle { background: #06D6E0; }
.cr-sp-opt.on .cr-sp-toggle::after { background: #fff; transform: translateX(16px); }
/* ── footer ── */
.cr-sp-footer {
padding: 12px 22px 18px; display: flex; align-items: center; justify-content: flex-end; gap: 10px;
border-top: 1px solid rgba(255,255,255,0.07);
}
.cr-sp-cancel {
padding: 9px 18px; border-radius: 10px; font-size: 0.76rem; font-weight: 700;
border: 1.5px solid rgba(255,255,255,0.1); background: none; color: rgba(255,255,255,0.45);
cursor: pointer; transition: all .12s;
}
.cr-sp-cancel:hover { border-color: rgba(255,255,255,0.25); color: rgba(255,255,255,0.75); }
.cr-sp-start {
padding: 9px 22px; border-radius: 10px; font-size: 0.76rem; font-weight: 800;
border: none; background: linear-gradient(135deg, #9B5DE5, #7B3DBF); color: #fff;
cursor: pointer; transition: all .15s; display: flex; align-items: center; gap: 8px;
box-shadow: 0 4px 14px rgba(155,93,229,0.35);
}
.cr-sp-start:hover { box-shadow: 0 6px 20px rgba(155,93,229,0.5); transform: translateY(-1px); }
.cr-sp-start:active { transform: translateY(0); }
.cr-sp-start:disabled { opacity: .4; cursor: not-allowed; transform: none; box-shadow: none; }
/* ── 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;
}
/* ── Quiz panel (teacher) ── */
.cr-quiz-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.cr-quiz-start-btn {
width: 100%; padding: 10px; border: none; border-radius: 10px;
background: linear-gradient(135deg, #06D6E0, #9B5DE5);
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800;
color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 7px;
transition: transform 0.12s, box-shadow 0.12s;
}
.cr-quiz-start-btn:hover { transform: translateY(-1px); box-shadow: 0 5px 18px rgba(155,93,229,0.4); }
.cr-quiz-start-btn:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: none; }
.cr-quiz-status-bar {
display: flex; align-items: center; justify-content: space-between;
padding: 7px 10px; background: rgba(6,214,160,0.07); border: 1px solid rgba(6,214,160,0.18);
border-radius: 10px; margin-bottom: 8px;
}
.cr-quiz-status-dot { width: 6px; height: 6px; border-radius: 50%; background: #06D6A0; animation: pulse-dot 1.5s ease infinite; flex-shrink: 0; }
.cr-quiz-status-text { font-size: 0.7rem; font-weight: 700; color: #059652; }
.cr-quiz-end-btn { font-size: 0.66rem; font-weight: 700; color: #EF476F; background: none; border: none; cursor: pointer; padding: 3px 8px; border-radius: 6px; transition: background 0.12s; white-space: nowrap; flex-shrink: 0; }
.cr-quiz-end-btn:hover { background: rgba(239,71,111,0.12); }
.cr-quiz-active-card { background: rgba(155,93,229,0.08); border: 1px solid rgba(155,93,229,0.22); border-radius: 12px; padding: 10px 12px; margin-top: 8px; }
.cr-quiz-active-label { font-size: 0.6rem; font-weight: 800; color: #9B5DE5; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 5px; display: flex; align-items: center; gap: 5px; }
.cr-quiz-active-text { font-size: 0.8rem; font-weight: 600; color: #e8e0f7; line-height: 1.5; margin-bottom: 9px; }
.cr-quiz-counter-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.cr-quiz-counter-val { font-family: 'Unbounded', sans-serif; font-size: 0.96rem; font-weight: 900; color: #9B5DE5; flex-shrink: 0; }
.cr-quiz-counter-lbl { font-size: 0.68rem; color: rgba(255,255,255,0.35); font-weight: 600; }
.cr-quiz-counter-bar-wrap { flex: 1; height: 5px; background: rgba(155,93,229,0.14); border-radius: 99px; overflow: hidden; }
.cr-quiz-counter-bar { height: 100%; background: linear-gradient(90deg, #9B5DE5, #06D6E0); border-radius: 99px; transition: width 0.4s ease; }
.cr-quiz-show-results-btn {
width: 100%; padding: 7px; border: none; border-radius: 8px;
background: linear-gradient(135deg, #06D6E0, #9B5DE5);
font-family: 'Unbounded', sans-serif; font-size: 0.66rem; font-weight: 800;
color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 5px; transition: transform 0.12s;
}
.cr-quiz-show-results-btn:hover { transform: translateY(-1px); }
.cr-quiz-result-stats { display: flex; flex-direction: column; gap: 5px; margin: 4px 0; overflow-y: auto; max-height: 200px; }
.cr-quiz-result-stat { flex: 1; padding: 5px 6px; border-radius: 7px; background: rgba(255,255,255,0.04); text-align: center; }
.cr-quiz-result-stat-val { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 900; color: #e8e0f7; }
.cr-quiz-result-stat-lbl { font-size: 0.56rem; color: rgba(255,255,255,0.3); font-weight: 700; text-transform: uppercase; }
.cr-quiz-result-stat.rs-ok .cr-quiz-result-stat-val { color: #06D6A0; }
.cr-quiz-result-stat.rs-bad .cr-quiz-result-stat-val { color: #EF476F; }
.cr-quiz-result-bars { display: flex; flex-direction: column; gap: 5px; }
.cr-quiz-result-row { display: flex; align-items: center; gap: 6px; }
.cr-quiz-result-key { width: 18px; height: 18px; border-radius: 5px; background: rgba(255,255,255,0.07); flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 0.58rem; font-weight: 800; color: rgba(255,255,255,0.4); }
.cr-quiz-result-key.correct { background: #06D6A0; color: #0e0824; }
.cr-quiz-result-track { flex: 1; height: 12px; background: rgba(255,255,255,0.05); border-radius: 99px; overflow: hidden; }
.cr-quiz-result-fill { height: 100%; border-radius: 99px; background: rgba(155,93,229,0.45); transition: width 0.5s ease; }
.cr-quiz-result-fill.correct-fill { background: linear-gradient(90deg, #06D6A0, #06D6E0); }
.cr-quiz-result-count { font-size: 0.64rem; font-weight: 800; color: rgba(255,255,255,0.5); min-width: 18px; text-align: right; }
.cr-quiz-filters { padding: 6px 10px 4px; display: flex; flex-direction: column; gap: 5px; }
.cr-quiz-search-wrap { position: relative; }
.cr-quiz-search-input { width: 100%; padding: 7px 10px 7px 28px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #e8e0f7; font-family: 'Manrope', sans-serif; font-size: 0.76rem; outline: none; box-sizing: border-box; transition: border-color 0.15s; }
.cr-quiz-search-input::placeholder { color: rgba(255,255,255,0.22); }
.cr-quiz-search-input:focus { border-color: rgba(155,93,229,0.5); }
.cr-quiz-search-icon { position: absolute; left: 8px; top: 50%; transform: translateY(-50%); width: 12px; height: 12px; color: rgba(255,255,255,0.22); pointer-events: none; }
.cr-quiz-filter-row { display: flex; gap: 5px; }
.cr-quiz-filter-sel { flex: 1; padding: 5px 7px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #e8e0f7; font-family: 'Manrope', sans-serif; font-size: 0.72rem; outline: none; cursor: pointer; appearance: none; }
.cr-quiz-filter-sel option { background: #1a0a3c; color: #e8e0f7; }
.cr-quiz-q-scroll { flex: 1; overflow-y: auto; padding: 2px 0; }
.cr-quiz-q-scroll::-webkit-scrollbar { width: 3px; }
.cr-quiz-q-scroll::-webkit-scrollbar-thumb { background: rgba(155,93,229,0.22); border-radius: 3px; }
.cr-quiz-q-item { display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px; border-radius: 9px; border: 1px solid transparent; margin: 1px 2px; transition: background 0.12s; }
.cr-quiz-q-item:hover { background: rgba(255,255,255,0.03); border-color: rgba(255,255,255,0.06); }
.cr-quiz-q-item.launched { background: rgba(6,214,160,0.06); border-color: rgba(6,214,160,0.18); }
.cr-quiz-q-body { flex: 1; min-width: 0; }
.cr-quiz-q-text { font-size: 0.76rem; font-weight: 600; color: rgba(232,224,247,0.85); line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.cr-quiz-q-meta { margin-top: 3px; display: flex; gap: 5px; flex-wrap: wrap; }
.cr-quiz-launch-btn { padding: 5px 9px; border: none; border-radius: 99px; background: rgba(155,93,229,0.16); color: #c4a8f5; font-family: 'Manrope', sans-serif; font-size: 0.68rem; font-weight: 800; cursor: pointer; flex-shrink: 0; transition: all 0.12s; margin-top: 1px; display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.cr-quiz-launch-btn:hover { background: rgba(155,93,229,0.32); color: #fff; }
.cr-quiz-launch-btn:disabled { opacity: 0.38; cursor: default; }
.cr-quiz-count { font-size: 0.62rem; color: rgba(255,255,255,0.22); padding: 2px 10px 3px; }
.cr-quiz-load-more { width: calc(100% - 20px); margin: 2px 10px 8px; padding: 7px; background: none; border: 1px dashed rgba(155,93,229,0.22); border-radius: 8px; color: rgba(155,93,229,0.65); font-size: 0.7rem; font-weight: 700; cursor: pointer; transition: all 0.12s; }
.cr-quiz-load-more:hover { border-color: rgba(155,93,229,0.45); color: #c4a8f5; }
.cr-quiz-load-more:disabled { opacity: 0.38; cursor: default; }
.cr-quiz-no-session-msg { text-align: center; padding: 28px 14px; color: rgba(255,255,255,0.22); font-size: 0.78rem; line-height: 1.7; }
/* ── Student quiz widget ── */
#cr-sq-widget { position: fixed; bottom: 24px; right: 24px; z-index: 7500; width: 310px;
background: #0e0824; border: 1.5px solid rgba(155,93,229,0.35); border-radius: 20px;
box-shadow: 0 16px 48px rgba(0,0,0,0.65); display: none; flex-direction: column; overflow: hidden; }
#cr-sq-widget.open { display: flex; animation: crSqIn .28s cubic-bezier(.34,1.56,.64,1); }
@keyframes crSqIn { from { opacity:0; transform:translateY(12px) scale(.96) } to { opacity:1; transform:none } }
#cr-sq-widget.sq-min .cr-sq-body { display: none; }
#cr-sq-widget.sq-min { border-bottom-left-radius: 20px; border-bottom-right-radius: 20px; }
.cr-sq-head { display: flex; align-items: center; justify-content: space-between; padding: 11px 14px 9px; border-bottom: 1px solid rgba(255,255,255,0.06); }
.cr-sq-badge { display: flex; align-items: center; gap: 7px; font-size: 0.68rem; font-weight: 800; color: #9B5DE5; text-transform: uppercase; letter-spacing: 0.06em; }
.cr-sq-badge-dot { width: 6px; height: 6px; border-radius: 50%; background: #9B5DE5; animation: pulse-dot 1.2s ease infinite; flex-shrink: 0; }
.cr-sq-collapse { background: none; border: none; color: rgba(255,255,255,0.35); cursor: pointer; padding: 4px; border-radius: 6px; transition: color .15s, background .15s; line-height: 1; }
.cr-sq-collapse:hover { color: rgba(255,255,255,0.75); background: rgba(255,255,255,0.07); }
.cr-sq-body { padding: 12px 14px 14px; overflow-y: auto; max-height: 380px; }
.cr-sq-q { font-size: 0.86rem; font-weight: 600; color: #e8e0f7; line-height: 1.6; margin-bottom: 12px; }
.cr-sq-opts { display: flex; flex-direction: column; gap: 7px; margin-bottom: 10px; }
.cr-sq-opt { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1.5px solid rgba(255,255,255,0.1); border-radius: 11px; cursor: pointer; transition: all .15s; background: transparent; }
.cr-sq-opt:hover { border-color: rgba(155,93,229,0.5); background: rgba(155,93,229,0.06); }
.cr-sq-opt.selected { border-color: #06D6E0; background: rgba(6,214,224,0.08); }
.cr-sq-opt.correct { border-color: #06D6A0 !important; background: rgba(6,214,160,0.1) !important; }
.cr-sq-opt.wrong { border-color: #EF476F !important; background: rgba(239,71,111,0.07) !important; }
.cr-sq-key { width: 26px; height: 26px; border-radius: 7px; background: rgba(255,255,255,0.07); flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 0.66rem; font-weight: 800; color: rgba(255,255,255,0.45); transition: all .15s; }
.cr-sq-opt.selected .cr-sq-key { background: #06D6E0; color: #0e0824; }
.cr-sq-opt.correct .cr-sq-key { background: #06D6A0; color: #0e0824; }
.cr-sq-opt.wrong .cr-sq-key { background: #EF476F; color: #fff; }
.cr-sq-opt-text { font-size: 0.8rem; color: #e8e0f7; flex: 1; line-height: 1.4; }
.cr-sq-status { text-align: center; font-size: 0.76rem; color: rgba(255,255,255,0.38); padding: 4px 0 0; }
.cr-sq-res-stats { display: flex; gap: 6px; margin-bottom: 9px; }
.cr-sq-res-stat { flex: 1; padding: 6px 7px; border-radius: 8px; background: rgba(255,255,255,0.04); text-align: center; }
.cr-sq-res-stat-val { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 900; color: #e8e0f7; }
.cr-sq-res-stat.ok .cr-sq-res-stat-val { color: #06D6A0; }
.cr-sq-res-stat.bad .cr-sq-res-stat-val { color: #EF476F; }
.cr-sq-res-stat-lbl { font-size: 0.56rem; color: rgba(255,255,255,0.28); font-weight: 700; text-transform: uppercase; }
.cr-sq-res-row { display: flex; align-items: center; gap: 7px; margin-bottom: 5px; }
.cr-sq-res-key { width: 20px; height: 20px; border-radius: 5px; background: rgba(255,255,255,0.07); flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 0.6rem; font-weight: 800; color: rgba(255,255,255,0.38); }
.cr-sq-res-key.ok { background: #06D6A0; color: #0e0824; }
.cr-sq-res-bar { flex: 1; height: 12px; background: rgba(255,255,255,0.06); border-radius: 99px; overflow: hidden; }
.cr-sq-res-fill { height: 100%; border-radius: 99px; background: rgba(155,93,229,0.45); transition: width .5s ease; }
.cr-sq-res-fill.ok { background: linear-gradient(90deg, #06D6A0, #06D6E0); }
.cr-sq-res-cnt { font-size: 0.64rem; font-weight: 800; color: rgba(255,255,255,0.45); min-width: 16px; text-align: right; }
@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: var(--text-3); 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: var(--text-3); 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); }
/* ── Settings side panel ───────────────────────────────────────────────── */
.cr-settings-overlay {
position: fixed; inset: 0; z-index: 3000;
background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);
display: flex; justify-content: flex-end;
opacity: 0; pointer-events: none;
transition: opacity .22s ease;
}
.cr-settings-overlay.open { opacity: 1; pointer-events: all; }
.cr-settings-panel {
width: 380px; max-width: 100vw;
background: #13101e; border-left: 1.5px solid rgba(155,93,229,0.25);
display: flex; flex-direction: column;
transform: translateX(30px);
transition: transform .22s ease;
overflow: hidden;
}
.cr-settings-overlay.open .cr-settings-panel { transform: translateX(0); }
.cr-sp-head {
padding: 18px 20px 12px; border-bottom: 1px solid rgba(255,255,255,0.07);
display: flex; align-items: center; gap: 12px; flex-shrink: 0;
}
.cr-sp-title {
flex: 1; font-family: 'Unbounded',sans-serif; font-size: 0.88rem;
font-weight: 800; color: #fff;
}
.cr-sp-close {
background: none; border: none; cursor: pointer; padding: 6px;
border-radius: 8px; color: var(--text-3); transition: color .15s, background .15s;
display: flex; align-items: center;
}
.cr-sp-close:hover { background: rgba(255,255,255,0.07); color: #fff; }
.cr-sp-tabs {
display: flex; border-bottom: 1px solid rgba(255,255,255,0.07); flex-shrink: 0;
}
.cr-sp-tab {
flex: 1; padding: 10px 6px; font-family: 'Manrope',sans-serif;
font-size: 0.7rem; font-weight: 700; color: var(--text-3);
background: none; border: none; cursor: pointer;
border-bottom: 2px solid transparent; transition: color .15s;
}
.cr-sp-tab.active { color: #9B5DE5; border-bottom-color: #9B5DE5; }
.cr-sp-body {
flex: 1; overflow-y: auto; padding: 20px;
scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.3) transparent;
}
.cr-sp-pane { display: none; flex-direction: column; gap: 22px; }
.cr-sp-pane.active { display: flex; }
.cr-sp-section { display: flex; flex-direction: column; gap: 10px; }
.cr-sp-section-label {
font-family: 'Manrope',sans-serif; font-size: 0.68rem; font-weight: 700;
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em;
}
.cr-sp-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.cr-sp-row-lbl { font-family: 'Manrope',sans-serif; font-size: 0.82rem; font-weight: 600; color: #c8d0db; flex: 1; line-height: 1.4; }
.cr-sp-row-sub { font-size: 0.69rem; color: var(--text-3); margin-top: 2px; }
/* Toggle */
.cr-toggle { position: relative; width: 38px; height: 22px; flex-shrink: 0; }
.cr-toggle input { opacity: 0; width: 0; height: 0; }
.cr-toggle-track {
position: absolute; inset: 0; cursor: pointer;
background: rgba(255,255,255,0.12); border-radius: 22px; transition: background .2s;
}
.cr-toggle input:checked + .cr-toggle-track { background: #9B5DE5; }
.cr-toggle-track::before {
content: ''; position: absolute; width: 16px; height: 16px;
left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: transform .2s;
}
.cr-toggle input:checked + .cr-toggle-track::before { transform: translateX(16px); }
/* Segmented control */
.cr-seg { display: flex; background: rgba(255,255,255,0.06); border-radius: 8px; overflow: hidden; }
.cr-seg button {
flex: 1; padding: 7px 8px; border: none; background: none; cursor: pointer;
font-family: 'Manrope',sans-serif; font-size: 0.72rem; font-weight: 700;
color: var(--text-3); transition: all .15s;
}
.cr-seg button.active { background: #9B5DE5; color: #fff; border-radius: 6px; }
/* Mic test */
.cr-mic-bar { height: 6px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; }
.cr-mic-fill { height: 100%; background: linear-gradient(90deg, #06D6A0, #9B5DE5); border-radius: 3px; width: 0%; transition: width 0.08s ease; }
.cr-sp-btn {
padding: 8px 16px; border-radius: 8px; border: 1px solid rgba(155,93,229,0.4);
background: rgba(155,93,229,0.1); color: #9B5DE5; font-family: 'Manrope',sans-serif;
font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: all .15s; align-self: flex-start;
}
.cr-sp-btn:hover { background: rgba(155,93,229,0.2); }
.cr-sp-btn.cyan { border-color: rgba(6,214,160,0.4); background: rgba(6,214,160,0.08); color: #06D6A0; }
.cr-sp-btn.cyan:hover { background: rgba(6,214,160,0.16); }
.cr-sp-btn.cyan.granted { opacity: 0.6; cursor: default; pointer-events: none; }
.cr-sp-note { font-family: 'Manrope',sans-serif; font-size: 0.72rem; color: var(--text-3); line-height: 1.5; }
/* Chat font size */
#cr-messages .cr-msg-text { font-size: 0.82rem; }
.cr-chat-fs-small #cr-messages .cr-msg-text { font-size: 0.74rem; }
.cr-chat-fs-large #cr-messages .cr-msg-text { font-size: 0.92rem; }
/* Left-hand mode: sidebar on left */
.cr-body.left-hand { flex-direction: row-reverse; }
/* ── Simulation panel ───────────────────────────────────────────────── */
.cr-sim-panel {
position: absolute; inset: 0; z-index: 40;
display: none; flex-direction: column;
background: #0A0814;
}
.cr-sim-panel.open { display: flex; }
.cr-sim-bar {
display: flex; align-items: center; gap: 10px;
padding: 0 14px; height: 40px; flex-shrink: 0;
background: rgba(14,10,28,0.97);
border-bottom: 1px solid rgba(155,93,229,0.2);
}
.cr-sim-bar-icon {
width: 22px; height: 22px; flex-shrink: 0;
background: linear-gradient(135deg, rgba(155,93,229,.35), rgba(6,214,224,.2));
border-radius: 6px; border: 1px solid rgba(155,93,229,0.3);
display: flex; align-items: center; justify-content: center;
}
.cr-sim-bar-icon svg { width: 12px; height: 12px; stroke: #9B5DE5; }
.cr-sim-bar-title { font-size: 0.82rem; font-weight: 700; color: var(--text-1, #f0e8ff); flex: 1; }
.cr-sim-bar-close {
width: 28px; height: 28px; border-radius: 7px; border: none;
background: rgba(255,255,255,0.06); color: var(--text-2, #aaa);
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background .15s;
}
.cr-sim-bar-close:hover { background: rgba(241,91,181,0.18); color: #F15BB5; }
.cr-sim-bar-close svg { width: 14px; height: 14px; }
/* Mode toggle (teacher only) */
.cr-sim-mode {
display: flex; gap: 3px; align-items: center; margin-right: 4px;
}
.cr-sim-mode-btn {
padding: 3px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1);
background: transparent; color: rgba(255,255,255,0.45);
font-family: 'Manrope',sans-serif; font-size: 0.7rem; font-weight: 700;
cursor: pointer; transition: all .15s;
}
.cr-sim-mode-btn:hover { color: rgba(255,255,255,0.7); }
.cr-sim-mode-btn.active { background: rgba(155,93,229,0.2); border-color: rgba(155,93,229,0.5); color: #d4b8ff; }
/* Student interaction blocker overlay (demo mode) */
.cr-sim-blocker {
position: absolute; inset: 40px 0 0 0; z-index: 6;
background: transparent; cursor: not-allowed;
display: none;
}
.cr-sim-blocker.active { display: block; }
.cr-sim-frame {
flex: 1; border: none; width: 100%; min-height: 0;
background: #0D0D1A;
}
/* ── Annotate-over-sim mode ───────────────────────────────────────────── */
/* Board floats above the sim panel (sim visible behind transparent canvas) */
.cr-board-area.annotate-active .cr-sim-panel,
.cr-board-area.annotate-active .cr-tb-panel { z-index: 1; }
.cr-board-area.annotate-active .cr-board-wrap {
z-index: 45;
background: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
}
.cr-board-area.annotate-active .cr-board-wrap::before,
.cr-board-area.annotate-active .cr-board-wrap::after { display: none !important; }
/* Floating bar shown while annotating */
.cr-annotate-bar {
display: none;
position: absolute; top: 0; left: 0; right: 0; z-index: 60;
height: 40px; flex-shrink: 0;
background: rgba(10,6,22,0.92);
border-bottom: 1.5px solid rgba(241,91,181,0.4);
backdrop-filter: blur(6px);
align-items: center; gap: 8px; padding: 0 12px;
}
.cr-board-area.annotate-active .cr-annotate-bar { display: flex; }
.cr-annotate-bar-label {
font-size: 0.75rem; font-weight: 700; color: #F15BB5;
display: flex; align-items: center; gap: 5px; flex: 1;
}
.cr-annotate-bar-label svg { width: 14px; height: 14px; stroke: #F15BB5; }
.cr-annotate-exit {
padding: 4px 12px; border-radius: 7px; border: 1px solid rgba(241,91,181,0.4);
background: rgba(241,91,181,0.1); color: #F15BB5;
font-family: 'Manrope',sans-serif; font-size: 0.72rem; font-weight: 700;
cursor: pointer; transition: all .15s;
}
.cr-annotate-exit:hover { background: rgba(241,91,181,0.22); }
/* "Draw" button in sim bar */
.cr-sim-annotate-btn {
display: none;
padding: 3px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.12);
background: transparent; color: rgba(255,255,255,0.5);
font-family: 'Manrope',sans-serif; font-size: 0.7rem; font-weight: 700;
cursor: pointer; transition: all .15s; align-items: center; gap: 4px;
margin-right: 4px;
}
.cr-sim-annotate-btn svg { width: 12px; height: 12px; flex-shrink: 0; }
.cr-sim-annotate-btn:hover { color: rgba(255,255,255,0.8); border-color: rgba(241,91,181,0.4); }
.cr-sim-annotate-btn.active { background: rgba(241,91,181,0.18); border-color: rgba(241,91,181,0.5); color: #F15BB5; }
/* Show draw button only for teachers when sim is open */
.cr-sim-panel.open .cr-sim-annotate-btn.teacher-ctrl { display: flex; }
/* ── Simulation picker modal ────────────────────────────────────────── */
.cr-sim-picker-overlay {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.72); backdrop-filter: blur(6px);
display: none; align-items: center; justify-content: center;
}
.cr-sim-picker-overlay.open { display: flex; }
.cr-sim-picker-modal {
background: #110a20; border-radius: 18px;
border: 1.5px solid rgba(155,93,229,0.25);
box-shadow: 0 24px 80px rgba(0,0,0,0.7);
width: min(720px, 96vw); max-height: 80vh;
display: flex; flex-direction: column; overflow: hidden;
}
.cr-sim-picker-head {
display: flex; align-items: center; gap: 12px;
padding: 20px 24px 0; flex-shrink: 0;
}
.cr-sim-picker-head h3 { flex: 1; font-family: 'Unbounded',sans-serif; font-size: 1.05rem; font-weight: 700; margin: 0; }
.cr-sim-picker-close {
width: 32px; height: 32px; border-radius: 9px; border: none;
background: rgba(255,255,255,0.06); color: var(--text-2, #aaa);
cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .15s;
}
.cr-sim-picker-close:hover { background: rgba(241,91,181,0.18); color: #F15BB5; }
.cr-sim-picker-close svg { width: 16px; height: 16px; }
.cr-sim-picker-cats {
display: flex; gap: 6px; padding: 14px 24px 10px; flex-shrink: 0; flex-wrap: wrap;
}
.cr-sim-cat-btn {
padding: 5px 14px; border-radius: 99px; border: 1.5px solid rgba(255,255,255,0.1);
background: transparent; color: rgba(255,255,255,0.55);
font-family: 'Manrope',sans-serif; font-size: 0.77rem; font-weight: 700;
cursor: pointer; transition: all .15s;
}
.cr-sim-cat-btn:hover { border-color: rgba(155,93,229,0.4); color: #c9b8ee; }
.cr-sim-cat-btn.active { background: rgba(155,93,229,0.15); border-color: rgba(155,93,229,0.5); color: #d4b8ff; }
.cr-sim-picker-body { flex: 1; overflow-y: auto; padding: 4px 24px 24px; scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.3) transparent; }
.cr-sim-picker-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; margin-top: 10px;
}
.cr-sim-picker-card {
border-radius: 12px; border: 1.5px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.04); padding: 14px 14px 12px;
cursor: pointer; transition: border-color .15s, background .15s, transform .15s;
display: flex; flex-direction: column; gap: 5px;
}
.cr-sim-picker-card:hover {
border-color: rgba(155,93,229,0.55); background: rgba(155,93,229,0.08);
transform: translateY(-1px);
}
.cr-sim-picker-card-title { font-size: 0.82rem; font-weight: 700; color: #e8e0f7; line-height: 1.3; }
.cr-sim-picker-card-cat {
font-size: 0.68rem; font-weight: 600;
padding: 2px 7px; border-radius: 99px; align-self: flex-start;
border: 1px solid transparent;
}
.cr-sim-picker-card-cat.math { background: rgba(155,93,229,0.15); border-color: rgba(155,93,229,0.3); color: #c9a0ff; }
.cr-sim-picker-card-cat.phys { background: rgba(6,214,224,0.1); border-color: rgba(6,214,224,0.3); color: #06D6E0; }
.cr-sim-picker-card-cat.chem { background: rgba(241,91,181,0.1); border-color: rgba(241,91,181,0.3); color: #F15BB5; }
.cr-sim-picker-card-cat.bio { background: rgba(168,224,99,0.1); border-color: rgba(168,224,99,0.3); color: #A8E063; }
.cr-sim-picker-card-cat.game { background: rgba(255,159,67,0.1); border-color: rgba(255,159,67,0.3); color: #FF9F43; }
/* ── Textbook panel (mirrors sim panel) ─────────────────────────────── */
.cr-tb-panel {
position: absolute; inset: 0; z-index: 5;
background: #fff; display: none; flex-direction: column;
border-left: 1px solid rgba(155,93,229,0.2);
}
.cr-tb-panel.open { display: flex; }
.cr-tb-bar {
display: flex; align-items: center; gap: 10px;
padding: 8px 14px; background: #1a0f2e; flex-shrink: 0;
border-bottom: 1px solid rgba(155,93,229,0.2);
}
.cr-tb-bar-icon {
width: 24px; height: 24px; border-radius: 7px;
background: rgba(6,214,224,0.15); border: 1px solid rgba(6,214,224,0.3);
display: flex; align-items: center; justify-content: center;
}
.cr-tb-bar-icon svg { width: 12px; height: 12px; stroke: #06D6E0; }
.cr-tb-bar-title { font-size: 0.82rem; font-weight: 700; color: #f0e8ff; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cr-tb-bar-close {
width: 26px; height: 26px; border-radius: 7px; border: none;
background: rgba(241,91,181,0.1); color: rgba(255,255,255,0.7);
cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .15s, color .15s;
}
.cr-tb-bar-close:hover { background: rgba(241,91,181,0.18); color: #F15BB5; }
.cr-tb-bar-close svg { width: 14px; height: 14px; }
.cr-tb-mode {
display: flex; gap: 4px; padding: 3px; background: rgba(255,255,255,0.04);
border-radius: 99px; border: 1px solid rgba(255,255,255,0.06);
}
.cr-tb-mode-btn {
padding: 4px 12px; border-radius: 99px; border: 1.5px solid transparent;
background: transparent; color: rgba(255,255,255,0.5);
font-family: 'Manrope',sans-serif; font-size: 0.72rem; font-weight: 700; cursor: pointer; transition: all .15s;
}
.cr-tb-mode-btn:hover { color: rgba(255,255,255,0.7); }
.cr-tb-mode-btn.active { background: rgba(6,214,224,0.18); border-color: rgba(6,214,224,0.45); color: #06D6E0; }
.cr-tb-frame {
flex: 1; width: 100%; border: 0; background: #fff;
}
.cr-tb-blocker {
position: absolute; inset: 38px 0 0 0;
display: none; background: transparent; cursor: not-allowed; z-index: 10;
}
.cr-tb-blocker.active { display: block; }
/* Draw-over button (in tb-bar; only visible when textbook open) */
.cr-tb-annotate-btn {
display: none; padding: 5px 10px; border-radius: 99px;
border: 1.5px solid rgba(241,91,181,0.3); background: transparent;
color: rgba(255,255,255,0.7); font-family: 'Manrope',sans-serif;
font-size: 0.72rem; font-weight: 700; cursor: pointer;
transition: all .15s; align-items: center; gap: 4px; margin-right: 4px;
}
.cr-tb-annotate-btn svg { width: 12px; height: 12px; flex-shrink: 0; }
.cr-tb-annotate-btn:hover { color: rgba(255,255,255,0.85); border-color: rgba(241,91,181,0.55); }
.cr-tb-annotate-btn.active { background: rgba(241,91,181,0.18); border-color: rgba(241,91,181,0.55); color: #F15BB5; }
.cr-tb-panel.open .cr-tb-annotate-btn.teacher-ctrl { display: flex; }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></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>
<button class="cr-header-btn" id="cr-sim-btn" onclick="crOpenSimPicker()" style="display:none" title="Открыть симуляцию">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"/></svg>
<span>Симуляция</span>
</button>
<button class="cr-header-btn" id="cr-tb-btn" onclick="crOpenTbPicker()" style="display:none" title="Открыть учебник">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
<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>
<!-- guest link button: teacher only -->
<button class="cr-header-btn" id="cr-guest-btn" onclick="crGuestOpen()" style="display:none" 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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
<span>Гостям</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>
<button class="cr-header-btn" onclick="openSettings()" title="Настройки урока" style="margin-left:4px">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div>
</div>
<!-- Body -->
<div class="cr-body" id="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>
<!-- Active speaker chip -->
<div class="cr-speaker-chip" id="cr-speaker-chip">
<div class="cr-speaker-chip-avatar" id="cr-speaker-chip-avatar"></div>
<span class="cr-speaker-chip-name" id="cr-speaker-chip-name"></span>
<div class="cr-speaker-chip-wave">
<span></span><span></span><span></span>
</div>
</div>
<!-- Fullscreen exit overlay (visible only when in fullscreen — for touchpad users) -->
<button id="cr-fs-exit-overlay" onclick="wbToggleFullscreen()" 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"><polyline points="8 3 3 3 3 8"/><polyline points="21 8 21 3 16 3"/><polyline points="3 16 3 21 8 21"/><polyline points="16 21 21 21 21 16"/></svg>
Свернуть
</button>
</div>
<!-- Simulation iframe overlay (shown when teacher opens a sim) -->
<div class="cr-sim-panel" id="cr-sim-panel">
<div class="cr-sim-bar">
<div class="cr-sim-bar-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"/></svg>
</div>
<span class="cr-sim-bar-title" id="cr-sim-bar-title">Симуляция</span>
<!-- Draw-over button (teacher only) -->
<button class="cr-sim-annotate-btn teacher-ctrl" id="cr-sim-annotate-btn" onclick="crToggleAnnotate()" title="Рисовать поверх симуляции">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
<!-- Mode toggle (teacher only) -->
<div class="cr-sim-mode" id="cr-sim-mode-toggle" style="display:none">
<button class="cr-sim-mode-btn active" id="cr-sim-mode-demo" onclick="crSetSimMode('demo')" title="Все видят одно и то же">Демо</button>
<button class="cr-sim-mode-btn" id="cr-sim-mode-free" onclick="crSetSimMode('free')" title="Каждый работает самостоятельно">Свободно</button>
</div>
<button class="cr-sim-bar-close" id="cr-sim-bar-close" onclick="crTeacherCloseSim()" title="Закрыть симуляцию" style="display:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<iframe class="cr-sim-frame" id="cr-sim-frame" src="about:blank" allow="fullscreen"></iframe>
<!-- Blocks student interaction in demo mode -->
<div class="cr-sim-blocker" id="cr-sim-blocker"></div>
</div>
<!-- Textbook iframe overlay (shown when teacher opens a textbook) -->
<div class="cr-tb-panel" id="cr-tb-panel">
<div class="cr-tb-bar">
<div class="cr-tb-bar-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
</div>
<span class="cr-tb-bar-title" id="cr-tb-bar-title">Учебник</span>
<button class="cr-tb-annotate-btn teacher-ctrl" id="cr-tb-annotate-btn" onclick="crToggleAnnotate()" title="Рисовать поверх учебника">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
<div class="cr-tb-mode" id="cr-tb-mode-toggle" style="display:none">
<button class="cr-tb-mode-btn active" id="cr-tb-mode-demo" onclick="crSetTbMode('demo')" title="Все видят то же, что и учитель">Демо</button>
<button class="cr-tb-mode-btn" id="cr-tb-mode-free" onclick="crSetTbMode('free')" title="Каждый листает самостоятельно">Свободно</button>
</div>
<button class="cr-tb-bar-close" id="cr-tb-bar-close" onclick="crTeacherCloseTb()" title="Закрыть учебник" style="display:none">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<iframe class="cr-tb-frame" id="cr-tb-frame" src="about:blank"></iframe>
<div class="cr-tb-blocker" id="cr-tb-blocker"></div>
</div>
<!-- Annotate-mode floating bar (shown on top of sim when drawing) -->
<div class="cr-annotate-bar" id="cr-annotate-bar">
<span class="cr-annotate-bar-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
Режим аннотации
</span>
<button class="cr-annotate-exit" id="cr-annotate-exit-btn" onclick="crToggleAnnotate()">Закончить рисование</button>
</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>
<button class="cr-fullscreen-btn" id="cr-student-fs-btn" onclick="wbToggleFullscreen()" title="На весь экран (F11)">
<svg class="fs-enter" 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>
<svg class="fs-exit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/></svg>
<span id="cr-fs-label">На весь экран</span>
</button>
<button class="cr-tool-btn" onclick="crSaveBoardRegion(this)" title="Сохранить часть доски в «Мои материалы»">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>
</button>
<button class="cr-tool-btn" onclick="crSaveBoardPage(this)" title="Сохранить страницу доски в «Мои материалы»">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
</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 tools -->
<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 dropdown -->
<div class="cr-drop" id="cr-shape-drop">
<button class="cr-tool-btn" id="wb-shape-picker-btn" onclick="wbToggleShapePicker()" title="Фигуры">
<span id="wb-shape-icon-slot"><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"><rect x="3" y="3" width="18" height="18" rx="2"/></svg></span>
<svg class="cr-drop-chevron" viewBox="0 0 8 5"><path d="M0 0l4 5 4-5z"/></svg>
</button>
<div class="cr-drop-popup cr-shape-popup-grid" id="cr-shape-popup">
<button class="cr-tool-btn" id="wb-tool-rect" onclick="wbPickShape('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="wbPickShape('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="wbPickShape('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="wbPickShape('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="wbPickShape('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="wbPickShape('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="wbPickShape('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="wbPickShape('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="wbPickShape('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="wbPickShape('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>
<div class="cr-pop-sep"></div>
<div class="cr-pop-full">
<button class="cr-pop-fill-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>
</div>
</div>
<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>
<div class="wb-tbl-drop cr-drop" id="wb-tbl-drop">
<button class="cr-tool-btn" id="wb-tool-table" onclick="wbToggleTablePicker()" 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>
<svg class="cr-drop-chevron" viewBox="0 0 8 5"><path d="M0 0l4 5 4-5z"/></svg>
</button>
<div class="wb-tbl-popup" id="wb-tbl-popup">
<div class="wb-tbl-grid" id="wb-tbl-grid"></div>
<div class="wb-tbl-label" id="wb-tbl-label">1 × 1</div>
</div>
</div>
<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-mindmap" onclick="wbSetTool('mindmap')" 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="9" x2="12" y2="3"/><circle cx="12" cy="3" r="1.5"/><line x1="14.6" y1="13.4" x2="19" y2="17"/><circle cx="20" cy="18" r="1.5"/><line x1="9.4" y1="13.4" x2="5" y2="17"/><circle cx="4" cy="18" r="1.5"/><line x1="15" y1="11" x2="21" y2="9"/><circle cx="22" cy="8.5" r="1.5"/><line x1="9" y1="11" x2="3" y2="9"/><circle cx="2" cy="8.5" r="1.5"/></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>
<button class="cr-tool-btn" id="wb-tool-image-ai" onclick="wbGenerateImage()" title="Сгенерировать картинку (ИИ)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 3l2.2 6.3L22 12l-6.8 2.7L13 21l-2.2-6.3L4 12l6.8-2.7z"/><path d="M5 4v3M3.5 5.5h3"/></svg></button>
<input type="file" id="wb-image-input" accept="image/*" style="display:none" onchange="wbImageSelected(this)">
<div class="cr-tool-sep"></div>
<!-- undo / redo / clear -->
<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>
<!-- page nav pushed right -->
<div class="cr-page-nav">
<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>
<!-- ── ROW 2: style + 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>
<!-- overlay properties (shown when ruler/protractor selected) -->
<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>
<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>
<div class="cr-tool-sep"></div>
<!-- board theme picker -->
<div class="wb-tpl-picker">
<label>Доска:</label>
<select id="wb-theme-select" onchange="wbSetBoardTheme(this.value)" title="Тема доски">
<option value="chalkboard">Мел</option>
<option value="blackboard">Классная</option>
<option value="corkboard">Пробка</option>
<option value="whiteboard">Белая</option>
</select>
</div>
<div class="cr-tool-sep"></div>
<!-- content templates picker -->
<div class="cr-drop" id="cr-tpl-drop">
<button class="cr-tool-btn" id="wb-tpl-picker-btn" onclick="wbToggleTplPicker()" 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"><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>
<svg class="cr-drop-chevron" viewBox="0 0 8 5"><path d="M0 0l4 5 4-5z"/></svg>
</button>
<div class="wb-tpl-popup-grid" id="wb-content-tpl-popup">
<div class="tpl-grid">
<button class="wb-tpl-item" onclick="wbInsertTemplate('venn2')">
<svg viewBox="0 0 40 28"><ellipse cx="14" cy="14" rx="12" ry="10" fill="none" stroke="#06D6E0" stroke-width="1.5"/><ellipse cx="26" cy="14" rx="12" ry="10" fill="none" stroke="#F15BB5" stroke-width="1.5"/></svg>
Венн 2
</button>
<button class="wb-tpl-item" onclick="wbInsertTemplate('venn3')">
<svg viewBox="0 0 40 28"><ellipse cx="14" cy="10" rx="11" ry="8" fill="none" stroke="#06D6E0" stroke-width="1.5"/><ellipse cx="26" cy="10" rx="11" ry="8" fill="none" stroke="#F15BB5" stroke-width="1.5"/><ellipse cx="20" cy="20" rx="11" ry="8" fill="none" stroke="#FFE066" stroke-width="1.5"/></svg>
Венн 3
</button>
<button class="wb-tpl-item" onclick="wbInsertTemplate('tchart')">
<svg viewBox="0 0 40 28"><rect x="2" y="2" width="36" height="24" fill="none" stroke="#9B5DE5" stroke-width="1.5"/><line x1="20" y1="2" x2="20" y2="26" stroke="#9B5DE5" stroke-width="1.5"/><line x1="2" y1="9" x2="38" y2="9" stroke="#9B5DE5" stroke-width="1.5"/></svg>
T-chart
</button>
<button class="wb-tpl-item" onclick="wbInsertTemplate('timeline')">
<svg viewBox="0 0 40 28"><line x1="3" y1="14" x2="37" y2="14" stroke="#06D6E0" stroke-width="1.5"/><polyline points="34,11 37,14 34,17" fill="none" stroke="#06D6E0" stroke-width="1.5"/><line x1="10" y1="10" x2="10" y2="18" stroke="#06D6E0" stroke-width="1.2"/><line x1="20" y1="10" x2="20" y2="18" stroke="#06D6E0" stroke-width="1.2"/><line x1="30" y1="10" x2="30" y2="18" stroke="#06D6E0" stroke-width="1.2"/></svg>
Шкала
</button>
<button class="wb-tpl-item" onclick="wbInsertTemplate('quadrant')">
<svg viewBox="0 0 40 28"><line x1="2" y1="14" x2="38" y2="14" stroke="#9B5DE5" stroke-width="1.5"/><line x1="20" y1="2" x2="20" y2="26" stroke="#9B5DE5" stroke-width="1.5"/></svg>
Квадрант
</button>
<button class="wb-tpl-item" onclick="wbInsertTemplate('pyramid')">
<svg viewBox="0 0 40 28"><polygon points="20,2 38,26 2,26" fill="none" stroke="#06D6E0" stroke-width="1.5"/><line x1="8" y1="17" x2="32" y2="17" stroke="#06D6E0" stroke-width="1"/><line x1="13" y1="11" x2="27" y2="11" stroke="#06D6E0" stroke-width="1"/></svg>
Пирамида
</button>
<button class="wb-tpl-item" onclick="wbInsertTemplate('swot')">
<svg viewBox="0 0 40 28"><rect x="2" y="2" width="36" height="24" fill="none" stroke="#9B5DE5" stroke-width="1.5"/><line x1="20" y1="2" x2="20" y2="26" stroke="#9B5DE5" stroke-width="1.5"/><line x1="2" y1="14" x2="38" y2="14" stroke="#9B5DE5" stroke-width="1.5"/></svg>
SWOT
</button>
</div>
<div class="wb-bg-section">
<div class="wb-bg-row">
<button class="wb-bg-btn" onclick="wbPickBgImage()" title="Изображение или PDF как фон страницы">
<svg style="width:11px;height:11px;vertical-align:middle;margin-right:3px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="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>
<button class="wb-bg-btn danger" id="wb-bg-remove-btn" onclick="wbRemoveBgImage()" style="display:none" title="Убрать фон">
<svg style="width:11px;height:11px;vertical-align:middle;margin-right:3px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="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"/></svg>
Удалить
</button>
</div>
<input type="file" id="wb-bg-file-input" accept="image/*,.pdf" style="display:none" onchange="wbBgFileSelected(this)">
</div>
</div>
</div>
<div class="cr-tool-sep"></div>
<!-- utils dropdown: fullscreen, export, templates, ruler, protractor, measurements -->
<div class="cr-drop" id="cr-utils-drop">
<button class="cr-tool-btn" id="wb-utils-picker-btn" onclick="wbToggleUtilsPicker()" 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"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M4.93 4.93a10 10 0 0 0 0 14.14"/></svg>
<svg class="cr-drop-chevron" viewBox="0 0 8 5"><path d="M0 0l4 5 4-5z"/></svg>
</button>
<div class="cr-drop-popup cr-utils-popup-grid" id="cr-utils-popup">
<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" 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-pop-sep"></div>
<button class="cr-tool-btn active" id="wb-btn-snap" onclick="wbToggleSnap(this)" 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="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></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>
</div>
</div>
</div><!-- /.cr-tb-row row2 -->
<!-- ── ROW 3: text options (visible only when text tool active) ── -->
<div class="cr-text-row" id="cr-text-row">
<label>Шрифт</label>
<select class="cr-text-font-sel" id="wb-text-font" onchange="wbSetTextFont(this.value)">
<option value="Manrope">Manrope</option>
<option value="Unbounded">Unbounded</option>
<option value="Caveat">Caveat</option>
<option value="Georgia">Georgia</option>
<option value="Arial">Arial</option>
<option value="Courier New">Courier New</option>
<option value="Impact">Impact</option>
</select>
<div class="cr-tool-sep"></div>
<label>Размер</label>
<input type="number" class="cr-text-size-inp" id="wb-text-size"
value="22" min="8" max="120" step="2"
onchange="wbSetTextSize(this.value)" oninput="wbSetTextSize(this.value)">
<div class="cr-tool-sep"></div>
<button class="cr-text-fmt-btn" id="wb-text-bold" onclick="wbToggleTextBold()" title="Жирный (Ctrl+B)" style="font-family:Georgia,serif">B</button>
<button class="cr-text-fmt-btn" id="wb-text-italic" onclick="wbToggleTextItalic()" title="Курсив (Ctrl+I)" style="font-family:Georgia,serif;font-style:italic">I</button>
<div class="cr-tool-sep"></div>
<button class="cr-text-fmt-btn active" id="wb-text-al-left" onclick="wbSetTextAlign('left')" title="По левому краю"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="2" y1="4" x2="14" y2="4"/><line x1="2" y1="8" x2="10" y2="8"/><line x1="2" y1="12" x2="12" y2="12"/></svg></button>
<button class="cr-text-fmt-btn" id="wb-text-al-center" onclick="wbSetTextAlign('center')" title="По центру"> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="2" y1="4" x2="14" y2="4"/><line x1="4" y1="8" x2="12" y2="8"/><line x1="3" y1="12" x2="13" y2="12"/></svg></button>
<button class="cr-text-fmt-btn" id="wb-text-al-right" onclick="wbSetTextAlign('right')" title="По правому краю"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="2" y1="4" x2="14" y2="4"/><line x1="6" y1="8" x2="14" y2="8"/><line x1="4" y1="12" x2="14" y2="12"/></svg></button>
</div>
<!-- ── Context row: sticky color presets ───────────────────── -->
<div class="wb-ctx-row" id="wb-sticky-row">
<span class="wb-ctx-lbl">Цвет:</span>
<button class="wb-sticky-col active" data-sc="#FFE066" style="background:#FFE066" onclick="wbSetStickyColor(this)" title="Жёлтый"></button>
<button class="wb-sticky-col" data-sc="#FF9F7F" style="background:#FF9F7F" onclick="wbSetStickyColor(this)" title="Персиковый"></button>
<button class="wb-sticky-col" data-sc="#B5EAD7" style="background:#B5EAD7" onclick="wbSetStickyColor(this)" title="Мятный"></button>
<button class="wb-sticky-col" data-sc="#C7CEEA" style="background:#C7CEEA" onclick="wbSetStickyColor(this)" title="Лавандовый"></button>
<button class="wb-sticky-col" data-sc="#FFDAC1" style="background:#FFDAC1" onclick="wbSetStickyColor(this)" title="Персиковый 2"></button>
<button class="wb-sticky-col" data-sc="#E2B7F5" style="background:#E2B7F5" onclick="wbSetStickyColor(this)" title="Сиреневый"></button>
</div>
<!-- ── Context row: connector arrows (shown when connector selected) -->
<div class="wb-ctx-row" id="wb-connector-row">
<span class="wb-ctx-lbl">Стрелки:</span>
<button class="cr-text-fmt-btn" id="wb-arr-start" onclick="wbToggleArrow('arrowStart')" title="Стрелка в начале">
<svg viewBox="0 0 20 14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="7" x2="16" y2="7"/><polyline points="8,3 4,7 8,11"/></svg>
</button>
<button class="cr-text-fmt-btn active" id="wb-arr-end" onclick="wbToggleArrow('arrowEnd')" title="Стрелка в конце">
<svg viewBox="0 0 20 14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="7" x2="16" y2="7"/><polyline points="12,3 16,7 12,11"/></svg>
</button>
</div>
<!-- ── Context row: multi-select alignment ──────────────────── -->
<div class="wb-ctx-row" id="wb-align-row">
<span class="wb-ctx-lbl">Выровнять:</span>
<button class="cr-tool-btn" onclick="wbAlign('left')" title="По левому краю"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="3" x2="4" y2="21"/><rect x="4" y="5" width="12" height="4" rx="1"/><rect x="4" y="14" width="8" height="4" rx="1"/></svg></button>
<button class="cr-tool-btn" onclick="wbAlign('centerH')" title="По центру (гориз)"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="3" x2="12" y2="21"/><rect x="5" y="5" width="14" height="4" rx="1"/><rect x="7" y="14" width="10" height="4" rx="1"/></svg></button>
<button class="cr-tool-btn" onclick="wbAlign('right')" title="По правому краю"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="20" y1="3" x2="20" y2="21"/><rect x="8" y="5" width="12" height="4" rx="1"/><rect x="12" y="14" width="8" height="4" rx="1"/></svg></button>
<div class="cr-tool-sep"></div>
<button class="cr-tool-btn" onclick="wbAlign('top')" title="По верхнему краю"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="4" x2="21" y2="4"/><rect x="5" y="4" width="4" height="12" rx="1"/><rect x="14" y="4" width="4" height="8" rx="1"/></svg></button>
<button class="cr-tool-btn" onclick="wbAlign('centerV')" title="По центру (верт)"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="12" x2="21" y2="12"/><rect x="5" y="5" width="4" height="14" rx="1"/><rect x="14" y="7" width="4" height="10" rx="1"/></svg></button>
<button class="cr-tool-btn" onclick="wbAlign('bottom')" title="По нижнему краю"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="20" x2="21" y2="20"/><rect x="5" y="8" width="4" height="12" rx="1"/><rect x="14" y="12" width="4" height="8" rx="1"/></svg></button>
</div>
</div>
</div>
</div>
<!-- Right panel -->
<div class="cr-right" id="cr-right">
<div class="cr-panel-tabs">
<div class="cr-panel-tabs-inner" id="cr-tabs-inner">
<button class="cr-tab active" id="tab-participants" onclick="crSwitchTab('participants')" title="Участники">
<i data-lucide="users" style="width:13px;height:13px"></i>
<span class="cr-tab-label">Участники</span>
<span class="cr-tab-badge" id="participants-count">0</span>
</button>
<button class="cr-tab" id="tab-chat" onclick="crSwitchTab('chat')" title="Чат">
<i data-lucide="message-circle" style="width:13px;height:13px"></i>
<span class="cr-tab-label">Чат</span>
<span class="cr-tab-badge" id="chat-unread" style="display:none">0</span>
</button>
<button class="cr-tab" id="tab-notes" onclick="crSwitchTab('notes')" title="Заметки">
<i data-lucide="notebook-pen" style="width:13px;height:13px"></i>
<span class="cr-tab-label">Заметки</span>
</button>
<button class="cr-tab" id="tab-quiz" onclick="crSwitchTab('quiz')" style="display:none" 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"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span class="cr-tab-label">Квиз</span>
<span class="cr-tab-badge" id="quiz-badge" style="display:none"></span>
</button>
</div>
<button class="cr-panel-collapse-btn" onclick="crToggleRightPanel()" 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>
</div>
<div class="cr-panel-content" id="cr-panel-content">
<!-- 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">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Личные заметки
</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 style="display:flex;align-items:center;justify-content:space-between;padding:0 2px;">
<span class="cr-notes-wordcount" id="notes-wordcount">0 слов</span>
<span style="font-size:0.6rem;color:rgba(255,255,255,0.15)">Только вам</span>
</div>
</div>
</div>
<!-- Quiz panel (teacher only) -->
<div id="panel-quiz" style="display:none; flex:1; flex-direction:column; overflow:hidden;">
<!-- No session msg -->
<div class="cr-quiz-no-session-msg" id="cr-quiz-no-session">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:32px;height:32px;stroke:#8898AA"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<p>Квиз доступен во время урока</p>
</div>
<!-- Quiz active state -->
<div id="cr-quiz-session" style="display:none;flex:1;flex-direction:column;overflow:hidden;">
<!-- Personal session warning (no class) -->
<div id="cr-quiz-no-class" style="display:none;padding:10px 12px 0">
<div style="background:rgba(255,183,0,0.08);border:1px solid rgba(255,183,0,0.2);border-radius:8px;padding:8px 10px;font-size:0.7rem;color:rgba(255,220,100,0.75);line-height:1.5">
Квиз доступен только в классных сессиях. Создайте урок с выбором класса.
</div>
</div>
<!-- Start button (no live quiz running) -->
<div id="cr-quiz-start-area" style="padding:10px 10px 6px">
<button class="cr-quiz-start-btn" id="cr-quiz-start-btn" onclick="crQuizStart()">
<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"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Запустить квиз
</button>
</div>
<!-- Status bar (quiz running) -->
<div class="cr-quiz-status-bar" id="cr-quiz-status-bar" style="display:none">
<span class="cr-quiz-status-dot"></span>
<span class="cr-quiz-status-text" id="cr-quiz-status-text">Вопрос активен</span>
<button class="cr-quiz-end-btn" onclick="crQuizEnd()">Завершить</button>
</div>
<!-- Active question card -->
<div class="cr-quiz-active-card" id="cr-quiz-active-card" style="display:none">
<div class="cr-quiz-active-label">Текущий вопрос</div>
<div class="cr-quiz-active-text" id="cr-quiz-active-text"></div>
<div class="cr-quiz-counter-row">
<span class="cr-quiz-counter-val" id="cr-quiz-ans-count">0</span>
<span class="cr-quiz-counter-lbl">ответов</span>
<button class="cr-quiz-show-results-btn" id="cr-quiz-show-results-btn" onclick="crQuizShowResults()">Результаты</button>
</div>
</div>
<!-- Results display -->
<div class="cr-quiz-result-stats" id="cr-quiz-result-stats" style="display:none"></div>
<!-- Filters -->
<div class="cr-quiz-filters" id="cr-quiz-filters">
<div class="cr-quiz-search-wrap">
<svg class="cr-quiz-search-icon" 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"/></svg>
<input class="cr-quiz-search-input" id="cr-quiz-search" type="text" placeholder="Поиск вопросов…" oninput="crQuizOnSearch(this.value)">
</div>
<div class="cr-quiz-filter-row">
<select class="cr-quiz-filter-sel" id="cr-quiz-topic-sel" onchange="crQuizOnTopicFilter()">
<option value="">Все темы</option>
</select>
<select class="cr-quiz-filter-sel" id="cr-quiz-diff-sel" onchange="crQuizOnDiffFilter()">
<option value="">Любой</option>
<option value="1">Лёгкий</option>
<option value="2">Средний</option>
<option value="3">Сложный</option>
</select>
</div>
<div class="cr-quiz-count" id="cr-quiz-count" style="display:none"></div>
</div>
<!-- Question list -->
<div class="cr-quiz-q-scroll" id="cr-quiz-q-scroll"></div>
<button class="cr-quiz-load-more" id="cr-quiz-load-more" style="display:none" onclick="crQuizLoadMore()">Загрузить ещё</button>
</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">
<div class="cr-chat-input-row">
<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)">
<button class="cr-share-lib-btn" id="cr-share-lib-btn" onclick="crOpenFilePicker()" 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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
</button>
<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><!-- /.cr-panel-content -->
</div><!-- /.cr-right -->
</div><!-- /.cr-body -->
</div><!-- /.sb-content -->
</div><!-- /.app-layout -->
<!-- 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>
<!-- ── Guest link modal ── -->
<div class="cr-modal-overlay" id="cr-guest-modal" onclick="if(event.target===this)crGuestClose()">
<div class="cr-modal" style="max-width:400px">
<div class="cr-modal-title">Гостевая ссылка</div>
<div class="cr-modal-sub">Поделитесь ссылкой — гость увидит доску без регистрации (только просмотр)</div>
<!-- state: no token yet -->
<div id="cr-guest-no-token">
<div class="cr-guest-actions" style="margin-top:20px">
<button class="cr-guest-create-btn" onclick="crGuestCreate()">
Создать ссылку
</button>
</div>
</div>
<!-- state: token exists -->
<div id="cr-guest-has-token" style="display:none">
<div class="cr-guest-link-wrap">
<span class="cr-guest-link-url" id="cr-guest-url"></span>
<button class="cr-guest-copy-btn" id="cr-guest-copy-btn" onclick="crGuestCopy()">Копировать</button>
</div>
<div class="cr-guest-note">Гости видят только доску. Чат, квизы и другие функции — недоступны.</div>
<div class="cr-guest-actions">
<button class="cr-guest-revoke-btn" onclick="crGuestRevoke()">Отозвать ссылку</button>
</div>
</div>
<div class="cr-modal-actions" style="margin-top:16px;justify-content:flex-end">
<button class="cr-modal-cancel" onclick="crGuestClose()">Закрыть</button>
</div>
</div>
</div>
<!-- Screen Picker Modal v2 -->
<div class="cr-screen-picker-overlay" id="cr-screen-picker-overlay" onclick="if(event.target===this)crCloseScreenPicker()">
<div class="cr-screen-picker">
<!-- Header -->
<div class="cr-sp-head">
<div class="cr-sp-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:18px;height:18px"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<div class="cr-sp-titles">
<div class="cr-sp-title">Трансляция экрана</div>
<div class="cr-sp-sub">Ученики увидят выбранное содержимое в реальном времени</div>
</div>
<button class="cr-sp-close" onclick="crCloseScreenPicker()">
<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>
<!-- Source type tabs -->
<div class="cr-sp-tabs">
<button class="cr-sp-tab active" id="cr-sptab-monitor" onclick="crSpSwitchTab('monitor')">
<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"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
Экраны
</button>
<button class="cr-sp-tab" id="cr-sptab-window" onclick="crSpSwitchTab('window')">
<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"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
Окна
</button>
<button class="cr-sp-tab" id="cr-sptab-browser" onclick="crSpSwitchTab('browser')">
<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"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
Вкладки браузера
</button>
</div>
<!-- Live preview area -->
<div class="cr-sp-preview-wrap">
<div class="cr-sp-preview-inner">
<!-- Empty state -->
<div class="cr-sp-empty" id="cr-sp-empty">
<div class="cr-sp-empty-icon" id="cr-sp-empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:26px;height:26px"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<div class="cr-sp-empty-title" id="cr-sp-empty-title">Экран не выбран</div>
<div class="cr-sp-empty-hint" id="cr-sp-empty-hint">Нажмите кнопку ниже — откроется диалог выбора источника</div>
<button class="cr-sp-pick-btn" id="cr-sp-pick-btn" onclick="crSpPickSource()">
<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"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
Выбрать экран
</button>
</div>
<!-- Video preview (shown after source selected) -->
<div class="cr-sp-video-wrap" id="cr-sp-video-wrap">
<video class="cr-sp-video" id="cr-sp-video" autoplay muted playsinline></video>
<div class="cr-sp-preview-bar">
<div class="cr-sp-src-icon-sm" id="cr-sp-bar-icon">
<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"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<span class="cr-sp-preview-name" id="cr-sp-preview-name">Экран 1</span>
<span class="cr-sp-res-badge" id="cr-sp-res-badge"></span>
<button class="cr-sp-change-btn" onclick="crSpPickSource()">Сменить</button>
</div>
</div>
</div>
</div>
<hr class="cr-sp-divider">
<!-- Settings -->
<div class="cr-sp-settings">
<div class="cr-sp-q-row">
<span class="cr-sp-q-label">Разрешение</span>
<div class="cr-sp-q-opts">
<button class="cr-sp-qbtn" data-q="720" onclick="crSpSetQ(this,'res')">720p</button>
<button class="cr-sp-qbtn active" data-q="1080" onclick="crSpSetQ(this,'res')">1080p</button>
<button class="cr-sp-qbtn" data-q="0" onclick="crSpSetQ(this,'res')">Источник</button>
</div>
</div>
<div class="cr-sp-q-row">
<span class="cr-sp-q-label">Частота кадров</span>
<div class="cr-sp-q-opts">
<button class="cr-sp-qbtn" data-q="15" onclick="crSpSetQ(this,'fps')">15 fps</button>
<button class="cr-sp-qbtn active" data-q="30" onclick="crSpSetQ(this,'fps')">30 fps</button>
<button class="cr-sp-qbtn" data-q="60" onclick="crSpSetQ(this,'fps')">60 fps</button>
</div>
</div>
<!-- Option cards grid -->
<div class="cr-sp-opts-grid">
<!-- Audio -->
<div class="cr-sp-opt" id="cr-sp-opt-audio" onclick="crSpToggleAudio()">
<span class="cr-sp-opt-ic">
<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"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
</span>
<div class="cr-sp-opt-text">
<div class="cr-sp-opt-title">Звук системы</div>
<div class="cr-sp-opt-hint">Передавать системный звук</div>
</div>
<button class="cr-sp-toggle" onclick="event.stopPropagation()"></button>
</div>
<!-- Cursor -->
<div class="cr-sp-opt on" id="cr-sp-opt-cursor" onclick="crSpToggleCursor()">
<span class="cr-sp-opt-ic">
<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="m4 4 7.07 17 2.51-7.39L21 11.07z"/></svg>
</span>
<div class="cr-sp-opt-text">
<div class="cr-sp-opt-title">Курсор</div>
<div class="cr-sp-opt-hint">Показывать указатель мыши</div>
</div>
<button class="cr-sp-toggle" onclick="event.stopPropagation()"></button>
</div>
</div>
</div>
<!-- Footer -->
<div class="cr-sp-footer">
<button class="cr-sp-cancel" onclick="crCloseScreenPicker()">Отмена</button>
<button class="cr-sp-start" id="cr-sp-start-btn" onclick="crSpStartShare()" disabled>
<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="5 3 19 12 5 21 5 3"/></svg>
Поделиться
</button>
</div>
</div>
</div>
<!-- File Picker Modal -->
<div class="cr-file-picker-overlay" id="cr-file-picker-overlay" onclick="if(event.target===this)crCloseFilePicker()">
<div class="cr-file-picker">
<div class="cr-file-picker-head">
<span class="cr-file-picker-title">Файлы из библиотеки</span>
<button class="cr-file-picker-close" onclick="crCloseFilePicker()" title="Закрыть">
<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>
<input class="cr-file-picker-search" id="cr-file-picker-search" type="text" placeholder="Поиск по названию..." oninput="crFilePickerSearch(this.value)">
<div class="cr-file-picker-list" id="cr-file-picker-list">
<div class="cr-file-picker-empty">Загрузка...</div>
</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/board-clip.js"></script>
<script src="/js/classroom-rtc.js"></script>
<script src="/js/api.js"></script>
<script src="/js/imggen.js"></script>
<script src="/js/sound.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script>
/* ── User prefs ─────────────────────────────────────────────────────────── */
const CR_PREFS_KEY = 'ls_cr_prefs';
const CR_PREFS_DEFAULT = {
chatFontSize: 'medium', // 'small' | 'medium' | 'large'
mutedOnJoin: false,
vadSensitivity: 'medium', // 'low' | 'medium' | 'high' → VAD threshold
chatSound: true,
handSound: true,
defaultTool: 'pencil', // 'pencil' | 'select'
leftHand: false,
stylusMultiplier: 0, // 0=off, 0.5, 1.0, 2.0
lessonPushNotif: false,
};
const VAD_THRESHOLDS = { low: 22, medium: 12, high: 5 };
let _prefs = { ...CR_PREFS_DEFAULT };
function crLoadPrefs() {
try { _prefs = { ...CR_PREFS_DEFAULT, ...JSON.parse(localStorage.getItem(CR_PREFS_KEY) || '{}') }; }
catch { _prefs = { ...CR_PREFS_DEFAULT }; }
}
function crSavePrefs() {
try { localStorage.setItem(CR_PREFS_KEY, JSON.stringify(_prefs)); } catch {}
}
function crApplyPrefs() {
// Right panel collapsed state
if (localStorage.getItem('cr_right_collapsed') === '1')
document.getElementById('cr-right')?.classList.add('collapsed');
// Chat font size
const chatWrap = document.getElementById('chat-active');
if (chatWrap) {
chatWrap.classList.remove('cr-chat-fs-small','cr-chat-fs-large');
if (_prefs.chatFontSize === 'small') chatWrap.classList.add('cr-chat-fs-small');
if (_prefs.chatFontSize === 'large') chatWrap.classList.add('cr-chat-fs-large');
}
// Left-hand mode
const bodyEl = document.getElementById('cr-body');
if (bodyEl) bodyEl.classList.toggle('left-hand', !!_prefs.leftHand);
// Whiteboard
if (window._wb) {
_wb.setStylusMultiplier(_prefs.stylusMultiplier);
}
}
function crSetPref(key, value) {
_prefs[key] = value;
crSavePrefs();
crApplyPrefs();
_crSyncSettingsUI();
}
/* ── Settings modal ───────────────────────────────────────────────────── */
let _micTestStream = null, _micTestACtx = null, _micTestTimer = null;
function openSettings() {
document.getElementById('cr-settings-overlay').classList.add('open');
_crSyncSettingsUI();
}
function closeSettings() {
document.getElementById('cr-settings-overlay').classList.remove('open');
_crMicTestStop();
}
function settingsTab(name) {
document.querySelectorAll('.cr-sp-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.cr-sp-pane').forEach(p => p.classList.toggle('active', p.id === 'crs-' + name));
}
function _crSyncSettingsUI() {
_crSegActive('crs-chat-size', _prefs.chatFontSize);
_crCheckSync('crs-muted-join', _prefs.mutedOnJoin);
_crSegActive('crs-vad', _prefs.vadSensitivity);
_crCheckSync('crs-chat-sound', _prefs.chatSound);
_crCheckSync('crs-hand-sound', _prefs.handSound);
if (window.LS && LS.sfx) {
_crCheckSync('crs-sfx-enabled', LS.sfx.enabled);
const vol = document.getElementById('crs-sfx-volume');
if (vol) vol.value = Math.round(LS.sfx.volume * 100);
}
_crSegActive('crs-def-tool', _prefs.defaultTool);
_crSegActive('crs-hand', _prefs.leftHand ? 'left' : 'right');
_crSegActive('crs-stylus', String(_prefs.stylusMultiplier));
const pushBtn = document.getElementById('crs-push-btn');
if (pushBtn && Notification.permission === 'granted') {
pushBtn.textContent = 'Уведомления включены'; pushBtn.classList.add('granted');
}
}
function _crSegActive(id, val) {
const el = document.getElementById(id);
if (!el) return;
el.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.val === String(val)));
}
function _crCheckSync(id, val) {
const el = document.getElementById(id);
if (el) el.checked = !!val;
}
/* Mic test */
async function crMicTestStart() {
_crMicTestStop();
const fill = document.getElementById('crs-mic-fill');
const status = document.getElementById('crs-mic-status');
const btn = document.getElementById('crs-mic-btn');
try {
_micTestStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
_micTestACtx = new AudioContext();
const src = _micTestACtx.createMediaStreamSource(_micTestStream);
const analyser = _micTestACtx.createAnalyser();
analyser.fftSize = 256;
src.connect(analyser);
const buf = new Uint8Array(analyser.frequencyBinCount);
if (status) status.textContent = 'Говорите в микрофон…';
if (btn) { btn.textContent = 'Остановить'; btn.onclick = _crMicTestStop; }
_micTestTimer = setInterval(() => {
analyser.getByteFrequencyData(buf);
const avg = buf.reduce((a,b) => a+b, 0) / buf.length;
if (fill) fill.style.width = Math.min(100, Math.round((avg/64)*100)) + '%';
}, 80);
} catch {
if (status) status.textContent = 'Нет доступа к микрофону';
}
}
function _crMicTestStop() {
if (_micTestTimer) { clearInterval(_micTestTimer); _micTestTimer = null; }
if (_micTestACtx) { try { _micTestACtx.close(); } catch {} _micTestACtx = null; }
if (_micTestStream) { _micTestStream.getTracks().forEach(t => t.stop()); _micTestStream = null; }
const fill = document.getElementById('crs-mic-fill');
const status = document.getElementById('crs-mic-status');
const btn = document.getElementById('crs-mic-btn');
if (fill) fill.style.width = '0%';
if (status) status.textContent = '';
if (btn) { btn.textContent = 'Начать тест'; btn.onclick = crMicTestStart; }
}
/* Push notifications */
async function crRequestPush() {
if (!('Notification' in window)) return;
const perm = await Notification.requestPermission();
if (perm === 'granted') {
_prefs.lessonPushNotif = true; crSavePrefs();
const btn = document.getElementById('crs-push-btn');
if (btn) { btn.textContent = 'Уведомления включены'; btn.classList.add('granted'); }
}
}
/* Notification sounds — delegated to LS.sfx */
function _crSfx(name) {
try { if (window.LS && LS.sfx) LS.sfx.play(name); } catch {}
}
function crSfxSetEnabled(v) {
if (window.LS && LS.sfx) { LS.sfx.setEnabled(v); if (v) LS.sfx.play('click'); }
}
function crSfxSetVolume(v) {
if (window.LS && LS.sfx) { LS.sfx.setVolume(v / 100); LS.sfx.play('click'); }
}
/* ── state ── */
let _me = null;
let _session = null;
let _sessionId = null;
let _sessionBoardTheme = null; // pending theme from session data, applied in initWhiteboard
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 _pageNames = {}; // {pageNum: name|null}
let _wbMenuPage = 1; // page targeted by context menu (global for onclick in menu HTML)
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)
const _WB_OWN_IDS_MAX = 2000; // cap to prevent unbounded growth on long lessons
/* ── WebSocket (low-latency cursor + preview) ── */
let _crWs = null; // WebSocket instance
let _crWsReady = false; // true when WS is open
/* ── 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 _simActive = null; // simId of currently open simulation (null = none)
let _simMode = 'demo'; // 'demo' (teacher controls, students watch) | 'free' (independent)
let _simStateThrottle = null; // timer for state relay throttle
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() { /* backup polls removed — WS delivers all events with SSE fallback */ }
function stopPolling() { /* no-op */ }
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 });
});
}
function _showClassroomDisabled() {
document.getElementById('cr-board-wrap').innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:20px;padding:40px;text-align:center">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:64px;height:64px;color:rgba(155,93,229,0.4)"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><line x1="1" y1="1" x2="23" y2="23" stroke="#F15BB5"/></svg>
<div style="font-family:'Unbounded',sans-serif;font-size:1.1rem;font-weight:800;color:var(--text,#e2d9f3)">Онлайн-уроки отключены</div>
<div style="font-size:0.9rem;color:rgba(255,255,255,0.5);max-width:340px;line-height:1.6">Администратор отключил модуль онлайн-уроков. Обратитесь к администратору платформы.</div>
</div>`;
document.getElementById('cr-toolbar')?.remove();
document.getElementById('cr-student-nav')?.remove();
}
async function init() {
if (!LS.isLoggedIn()) { window.location.href = '/login'; return; }
crLoadPrefs();
crApplyPrefs();
_me = LS.getUser();
if (!_me) {
try { _me = await LS.get('/api/auth/me'); LS.setUser(_me); } catch { window.location.href = '/login'; return; }
}
// setup nav
LS.renderNavAvatar(document.getElementById('nav-avatar'), _me);
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');
}
// Check classroom module enabled (teacher sees disabled screen if off)
if (_me.role === 'teacher') {
try {
const features = await LS.get('/api/admin/features').catch(() => ({}));
if (features.classroom === false) {
_showClassroomDisabled();
return;
}
} catch { /* if endpoint fails — allow access */ }
}
// 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, then fetch fresh data
await LS.post(`/api/classroom/${_sessionId}/join`).catch(() => {});
const fresh = await LS.get(`/api/classroom/${_sessionId}`).catch(() => data.session);
_session = fresh;
enterActiveState(fresh);
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, fromWS = false) {
// When WS is active, classroom events are delivered via WS.
// Ignore classroom_ events that arrive via SSE to avoid duplicates.
// Events coming from WS (fromWS=true) are always processed.
if (!fromWS && _crWsReady && data.type?.startsWith('classroom_') && data.type !== '_sse_reconnect') return;
// 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);
_crSfx('lesson_start');
} else if (data.type === 'classroom_ended') {
if (_sessionId == data.sessionId) { onClassroomEnded(true); _crSfx('lesson_end'); }
} else if (data.type === 'classroom_user_joined') {
if (_sessionId == data.sessionId) { onUserJoined(data); _crSfx('user_joined'); }
} else if (data.type === 'classroom_user_left') {
if (_sessionId == data.sessionId) { onUserLeft(data); _crSfx('user_left'); }
} 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_board_theme') {
if (_sessionId == data.sessionId && _wb) {
_wb.setBoardTheme(data.theme);
const sel = document.getElementById('wb-theme-select');
if (sel) sel.value = data.theme;
}
} 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) {
_crSfx('muted');
_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(); _crSfx('draw_permitted'); }
} 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 === 'classroom_page_renamed') {
if (_sessionId == data.sessionId) {
_pageNames[data.pageNum] = data.name || null;
wbRebuildThumbnails();
}
} else if (data.type === 'classroom_page_duplicated') {
if (_sessionId == data.sessionId) {
if (data.name) _pageNames[data.newPage] = data.name;
onPageAdded(data.newPage);
}
} else if (data.type === 'classroom_page_deleted') {
if (_sessionId == data.sessionId) {
const del = data.pageNum;
const newNames = {};
Object.keys(_pageNames).forEach(k => {
const n = parseInt(k);
if (n < del) newNames[n] = _pageNames[n];
else if (n > del) newNames[n - 1] = _pageNames[n];
});
_pageNames = newNames;
_totalPages = Math.max(1, _totalPages - 1);
if (!isTeacher && _followTeacher) {
// navigate to teacher's current page
_wbCurrentPage = data.newCurrent;
updatePageLabel();
if (_wb) {
_wb.clearPage();
LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${_wbCurrentPage}`).then(d => {
_wb.loadStrokes(d.strokes || []);
if (d.template) _wb.setTemplate(d.template);
_wbUpdateMaxSeq(d.strokes || []); _wbMaxSeq = 0; _wbClearGen++;
}).catch(() => {});
}
}
wbRebuildThumbnails();
}
} else if (data.type === 'classroom_sim_open') {
if (_sessionId == data.sessionId) onSimOpen(data.simId, data.title);
} else if (data.type === 'classroom_sim_close') {
if (_sessionId == data.sessionId) onSimClose();
} else if (data.type === 'classroom_sim_state') {
if (_sessionId == data.sessionId && _simActive && _simMode === 'demo') {
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
if (!isTeacher) {
// Student: apply teacher's state to own iframe
document.getElementById('cr-sim-frame')?.contentWindow?.postMessage(
{ type: 'apply_sim_state', state: data.state }, '*'
);
}
}
} else if (data.type === 'classroom_sim_mode') {
if (_sessionId == data.sessionId) onSimModeChange(data.mode);
} else if (data.type === 'classroom_sim_annotate') {
if (_sessionId == data.sessionId) _crApplyAnnotate(data.active);
} else if (data.type === 'classroom_textbook_open') {
if (_sessionId == data.sessionId) onTbOpen(data);
} else if (data.type === 'classroom_textbook_close') {
if (_sessionId == data.sessionId) onTbClose();
} else if (data.type === 'classroom_textbook_nav') {
if (_sessionId == data.sessionId) onTbNav(data);
} else if (data.type === 'classroom_textbook_mode') {
if (_sessionId == data.sessionId) onTbModeChange(data.mode);
} else if (data.type === '_sse_reconnect') {
// SSE reconnected after a drop — re-sync all real-time state to fill the gap
if (_sessionId) resyncAfterReconnect();
} else if (data.type === 'live_question') {
// live_* events are class-scoped, no sessionId field
const _iqTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
if (_iqTeacher) {
if (_quizLiveId == null) _quizLiveId = data.liveId;
crQuizShowActiveCard(data.question);
} else {
crStudentOnQuestion(data.question, data.liveId);
}
} else if (data.type === 'live_answer_count') {
if (_me && (_me.role === 'teacher' || _me.role === 'admin')) crQuizUpdateCounter(data.count);
} else if (data.type === 'live_results') {
const _irTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
if (_irTeacher) crQuizRenderResults(data);
else crStudentOnResults(data);
} else if (data.type === 'live_ended') {
const _ieTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
if (_ieTeacher) {
_quizLiveId = null;
document.getElementById('cr-quiz-status-bar').style.display = 'none';
document.getElementById('cr-quiz-start-area').style.display = 'block';
document.getElementById('cr-quiz-active-card').style.display = 'none';
document.getElementById('cr-quiz-result-stats').style.display = 'none';
} else {
crStudentOnEnded();
}
} else if (data.type === 'classroom_guest_joined') {
if (_sessionId == data.sessionId) {
_participants[data.guestId] = { name: data.guestName || 'Гость', role: 'guest', micMuted: false };
updateParticipantsList();
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"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
`Гость «${LS.escapeHtml(data.guestName || 'Гость')}» присоединился`);
}
} else if (data.type === 'classroom_guest_left') {
if (_sessionId == data.sessionId) {
delete _participants[data.guestId];
updateParticipantsList();
}
}
}
/* eslint-enable eqeqeq */
function onClassroomStarted(data) {
const isTeacher = _me.role === 'teacher' || _me.role === 'admin';
if (!isTeacher) {
showJoinBanner({ id: data.sessionId, title: data.title });
// Push notification if enabled and page not focused
if (_prefs.lessonPushNotif && Notification.permission === 'granted' && document.hidden) {
try {
new Notification('Урок начался', {
body: data.title || 'Учитель начал урок. Присоединяйтесь!',
icon: '/favicon.svg',
});
} catch {}
}
}
}
function onClassroomEnded(showToast = true) {
if (!_sessionId) return; // guard against double-call (direct + WS event)
stopPolling();
wbStopBatch();
_crWsClose();
// 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 = {};
document.getElementById('cr-speaker-chip')?.classList.remove('visible');
_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-share-lib-btn').style.display = 'none';
const _tabQuizEnd = document.getElementById('tab-quiz');
if (_tabQuizEnd) _tabQuizEnd.style.display = 'none';
document.getElementById('cr-tabs-inner')?.classList.remove('tabs-4');
if (_activeTab === 'quiz') crSwitchTab('participants');
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';
// Close sim panel if open
if (_simActive) onSimClose();
updateParticipantsList();
if (showToast) 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();
_updateSpeakerChip();
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}" data-uid="${u.id}" data-uname="${LS.esc(u.name || '')}">
<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('');
list.querySelectorAll('.cr-online-item').forEach(el => {
el.addEventListener('click', () => crToggleOnlineUser(Number(el.dataset.uid), el.dataset.uname));
});
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-sim-btn').style.display = isTeacher ? 'flex' : 'none';
document.getElementById('cr-tb-btn').style.display = isTeacher ? 'flex' : 'none';
document.getElementById('cr-guest-btn').style.display = isTeacher ? 'flex' : 'none';
document.getElementById('cr-share-lib-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';
// Quiz panel
crQuizOnSessionActive(isTeacher);
// page state
_wbCurrentPage = session.current_page || 1;
_totalPages = session.pageCount || 1;
// Remember board theme from session — will be applied in initWhiteboard
if (session.board_theme) _sessionBoardTheme = session.board_theme;
_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
// SQLite stores UTC without 'T'/'Z' — force UTC parse
_sessionStartTime = session.created_at
? new Date(session.created_at.replace(' ', 'T') + 'Z')
: 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);
// open WebSocket for low-latency cursor + preview
_crWsConnect();
// 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';
// Hide the right panel entirely when no session is active (avoids empty strip on mobile)
document.getElementById('cr-right').style.display = active ? '' : 'none';
}
/* ── 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,
onFormulaInsert: canEdit ? showFormulaModal : null,
onCoordEdit: canEdit ? showCoordModal : null,
onNumberLineEdit: canEdit ? showNumLineModal : null,
onObjectCreated: canEdit ? () => wbSetTool('select') : null,
onToolSwitch: canEdit ? tool => wbSetTool(tool) : null,
onZoomChange: z => { const el = document.getElementById('wb-zoom-label'); if (el) el.textContent = Math.round(z * 100) + '%'; },
onOverlayChange: wbOvChange,
stylusMultiplier: _prefs.stylusMultiplier,
});
// Apply session board theme (set by teacher, synced for all participants)
if (_sessionBoardTheme && _sessionBoardTheme !== 'chalkboard') {
_wb.setBoardTheme(_sessionBoardTheme);
}
{ const sel = document.getElementById('wb-theme-select'); if (sel) sel.value = _sessionBoardTheme || 'chalkboard'; }
_sessionBoardTheme = null; // consumed
// Apply saved whiteboard defaults from user preferences
if (canEdit && LS.prefs) {
LS.prefs.init().then(() => {
const wb = LS.prefs.get('wb', {});
if (wb.color) {
_wb.setColor(wb.color);
document.querySelectorAll('.cr-color-btn').forEach(b => b.classList.remove('active'));
const match = document.querySelector(`.cr-color-btn[data-color="${wb.color}"]`);
if (match) match.classList.add('active');
}
if (wb.width) {
_wb.setWidth(wb.width);
document.querySelectorAll('.cr-width-btn').forEach(b => b.classList.remove('active'));
const wBtn = document.getElementById(`wb-w${wb.width}`);
if (wBtn) wBtn.classList.add('active');
}
if (wb.lineStyle) {
_wb.setLineStyle(wb.lineStyle);
document.querySelectorAll('.cr-linestyle-btn').forEach(b => b.classList.remove('active'));
const lBtn = document.getElementById(`wb-ls-${wb.lineStyle}`);
if (lBtn) lBtn.classList.add('active');
}
if (wb.theme) {
_wb.setBoardTheme(wb.theme);
const sel = document.getElementById('wb-theme-select');
if (sel) sel.value = wb.theme;
}
}).catch(() => {});
}
// 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';
// Apply default tool preference
if (canEdit && _prefs.defaultTool && _prefs.defaultTool !== 'pencil') {
wbSetTool(_prefs.defaultTool);
}
// load page names
try {
const pagesRes = await LS.get(`/api/classroom/${sessionId}/pages`);
_pageNames = {};
(pagesRes.pages || []).forEach(p => { if (p.name) _pageNames[p.page_num] = p.name; });
} catch {}
// 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; }
if (res.name !== undefined) _pageNames[1] = res.name || null;
_wbUpdateBgBtn();
wbUpdateThumbnail(1);
} catch {}
// Unlock SSE processing and apply any buffered strokes
_wbInitializing = false;
if (_wbPendingSSE.length > 0) {
_wbUpdateMaxSeq(_wbPendingSSE);
_wb.addStrokes(_wbPendingSSE);
_wbPendingSSE = [];
}
// apply initial cursor style based on active tool
wbUpdateCursorStyle();
// Selection-based context UI (connector arrows, multi-select alignment)
canvas.addEventListener('pointerup', () => setTimeout(wbCheckSelectionUI, 20));
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();
}
// Draw permission delivered via WS — no periodic poll needed
}
}
/** 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);
}
/* ── WebSocket connect/close (classroom session scope) ── */
function _crWsConnect() {
if (_crWs && (_crWs.readyState === WebSocket.OPEN || _crWs.readyState === WebSocket.CONNECTING)) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
_crWs = new WebSocket(`${proto}//${location.host}/ws`);
_crWs.onopen = () => {
// Authenticate via first message — token never appears in URL or proxy logs
const token = LS.getToken() || '';
_crWs.send(JSON.stringify({ type: 'auth', token }));
};
_crWs.onclose = () => {
_crWsReady = false;
// Auto-reconnect after 2s (SSE stays active as fallback during gap)
if (_sessionId) setTimeout(() => { if (_sessionId) _crWsConnect(); }, 2000);
};
_crWs.onerror = () => { _crWsReady = false; };
_crWs.onmessage = e => {
let msg;
try { msg = JSON.parse(e.data); } catch { return; }
if (msg.type === 'auth_ok') {
_crWsReady = true;
// Register in session so server delivers events via WS instead of SSE
if (_sessionId) _crWs.send(JSON.stringify({ type: 'classroom_join', sessionId: _sessionId }));
return;
}
// All other classroom server→client events
try { handleSSE(msg, true); } catch {}
};
}
function _crWsClose() {
_crWsReady = false;
if (_crWs) { try { _crWs.close(); } catch {} _crWs = null; }
}
function _crWsSend(obj) {
if (_crWsReady && _crWs?.readyState === WebSocket.OPEN) {
try { _crWs.send(JSON.stringify(obj)); return true; } catch {}
}
return false; // fallback to HTTP
}
/* Called by Whiteboard during drawing (throttled ~20ms inside the class).
Sends preview via WebSocket (fast path); falls back to HTTP if WS not ready. */
function wbOnStrokeProgress(p) {
if (!_sessionId) return;
if (p.cancel) {
if (!_crWsSend({ type: 'preview', sessionId: _sessionId, liveId: p.liveId, pageNum: _wbCurrentPage, cancel: true })) {
LS.post(`/api/classroom/${_sessionId}/stroke-preview`, {
live_id: p.liveId, cancel: true, page_num: _wbCurrentPage,
}).catch(() => {});
}
return;
}
if (!_crWsSend({ type: 'preview', sessionId: _sessionId, liveId: p.liveId, tool: p.tool, data: p.data, pageNum: _wbCurrentPage })) {
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;
if (!_crWsSend({ type: 'cursor', sessionId: _sessionId, x: vx, y: vy, pageNum: _wbCurrentPage })) {
LS.post(`/api/classroom/${_sessionId}/cursor`, {
x: vx, y: vy, page_num: _wbCurrentPage,
}).catch(() => {});
}
}
let _wbFlushFails = 0;
async function wbFlushBatch() {
if (!_sessionId || _wbBatch.length === 0) return;
// Backoff: skip ticks after consecutive failures (max ~5s pause)
if (_wbFlushFails > 0) {
const skipTicks = Math.min(60, Math.pow(2, _wbFlushFails)); // 2,4,8,16...60
if (Math.random() > 1 / skipTicks) 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 })),
});
_wbFlushFails = 0;
// 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
if (_wbOwnIds.size > _WB_OWN_IDS_MAX) { const it = _wbOwnIds.values().next().value; _wbOwnIds.delete(it); }
_wb.confirmStroke(toSend[i].id, saved.id);
}
});
}
} catch {
_wbFlushFails++;
_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;
}
}
/* Кол-во подтверждённых сервером штрихов на доске: у них положительный id
(локальные, ещё не отправленные — отрицательный). */
function _wbLoadedServerCount() {
try { return (_wb && _wb._strokes) ? _wb._strokes.filter(s => s.id > 0).length : 0; }
catch (e) { return 0; }
}
/* Student-side poll (2s, страховка к WS/SSE). Сначала тянет лёгкую сигнатуру
страницы (maxSeq + count); если доска уже совпадает с сервером (обычный случай
при живом WS) — НИЧЕГО не грузит. Полная перезагрузка — только при расхождении
(пропущенные при обрыве события, клиры, удаления). */
function wbStartPoll() {
wbStopPoll();
_strokePollTimer = setInterval(async () => {
if (!_sessionId || !_wb || _wbInitializing) return;
const page = _wbCurrentPage;
const gen = _wbClearGen;
try {
const sig = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${page}&meta=1`);
if (_wbClearGen !== gen || _wbCurrentPage !== page) return;
// Доска идентична серверу → пропускаем тяжёлую перезагрузку
if (sig && sig.maxSeq === _wbMaxSeq && sig.count === _wbLoadedServerCount()) return;
// Расхождение → полная перезагрузка (проверенный путь: корректно отражает клиры/undo/пропуски)
const res = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${page}`);
if (_wbClearGen !== gen || _wbCurrentPage !== page) return;
const strokes = res.strokes || [];
_wbMaxSeq = 0;
_wbUpdateMaxSeq(strokes);
_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'];
/* ── Custom cursors ── */
function _makePencilCursor(color, lineW) {
const r = Math.max(2, Math.min(8, lineW * 0.8));
const sz = 32;
const tip = sz - 4;
const enc = encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="${sz}" height="${sz}" viewBox="0 0 ${sz} ${sz}">` +
`<circle cx="${tip}" cy="${tip}" r="${r}" fill="${color}" opacity="0.85" stroke="rgba(0,0,0,0.5)" stroke-width="1"/>` +
`<line x1="0" y1="0" x2="${tip - r}" y2="${tip - r}" stroke="rgba(0,0,0,0.4)" stroke-width="0.7"/>` +
`</svg>`
);
return `url("data:image/svg+xml,${enc}") ${tip} ${tip}, crosshair`;
}
function _makeEraserCursor(lineW) {
const s = Math.max(8, Math.min(28, lineW * 2));
const sz = s + 6;
const off = 3;
const enc = encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="${sz}" height="${sz}" viewBox="0 0 ${sz} ${sz}">` +
`<rect x="${off}" y="${off}" width="${s}" height="${s}" rx="2" fill="rgba(255,255,255,0.15)" stroke="rgba(255,255,255,0.7)" stroke-width="1.2"/>` +
`</svg>`
);
const hot = Math.round(sz / 2);
return `url("data:image/svg+xml,${enc}") ${hot} ${hot}, cell`;
}
function _makeHighlighterCursor(color) {
const enc = encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">` +
`<rect x="8" y="14" width="12" height="10" rx="1" fill="${color}" opacity="0.55" stroke="rgba(0,0,0,0.3)" stroke-width="1"/>` +
`<line x1="14" y1="0" x2="14" y2="28" stroke="rgba(0,0,0,0.25)" stroke-width="0.5"/>` +
`<line x1="0" y1="14" x2="28" y2="14" stroke="rgba(0,0,0,0.25)" stroke-width="0.5"/>` +
`</svg>`
);
return `url("data:image/svg+xml,${enc}") 14 14, crosshair`;
}
function wbUpdateCursorStyle() {
if (!_wb) return;
const tool = _wb._tool;
const color = _wb._color || '#ffffff';
const lineW = _wb._lineWidth || 4;
let cursor;
if (tool === 'pencil') cursor = _makePencilCursor(color, lineW);
else if (tool === 'highlighter') cursor = _makeHighlighterCursor(color);
else if (tool === 'eraser') cursor = _makeEraserCursor(lineW);
else {
const map = { text: 'text', select: 'default', sticky: 'copy', formula: 'copy',
table: 'crosshair', connector: 'crosshair', laser: 'none',
coordinate: 'copy', numberline: 'copy', compass: 'copy' };
cursor = map[tool] || 'crosshair';
}
document.getElementById('cr-canvas').style.cursor = cursor;
}
const WB_SHAPE_TOOLS = ['rect','ellipse','line','arrow','triangle','diamond','hexagon','star','roundedrect','callout'];
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);
});
// shape picker trigger: highlight if a shape tool is active, update icon
const isShape = WB_SHAPE_TOOLS.includes(tool);
const shapeTrigger = document.getElementById('wb-shape-picker-btn');
if (shapeTrigger) shapeTrigger.classList.toggle('active', isShape);
if (isShape) {
const srcBtn = document.getElementById(`wb-tool-${tool}`);
const slot = document.getElementById('wb-shape-icon-slot');
if (srcBtn && slot) slot.innerHTML = srcBtn.querySelector('svg').outerHTML;
}
// text row visibility
document.getElementById('cr-text-row')?.classList.toggle('visible', tool === 'text');
// sticky color row visibility
document.getElementById('wb-sticky-row')?.classList.toggle('visible', tool === 'sticky');
// hide connector/align rows when switching tools
document.getElementById('wb-connector-row')?.classList.remove('visible');
document.getElementById('wb-align-row')?.classList.remove('visible');
wbUpdateCursorStyle();
}
// Position a fixed popup above its trigger button (no inline display — CSS class controls visibility)
function _positionDropPopup(popup, btn, approxW) {
const r = btn.getBoundingClientRect();
let left = r.left + r.width / 2 - approxW / 2;
left = Math.max(4, Math.min(left, window.innerWidth - approxW - 4));
popup.style.left = left + 'px';
popup.style.top = 'auto';
popup.style.bottom = (window.innerHeight - r.top + 8) + 'px';
}
// Shape picker dropdown (~182px wide: 5×32 + 4×3gap + 2×5pad)
function wbToggleShapePicker() {
const popup = document.getElementById('cr-shape-popup');
const btn = document.getElementById('wb-shape-picker-btn');
const wasOpen = popup.classList.contains('open');
document.getElementById('cr-utils-popup')?.classList.remove('open');
if (wasOpen) { popup.classList.remove('open'); return; }
_positionDropPopup(popup, btn, 182);
popup.classList.add('open');
setTimeout(() => document.addEventListener('click', _closeShapePicker, { once: true }), 0);
}
function _closeShapePicker(e) {
if (!document.getElementById('cr-shape-drop')?.contains(e.target))
document.getElementById('cr-shape-popup')?.classList.remove('open');
else if (document.getElementById('cr-shape-popup')?.classList.contains('open'))
document.addEventListener('click', _closeShapePicker, { once: true });
}
function wbPickShape(tool) {
document.getElementById('cr-shape-popup')?.classList.remove('open');
wbSetTool(tool);
}
// Text alignment
function wbSetTextAlign(align) {
if (!_wb) return;
_wb.setTextAlign(align);
['left','center','right'].forEach(a => {
document.getElementById(`wb-text-al-${a}`)?.classList.toggle('active', a === align);
});
}
// Text tool options
function wbSetTextFont(family) {
if (!_wb) return;
_wb.setTextFontFamily(family);
// Also update selected text/sticky strokes
if (_wb._selectedIds.size > 0) {
for (const id of _wb._selectedIds) {
const s = _wb._strokes.find(x => x.id === id);
if (s && (s.tool === 'text' || s.tool === 'sticky')) {
s.data.fontFamily = family;
if (_wb._onStrokeUpdated) _wb._onStrokeUpdated(s);
}
}
_wb._staticDirty = true;
_wb.render();
}
}
function wbSetTextSize(v) {
const n = parseInt(v);
if (!_wb || isNaN(n)) return;
_wb.setTextFontSize(n);
const inp = document.getElementById('wb-text-size');
if (inp) inp.value = Math.max(8, Math.min(120, n));
}
function wbToggleTextBold() {
if (!_wb) return;
_wb._textBold = !_wb._textBold;
_wb.setTextBold(_wb._textBold);
document.getElementById('wb-text-bold')?.classList.toggle('active', _wb._textBold);
}
function wbToggleTextItalic() {
if (!_wb) return;
_wb._textItalic = !_wb._textItalic;
_wb.setTextItalic(_wb._textItalic);
document.getElementById('wb-text-italic')?.classList.toggle('active', _wb._textItalic);
}
// Sticky color presets
function wbSetStickyColor(btn) {
if (!_wb) return;
const c = btn.dataset.sc;
_wb.setStickyColor(c);
document.querySelectorAll('.wb-sticky-col').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
}
// Connector arrow toggles
function wbToggleArrow(which) {
if (!_wb) return;
_wb.toggleConnectorArrow(which);
// Update button state
const sel = _wb._strokes.find(s => s.id === _wb._selectedId);
if (sel && sel.tool === 'connector') {
document.getElementById('wb-arr-start')?.classList.toggle('active', !!sel.data.arrowStart);
document.getElementById('wb-arr-end')?.classList.toggle('active', !!sel.data.arrowEnd);
}
}
// Multi-select alignment
function wbAlign(dir) {
if (_wb) _wb.alignStrokes(dir);
}
// Table grid picker
function _buildTableGrid() {
const grid = document.getElementById('wb-tbl-grid');
const label = document.getElementById('wb-tbl-label');
if (!grid) return;
grid.innerHTML = '';
const ROWS = 6, COLS = 8;
let hlR = 0, hlC = 0;
for (let r = 1; r <= ROWS; r++) {
const row = document.createElement('div');
row.className = 'wb-tbl-row';
for (let c = 1; c <= COLS; c++) {
const cell = document.createElement('div');
cell.className = 'wb-tbl-cell';
cell.dataset.r = r; cell.dataset.c = c;
cell.addEventListener('mouseover', () => {
hlR = r; hlC = c;
label.textContent = `${r} × ${c}`;
grid.querySelectorAll('.wb-tbl-cell').forEach(el => {
el.classList.toggle('hl', +el.dataset.r <= hlR && +el.dataset.c <= hlC);
});
});
cell.addEventListener('click', () => {
if (_wb) { _wb.setTableSize(r, c); wbSetTool('table'); }
document.getElementById('wb-tbl-popup')?.classList.remove('open');
});
row.appendChild(cell);
}
grid.appendChild(row);
}
}
function wbToggleTablePicker() {
const popup = document.getElementById('wb-tbl-popup');
const btn = document.getElementById('wb-tool-table');
if (!popup || !btn) return;
const wasOpen = popup.classList.contains('open');
document.getElementById('cr-shape-popup')?.classList.remove('open');
document.getElementById('cr-utils-popup')?.classList.remove('open');
if (wasOpen) { popup.classList.remove('open'); return; }
if (!popup.dataset.built) { _buildTableGrid(); popup.dataset.built = '1'; }
// Reset highlight
document.getElementById('wb-tbl-label').textContent = '1 × 1';
popup.querySelectorAll('.wb-tbl-cell').forEach(el => el.classList.remove('hl'));
_positionDropPopup(popup, btn, 190);
popup.classList.add('open');
setTimeout(() => document.addEventListener('click', _closeTablePicker, { once: true }), 0);
}
function _closeTablePicker(e) {
if (!document.getElementById('wb-tbl-drop')?.contains(e.target))
document.getElementById('wb-tbl-popup')?.classList.remove('open');
else if (document.getElementById('wb-tbl-popup')?.classList.contains('open'))
document.addEventListener('click', _closeTablePicker, { once: true });
}
// ── Content templates picker ────────────────────────────────────────
function wbToggleTplPicker() {
const popup = document.getElementById('wb-content-tpl-popup');
const btn = document.getElementById('wb-tpl-picker-btn');
const wasOpen = popup.classList.contains('open');
document.getElementById('wb-tpl-popup')?.classList.remove('open');
document.getElementById('cr-utils-popup')?.classList.remove('open');
if (wasOpen) { popup.classList.remove('open'); return; }
_positionDropPopup(popup, btn, 230);
popup.classList.add('open');
setTimeout(() => document.addEventListener('click', _closeTplPicker, { once: true }), 0);
}
function _closeTplPicker(e) {
if (!document.getElementById('cr-tpl-drop')?.contains(e.target))
document.getElementById('wb-content-tpl-popup')?.classList.remove('open');
else if (document.getElementById('wb-content-tpl-popup')?.classList.contains('open'))
document.addEventListener('click', _closeTplPicker, { once: true });
}
function wbInsertTemplate(name) {
if (!_wb) return;
document.getElementById('wb-content-tpl-popup')?.classList.remove('open');
_wb.insertTemplate(name);
wbSetTool('select');
}
// ── Page background image ────────────────────────────────────────────
function wbPickBgImage() {
document.getElementById('wb-content-tpl-popup')?.classList.remove('open');
document.getElementById('wb-bg-file-input')?.click();
}
function wbBgFileSelected(input) {
const file = input.files?.[0];
if (!file || !_wb) return;
input.value = '';
if (file.type === 'application/pdf') {
_wbLoadPdfAsBackground(file);
} else {
_wb.pasteImageAsBackground(file);
_wbUpdateBgBtn();
}
}
function wbRemoveBgImage() {
if (!_wb) return;
_wb.removePageBackground();
_wbUpdateBgBtn();
}
function _wbUpdateBgBtn() {
const btn = document.getElementById('wb-bg-remove-btn');
if (btn) btn.style.display = _wb?.hasPageBackground() ? '' : 'none';
}
async function _wbLoadPdfAsBackground(file) {
try {
// Dynamically load PDF.js if not already loaded
if (!window.pdfjsLib) {
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@4.4.168/build/pdf.min.mjs';
s.type = 'module';
s.onload = res; s.onerror = rej;
document.head.appendChild(s);
});
if (!window.pdfjsLib) throw new Error('PDF.js не загрузился');
}
const ab = await file.arrayBuffer();
const pdf = await window.pdfjsLib.getDocument({ data: ab }).promise;
const page = await pdf.getPage(1);
const vp = page.getViewport({ scale: 1 });
// Render at full resolution, capped at 1920 wide
const scale = Math.min(1, 1920 / vp.width);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = Math.round(viewport.width);
canvas.height = Math.round(viewport.height);
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
canvas.toBlob(blob => {
if (blob) { _wb.pasteImageAsBackground(blob); _wbUpdateBgBtn(); }
}, 'image/jpeg', 0.85);
} catch (err) {
LS.toast('Ошибка загрузки PDF: ' + (err.message || err), 'error');
}
}
// Selection UI: show connector/align context rows based on current selection
function wbCheckSelectionUI() {
if (!_wb || _wb._tool !== 'select') {
document.getElementById('wb-connector-row')?.classList.remove('visible');
document.getElementById('wb-align-row')?.classList.remove('visible');
return;
}
const ids = [..._wb._selectedIds];
const cnt = ids.length;
// Multi-select alignment row
document.getElementById('wb-align-row')?.classList.toggle('visible', cnt > 1);
// Connector row (single connector selected)
let showConn = false;
if (cnt === 1) {
const sel = _wb._strokes.find(s => s.id === ids[0]);
if (sel && sel.tool === 'connector') {
showConn = true;
document.getElementById('wb-arr-start')?.classList.toggle('active', !!sel.data.arrowStart);
document.getElementById('wb-arr-end')?.classList.toggle('active', !!sel.data.arrowEnd);
}
}
document.getElementById('wb-connector-row')?.classList.toggle('visible', showConn);
// Text/sticky font row
let showTextRow = false;
if (cnt === 1) {
const sel = _wb._strokes.find(s => s.id === ids[0]);
if (sel && (sel.tool === 'text' || sel.tool === 'sticky')) {
showTextRow = true;
const ff = sel.data.fontFamily || 'Manrope';
const fSel = document.getElementById('wb-text-font');
if (fSel && fSel.value !== ff) fSel.value = ff;
}
}
const textRow = document.getElementById('cr-text-row');
if (textRow && _wb._tool === 'select') {
textRow.classList.toggle('visible', showTextRow);
}
}
// Utils picker dropdown (~112px wide: 3×32 + 2×3gap + 2×5pad)
function wbToggleUtilsPicker() {
const popup = document.getElementById('cr-utils-popup');
const btn = document.getElementById('wb-utils-picker-btn');
const wasOpen = popup.classList.contains('open');
document.getElementById('cr-shape-popup')?.classList.remove('open');
if (wasOpen) { popup.classList.remove('open'); return; }
_positionDropPopup(popup, btn, 112);
popup.classList.add('open');
setTimeout(() => document.addEventListener('click', _closeUtilsPicker, { once: true }), 0);
}
function _closeUtilsPicker(e) {
if (!document.getElementById('cr-utils-drop')?.contains(e.target))
document.getElementById('cr-utils-popup')?.classList.remove('open');
else if (document.getElementById('cr-utils-popup')?.classList.contains('open'))
document.addEventListener('click', _closeUtilsPicker, { once: true });
}
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');
else wbUpdateCursorStyle();
if (LS.prefs) LS.prefs.set('wb.color', color);
}
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');
wbUpdateCursorStyle();
if (LS.prefs) LS.prefs.set('wb.width', px);
}
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');
if (LS.prefs) LS.prefs.set('wb.lineStyle', style);
}
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 wbToggleSnap(btn) {
if (!_wb) return;
const on = !_wb._snapEnabled;
_wb.setSnapEnabled(on);
btn.classList.toggle('active', on);
}
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();
setTimeout(wbCheckSelectionUI, 20);
}
async function wbClear() {
if (!_wb || !_sessionId) return;
const ok = await crConfirm('Очистить страницу?', 'Все рисунки на текущей странице будут удалены для всех участников урока.', { okText: 'Очистить', type: 'warn' });
if (!ok) return;
_wb.clearPage();
_wbBatch = [];
_crWsSend({ type: 'page_clear', sessionId: _sessionId, pageNum: _wbCurrentPage });
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(false);
LS.toast('Урок завершён', 'info');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function crLeaveSession() {
if (!_sessionId) return;
try {
await LS.post(`/api/classroom/${_sessionId}/leave`);
onClassroomEnded(false);
LS.toast('Вы вышли из урока', 'info');
// If the session is still active, show the join banner so student can rejoin without refresh
const data = await LS.get('/api/classroom/my/session').catch(() => null);
if (data?.session) showJoinBanner(data.session);
} catch {}
}
/* ── guest link ── */
function crGuestOpen() {
if (!_sessionId) return;
const overlay = document.getElementById('cr-guest-modal');
overlay.classList.add('open');
// Load current token state
LS.get(`/api/classroom/${_sessionId}/guest-token`).then(data => {
if (data.token) {
_crGuestShowToken(data.token);
} else {
document.getElementById('cr-guest-no-token').style.display = 'block';
document.getElementById('cr-guest-has-token').style.display = 'none';
}
}).catch(() => {});
}
function crGuestClose() {
document.getElementById('cr-guest-modal').classList.remove('open');
}
function _crGuestShowToken(token) {
const url = `${location.origin}/guest-board?token=${token}`;
document.getElementById('cr-guest-url').textContent = url;
document.getElementById('cr-guest-no-token').style.display = 'none';
document.getElementById('cr-guest-has-token').style.display = 'block';
const copyBtn = document.getElementById('cr-guest-copy-btn');
if (copyBtn) copyBtn.classList.remove('copied');
}
async function crGuestCreate() {
if (!_sessionId) return;
try {
const data = await LS.post(`/api/classroom/${_sessionId}/guest-token`);
_crGuestShowToken(data.token);
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function crGuestRevoke() {
if (!_sessionId) return;
const ok = await crConfirm('Отозвать ссылку?', 'Гости, перешедшие по старой ссылке, потеряют доступ.', { okText: 'Отозвать', type: 'danger' });
if (!ok) return;
try {
await LS.del(`/api/classroom/${_sessionId}/guest-token`);
document.getElementById('cr-guest-no-token').style.display = 'block';
document.getElementById('cr-guest-has-token').style.display = 'none';
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
function crGuestCopy() {
const url = document.getElementById('cr-guest-url')?.textContent || '';
if (!url) return;
navigator.clipboard.writeText(url).then(() => {
const btn = document.getElementById('cr-guest-copy-btn');
if (btn) {
btn.textContent = 'Скопировано';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = 'Копировать'; btn.classList.remove('copied'); }, 2000);
}
}).catch(() => {
LS.toast('Не удалось скопировать', 'error');
});
}
/* ── file picker (share library file) ── */
let _crFilePickerFiles = [];
function crOpenFilePicker() {
document.getElementById('cr-file-picker-overlay').classList.add('open');
document.getElementById('cr-file-picker-search').value = '';
document.getElementById('cr-file-picker-list').innerHTML = '<div class="cr-file-picker-empty">Загрузка...</div>';
LS.get('/api/files').then(data => {
_crFilePickerFiles = (data.files || data || []);
crFilePickerSearch('');
}).catch(() => {
document.getElementById('cr-file-picker-list').innerHTML = '<div class="cr-file-picker-empty">Ошибка загрузки</div>';
});
}
function crCloseFilePicker() {
document.getElementById('cr-file-picker-overlay').classList.remove('open');
}
function crFilePickerSearch(q) {
const query = q.trim().toLowerCase();
const filtered = query
? _crFilePickerFiles.filter(f => (f.title || f.original_name || '').toLowerCase().includes(query))
: _crFilePickerFiles;
crFilePickerRender(filtered);
}
function crFilePickerRender(files) {
const list = document.getElementById('cr-file-picker-list');
if (!files.length) {
list.innerHTML = '<div class="cr-file-picker-empty">Файлы не найдены</div>';
return;
}
list.innerHTML = files.map(f => {
const name = f.title || f.original_name || 'Файл';
const ext = _crFileExt(f.mimetype || '');
const size = f.size ? _crFmtSize(f.size) : '';
const sub = [f.subject_slug || '', size].filter(Boolean).join(' · ');
return `<div class="cr-file-item">
<div class="cr-file-icon ${ext}">${ext.toUpperCase()}</div>
<div class="cr-file-info">
<div class="cr-file-name" title="${LS.esc(name)}">${LS.esc(name)}</div>
${sub ? `<div class="cr-file-meta">${LS.esc(sub)}</div>` : ''}
</div>
<button class="cr-file-pick-btn" onclick="crShareLibraryFile(${f.id}, ${JSON.stringify(name)}, ${JSON.stringify(f.mimetype || '')})">
Поделиться
</button>
</div>`;
}).join('');
}
async function crShareLibraryFile(fileId, title, mimetype) {
if (!_sessionId) return;
crCloseFilePicker();
try {
await LS.post(`/api/classroom/${_sessionId}/chat`, {
message: title,
attachment_url: fileId + '|' + (mimetype || ''),
attachment_type: 'library_file',
});
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
function crDownloadLibraryFile(fileId, name) {
const token = localStorage.getItem('ls_token');
fetch(`/api/files/${fileId}/download`, {
headers: { Authorization: 'Bearer ' + token },
})
.then(r => {
if (!r.ok) throw new Error('Ошибка загрузки');
return r.blob();
})
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name;
document.body.appendChild(a); a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 15000);
})
.catch(e => LS.toast(e.message || 'Ошибка загрузки', 'error'));
}
function _crFileExt(mimetype) {
if (!mimetype) return 'other';
if (mimetype === 'application/pdf') return 'pdf';
if (mimetype.startsWith('image/')) return 'img';
if (mimetype.includes('word') || mimetype.includes('document') || mimetype === 'text/plain') return 'doc';
if (mimetype.includes('sheet') || mimetype.includes('excel') || mimetype.includes('csv')) return 'xls';
if (mimetype.includes('presentation') || mimetype.includes('powerpoint')) return 'ppt';
if (mimetype.startsWith('video/')) return 'vid';
return 'other';
}
function _crFmtSize(bytes) {
if (bytes < 1024) return bytes + ' Б';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' КБ';
return (bytes / (1024 * 1024)).toFixed(1) + ' МБ';
}
/* ── 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;
// Always clear previous DOM entries first
list.querySelectorAll('.cr-participant').forEach(el => el.remove());
if (!_sessionId) {
noSession.style.display = 'flex';
return;
}
noSession.style.display = ids.length ? 'none' : 'flex';
if (!ids.length) return;
const isTeacherView = _me?.role === 'teacher' || _me?.role === 'admin';
ids.forEach(uid => {
const p = _participants[uid];
const isGuest = p.role === 'guest';
const initials = (p.name || '?').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('') || '?';
const isMe = !isGuest && String(uid) === String(_me?.id);
const isSessionTeacher = !isGuest && _session && String(uid) === String(_session.teacher_id);
const hasHand = !isGuest && !!_raisedHands[uid];
const div = document.createElement('div');
div.className = 'cr-participant';
div.dataset.uid = uid;
const hasDrawPerm = !isGuest && _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 (not for guests)
const muteBtnHtml = isTeacherView && !isMe && !isSessionTeacher && !isGuest ? (
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>`
) : '';
if (p.speaking) div.classList.add('speaking');
/* per-user avatar gradient from uid hash */
const avatarGrads = [
['#9B5DE5','#06D6E0'], ['#F15BB5','#9B5DE5'], ['#06D6A0','#2979FF'],
['#FF9F43','#F15BB5'], ['#4361EE','#06D6E0'], ['#A8E063','#06D6A0'],
['#9B5DE5','#F15BB5'], ['#06D6E0','#4361EE'],
];
const uidHash = isGuest
? uid.split('').reduce((a, c) => (a * 31 + c.charCodeAt(0)) & 0xffff, 0)
: (Number(uid) || 0);
const grad = avatarGrads[uidHash % avatarGrads.length];
div.innerHTML = `
<div class="cr-p-avatar-wrap">
<div class="cr-p-avatar${p.speaking ? ' speaking' : ''}"
style="background:linear-gradient(135deg,${grad[0]},${grad[1]})">${initials}</div>
<div class="cr-p-online-dot"></div>
</div>
<div class="cr-audio-bars" id="cr-bars-${uid}"><span></span><span></span><span></span></div>
<div class="cr-p-info">
<div class="cr-p-name">${LS.escapeHtml(p.name)}</div>
<div class="cr-p-sub">
${isGuest
? '<span class="cr-p-guest-badge">Гость</span>'
: isSessionTeacher ? '<span class="cr-p-role-tag teacher">Учитель</span>' : '<span class="cr-p-role-tag student">Ученик</span>'}
${isMe ? '<span class="cr-p-you">Вы</span>' : ''}
${!isTeacherView && hasDrawPerm ? `<span class="cr-p-draw-badge">${svgPencil} рисует</span>` : ''}
</div>
</div>
<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 && !isGuest ? `
<button class="cr-p-draw-toggle${hasDrawPerm ? ' granted' : ''}" onclick="crToggleDrawPermission(${uid})">
${svgPencil}
${hasDrawPerm ? 'Запретить' : 'Рисовать'}
</button>` : ''}`;
list.appendChild(div);
});
if (window.lucide) lucide.createIcons();
}
/* ── hand raise ── */
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 ? 'Опустить руку' : 'Поднять руку';
_crWsSend({ type: _handRaised ? 'hand_raise' : 'hand_lower', sessionId: _sessionId });
}
function onHandRaised(userId, userName) {
_raisedHands[userId] = userName;
updateHandsList();
updateParticipantsList();
if (_prefs.handSound && String(userId) !== String(_me?.id))
_crSfx('hand_raise');
}
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);
else wbRebuildThumbnails();
}
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);
_wbUpdateBgBtn();
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;
const name = _pageNames[i];
item.title = name || `Страница ${i}`;
item.onclick = () => { if (i !== _wbCurrentPage) _wbChangePage(i); };
item.ondblclick = (e) => { e.stopPropagation(); wbStartRenaming(i); };
item.oncontextmenu = (e) => { e.preventDefault(); wbShowPageMenu(e, i); };
const cvs = document.createElement('canvas');
cvs.width = 192; cvs.height = 108;
item.appendChild(cvs);
const lbl = document.createElement('span');
lbl.className = 'wb-thumb-label';
lbl.textContent = name || '';
item.appendChild(lbl);
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'); }
}
function wbSetPageTemplate(template) {
if (!_sessionId || !_wb) return;
_wb.setTemplate(template);
_crWsSend({ type: 'template_change', sessionId: _sessionId, pageNum: _wbCurrentPage, template });
wbUpdateThumbnail(_wbCurrentPage);
}
function wbSetBoardTheme(theme) {
if (!_wb) return;
_wb.setBoardTheme(theme);
const sel = document.getElementById('wb-theme-select');
if (sel) sel.value = theme;
if (LS.prefs) LS.prefs.set('wb.theme', theme);
// Broadcast to students if in active session
if (_sessionId) {
LS.patch(`/api/classroom/${_sessionId}/board-theme`, { theme }).catch(() => {});
}
}
/* ── Page context menu ── */
function wbShowPageMenu(e, pageNum) {
_wbMenuPage = pageNum;
const menu = document.getElementById('wb-page-menu');
menu.classList.add('open');
const x = Math.min(e.clientX, window.innerWidth - 155);
const y = Math.min(e.clientY, window.innerHeight - 140);
menu.style.left = x + 'px';
menu.style.top = y + 'px';
setTimeout(() => document.addEventListener('click', _wbMenuClose, { once: true }), 0);
}
function _wbMenuClose() { document.getElementById('wb-page-menu').classList.remove('open'); }
function wbHidePageMenu() { _wbMenuClose(); }
async function wbPageDuplicate(pageNum) {
wbHidePageMenu();
if (!_sessionId) return;
try {
const res = await LS.post(`/api/classroom/${_sessionId}/pages/${pageNum}/duplicate`);
// SSE handles page addition; set name if returned
if (res.name) _pageNames[res.newPage] = res.name;
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function wbPageDelete(pageNum) {
wbHidePageMenu();
if (!_sessionId || _totalPages <= 1) return;
try {
const res = await LS.del(`/api/classroom/${_sessionId}/pages/${pageNum}`);
// Renumber _pageNames
const newNames = {};
Object.keys(_pageNames).forEach(k => {
const n = parseInt(k);
if (n < pageNum) newNames[n] = _pageNames[n];
else if (n > pageNum) newNames[n - 1] = _pageNames[n];
});
_pageNames = newNames;
_totalPages = Math.max(1, _totalPages - 1);
if (_wbCurrentPage > pageNum) _wbCurrentPage = _wbCurrentPage - 1;
else if (_wbCurrentPage === pageNum) _wbCurrentPage = res.newCurrent;
updatePageLabel();
if (_wb) {
_wb.clearPage();
const d = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${_wbCurrentPage}`);
_wb.loadStrokes(d.strokes || []);
if (d.template) _wb.setTemplate(d.template);
_wbMaxSeq = 0; _wbClearGen++;
_wbUpdateMaxSeq(d.strokes || []);
_wbUpdateBgBtn();
}
wbRebuildThumbnails();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
function wbPageClear(pageNum) {
wbHidePageMenu();
if (!_sessionId) return;
_crWsSend({ type: 'page_clear', sessionId: _sessionId, pageNum });
if (pageNum === _wbCurrentPage && _wb) { _wb.clearPage(); _wbMaxSeq = 0; _wbClearGen++; }
wbUpdateThumbnail(pageNum);
}
/* ── Page naming ── */
function wbStartRenaming(pageNum) {
const list = document.getElementById('wb-thumbs-list');
const item = list?.querySelector(`.wb-thumb-item[data-page="${pageNum}"]`);
if (!item) return;
// Remove existing input if any
item.querySelector('.wb-thumb-rename')?.remove();
const input = document.createElement('input');
input.className = 'wb-thumb-rename';
input.value = _pageNames[pageNum] || '';
input.placeholder = `Стр. ${pageNum}`;
item.appendChild(input);
input.focus(); input.select();
input.onkeydown = (e) => {
if (e.key === 'Enter') { e.preventDefault(); wbFinishRenaming(pageNum, input.value.trim()); }
if (e.key === 'Escape') { input.remove(); }
};
input.onblur = () => wbFinishRenaming(pageNum, input.value.trim());
}
async function wbFinishRenaming(pageNum, name) {
const list = document.getElementById('wb-thumbs-list');
const item = list?.querySelector(`.wb-thumb-item[data-page="${pageNum}"]`);
item?.querySelector('.wb-thumb-rename')?.remove();
const finalName = name || null;
_pageNames[pageNum] = finalName;
// Update label text
const lbl = item?.querySelector('.wb-thumb-label');
if (lbl) lbl.textContent = finalName || '';
if (item) item.title = finalName || `Страница ${pageNum}`;
if (!_sessionId) return;
try { await LS.patch(`/api/classroom/${_sessionId}/pages/${pageNum}/name`, { name: finalName }); } catch {}
}
async function _wbChangePage(pageNum) {
if (!_sessionId) return;
_wbBatch = []; // discard unsent strokes from old page
// Send via WS (server updates DB + broadcasts to all)
_crWsSend({ type: 'page_change', sessionId: _sessionId, pageNum });
// Navigate locally without waiting for echo
_wbCurrentPage = pageNum;
_wbMaxSeq = 0; _wbClearGen++;
updatePageLabel();
if (_wb) {
_wb.clearPage();
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);
const sel = document.getElementById('wb-tpl-select');
if (sel) sel.value = res.template;
}
if (res.name !== undefined) _pageNames[pageNum] = res.name || null;
_wbUpdateBgBtn();
wbRebuildThumbnails();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
}
/* ── tabs ── */
function crToggleRightPanel() {
const panel = document.getElementById('cr-right');
const collapsed = panel.classList.toggle('collapsed');
localStorage.setItem('cr_right_collapsed', collapsed ? '1' : '0');
// Trigger canvas resize after transition
setTimeout(() => { if (_wb) _wb.fit(); }, 240);
}
function crSwitchTab(tab) {
// Auto-expand if collapsed
const panel = document.getElementById('cr-right');
if (panel?.classList.contains('collapsed')) crToggleRightPanel();
_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');
const tabQuiz = document.getElementById('tab-quiz');
if (tabQuiz) tabQuiz.classList.toggle('active', tab === 'quiz');
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';
document.getElementById('panel-quiz').style.display = tab === 'quiz' ? 'flex' : 'none';
if (tab === 'quiz') {
document.getElementById('quiz-badge').style.display = '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'; }
if (_prefs.chatSound) _crSfx('chat_message');
}
const wrap = document.getElementById('cr-messages');
const isMsgTeacher = data.userId === _session?.teacher_id;
const isTeacherView = _me?.role === 'teacher' || _me?.role === 'admin';
const isOwnMsg = String(data.userId) === String(_me?.id);
const time = new Date(data.createdAt).toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
/* row wrapper for bubble alignment */
const row = document.createElement('div');
row.className = 'cr-msg-row ' + (isOwnMsg ? 'mine' : 'theirs');
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>`;
let attachHtml = '';
if (data.attachmentUrl) {
if (data.attachmentType === 'image') {
attachHtml = `<img class="cr-msg-img" src="${LS.esc(data.attachmentUrl)}" alt="изображение" onclick="window.open('${LS.esc(data.attachmentUrl)}','_blank')">`;
} else if (data.attachmentType === 'library_file') {
const [fileId, ...mtParts] = (data.attachmentUrl || '').split('|');
const mime = mtParts.join('|') || '';
const ext = _crFileExt(mime);
const displayName = data.message || 'Файл';
attachHtml = `<div class="cr-msg-file-card" onclick="crDownloadLibraryFile(${parseInt(fileId,10)}, ${JSON.stringify(displayName)})">
<div class="cr-file-icon ${ext}">${ext.toUpperCase()}</div>
<div class="cr-file-info">
<div class="cr-file-name">${LS.esc(displayName)}</div>
<div class="cr-msg-shared-label">из библиотеки</div>
</div>
<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;flex-shrink:0;color:rgba(255,255,255,0.35)"><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>
</div>`;
} else {
attachHtml = `<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">
${!isOwnMsg ? `<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 && data.attachmentType !== 'library_file' ? `<div class="cr-msg-text">${LS.esc(data.message)}</div>` : ''}
${attachHtml}
<div class="cr-msg-reactions" id="msg-reactions-${data.id}">${reactionsHtml}</div>`;
row.appendChild(div);
wrap.appendChild(row);
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 = 'Изменено…';
/* word count */
const ta = document.getElementById('cr-notes-ta');
const wc = document.getElementById('notes-wordcount');
if (ta && wc) {
const words = ta.value.trim() ? ta.value.trim().split(/\s+/).length : 0;
wc.textContent = words + ' ' + (words === 1 ? 'слово' : words < 5 ? 'слова' : 'слов');
}
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(); setTimeout(wbCheckSelectionUI, 20); } }
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(); setTimeout(wbCheckSelectionUI, 20); } }
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();
}
// Поместить загруженный <img> на доску (ресайз до 800px, по центру)
function wbPlaceImageFromImg(img) {
if (!_wb || !_sessionId) return;
const maxPx = 800;
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);
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);
}
function wbImageSelected(input) {
const file = input.files?.[0];
if (!file || !_wb || !_sessionId) return;
input.value = '';
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => wbPlaceImageFromImg(img);
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
// Сгенерировать картинку ИИ и вставить на доску
function wbGenerateImage() {
if (!_wb || !_sessionId) return;
if (!LS.imagePromptModal) { LS.toast?.('Модуль генерации не загружен'); return; }
LS.imagePromptModal({
title: 'Картинка на доску (ИИ)',
placeholder: 'Опиши иллюстрацию: «схема круговорота воды, плоский стиль»',
useLabel: 'Вставить на доску',
onUse: (url) => {
const img = new Image();
img.onload = () => wbPlaceImageFromImg(img);
img.onerror = () => LS.toast?.('Не удалось загрузить картинку', 'error');
img.src = url;
},
});
}
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');
if (LS.prefs) LS.prefs.set('wb.color', input.value);
}
function wbToggleFullscreen() {
const el = document.getElementById('cr-board-wrap');
if (!document.fullscreenElement) {
el.requestFullscreen?.() || el.webkitRequestFullscreen?.();
} else {
document.exitFullscreen?.() || document.webkitExitFullscreen?.();
}
}
function _updateStudentFsBtn() {
const isFs = !!document.fullscreenElement;
const lbl = document.getElementById('cr-fs-label');
if (lbl) lbl.textContent = isFs ? 'Свернуть' : 'На весь экран';
}
document.addEventListener('fullscreenchange', _updateStudentFsBtn);
document.addEventListener('webkitfullscreenchange', _updateStudentFsBtn);
/* ── Сохранить доску/фрагмент в «Мои материалы» (ученик) ── */
function _crClipMeta() {
return {
sourceSessionId: _sessionId,
sourceTitle: (_session && _session.title) || 'Онлайн-урок',
pageNum: (typeof _wbCurrentPage !== 'undefined' ? _wbCurrentPage : null),
};
}
function crSaveBoardPage(btn) { if (window.BoardClip) BoardClip.savePage(_wb, _crClipMeta(), btn); }
function crSaveBoardRegion(btn) { if (window.BoardClip) BoardClip.saveRegion(_wb, _crClipMeta(), btn); }
/* ── 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';
video.play().catch(() => {});
} else {
video.srcObject = null;
video.style.display = 'none';
label.style.display = 'none';
}
},
onMicActive: (uid, speaking) => {
if (_participants[uid]) {
_participants[uid].speaking = speaking;
// Update avatar and row without full re-render
const row = document.querySelector(`.cr-participant[data-uid="${uid}"]`);
if (row) {
row.classList.toggle('speaking', speaking);
row.querySelector('.cr-p-avatar')?.classList.toggle('speaking', speaking);
}
_updateSpeakerChip();
}
},
onMicLevel: (uid, level) => {
if (_participants[uid]) _participants[uid].micLevel = level;
const bars = document.getElementById(`cr-bars-${uid}`);
if (bars) {
const spans = bars.querySelectorAll('span');
// 3 bars: left/center/right with natural stagger heights
const h2 = Math.max(2, Math.round((level / 100) * 12));
const h1 = Math.max(2, Math.round(h2 * 0.65));
const h3 = Math.max(2, Math.round(h2 * 0.45));
if (spans[0]) spans[0].style.height = h1 + 'px';
if (spans[1]) spans[1].style.height = h2 + 'px';
if (spans[2]) spans[2].style.height = h3 + 'px';
}
},
vadThreshold: VAD_THRESHOLDS[_prefs.vadSensitivity] ?? 12,
});
const micOk = await _rtc.startAudio();
if (!micOk) LS.toast('Нет доступа к микрофону', 'warning');
// Apply muted-on-join preference
if (_prefs.mutedOnJoin && !_rtc.isMuted()) {
_rtc.forceMute();
if (_participants[_me?.id]) _participants[_me.id].micMuted = true;
}
document.getElementById('cr-mute-btn').style.display = 'flex';
updateMuteBtn();
if (peerIds.length > 0) await _rtc.connectTo(peerIds);
}
function _updateSpeakerChip() {
const chip = document.getElementById('cr-speaker-chip');
if (!chip) return;
// Find all currently speaking participants, pick the loudest
const speaking = Object.entries(_participants).filter(([, p]) => p.speaking);
if (!speaking.length) {
chip.classList.remove('visible');
return;
}
const [, p] = speaking.sort((a, b) => (b[1].micLevel || 0) - (a[1].micLevel || 0))[0];
const initials = (p.name || '?').split(' ').slice(0, 2).map(w => w[0]?.toUpperCase() || '').join('') || '?';
const avatarEl = document.getElementById('cr-speaker-chip-avatar');
const nameEl = document.getElementById('cr-speaker-chip-name');
if (avatarEl) avatarEl.textContent = initials;
if (nameEl) nameEl.textContent = p.name || '';
chip.classList.add('visible');
}
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>`;
}
/* ── screen picker v2 ── */
let _crSpTab = 'monitor'; // 'monitor' | 'window' | 'browser'
let _crSpRes = 1080; // 720 | 1080 | 0 (native)
let _crSpFps = 30;
let _crSpAudio = false;
let _crSpCursor = true;
let _crSpTempStream = null; // preview stream, handed to RTC on confirm
/* Tab metadata */
const _CR_SP_TABS = {
monitor: {
label: 'Выбрать экран',
emptyTitle: 'Экран не выбран',
emptyHint: 'Нажмите кнопку ниже — откроется выбор монитора',
barSvg: '<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"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>',
emptySvg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:26px;height:26px"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>',
},
window: {
label: 'Выбрать окно',
emptyTitle: 'Окно не выбрано',
emptyHint: 'Нажмите кнопку ниже — откроется выбор приложения',
barSvg: '<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"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>',
emptySvg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:26px;height:26px"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>',
},
browser: {
label: 'Выбрать вкладку',
emptyTitle: 'Вкладка не выбрана',
emptyHint: 'Нажмите кнопку ниже — откроется выбор вкладки браузера',
barSvg: '<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"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
emptySvg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:26px;height:26px"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
},
};
function crToggleScreen() {
if (!_rtc || !_sessionId) return;
if (_rtc.isSharing()) {
_rtc.stopScreenShare();
document.getElementById('cr-screen-btn').classList.remove('cr-btn-sharing');
_crWsSend({ type: 'screen_stop', sessionId: _sessionId });
} else {
crOpenScreenPicker();
}
}
function crOpenScreenPicker() {
// Reset preview state (but keep quality prefs)
_crSpTempStreamStop();
_crSpShowEmpty();
// Sync UI state
['monitor','window','browser'].forEach(t =>
document.getElementById(`cr-sptab-${t}`)?.classList.toggle('active', t === _crSpTab)
);
_crSpUpdateEmptyText();
document.getElementById('cr-screen-picker-overlay').classList.add('open');
}
function crCloseScreenPicker() {
document.getElementById('cr-screen-picker-overlay').classList.remove('open');
_crSpTempStreamStop(); // discard preview if not confirmed
}
function crSpSwitchTab(tab) {
if (_crSpTab === tab) return;
_crSpTab = tab;
['monitor','window','browser'].forEach(t =>
document.getElementById(`cr-sptab-${t}`)?.classList.toggle('active', t === tab)
);
// Reset preview — different surface type
_crSpTempStreamStop();
_crSpShowEmpty();
_crSpUpdateEmptyText();
}
function _crSpUpdateEmptyText() {
const meta = _CR_SP_TABS[_crSpTab];
const emptyIcon = document.getElementById('cr-sp-empty-icon');
const emptyTitle = document.getElementById('cr-sp-empty-title');
const emptyHint = document.getElementById('cr-sp-empty-hint');
const pickBtn = document.getElementById('cr-sp-pick-btn');
if (emptyIcon) emptyIcon.innerHTML = meta.emptySvg;
if (emptyTitle) emptyTitle.textContent = meta.emptyTitle;
if (emptyHint) emptyHint.textContent = meta.emptyHint;
if (pickBtn) {
// keep the SVG icon inside the button, just update text node
pickBtn.innerHTML = meta.barSvg + ' ' + meta.label;
}
}
function _crSpShowEmpty() {
document.getElementById('cr-sp-empty').style.display = 'flex';
document.getElementById('cr-sp-video-wrap').classList.remove('visible');
document.getElementById('cr-sp-start-btn').disabled = true;
const v = document.getElementById('cr-sp-video');
if (v) v.srcObject = null;
}
function _crSpShowPreview(stream) {
const meta = _CR_SP_TABS[_crSpTab];
const track = stream.getVideoTracks()[0];
const label = track?.label || meta.emptyTitle.replace('не выбран', '').trim() || 'Источник';
const settings = track?.getSettings?.() || {};
const w = settings.width || 0, h = settings.height || 0;
const resBadge = w && h ? `${w}×${h}` : '';
document.getElementById('cr-sp-bar-icon').innerHTML = meta.barSvg;
document.getElementById('cr-sp-preview-name').textContent = label;
document.getElementById('cr-sp-res-badge').textContent = resBadge;
document.getElementById('cr-sp-video').srcObject = stream;
document.getElementById('cr-sp-empty').style.display = 'none';
document.getElementById('cr-sp-video-wrap').classList.add('visible');
document.getElementById('cr-sp-start-btn').disabled = false;
}
function _crSpTempStreamStop() {
if (_crSpTempStream) {
_crSpTempStream.getTracks().forEach(t => t.stop());
_crSpTempStream = null;
}
}
async function crSpPickSource() {
_crSpTempStreamStop();
_crSpShowEmpty();
const vc = { displaySurface: _crSpTab, cursor: _crSpCursor ? 'always' : 'never' };
if (_crSpRes === 720) { vc.width = { ideal: 1280 }; vc.height = { ideal: 720 }; }
if (_crSpRes === 1080) { vc.width = { ideal: 1920 }; vc.height = { ideal: 1080 }; }
if (_crSpFps > 0) vc.frameRate = { ideal: _crSpFps };
let stream;
try {
stream = await navigator.mediaDevices.getDisplayMedia({ video: vc, audio: _crSpAudio });
} catch { return; } // user cancelled
_crSpTempStream = stream;
_crSpShowPreview(stream);
// If user hits browser's "Stop sharing" button while modal open → reset
const vt = stream.getVideoTracks()[0];
if (vt) vt.onended = () => {
if (_crSpTempStream === stream) { _crSpTempStream = null; _crSpShowEmpty(); }
};
}
function crSpSetQ(btn, type) {
const row = btn.closest('.cr-sp-q-opts');
row.querySelectorAll('.cr-sp-qbtn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const val = Number(btn.dataset.q);
if (type === 'res') _crSpRes = val;
else _crSpFps = val;
}
function crSpToggleAudio() {
_crSpAudio = !_crSpAudio;
document.getElementById('cr-sp-opt-audio')?.classList.toggle('on', _crSpAudio);
}
function crSpToggleCursor() {
_crSpCursor = !_crSpCursor;
document.getElementById('cr-sp-opt-cursor')?.classList.toggle('on', _crSpCursor);
}
async function crSpStartShare() {
if (!_crSpTempStream || !_rtc || !_sessionId) return;
const stream = _crSpTempStream;
_crSpTempStream = null; // hand off ownership — don't stop it
document.getElementById('cr-screen-picker-overlay').classList.remove('open');
await _rtc.useExistingScreenStream(stream);
const vt = stream.getVideoTracks()[0];
if (vt) {
vt.onended = () => {
_rtc.stopScreenShare();
onScreenShareStopped();
};
}
document.getElementById('cr-screen-btn').classList.add('cr-btn-sharing');
_crWsSend({ type: 'screen_start', sessionId: _sessionId });
}
function onScreenShareStopped() {
document.getElementById('cr-screen-btn').classList.remove('cr-btn-sharing');
if (_sessionId) _crWsSend({ type: 'screen_stop', sessionId: _sessionId });
}
/* ── Simulation integration ──────────────────────────────────────────── */
// Compact sims catalogue (id + title + category) mirrored from lab.html
const CR_SIMS = [
{ id:'geometry', cat:'math', title:'Планиметрия' },
{ id:'graph', cat:'math', title:'График функции' },
{ id:'graphtransform',cat:'math', title:'Трансформации графиков' },
{ id:'triangle', cat:'math', title:'Геометрия треугольника' },
{ id:'quadratic', cat:'math', title:'Корни квадратного уравнения' },
{ id:'stereo', cat:'math', title:'Стереометрия 3D' },
{ id:'probability', cat:'math', title:'Теория вероятностей' },
{ id:'trigcircle', cat:'math', title:'Тригонометрическая окружность' },
{ id:'normaldist', cat:'math', title:'Нормальное распределение' },
{ id:'projectile', cat:'phys', title:'Бросок тела' },
{ id:'pendulum', cat:'phys', title:'Маятник' },
{ id:'collision', cat:'phys', title:'Столкновение шаров' },
{ id:'magnetic', cat:'phys', title:'Магнитное поле токов' },
{ id:'circuit', cat:'phys', title:'Электрические цепи' },
{ id:'coulomb', cat:'phys', title:'Закон Кулона' },
{ id:'dynamics', cat:'phys', title:'Динамика' },
{ id:'thinlens', cat:'phys', title:'Тонкая линза' },
{ id:'refraction', cat:'phys', title:'Преломление света' },
{ id:'mirrors', cat:'phys', title:'Зеркала' },
{ id:'isoprocess', cat:'phys', title:'Изопроцессы' },
{ id:'waves', cat:'phys', title:'Волны и звук' },
{ id:'molphys', cat:'chem', title:'Молекулярная физика' },
{ id:'chemistry', cat:'chem', title:'Химические реакции' },
{ id:'equilibrium', cat:'chem', title:'Химическое равновесие' },
{ id:'electrolysis', cat:'chem', title:'Электролиз' },
{ id:'bohratom', cat:'chem', title:'Атом Бора' },
{ id:'orbitals', cat:'chem', title:'Молекулярные орбитали' },
{ id:'titration', cat:'chem', title:'pH и кривая титрования' },
{ id:'chemsandbox', cat:'chem', title:'Химическая песочница' },
{ id:'crystal', cat:'chem', title:'Кристаллическая решётка' },
{ id:'celldivision', cat:'bio', title:'Деление клетки' },
{ id:'photosynthesis',cat:'bio', title:'Фотосинтез и дыхание' },
{ id:'angrybirds', cat:'game', title:'Angry Birds Physics' },
];
const CAT_LABELS = { math:'Математика', phys:'Физика', chem:'Химия', bio:'Биология', game:'Игра' };
let _simPickerCat = 'all'; // active filter in picker
// Конструктор симуляций (Фаза 7): свои + published custom-симуляции для доски.
let _crCustomSims = null; // [{ id, cat, title, _custom:true }] — кэш списка
async function _crLoadCustomSims() {
if (_crCustomSims) return _crCustomSims;
try {
const data = await LS.customSimsList();
const rows = (data && data.sims) || [];
_crCustomSims = rows.map(s => ({
id: 'custom:' + s.id,
cat: s.cat || 'phys',
title: s.title || ('Симуляция #' + s.id),
_custom: true,
}));
} catch (e) { _crCustomSims = []; }
return _crCustomSims;
}
async function crOpenSimPicker() {
if (_simActive) {
// If sim already open — clicking "Симуляция" closes it (teacher action)
crTeacherCloseSim();
return;
}
_simPickerCat = 'all';
await _crLoadCustomSims();
_crRenderSimGrid('all');
const overlay = document.getElementById('cr-sim-picker-overlay');
overlay.classList.add('open');
// reset cat buttons
overlay.querySelectorAll('.cr-sim-cat-btn').forEach((b, i) => b.classList.toggle('active', i === 0));
}
function crCloseSimPicker() {
document.getElementById('cr-sim-picker-overlay').classList.remove('open');
}
function crFilterSims(cat, btn) {
_simPickerCat = cat;
document.querySelectorAll('.cr-sim-cat-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_crRenderSimGrid(cat);
}
function _crRenderSimGrid(cat) {
const grid = document.getElementById('cr-sim-picker-grid');
// Конструктор симуляций (Фаза 7): встроенные + свои/published custom-sims.
const all = CR_SIMS.concat(_crCustomSims || []);
const sims = cat === 'all' ? all : all.filter(s => s.cat === cat);
const esc = v => String(v == null ? '' : v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
grid.innerHTML = sims.map(s => `
<div class="cr-sim-picker-card" onclick="crPickSim('${String(s.id).replace(/'/g,"\\'")}','${esc(s.title).replace(/'/g,"\\'")}')" title="${esc(s.title)}">
<span class="cr-sim-picker-card-cat ${s.cat}">${s._custom ? 'Моя' : (CAT_LABELS[s.cat] || s.cat)}</span>
<span class="cr-sim-picker-card-title">${esc(s.title)}</span>
</div>
`).join('');
}
async function crPickSim(simId, title) {
crCloseSimPicker();
if (!_sessionId) return;
try {
await LS.post(`/api/classroom/${_sessionId}/sim`, { simId, title });
// onSimOpen will be called via SSE echo (including own client)
} catch (e) {
LS.toast(e.message || 'Ошибка открытия симуляции', 'error');
}
}
async function crTeacherCloseSim() {
if (!_sessionId) return;
try {
await LS.del(`/api/classroom/${_sessionId}/sim`);
// onSimClose will be called via SSE echo
} catch (e) {
LS.toast(e.message || 'Ошибка', 'error');
}
}
function onSimOpen(simId, title) {
_simActive = simId;
const panel = document.getElementById('cr-sim-panel');
const frame = document.getElementById('cr-sim-frame');
const titleEl = document.getElementById('cr-sim-bar-title');
const closeBtn = document.getElementById('cr-sim-bar-close');
const modeToggle = document.getElementById('cr-sim-mode-toggle');
const headerBtn = document.getElementById('cr-sim-btn');
const blocker = document.getElementById('cr-sim-blocker');
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
titleEl.textContent = title || simId;
frame.src = `/lab?embed=1&sim=${encodeURIComponent(simId)}`;
panel.classList.add('open');
// Teacher: show close button + mode toggle + update header label
if (closeBtn) closeBtn.style.display = isTeacher ? 'flex' : 'none';
if (modeToggle) modeToggle.style.display = isTeacher ? 'flex' : 'none';
if (headerBtn && isTeacher) {
headerBtn.title = 'Закрыть симуляцию';
headerBtn.querySelector('span').textContent = 'Закрыть';
}
// Student in demo mode: block interaction
if (blocker) blocker.classList.toggle('active', !isTeacher && _simMode === 'demo');
// Sync mode buttons to current state
onSimModeChange(_simMode);
}
function onSimClose() {
_simActive = null;
const panel = document.getElementById('cr-sim-panel');
const frame = document.getElementById('cr-sim-frame');
const headerBtn = document.getElementById('cr-sim-btn');
const modeToggle = document.getElementById('cr-sim-mode-toggle');
const blocker = document.getElementById('cr-sim-blocker');
// Exit annotate mode when sim closes
if (_annotateActive) _crApplyAnnotate(false);
panel.classList.remove('open');
if (modeToggle) modeToggle.style.display = 'none';
if (blocker) blocker.classList.remove('active');
// Delay src reset so the iframe doesn't flash
setTimeout(() => { if (frame) frame.src = 'about:blank'; }, 300);
// Restore header button label
if (headerBtn) {
headerBtn.title = 'Открыть симуляцию';
const sp = headerBtn.querySelector('span');
if (sp) sp.textContent = 'Симуляция';
}
}
let _annotateActive = false;
let _annotateTool = 'pencil'; // saved tool before entering annotate mode
async function crToggleAnnotate() {
if (!_simActive && !_tbActive) return;
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
const newVal = !_annotateActive;
_crApplyAnnotate(newVal);
// Only teacher broadcasts to students (reuse sim/annotate channel — same payload)
if (isTeacher && _sessionId) {
try {
await LS.post(`/api/classroom/${_sessionId}/sim/annotate`, { active: newVal });
} catch (e) { /* non-critical */ }
}
}
function _crApplyAnnotate(active) {
_annotateActive = active;
const boardArea = document.getElementById('cr-board-area');
const simBtn = document.getElementById('cr-sim-annotate-btn');
const tbBtn = document.getElementById('cr-tb-annotate-btn');
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
boardArea?.classList.toggle('annotate-active', active);
if (simBtn) simBtn.classList.toggle('active', active);
if (tbBtn) tbBtn.classList.toggle('active', active);
if (!_wb) return;
_wb.setAnnotateMode(active);
if (active) {
// Remember current tool, switch to pencil for drawing
_annotateTool = _wb._currentTool || 'pencil';
if (isTeacher) {
_wb.setTool('pencil');
document.querySelectorAll('.cr-tool-btn').forEach(b => b.classList.remove('active'));
document.getElementById('cr-tool-pencil')?.classList.add('active');
}
} else {
// Restore previous tool
if (isTeacher) {
_wb.setTool(_annotateTool);
document.getElementById(`cr-tool-${_annotateTool}`)?.classList.add('active');
}
}
}
async function crSetSimMode(mode) {
if (!_sessionId) return;
try {
await LS.post(`/api/classroom/${_sessionId}/sim/mode`, { mode });
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
function onSimModeChange(mode) {
_simMode = mode;
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
const blocker = document.getElementById('cr-sim-blocker');
if (blocker) blocker.classList.toggle('active', !isTeacher && mode === 'demo');
// Update toggle buttons (teacher UI)
document.getElementById('cr-sim-mode-demo')?.classList.toggle('active', mode === 'demo');
document.getElementById('cr-sim-mode-free')?.classList.toggle('active', mode === 'free');
}
// Teacher: relay sim state from iframe to backend → SSE to students
window.addEventListener('message', e => {
if (!_simActive || !_sessionId) return;
const isTeacher = _me?.role === 'teacher' || _me?.role === 'admin';
if (!isTeacher) return;
if (e.data?.type !== 'sim_state') return;
clearTimeout(_simStateThrottle);
_simStateThrottle = setTimeout(async () => {
try { await LS.post(`/api/classroom/${_sessionId}/sim/state`, { state: e.data.state }); } catch {}
}, 300);
});
/* ════════════════════════════════════════════════════════════════════
TEXTBOOK in classroom — open any textbook in shared iframe
════════════════════════════════════════════════════════════════════ */
let _tbActive = null; // current slug, or null
let _tbMode = 'demo'; // 'demo' | 'free'
let _tbList = null; // cached catalog from GET /api/textbooks
let _tbPickerSubj = 'all';
let _tbNavThrottle = null;
async function crOpenTbPicker() {
if (_tbActive) { crTeacherCloseTb(); return; }
const overlay = document.getElementById('cr-tb-picker-overlay');
overlay.classList.add('open');
if (!_tbList) {
try {
const data = await LS.get('/api/textbooks');
_tbList = Array.isArray(data) ? data : (data?.textbooks || data?.items || []);
} catch (e) {
document.getElementById('cr-tb-picker-grid').innerHTML =
`<div style="grid-column:1/-1;color:#F15BB5;padding:24px;text-align:center;font-size:.85rem">Не удалось загрузить список учебников</div>`;
return;
}
_crRenderTbCats();
}
_crRenderTbGrid(_tbPickerSubj);
}
function crCloseTbPicker() {
document.getElementById('cr-tb-picker-overlay').classList.remove('open');
}
function crFilterTbs(subj, btn) {
_tbPickerSubj = subj;
document.querySelectorAll('#cr-tb-picker-cats .cr-sim-cat-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_crRenderTbGrid(subj);
}
function _crRenderTbCats() {
const subjs = [...new Set((_tbList || []).map(t => t.subject).filter(Boolean))].sort();
const SUBJ_LABEL = {
math: 'Математика', algebra: 'Алгебра', geometry: 'Геометрия',
physics: 'Физика', chemistry: 'Химия', biology: 'Биология',
informatics: 'Информатика', russian: 'Русский', english: 'Английский',
};
const cats = document.getElementById('cr-tb-picker-cats');
cats.innerHTML = `<button class="cr-sim-cat-btn active" onclick="crFilterTbs('all', this)">Все</button>` +
subjs.map(s => `<button class="cr-sim-cat-btn" onclick="crFilterTbs('${s}', this)">${SUBJ_LABEL[s] || s}</button>`).join('');
}
function _crRenderTbGrid(subj) {
const grid = document.getElementById('cr-tb-picker-grid');
const list = (_tbList || []).filter(t => subj === 'all' || t.subject === subj);
if (!list.length) {
grid.innerHTML = `<div style="grid-column:1/-1;color:rgba(255,255,255,0.5);padding:24px;text-align:center;font-size:.85rem">Нет учебников</div>`;
return;
}
grid.innerHTML = list.map(t => {
const slugSafe = String(t.slug).replace(/[^a-z0-9_-]/gi, '');
const titleEsc = String(t.title || t.slug).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const grade = t.grade ? `${t.grade} кл.` : '';
return `
<div class="cr-sim-picker-card" onclick="crPickTb('${slugSafe}')" title="${titleEsc}">
<span class="cr-sim-picker-card-cat math">${grade || (t.subject || '')}</span>
<span class="cr-sim-picker-card-title">${titleEsc}</span>
</div>`;
}).join('');
}
async function crPickTb(slug) {
crCloseTbPicker();
if (!_sessionId) return;
try {
await LS.post(`/api/classroom/${_sessionId}/textbook`, { slug });
// onTbOpen will fire via SSE echo
} catch (e) {
LS.toast(e.message || 'Ошибка открытия учебника', 'error');
}
}
async function crTeacherCloseTb() {
if (!_sessionId) return;
try { await LS.del(`/api/classroom/${_sessionId}/textbook`); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function crSetTbMode(mode) {
if (!_sessionId) return;
try { await LS.post(`/api/classroom/${_sessionId}/textbook/mode`, { mode }); }
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
function onTbOpen(data) {
_tbActive = data.slug;
const panel = document.getElementById('cr-tb-panel');
const frame = document.getElementById('cr-tb-frame');
const titleEl = document.getElementById('cr-tb-bar-title');
const closeBtn = document.getElementById('cr-tb-bar-close');
const modeTog = document.getElementById('cr-tb-mode-toggle');
const headerBtn = document.getElementById('cr-tb-btn');
const blocker = document.getElementById('cr-tb-blocker');
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
titleEl.textContent = data.title || data.slug;
let src = `/textbook/${encodeURIComponent(data.slug)}?embed=1`;
if (data.hash) src += '#' + data.hash;
frame.src = src;
panel.classList.add('open');
if (closeBtn) closeBtn.style.display = isTeacher ? 'flex' : 'none';
if (modeTog) modeTog.style.display = isTeacher ? 'flex' : 'none';
if (headerBtn && isTeacher) {
headerBtn.title = 'Закрыть учебник';
headerBtn.querySelector('span').textContent = 'Закрыть';
}
if (blocker) blocker.classList.toggle('active', !isTeacher && _tbMode === 'demo');
onTbModeChange(_tbMode);
}
function onTbClose() {
if (_annotateActive) _crApplyAnnotate(false);
_tbActive = null;
const panel = document.getElementById('cr-tb-panel');
const frame = document.getElementById('cr-tb-frame');
const headerBtn = document.getElementById('cr-tb-btn');
const modeTog = document.getElementById('cr-tb-mode-toggle');
const blocker = document.getElementById('cr-tb-blocker');
panel.classList.remove('open');
if (modeTog) modeTog.style.display = 'none';
if (blocker) blocker.classList.remove('active');
setTimeout(() => { if (frame) frame.src = 'about:blank'; }, 300);
if (headerBtn) {
headerBtn.title = 'Открыть учебник';
const sp = headerBtn.querySelector('span'); if (sp) sp.textContent = 'Учебник';
}
}
function onTbNav(data) {
if (!_tbActive) return;
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
// In demo mode студенты следуют за учителем. В free — игнорируем nav-события.
if (isTeacher) return;
if (_tbMode !== 'demo') return;
const frame = document.getElementById('cr-tb-frame');
if (!frame) return;
// Если slug сменился — перезагружаем iframe, иначе шлём postMessage
if (data.slug && data.slug !== _tbActive) {
_tbActive = data.slug;
document.getElementById('cr-tb-bar-title').textContent = data.title || data.slug;
let src = `/textbook/${encodeURIComponent(data.slug)}?embed=1`;
if (data.hash) src += '#' + data.hash;
frame.src = src;
} else {
frame.contentWindow?.postMessage(
{ type: 'ls_tb_apply', hash: data.hash, scrollY: data.scrollY }, '*'
);
}
}
function onTbModeChange(mode) {
_tbMode = mode;
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
const blocker = document.getElementById('cr-tb-blocker');
const frame = document.getElementById('cr-tb-frame');
if (blocker) blocker.classList.toggle('active', !isTeacher && mode === 'demo');
document.getElementById('cr-tb-mode-demo')?.classList.toggle('active', mode === 'demo');
document.getElementById('cr-tb-mode-free')?.classList.toggle('active', mode === 'free');
// Сообщаем iframe — блокировать клики у студента в demo
if (frame && !isTeacher) {
frame.contentWindow?.postMessage({ type: 'ls_tb_lock', locked: mode === 'demo' }, '*');
}
}
// Teacher: relay textbook nav (hash/scroll) from iframe → backend → SSE
window.addEventListener('message', e => {
if (!_tbActive || !_sessionId) return;
const isTeacher = _me?.role === 'teacher' || _me?.role === 'admin';
if (!isTeacher) return;
const d = e.data;
if (!d || typeof d !== 'object') return;
if (d.type === 'ls_tb_nav') {
// navigation: hash или ссылка на другой учебник — отправляем сразу
const slug = d.slug || _tbActive;
LS.post(`/api/classroom/${_sessionId}/textbook/nav`, {
slug, hash: d.hash || null
}).catch(() => {});
// если открыли другой учебник — учитель сам переключает iframe (студенты получат через SSE)
if (slug !== _tbActive) {
const frame = document.getElementById('cr-tb-frame');
if (frame) {
let src = `/textbook/${encodeURIComponent(slug)}?embed=1`;
if (d.hash) src += '#' + d.hash;
frame.src = src;
_tbActive = slug;
}
}
} else if (d.type === 'ls_tb_scroll') {
clearTimeout(_tbNavThrottle);
_tbNavThrottle = setTimeout(() => {
LS.post(`/api/classroom/${_sessionId}/textbook/nav`, {
slug: _tbActive, scrollY: d.scrollY
}).catch(() => {});
}, 350);
}
});
function crToggleDrawPermission(uid) {
if (!_sessionId) return;
const numUid = Number(uid);
const hasPermit = _permittedStudents.has(numUid);
if (hasPermit) {
_permittedStudents.delete(numUid);
_crWsSend({ type: 'revoke_draw', sessionId: _sessionId, targetUserId: numUid });
} else {
_permittedStudents.add(numUid);
_crWsSend({ type: 'allow_draw', sessionId: _sessionId, targetUserId: numUid });
}
updateParticipantsList();
}
function crMutePeer(uid) {
if (!_sessionId) return;
_crWsSend({ type: 'mute_peer', sessionId: _sessionId, targetUserId: Number(uid) });
if (_participants[uid]) { _participants[uid].micMuted = true; updateParticipantsList(); }
}
/* ── 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();
}
/* ── Quiz integration ── */
// Suppress api.js full-screen quiz overlay on classroom page
window._lsLiveOverriddenByClassroom = true;
let _quizLiveId = null;
let _quizQuestions = [];
let _quizPage = 0;
let _quizTotal = 0;
let _quizSearchTimer = null;
const _Q_LIMIT = 20;
function _crMathHtml(text) {
if (!text) return '';
const kat = window.katex;
if (!kat) { const d = document.createElement('span'); d.textContent = text; return d.innerHTML; }
let out = '', i = 0;
while (i < text.length) {
const ii = text.indexOf('\\(', i), bi = text.indexOf('\\[', i);
let next = -1, close = '', disp = false;
if (ii >= 0 && (bi < 0 || ii <= bi)) { next = ii; close = '\\)'; disp = false; }
else if (bi >= 0) { next = bi; close = '\\]'; disp = true; }
const plain = document.createElement('span');
if (next < 0) { plain.textContent = text.slice(i); out += plain.innerHTML; break; }
plain.textContent = text.slice(i, next); out += plain.innerHTML;
const ci = text.indexOf(close, next + 2);
if (ci < 0) { const p2 = document.createElement('span'); p2.textContent = text.slice(next); out += p2.innerHTML; break; }
try { out += kat.renderToString(text.slice(next + 2, ci), { displayMode: disp, throwOnError: false }); }
catch(e) { const p2 = document.createElement('span'); p2.textContent = text.slice(next, ci + close.length); out += p2.innerHTML; }
i = ci + close.length;
}
return out;
}
function crQuizOnSessionActive(isTeacher) {
if (isTeacher) {
const tabQuiz = document.getElementById('tab-quiz');
if (tabQuiz) tabQuiz.style.display = '';
document.getElementById('cr-tabs-inner')?.classList.add('tabs-4');
document.getElementById('cr-quiz-no-session').style.display = 'none';
document.getElementById('cr-quiz-session').style.display = 'flex';
// Warn and disable start if no class (personal session)
const hasClass = !!_session?.class_id;
const noClassEl = document.getElementById('cr-quiz-no-class');
if (noClassEl) noClassEl.style.display = hasClass ? 'none' : 'block';
const startBtn = document.getElementById('cr-quiz-start-btn');
if (startBtn) startBtn.disabled = !hasClass;
crQuizInit();
}
crStudentWidgetInit();
}
async function crQuizInit() {
await crQuizLoadTopics();
crQuizLoadQuestions(true);
// Check if a quiz is already running for this class
if (!_session?.class_id) return;
try {
const data = await LS.get(`/api/live/class/${_session.class_id}/active`);
if (data?.active && data.live) {
_quizLiveId = data.live.id;
document.getElementById('cr-quiz-start-area').style.display = 'none';
document.getElementById('cr-quiz-status-bar').style.display = 'flex';
document.getElementById('cr-quiz-status-text').textContent = 'Квиз активен';
if (data.question) crQuizShowActiveCard(data.question);
}
} catch {}
}
async function crQuizLoadTopics() {
// Derive topics from the first batch of questions (no dedicated topics endpoint)
try {
const data = await LS.get(`/api/questions?limit=200`);
const sel = document.getElementById('cr-quiz-topic-sel');
if (!sel) return;
const seen = new Map();
(data.rows || []).forEach(q => {
if (q.topic_id && q.topic && !seen.has(q.topic_id)) {
seen.set(q.topic_id, q.topic);
}
});
seen.forEach((name, id) => {
const o = document.createElement('option');
o.value = id; o.textContent = name;
sel.appendChild(o);
});
} catch {}
}
async function crQuizLoadQuestions(reset = true) {
if (reset) { _quizPage = 1; _quizQuestions = []; }
const search = document.getElementById('cr-quiz-search')?.value || '';
const topicId = document.getElementById('cr-quiz-topic-sel')?.value || '';
const diff = document.getElementById('cr-quiz-diff-sel')?.value || '';
const params = new URLSearchParams({ limit: _Q_LIMIT, page: _quizPage });
if (search) params.set('q', search);
if (topicId) params.set('topic_id', topicId);
if (diff) params.set('difficulty', diff);
try {
const data = await LS.get(`/api/questions?${params}`);
const qs = data.rows || [];
_quizTotal = data.total || qs.length;
_quizQuestions = reset ? qs : [..._quizQuestions, ...qs];
crQuizRenderList(reset ? qs : _quizQuestions);
const countEl = document.getElementById('cr-quiz-count');
if (countEl) { countEl.textContent = `${_quizTotal} вопросов`; countEl.style.display = _quizTotal > 0 ? '' : 'none'; }
const moreBtn = document.getElementById('cr-quiz-load-more');
if (moreBtn) moreBtn.style.display = _quizQuestions.length < _quizTotal ? '' : 'none';
} catch {}
}
function crQuizRenderList(questions) {
const scroll = document.getElementById('cr-quiz-q-scroll');
if (!scroll) return;
if (!questions.length) {
scroll.innerHTML = '<div style="padding:16px 12px;color:rgba(255,255,255,0.3);font-size:0.72rem;text-align:center">Вопросы не найдены</div>';
return;
}
scroll.innerHTML = questions.map(q => {
const diffColor = q.difficulty === 1 ? '#A8E063' : q.difficulty === 3 ? '#FF6B6B' : '#FFE066';
const diffLabel = q.difficulty === 1 ? 'Лёгкий' : q.difficulty === 3 ? 'Сложный' : 'Средний';
const safeText = (q.text || '').replace(/"/g, '&quot;');
return `<div class="cr-quiz-q-item">
<div class="cr-quiz-q-body">
<div class="cr-quiz-q-text" data-text="${safeText}"></div>
<div class="cr-quiz-q-meta">
<span style="color:${diffColor}">${diffLabel}</span>
${q.topic ? `<span>· ${q.topic}</span>` : ''}
</div>
</div>
<button class="cr-quiz-launch-btn" onclick="crQuizLaunch(${q.id})" title="Запустить вопрос" ${_session?.class_id ? '' : 'disabled'}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</button>
</div>`;
}).join('');
scroll.querySelectorAll('[data-text]').forEach(el => { el.innerHTML = _crMathHtml(el.dataset.text); });
}
function crQuizLoadMore() { _quizPage++; crQuizLoadQuestions(false); }
function crQuizOnSearch(v) {
clearTimeout(_quizSearchTimer);
_quizSearchTimer = setTimeout(() => crQuizLoadQuestions(true), 350);
}
function crQuizOnTopicFilter() { crQuizLoadQuestions(true); }
function crQuizOnDiffFilter() { crQuizLoadQuestions(true); }
async function crQuizStart() {
if (!_session?.class_id) {
LS.toast('Квиз доступен только для классных сессий', 'warn');
return;
}
try {
const data = await LS.post('/api/live', { class_id: _session.class_id });
_quizLiveId = data.id;
document.getElementById('cr-quiz-start-area').style.display = 'none';
document.getElementById('cr-quiz-status-bar').style.display = 'flex';
document.getElementById('cr-quiz-status-text').textContent = 'Квиз запущен';
} catch(e) {
LS.toast(e.message || 'Ошибка запуска квиза', 'error');
}
}
async function crQuizEnd() {
if (!_quizLiveId) return;
try { await LS.del(`/api/live/${_quizLiveId}`); } catch {}
_quizLiveId = null;
document.getElementById('cr-quiz-start-area').style.display = 'block';
document.getElementById('cr-quiz-status-bar').style.display = 'none';
document.getElementById('cr-quiz-active-card').style.display = 'none';
document.getElementById('cr-quiz-result-stats').style.display = 'none';
}
async function crQuizLaunch(questionId) {
if (!_quizLiveId) await crQuizStart();
if (!_quizLiveId) return;
try {
const resp = await LS.put(`/api/live/${_quizLiveId}/question`, { question_id: questionId });
crQuizShowActiveCard(resp.question || { text: '...' });
document.getElementById('cr-quiz-result-stats').style.display = 'none';
} catch(e) { console.error('crQuizLaunch', e); }
}
function crQuizShowActiveCard(q) {
const card = document.getElementById('cr-quiz-active-card');
const textEl = document.getElementById('cr-quiz-active-text');
if (!card || !textEl) return;
card.style.display = 'block';
textEl.innerHTML = _crMathHtml(q.text || '');
document.getElementById('cr-quiz-ans-count').textContent = '0';
document.getElementById('cr-quiz-status-text').textContent = 'Вопрос активен';
document.getElementById('cr-quiz-result-stats').style.display = 'none';
}
function crQuizUpdateCounter(count) {
const el = document.getElementById('cr-quiz-ans-count');
if (el) el.textContent = count;
}
async function crQuizShowResults() {
if (!_quizLiveId) return;
try { const data = await LS.get(`/api/live/${_quizLiveId}/results`); crQuizRenderResults(data); } catch {}
}
function crQuizRenderResults(data) {
const container = document.getElementById('cr-quiz-result-stats');
if (!container) return;
const opts = data.options || [];
const stats = data.stats || {};
const total = stats.total || 0;
const correct = stats.correct || 0;
const maxCnt = Math.max(...opts.map(o => o.chosen_count || 0), 1);
const pct = total > 0 ? Math.round(correct / total * 100) : 0;
container.innerHTML = `
<div style="display:flex;gap:6px;padding:6px 10px 2px">
<div class="cr-quiz-result-stat"><span class="cr-quiz-result-stat-val" style="color:#A8E063">${pct}%</span><span class="cr-quiz-result-stat-lbl">Правильно</span></div>
<div class="cr-quiz-result-stat"><span class="cr-quiz-result-stat-val">${total}</span><span class="cr-quiz-result-stat-lbl">Ответов</span></div>
</div>
<div class="cr-quiz-result-bars">
${opts.map((o, i) => {
const cnt = o.chosen_count || 0;
const fill = Math.round(cnt / maxCnt * 100);
return `<div class="cr-quiz-result-row">
<span class="cr-quiz-result-key ${o.is_correct ? 'correct' : ''}">${String.fromCharCode(65+i)}</span>
<div class="cr-quiz-result-track"><div class="cr-quiz-result-fill ${o.is_correct ? 'correct-fill' : ''}" style="width:${fill}%"></div></div>
<span class="cr-quiz-result-count">${cnt}</span>
</div>`;
}).join('')}
</div>`;
container.style.display = 'block';
}
/* ── Student quiz widget ── */
let _sqAnswered = false;
let _sqLiveId = null;
let _sqCollapsed = false;
async function crStudentWidgetInit() {
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
if (isTeacher || document.getElementById('cr-sq-widget')) return;
const w = document.createElement('div');
w.id = 'cr-sq-widget';
w.style.display = 'none';
w.innerHTML = `
<div class="cr-sq-head">
<span class="cr-sq-badge"><span class="cr-sq-badge-dot"></span>Квиз</span>
<button class="cr-sq-collapse" onclick="crSqToggle()" title="Свернуть/развернуть">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>
</button>
</div>
<div class="cr-sq-body" id="cr-sq-body">
<div class="cr-sq-q" id="cr-sq-q"></div>
<div class="cr-sq-opts" id="cr-sq-opts"></div>
<div class="cr-sq-res" id="cr-sq-res" style="display:none"></div>
</div>`;
document.body.appendChild(w);
// Recover active question state on page reload
if (_session?.class_id) {
LS.get(`/api/live/class/${_session.class_id}/active`).then(data => {
if (!data?.active || !data.live || !data.question) return;
_sqLiveId = data.live.id;
// Merge options into question object
const q = { ...data.question, options: data.options || [] };
crStudentOnQuestion(q, data.live.id);
if (data.myAnswer) {
_sqAnswered = true;
document.querySelectorAll('.cr-sq-opt').forEach(b => b.disabled = true);
// Mark the previously selected option
const selBtn = document.querySelector(`.cr-sq-opt[onclick="crStudentAnswer(${data.myAnswer.option_id})"]`);
if (selBtn) selBtn.classList.add('selected');
}
}).catch(() => {});
}
}
function crSqToggle() {
_sqCollapsed = !_sqCollapsed;
const body = document.getElementById('cr-sq-body');
if (body) body.style.display = _sqCollapsed ? 'none' : '';
const icon = document.querySelector('#cr-sq-widget .cr-sq-collapse svg');
if (icon) icon.style.transform = _sqCollapsed ? 'rotate(180deg)' : '';
}
function crStudentOnQuestion(q, liveId) {
_sqAnswered = false;
_sqLiveId = liveId || q.liveId || null;
crStudentWidgetInit(); // ensure widget exists
const w = document.getElementById('cr-sq-widget');
if (!w) return;
w.style.display = 'flex';
if (_sqCollapsed) crSqToggle();
const qEl = document.getElementById('cr-sq-q');
if (qEl) qEl.innerHTML = _crMathHtml(q.text || '');
const optsEl = document.getElementById('cr-sq-opts');
if (optsEl) {
optsEl.innerHTML = (q.options || []).map((o, i) => {
const safeText = (o.text || '').replace(/"/g, '&quot;');
return `<button class="cr-sq-opt" onclick="crStudentAnswer(${o.id})">
<span class="cr-sq-key">${String.fromCharCode(65+i)}</span>
<span class="cr-sq-opt-text" data-text="${safeText}"></span>
</button>`;
}).join('');
optsEl.querySelectorAll('[data-text]').forEach(el => { el.innerHTML = _crMathHtml(el.dataset.text); });
}
const resEl = document.getElementById('cr-sq-res');
if (resEl) resEl.style.display = 'none';
}
async function crStudentAnswer(optionId) {
if (_sqAnswered || !_sqLiveId) return;
_sqAnswered = true;
document.querySelectorAll('.cr-sq-opt').forEach(b => b.disabled = true);
const btn = document.querySelector(`.cr-sq-opt[onclick="crStudentAnswer(${optionId})"]`);
if (btn) btn.classList.add('selected');
try { await LS.post(`/api/live/${_sqLiveId}/answer`, { option_id: optionId }); } catch {}
}
function crStudentOnResults(data) {
const optsEl = document.getElementById('cr-sq-opts');
const resEl = document.getElementById('cr-sq-res');
if (!optsEl || !resEl) return;
const opts = data.options || [];
const btns = optsEl.querySelectorAll('.cr-sq-opt');
btns.forEach((b, i) => {
if (!opts[i]) return;
if (opts[i].is_correct) b.classList.add('cr-sq-opt-correct');
else if (b.classList.contains('selected')) b.classList.add('cr-sq-opt-wrong');
});
const stats = data.stats || {};
const total = stats.total || 0;
const corr = stats.correct || 0;
const pct = total > 0 ? Math.round(corr / total * 100) : 0;
resEl.innerHTML = `<div style="font-size:0.68rem;color:rgba(255,255,255,0.45);padding:4px 0 2px">${corr} из ${total} правильно (${pct}%)</div>`;
resEl.style.display = '';
}
function crStudentOnEnded() {
setTimeout(() => {
const w = document.getElementById('cr-sq-widget');
if (w) w.style.display = 'none';
}, 4000);
}
/* ── stop polling + leave on navigate away ── */
window.addEventListener('pagehide', () => {
stopPolling();
if (_timerHandle) { clearInterval(_timerHandle); _timerHandle = null; }
if (_rtc) { _rtc.destroy(); _rtc = null; }
if (_sessionId) {
const url = `/api/classroom/${_sessionId}/leave`;
const token = localStorage.getItem('ls_token');
// keepalive: true ensures the request completes even if the page is unloading
fetch(url, {
method: 'POST', keepalive: true,
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: '{}',
}).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>
<!-- Page context menu -->
<div class="wb-page-menu" id="wb-page-menu">
<button onclick="wbHidePageMenu();wbStartRenaming(_wbMenuPage)">Переименовать</button>
<button onclick="wbPageDuplicate(_wbMenuPage)">Дублировать</button>
<hr>
<button onclick="wbPageClear(_wbMenuPage)">Очистить страницу</button>
<button class="danger" onclick="wbPageDelete(_wbMenuPage)">Удалить страницу</button>
</div>
<!-- ── Settings panel ───────────────────────────────────────────────────── -->
<div class="cr-settings-overlay" id="cr-settings-overlay" onclick="if(event.target===this)closeSettings()">
<div class="cr-settings-panel">
<div class="cr-sp-head">
<div class="cr-sp-title">Настройки урока</div>
<button class="cr-sp-close" onclick="closeSettings()">
<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"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="cr-sp-tabs">
<button class="cr-sp-tab active" data-tab="appearance" onclick="settingsTab('appearance')">Внешний вид</button>
<button class="cr-sp-tab" data-tab="audio" onclick="settingsTab('audio')">Аудио</button>
<button class="cr-sp-tab" data-tab="board" onclick="settingsTab('board')">Доска</button>
</div>
<div class="cr-sp-body">
<!-- Внешний вид -->
<div class="cr-sp-pane active" id="crs-appearance">
<div class="cr-sp-section">
<div class="cr-sp-section-label">Чат</div>
<div class="cr-sp-row">
<div class="cr-sp-row-lbl">Размер шрифта</div>
<div class="cr-seg" id="crs-chat-size">
<button data-val="small" onclick="crSetPref('chatFontSize','small')">S</button>
<button data-val="medium" onclick="crSetPref('chatFontSize','medium')">M</button>
<button data-val="large" onclick="crSetPref('chatFontSize','large')">L</button>
</div>
</div>
</div>
</div>
<!-- Аудио -->
<div class="cr-sp-pane" id="crs-audio">
<div class="cr-sp-section">
<div class="cr-sp-section-label">Микрофон</div>
<div class="cr-sp-row">
<div>
<div class="cr-sp-row-lbl">Войти без микрофона</div>
<div class="cr-sp-row-sub">Микрофон будет выключен при входе в урок</div>
</div>
<label class="cr-toggle">
<input type="checkbox" id="crs-muted-join" onchange="crSetPref('mutedOnJoin',this.checked)">
<span class="cr-toggle-track"></span>
</label>
</div>
<div class="cr-sp-row">
<div class="cr-sp-row-lbl">Чувствительность</div>
<div class="cr-seg" id="crs-vad">
<button data-val="low" onclick="crSetPref('vadSensitivity','low')">Низкая</button>
<button data-val="medium" onclick="crSetPref('vadSensitivity','medium')">Средняя</button>
<button data-val="high" onclick="crSetPref('vadSensitivity','high')">Высокая</button>
</div>
</div>
</div>
<div class="cr-sp-section">
<div class="cr-sp-section-label">Звуки</div>
<div class="cr-sp-row">
<div class="cr-sp-row-lbl">Звуки включены</div>
<label class="cr-toggle">
<input type="checkbox" id="crs-sfx-enabled" onchange="crSfxSetEnabled(this.checked)">
<span class="cr-toggle-track"></span>
</label>
</div>
<div class="cr-sp-row" style="gap:10px">
<div class="cr-sp-row-lbl">Громкость</div>
<input type="range" id="crs-sfx-volume" min="0" max="100" value="75"
style="flex:1;accent-color:var(--violet)"
oninput="crSfxSetVolume(this.value)">
</div>
<div class="cr-sp-row">
<div class="cr-sp-row-lbl">Звук при новом сообщении</div>
<label class="cr-toggle">
<input type="checkbox" id="crs-chat-sound" onchange="crSetPref('chatSound',this.checked)">
<span class="cr-toggle-track"></span>
</label>
</div>
<div class="cr-sp-row">
<div class="cr-sp-row-lbl">Звук при поднятой руке</div>
<label class="cr-toggle">
<input type="checkbox" id="crs-hand-sound" onchange="crSetPref('handSound',this.checked)">
<span class="cr-toggle-track"></span>
</label>
</div>
</div>
<div class="cr-sp-section">
<div class="cr-sp-section-label">Тест микрофона</div>
<div class="cr-mic-bar"><div class="cr-mic-fill" id="crs-mic-fill"></div></div>
<div class="cr-sp-note" id="crs-mic-status"></div>
<button class="cr-sp-btn" id="crs-mic-btn" onclick="crMicTestStart()">Начать тест</button>
</div>
<div class="cr-sp-section" id="crs-push-section">
<div class="cr-sp-section-label">Уведомления</div>
<div class="cr-sp-row">
<div>
<div class="cr-sp-row-lbl">Push-уведомление «Урок начался»</div>
<div class="cr-sp-row-sub">Браузер уведомит когда учитель начнёт урок</div>
</div>
</div>
<button class="cr-sp-btn cyan" id="crs-push-btn" onclick="crRequestPush()">Включить уведомления</button>
</div>
</div>
<!-- Доска -->
<div class="cr-sp-pane" id="crs-board">
<div class="cr-sp-section">
<div class="cr-sp-section-label">Инструмент при входе</div>
<div class="cr-seg" id="crs-def-tool">
<button data-val="pencil" onclick="crSetPref('defaultTool','pencil')">Карандаш</button>
<button data-val="select" onclick="crSetPref('defaultTool','select')">Выделение</button>
</div>
</div>
<div class="cr-sp-section">
<div class="cr-sp-section-label">Ведущая рука</div>
<div class="cr-sp-note">Панель чата и участников переедет на левую сторону экрана</div>
<div class="cr-seg" id="crs-hand">
<button data-val="right" onclick="crSetPref('leftHand',false)">Правша (панель справа)</button>
<button data-val="left" onclick="crSetPref('leftHand',true)">Левша (панель слева)</button>
</div>
</div>
<div class="cr-sp-section">
<div class="cr-sp-section-label">Стилус / палец — давление</div>
<div class="cr-sp-note">Толщина линии зависит от силы нажатия стилуса на планшет</div>
<div class="cr-seg" id="crs-stylus">
<button data-val="0" onclick="crSetPref('stylusMultiplier',0)">Выкл</button>
<button data-val="0.5" onclick="crSetPref('stylusMultiplier',0.5)">Слабая</button>
<button data-val="1" onclick="crSetPref('stylusMultiplier',1)">Средняя</button>
<button data-val="2" onclick="crSetPref('stylusMultiplier',2)">Сильная</button>
</div>
</div>
</div>
</div><!-- /.cr-sp-body -->
</div><!-- /.cr-settings-panel -->
</div><!-- /.cr-settings-overlay -->
<!-- ── Textbook picker modal (re-uses .cr-sim-picker-* styles) ─────────── -->
<div class="cr-sim-picker-overlay" id="cr-tb-picker-overlay" onclick="if(event.target===this)crCloseTbPicker()">
<div class="cr-sim-picker-modal">
<div class="cr-sim-picker-head">
<h3>Выбрать учебник</h3>
<button class="cr-sim-picker-close" onclick="crCloseTbPicker()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="cr-sim-picker-cats" id="cr-tb-picker-cats">
<button class="cr-sim-cat-btn active" onclick="crFilterTbs('all', this)">Все</button>
</div>
<div class="cr-sim-picker-body">
<div class="cr-sim-picker-grid" id="cr-tb-picker-grid">
<div style="grid-column:1/-1;color:rgba(255,255,255,0.5);padding:24px;text-align:center;font-size:.85rem">Загрузка…</div>
</div>
</div>
</div>
</div>
<!-- ── Simulation picker modal ─────────────────────────────────────────── -->
<div class="cr-sim-picker-overlay" id="cr-sim-picker-overlay" onclick="if(event.target===this)crCloseSimPicker()">
<div class="cr-sim-picker-modal">
<div class="cr-sim-picker-head">
<h3>Выбрать симуляцию</h3>
<button class="cr-sim-picker-close" onclick="crCloseSimPicker()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="cr-sim-picker-cats">
<button class="cr-sim-cat-btn active" onclick="crFilterSims('all', this)">Все</button>
<button class="cr-sim-cat-btn" onclick="crFilterSims('math', this)">Математика</button>
<button class="cr-sim-cat-btn" onclick="crFilterSims('phys', this)">Физика</button>
<button class="cr-sim-cat-btn" onclick="crFilterSims('chem', this)">Химия</button>
<button class="cr-sim-cat-btn" onclick="crFilterSims('bio', this)">Биология</button>
<button class="cr-sim-cat-btn" onclick="crFilterSims('game', this)">Игры</button>
</div>
<div class="cr-sim-picker-body">
<div class="cr-sim-picker-grid" id="cr-sim-picker-grid"></div>
</div>
</div>
</div>
</body>
</html>