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