8262 lines
436 KiB
HTML
8262 lines
436 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&family=Caveat:wght@400;600;700&display=swap" rel="stylesheet" />
|
||
<link rel="stylesheet" href="/css/ls.css" />
|
||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||
<style>
|
||
.sb-content { background: #0e1117; overflow: hidden; display: flex; flex-direction: column; height: 100vh; }
|
||
|
||
/* ── header ── */
|
||
.cr-header {
|
||
background: linear-gradient(140deg, #0a0220 0%, #1a0a3c 60%, #080818 100%);
|
||
padding: 16px 20px; display: flex; align-items: center; gap: 12px;
|
||
flex-shrink: 0; position: relative; overflow: hidden;
|
||
}
|
||
.cr-header-dots {
|
||
position: absolute; inset: 0;
|
||
background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px);
|
||
background-size: 22px 22px; pointer-events: none;
|
||
}
|
||
.cr-header-inner { display: flex; align-items: center; gap: 12px; width: 100%; position: relative; z-index: 1; }
|
||
.cr-title {
|
||
font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800; color: #fff;
|
||
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
|
||
}
|
||
.cr-title span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.cr-session-chip {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
padding: 4px 12px; border-radius: 999px;
|
||
background: rgba(6,214,160,0.15); border: 1.5px solid rgba(6,214,160,0.3);
|
||
font-size: 0.72rem; font-weight: 700; color: #06D6A0;
|
||
font-family: 'Unbounded', sans-serif;
|
||
}
|
||
.cr-session-chip .dot {
|
||
width: 6px; height: 6px; border-radius: 50%; background: #06D6A0;
|
||
animation: pulse-dot 1.5s ease infinite;
|
||
}
|
||
@keyframes pulse-dot { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.5;transform:scale(.7)} }
|
||
|
||
.cr-header-btn {
|
||
display: flex; align-items: center; gap: 6px;
|
||
padding: 7px 14px; border-radius: 99px;
|
||
border: 1.5px solid rgba(255,255,255,0.15); background: transparent;
|
||
color: rgba(255,255,255,0.7); font-family: 'Manrope',sans-serif;
|
||
font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: all .15s;
|
||
white-space: nowrap;
|
||
}
|
||
.cr-header-btn:hover { border-color: rgba(255,255,255,0.4); color: #fff; }
|
||
.cr-header-btn.danger { border-color: rgba(241,91,181,0.4); color: #F15BB5; }
|
||
.cr-header-btn.danger:hover { background: rgba(241,91,181,0.12); }
|
||
.cr-header-btn svg { width: 14px; height: 14px; }
|
||
|
||
/* ── body ── */
|
||
.cr-body { flex: 1; display: flex; min-height: 0; }
|
||
|
||
/* ── main area (whiteboard placeholder) ── */
|
||
.cr-main {
|
||
flex: 1; display: flex; flex-direction: column;
|
||
align-items: center; justify-content: center;
|
||
background: #0e1117; min-width: 0;
|
||
}
|
||
.cr-idle {
|
||
display: flex; flex-direction: column; align-items: center; gap: 20px;
|
||
text-align: center; padding: 40px;
|
||
}
|
||
.cr-idle-icon {
|
||
width: 72px; height: 72px; border-radius: 20px;
|
||
background: linear-gradient(135deg, rgba(155,93,229,0.2), rgba(6,214,224,0.1));
|
||
border: 1.5px solid rgba(155,93,229,0.3);
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.cr-idle-icon svg { width: 36px; height: 36px; stroke: #9B5DE5; }
|
||
.cr-idle h2 {
|
||
font-family: 'Unbounded',sans-serif; font-size: 1.1rem; font-weight: 800;
|
||
color: #fff; margin: 0;
|
||
}
|
||
.cr-idle p { color: var(--text-3); font-size: 0.85rem; margin: 0; max-width: 340px; line-height: 1.6; }
|
||
.cr-start-btn {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 12px 28px; border-radius: 99px;
|
||
background: linear-gradient(135deg, #9B5DE5, #7B3FC5);
|
||
border: none; color: #fff; font-family: 'Manrope',sans-serif;
|
||
font-size: 0.88rem; font-weight: 700; cursor: pointer;
|
||
transition: all .2s; box-shadow: 0 4px 20px rgba(155,93,229,0.35);
|
||
}
|
||
.cr-start-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 28px rgba(155,93,229,0.5); }
|
||
.cr-start-btn svg { width: 16px; height: 16px; }
|
||
|
||
/* active session main area — whiteboard */
|
||
.cr-active-main {
|
||
flex: 1; align-self: stretch; display: flex; flex-direction: column;
|
||
overflow: hidden; min-height: 0;
|
||
}
|
||
.cr-board-area {
|
||
flex: 1; display: flex; flex-direction: row;
|
||
overflow: hidden; min-height: 0; position: relative;
|
||
}
|
||
.cr-board-wrap {
|
||
flex: 1; position: relative; overflow: hidden;
|
||
background: #213d26;
|
||
/* Chalkboard wooden frame */
|
||
border: 10px solid #3a2210;
|
||
border-top: 12px solid #3a2210;
|
||
border-bottom: 14px solid #2c1a0d;
|
||
box-shadow:
|
||
inset 0 0 0 2px rgba(0,0,0,0.55),
|
||
inset 0 0 0 3px rgba(255,200,100,0.07),
|
||
0 4px 18px rgba(0,0,0,0.6),
|
||
0 1px 0 rgba(255,200,80,0.08);
|
||
/* Wood grain via gradient */
|
||
background-image: none;
|
||
}
|
||
/* Chalk-dust highlight at inner bottom edge of frame */
|
||
.cr-board-wrap::before {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: 0; left: 0; right: 0;
|
||
height: 3px;
|
||
background: rgba(255,255,255,0.06);
|
||
pointer-events: none; z-index: 10;
|
||
}
|
||
/* Wood highlight — top inner edge */
|
||
.cr-board-wrap::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0; left: 0; right: 0;
|
||
height: 2px;
|
||
background: rgba(255,200,100,0.08);
|
||
pointer-events: none; z-index: 10;
|
||
}
|
||
#cr-canvas {
|
||
display: block; cursor: crosshair;
|
||
touch-action: none;
|
||
}
|
||
/* Thumbnail panel (pages sidebar) */
|
||
.wb-thumbs-panel {
|
||
width: 110px; flex-shrink: 0;
|
||
background: #120d1e;
|
||
border-right: 1px solid rgba(155,93,229,0.2);
|
||
display: flex; flex-direction: column;
|
||
overflow-y: auto; overflow-x: hidden;
|
||
padding: 6px 4px 40px;
|
||
gap: 6px; position: relative;
|
||
scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.3) transparent;
|
||
}
|
||
.wb-thumb-item {
|
||
position: relative; cursor: pointer; flex-shrink: 0;
|
||
border-radius: 4px; overflow: hidden;
|
||
border: 1.5px solid transparent;
|
||
transition: border-color .12s;
|
||
}
|
||
.wb-thumb-item:hover { border-color: rgba(155,93,229,0.5); }
|
||
.wb-thumb-item.active { border-color: #9B5DE5; }
|
||
.wb-thumb-item canvas { display: block; width: 100%; height: auto; }
|
||
.wb-thumb-num {
|
||
position: absolute; bottom: 2px; right: 4px;
|
||
font-size: 9px; color: rgba(255,255,255,0.4);
|
||
font-family: 'Manrope', sans-serif;
|
||
}
|
||
.wb-thumbs-add {
|
||
position: sticky; bottom: 0;
|
||
width: 100%; padding: 4px; background: #120d1e;
|
||
border-top: 1px solid rgba(155,93,229,0.15);
|
||
}
|
||
.wb-thumbs-add-btn {
|
||
width: 100%; height: 28px; border-radius: 4px;
|
||
background: rgba(155,93,229,0.12); border: 1px solid rgba(155,93,229,0.25);
|
||
color: rgba(255,255,255,0.5); font-size: 16px; cursor: pointer;
|
||
transition: all .12s;
|
||
}
|
||
.wb-thumbs-add-btn:hover { background: rgba(155,93,229,0.22); color: #fff; }
|
||
.wb-thumb-label {
|
||
position: absolute; bottom: 14px; left: 0; right: 0;
|
||
font-size: 9px; color: rgba(255,255,255,0.55);
|
||
font-family: 'Manrope', sans-serif; text-align: center;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
padding: 0 3px; pointer-events: none;
|
||
}
|
||
.wb-thumb-rename {
|
||
position: absolute; bottom: 10px; left: 3px; right: 3px; z-index: 10;
|
||
font-size: 9px; font-family: 'Manrope', sans-serif;
|
||
background: #1a1030; border: 1px solid #9B5DE5;
|
||
color: #fff; border-radius: 3px; padding: 1px 3px;
|
||
outline: none; width: calc(100% - 6px);
|
||
}
|
||
/* Page context menu */
|
||
.wb-page-menu {
|
||
position: fixed; z-index: 9999;
|
||
background: #1c1030; border: 1px solid rgba(155,93,229,0.35);
|
||
border-radius: 6px; padding: 4px 0;
|
||
box-shadow: 0 6px 20px rgba(0,0,0,0.55);
|
||
min-width: 140px; display: none;
|
||
}
|
||
.wb-page-menu.open { display: block; }
|
||
.wb-page-menu button {
|
||
display: block; width: 100%; padding: 6px 12px;
|
||
background: none; border: none; color: rgba(255,255,255,0.8);
|
||
font-size: 11px; font-family: 'Manrope', sans-serif;
|
||
text-align: left; cursor: pointer; transition: background .1s;
|
||
}
|
||
.wb-page-menu button:hover { background: rgba(155,93,229,0.2); color: #fff; }
|
||
.wb-page-menu button.danger:hover { background: rgba(255,80,80,0.18); color: #ff8080; }
|
||
.wb-page-menu hr { border: none; border-top: 1px solid rgba(255,255,255,0.07); margin: 3px 0; }
|
||
/* Template picker */
|
||
.wb-tpl-picker {
|
||
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
|
||
}
|
||
.wb-tpl-picker label { font-size: 10px; color: rgba(255,230,180,0.45); white-space: nowrap; }
|
||
.wb-tpl-picker select {
|
||
font-size: 11px; background: #2a1a0e;
|
||
border: 1px solid rgba(255,180,60,0.3); border-radius: 4px;
|
||
color: rgba(255,230,180,0.9); padding: 2px 4px; cursor: pointer;
|
||
max-width: 90px;
|
||
}
|
||
.wb-tpl-picker select option {
|
||
background: #2a1a0e;
|
||
color: #ffe0b0;
|
||
}
|
||
/* toolbar — wood-tray look beneath the board */
|
||
.cr-toolbar {
|
||
flex-shrink: 0;
|
||
background: linear-gradient(to bottom, #2c1e10, #1e1509);
|
||
border-top: 2px solid #4a2e18;
|
||
border-bottom: 1px solid #110e08;
|
||
display: flex; flex-direction: column; align-items: stretch;
|
||
box-shadow: 0 3px 10px rgba(0,0,0,0.5);
|
||
}
|
||
.cr-tb-row {
|
||
height: 44px; flex-shrink: 0;
|
||
display: flex; align-items: center; gap: 3px; padding: 0 8px;
|
||
overflow-x: auto; overflow-y: hidden;
|
||
scrollbar-width: thin; scrollbar-color: rgba(255,180,60,0.18) transparent;
|
||
}
|
||
.cr-tb-row + .cr-tb-row {
|
||
border-top: 1px solid rgba(255,180,60,0.1);
|
||
}
|
||
.cr-tb-row::-webkit-scrollbar { height: 2px; }
|
||
.cr-tb-row::-webkit-scrollbar-thumb { background: rgba(255,180,60,0.2); border-radius:2px; }
|
||
.cr-tool-sep {
|
||
width: 1px; height: 20px; background: rgba(255,255,255,0.12);
|
||
margin: 0 4px; flex-shrink: 0;
|
||
}
|
||
.cr-tool-btn {
|
||
display: flex; align-items: center; justify-content: center;
|
||
width: 32px; height: 32px; border-radius: 7px; border: 1.5px solid transparent;
|
||
background: transparent; cursor: pointer; color: rgba(255,230,180,0.55);
|
||
transition: all .12s; flex-shrink: 0;
|
||
}
|
||
.cr-tool-btn:hover { background: rgba(255,200,100,0.1); color: rgba(255,230,180,0.95); }
|
||
.cr-tool-btn.active {
|
||
background: rgba(255,180,60,0.15);
|
||
border-color: rgba(255,180,60,0.45);
|
||
color: #f5b942;
|
||
}
|
||
.cr-tool-btn svg { width: 15px; height: 15px; }
|
||
.cr-color-btn {
|
||
width: 20px; height: 20px; border-radius: 50%;
|
||
border: 2px solid transparent; cursor: pointer;
|
||
flex-shrink: 0; transition: transform .12s;
|
||
outline: none;
|
||
}
|
||
.cr-color-btn:hover { transform: scale(1.15); }
|
||
.cr-color-btn.active { border-color: #fff; transform: scale(1.15); }
|
||
.cr-width-btn {
|
||
display: flex; align-items: center; justify-content: center;
|
||
width: 32px; height: 32px; border-radius: 8px; border: 1.5px solid transparent;
|
||
background: transparent; cursor: pointer; flex-shrink: 0;
|
||
transition: all .12s;
|
||
}
|
||
.cr-width-btn:hover { background: rgba(255,200,100,0.1); }
|
||
.cr-width-btn.active { background: rgba(255,180,60,0.15); border-color: rgba(255,180,60,0.45); }
|
||
.cr-width-dot {
|
||
border-radius: 50%; background: rgba(255,230,180,0.6);
|
||
pointer-events: none;
|
||
}
|
||
.cr-width-btn.active .cr-width-dot { background: #f5b942; }
|
||
.cr-linestyle-btn {
|
||
display: flex; align-items: center; justify-content: center;
|
||
width: 32px; height: 32px; border-radius: 8px; border: 1.5px solid transparent;
|
||
background: transparent; cursor: pointer; flex-shrink: 0;
|
||
transition: all .12s; color: rgba(255,230,180,0.55);
|
||
font-size: 13px; font-weight: bold; letter-spacing: 1px;
|
||
}
|
||
.cr-linestyle-btn:hover { background: rgba(255,200,100,0.1); }
|
||
.cr-linestyle-btn.active { background: rgba(255,180,60,0.15); border-color: rgba(255,180,60,0.45); color: #f5b942; }
|
||
.cr-opacity-row {
|
||
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
|
||
}
|
||
.cr-opacity-row label { font-size: 10px; color: rgba(255,230,180,0.45); white-space: nowrap; }
|
||
#wb-opacity-slider {
|
||
width: 60px; height: 4px; accent-color: #f5b942; cursor: pointer;
|
||
}
|
||
.wb-zoom-row {
|
||
display: flex; align-items: center; gap: 2px; flex-shrink: 0;
|
||
}
|
||
.wb-zoom-label {
|
||
font-family: 'Manrope', sans-serif; font-size: 0.7rem; font-weight: 700;
|
||
color: rgba(255,210,140,0.6); min-width: 36px; text-align: center;
|
||
white-space: nowrap;
|
||
}
|
||
.wb-zoom-label:hover { color: #f5b942; }
|
||
/* overlay properties panel */
|
||
.wb-ov-props {
|
||
display: none; align-items: center; gap: 6px; flex-shrink: 0;
|
||
background: rgba(28,20,44,0.92); border: 1px solid rgba(155,93,229,0.3);
|
||
border-radius: 8px; padding: 3px 10px; font-family: 'Manrope',sans-serif;
|
||
}
|
||
.wb-ov-props.visible { display: flex; }
|
||
.wb-ov-props label { font-size: 0.68rem; color: rgba(255,255,255,0.55); white-space: nowrap; }
|
||
.wb-ov-props input[type=number] {
|
||
width: 52px; background: rgba(255,255,255,0.07); border: 1px solid rgba(155,93,229,0.3);
|
||
border-radius: 5px; color: #e8e0f7; font-size: 0.72rem; padding: 2px 4px;
|
||
text-align: center; -moz-appearance: textfield;
|
||
}
|
||
.wb-ov-props input[type=number]::-webkit-inner-spin-button { opacity: 0.4; }
|
||
.wb-ov-props-type { font-size: 0.68rem; font-weight: 700; color: rgba(155,93,229,0.8); }
|
||
|
||
/* ── toolbar dropdown popups (shapes / utils) ──────────────────────── */
|
||
.cr-drop { position: relative; display: inline-flex; flex-shrink: 0; }
|
||
.cr-drop > .cr-tool-btn { position: relative; }
|
||
.cr-drop-chevron {
|
||
position: absolute; bottom: 3px; right: 2px; pointer-events: none;
|
||
width: 6px; height: 4px; fill: rgba(255,200,100,0.32);
|
||
}
|
||
.cr-drop-popup {
|
||
position: fixed;
|
||
background: linear-gradient(180deg, #2c1e10 0%, #1e1509 100%);
|
||
border: 1.5px solid #4a2e18; border-radius: 10px; padding: 5px;
|
||
z-index: 9999; display: none; gap: 3px;
|
||
box-shadow: 0 -6px 28px rgba(0,0,0,0.65);
|
||
}
|
||
.cr-drop-popup.open { display: grid; }
|
||
.cr-shape-popup-grid { grid-template-columns: repeat(5, 32px); }
|
||
.cr-utils-popup-grid { grid-template-columns: repeat(3, 32px); }
|
||
.cr-pop-sep {
|
||
grid-column: 1 / -1; height: 1px;
|
||
background: rgba(255,180,60,0.12); margin: 2px 0;
|
||
}
|
||
.cr-pop-full { grid-column: 1 / -1; display: flex; justify-content: center; }
|
||
.cr-pop-fill-btn {
|
||
display: flex; align-items: center; gap: 5px; padding: 0 10px;
|
||
height: 28px; border-radius: 7px; border: 1.5px solid transparent;
|
||
background: transparent; cursor: pointer; color: rgba(255,230,180,0.6);
|
||
font-family: 'Manrope',sans-serif; font-size: 11px; font-weight: 600;
|
||
transition: all .12s; white-space: nowrap;
|
||
}
|
||
.cr-pop-fill-btn:hover { background: rgba(255,200,100,0.1); color: rgba(255,230,180,0.95); }
|
||
.cr-pop-fill-btn.active { background: rgba(255,180,60,0.15); border-color: rgba(255,180,60,0.45); color: #f5b942; }
|
||
.cr-pop-fill-btn svg { width: 13px; height: 13px; flex-shrink: 0; }
|
||
|
||
/* ── text options row ────────────────────────────────────────────────── */
|
||
.cr-text-row {
|
||
height: 38px; flex-shrink: 0;
|
||
display: none; align-items: center; gap: 4px; padding: 0 10px;
|
||
border-top: 1px solid rgba(255,180,60,0.12);
|
||
background: rgba(0,0,0,0.18);
|
||
}
|
||
.cr-text-row.visible { display: flex; }
|
||
.cr-text-row label {
|
||
font-family: 'Manrope',sans-serif; font-size: 10px;
|
||
color: rgba(255,230,180,0.4); white-space: nowrap;
|
||
}
|
||
.cr-text-font-sel {
|
||
height: 26px; padding: 0 6px;
|
||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,180,60,0.2);
|
||
border-radius: 6px; color: rgba(255,230,180,0.85);
|
||
font-family: 'Manrope',sans-serif; font-size: 11px; cursor: pointer;
|
||
outline: none;
|
||
}
|
||
.cr-text-size-inp {
|
||
width: 44px; height: 26px; text-align: center;
|
||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,180,60,0.2);
|
||
border-radius: 6px; color: rgba(255,230,180,0.85);
|
||
font-family: 'Manrope',sans-serif; font-size: 12px; font-weight: 700;
|
||
outline: none; -moz-appearance: textfield;
|
||
}
|
||
.cr-text-size-inp::-webkit-inner-spin-button { opacity: 0.4; }
|
||
.cr-text-fmt-btn {
|
||
width: 28px; height: 26px; border-radius: 6px; border: 1.5px solid transparent;
|
||
background: transparent; cursor: pointer; color: rgba(255,230,180,0.55);
|
||
font-family: Georgia,serif; font-size: 14px; font-weight: bold;
|
||
display: flex; align-items: center; justify-content: center;
|
||
transition: all .12s; flex-shrink: 0;
|
||
}
|
||
.cr-text-fmt-btn:hover { background: rgba(255,200,100,0.1); color: rgba(255,230,180,0.95); }
|
||
.cr-text-fmt-btn.active { background: rgba(255,180,60,0.15); border-color: rgba(255,180,60,0.45); color: #f5b942; }
|
||
|
||
/* ── context rows (connector / sticky / multi-align) ──────────────── */
|
||
.wb-ctx-row {
|
||
height: 38px; flex-shrink: 0;
|
||
display: none; align-items: center; gap: 4px; padding: 0 10px;
|
||
border-top: 1px solid rgba(255,180,60,0.12);
|
||
background: rgba(0,0,0,0.18);
|
||
font-family: 'Manrope',sans-serif;
|
||
}
|
||
.wb-ctx-row.visible { display: flex; }
|
||
.wb-ctx-lbl {
|
||
font-size: 10px; color: rgba(255,230,180,0.4);
|
||
white-space: nowrap; margin-right: 2px;
|
||
}
|
||
/* sticky color swatches */
|
||
.wb-sticky-col {
|
||
width: 22px; height: 22px; border-radius: 50%;
|
||
border: 2.5px solid transparent; cursor: pointer;
|
||
transition: transform .12s; outline: none; flex-shrink: 0;
|
||
}
|
||
.wb-sticky-col:hover { transform: scale(1.15); }
|
||
.wb-sticky-col.active { border-color: #fff; transform: scale(1.15); }
|
||
/* table grid picker popup */
|
||
.wb-tbl-drop { position: relative; display: inline-flex; flex-shrink: 0; }
|
||
.wb-tbl-popup {
|
||
position: fixed; z-index: 9999;
|
||
background: linear-gradient(180deg, #2c1e10 0%, #1e1509 100%);
|
||
border: 1.5px solid #4a2e18; border-radius: 10px; padding: 8px 8px 6px;
|
||
display: none; flex-direction: column; gap: 2px; align-items: center;
|
||
box-shadow: 0 -6px 28px rgba(0,0,0,0.65);
|
||
}
|
||
.wb-tbl-popup.open { display: flex; }
|
||
.wb-tbl-grid { display: flex; flex-direction: column; gap: 2px; }
|
||
.wb-tbl-row { display: flex; gap: 2px; }
|
||
.wb-tbl-cell {
|
||
width: 20px; height: 20px; border: 1px solid rgba(255,180,60,0.18);
|
||
border-radius: 3px; background: transparent; cursor: pointer;
|
||
transition: background 0.08s, border-color 0.08s;
|
||
}
|
||
.wb-tbl-cell.hl { background: rgba(255,180,60,0.38); border-color: rgba(255,180,60,0.7); }
|
||
.wb-tbl-label {
|
||
font-family: 'Manrope',sans-serif; font-size: 10px;
|
||
color: rgba(255,230,180,0.5); margin-top: 2px;
|
||
}
|
||
|
||
/* template & bg-image picker popup */
|
||
.wb-tpl-popup-grid {
|
||
position: fixed; z-index: 9999;
|
||
background: linear-gradient(180deg, #1e1530 0%, #140e26 100%);
|
||
border: 1.5px solid rgba(155,93,229,0.35); border-radius: 10px;
|
||
padding: 8px; display: none; flex-direction: column; gap: 2px;
|
||
box-shadow: 0 -6px 28px rgba(0,0,0,0.65);
|
||
}
|
||
.wb-tpl-popup-grid.open { display: flex; }
|
||
.wb-tpl-popup-grid .tpl-grid { display: grid; grid-template-columns: repeat(3, 68px); gap: 5px; }
|
||
.wb-tpl-item {
|
||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||
padding: 6px 4px; border-radius: 7px; cursor: pointer;
|
||
border: 1.5px solid rgba(155,93,229,0.2); background: rgba(155,93,229,0.06);
|
||
font-family: 'Manrope',sans-serif; font-size: 10px; color: rgba(230,220,255,0.7);
|
||
transition: all .15s; white-space: nowrap;
|
||
}
|
||
.wb-tpl-item:hover { background: rgba(155,93,229,0.18); border-color: rgba(155,93,229,0.5); color: #e8e0f7; }
|
||
.wb-tpl-item svg { width: 40px; height: 28px; }
|
||
.wb-bg-section { display: flex; flex-direction: column; gap: 4px; padding: 4px 0 0; border-top: 1px solid rgba(155,93,229,0.15); }
|
||
.wb-bg-row { display: flex; gap: 4px; }
|
||
.wb-bg-btn { flex: 1; padding: 5px 6px; border-radius: 5px; font-size: 10px; font-family: 'Manrope',sans-serif; cursor: pointer; border: 1px solid rgba(155,93,229,0.3); background: rgba(155,93,229,0.08); color: rgba(200,185,240,0.85); transition: all .15s; }
|
||
.wb-bg-btn:hover { background: rgba(155,93,229,0.2); color: #e8e0f7; }
|
||
.wb-bg-btn.danger { border-color: rgba(241,91,181,0.3); background: rgba(241,91,181,0.06); color: rgba(241,91,181,0.8); }
|
||
.wb-bg-btn.danger:hover { background: rgba(241,91,181,0.18); color: #F15BB5; }
|
||
|
||
/* read-only overlay label */
|
||
.cr-board-readonly {
|
||
position: absolute; bottom: 10px; left: 50%; transform: translateX(-50%);
|
||
background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.1);
|
||
border-radius: 99px; padding: 4px 14px;
|
||
font-family: 'Manrope',sans-serif; font-size: 0.72rem; color: rgba(255,255,255,0.4);
|
||
pointer-events: none; white-space: nowrap;
|
||
}
|
||
|
||
/* page nav */
|
||
.cr-page-nav {
|
||
display: flex; align-items: center; gap: 4px; margin-left: auto; flex-shrink: 0;
|
||
}
|
||
.cr-page-label {
|
||
font-family: 'Manrope',sans-serif; font-size: 0.72rem; font-weight: 700;
|
||
color: rgba(255,210,140,0.5); min-width: 42px; text-align: center;
|
||
}
|
||
/* hand raise */
|
||
.cr-hand-btn {
|
||
display: flex; align-items: center; gap: 6px;
|
||
padding: 7px 14px; border-radius: 99px;
|
||
border: 1.5px solid rgba(255,255,255,0.15); background: transparent;
|
||
color: rgba(255,255,255,0.7); font-family: 'Manrope',sans-serif;
|
||
font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: all .15s;
|
||
}
|
||
.cr-hand-btn:hover { border-color: #FFB347; color: #FFB347; }
|
||
.cr-hand-btn.raised { background: rgba(255,179,71,0.15); border-color: #FFB347; color: #FFB347; }
|
||
.cr-hand-btn svg { width: 14px; height: 14px; }
|
||
/* student fullscreen button */
|
||
.cr-fullscreen-btn {
|
||
display: flex; align-items: center; gap: 7px;
|
||
padding: 6px 18px; border-radius: 99px; cursor: pointer;
|
||
border: 1.5px solid rgba(155,93,229,0.5);
|
||
background: rgba(155,93,229,0.12);
|
||
color: rgba(200,185,255,0.9);
|
||
font-family: 'Manrope',sans-serif; font-size: 0.8rem; font-weight: 700;
|
||
transition: all .15s; white-space: nowrap; margin-left: auto;
|
||
}
|
||
.cr-fullscreen-btn:hover {
|
||
background: rgba(155,93,229,0.28); border-color: #9B5DE5;
|
||
color: #fff; box-shadow: 0 0 12px rgba(155,93,229,0.35);
|
||
}
|
||
.cr-fullscreen-btn svg { width: 15px; height: 15px; flex-shrink: 0; }
|
||
/* nav fullscreen btn: update icon in fullscreen */
|
||
:fullscreen #cr-student-fs-btn svg.fs-exit { display: inline; }
|
||
:fullscreen #cr-student-fs-btn svg.fs-enter { display: none; }
|
||
#cr-student-fs-btn svg.fs-exit { display: none; }
|
||
/* floating exit-fullscreen button (inside board, touch-friendly) */
|
||
#cr-fs-exit-overlay {
|
||
position: absolute; top: 14px; right: 14px; z-index: 500;
|
||
display: none;
|
||
align-items: center; gap: 7px;
|
||
padding: 9px 16px; border-radius: 99px;
|
||
background: rgba(20,14,30,0.72); backdrop-filter: blur(8px);
|
||
border: 1.5px solid rgba(255,255,255,0.18); color: rgba(255,255,255,0.75);
|
||
font-family: 'Manrope',sans-serif; font-size: 0.75rem; font-weight: 700;
|
||
cursor: pointer; transition: all .15s; user-select: none;
|
||
min-width: 44px; min-height: 44px; box-shadow: 0 4px 20px rgba(0,0,0,0.45);
|
||
}
|
||
#cr-fs-exit-overlay:hover,
|
||
#cr-fs-exit-overlay:active { background: rgba(155,93,229,0.35); border-color: #9B5DE5; color: #fff; }
|
||
:fullscreen #cr-fs-exit-overlay { display: flex; }
|
||
:-webkit-full-screen #cr-fs-exit-overlay { display: flex; }
|
||
/* raised hands section in participants panel */
|
||
.cr-hands-section {
|
||
padding: 8px 8px 0; flex-shrink: 0;
|
||
}
|
||
.cr-hands-title {
|
||
font-family: 'Manrope',sans-serif; font-size: 0.7rem; font-weight: 700;
|
||
color: #FFB347; text-transform: uppercase; letter-spacing: .05em;
|
||
padding: 4px 2px 6px; display: flex; align-items: center; gap: 6px;
|
||
}
|
||
.cr-hands-title svg { width: 12px; height: 12px; }
|
||
.cr-hand-item {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 5px 10px; border-radius: 8px; background: rgba(255,179,71,0.08);
|
||
font-family: 'Manrope',sans-serif; font-size: 0.78rem; color: #FFB347;
|
||
font-weight: 600; margin-bottom: 3px;
|
||
}
|
||
.cr-hand-item svg { width: 13px; height: 13px; flex-shrink: 0; }
|
||
|
||
/* ── right panel ── */
|
||
.cr-right {
|
||
width: 310px; flex-shrink: 0;
|
||
background: linear-gradient(180deg, #0f1220 0%, #0c0e1a 100%);
|
||
border-left: 1px solid rgba(155,93,229,0.13);
|
||
display: flex; flex-direction: column;
|
||
transition: width 0.22s cubic-bezier(0.4,0,0.2,1);
|
||
}
|
||
.cr-right.collapsed { width: 36px; }
|
||
.cr-panel-content { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||
.cr-right.collapsed .cr-panel-tabs-inner,
|
||
.cr-right.collapsed .cr-panel-content { display: none; }
|
||
|
||
/* ── Panel header with tabs ── */
|
||
.cr-panel-tabs {
|
||
display: flex; flex-direction: row; align-items: center;
|
||
padding: 10px 10px 0; gap: 4px; flex-shrink: 0;
|
||
background: transparent;
|
||
}
|
||
.cr-panel-collapse-btn {
|
||
width: 26px; height: 26px; flex-shrink: 0; align-self: flex-start; margin-left: 2px; margin-top: 2px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
|
||
border-radius: 7px; cursor: pointer; color: rgba(255,255,255,0.4);
|
||
transition: color .15s, background .15s, border-color .15s;
|
||
}
|
||
.cr-panel-collapse-btn:hover { color: #9B5DE5; background: rgba(155,93,229,0.12); border-color: rgba(155,93,229,0.35); }
|
||
.cr-panel-collapse-btn svg { width: 12px; height: 12px; transition: transform 0.22s; transform: rotate(180deg); }
|
||
.cr-right.collapsed .cr-panel-collapse-btn { margin: 8px auto 0; }
|
||
.cr-right.collapsed .cr-panel-collapse-btn svg { transform: rotate(0deg); }
|
||
|
||
/* ── Pill tabs ── */
|
||
.cr-panel-tabs-inner {
|
||
display: flex; flex: 1; min-width: 0;
|
||
background: rgba(255,255,255,0.04);
|
||
border: 1px solid rgba(255,255,255,0.07);
|
||
border-radius: 10px; padding: 3px; gap: 2px;
|
||
}
|
||
.cr-tab {
|
||
flex: 1; padding: 6px 6px; font-family: 'Manrope',sans-serif;
|
||
font-size: 0.72rem; font-weight: 700; color: rgba(255,255,255,0.4);
|
||
background: transparent; border: none; cursor: pointer; border-radius: 7px;
|
||
transition: all .15s; display: flex; align-items: center; justify-content: center; gap: 4px;
|
||
white-space: nowrap;
|
||
}
|
||
.cr-tab:hover { color: rgba(255,255,255,0.75); background: rgba(255,255,255,0.05); }
|
||
.cr-tab.active {
|
||
color: #e8e0f7; background: rgba(155,93,229,0.22);
|
||
box-shadow: 0 1px 6px rgba(155,93,229,0.18);
|
||
}
|
||
.cr-tab svg, .cr-tab i { width: 12px; height: 12px; flex-shrink: 0; }
|
||
.cr-tab-badge {
|
||
background: #F15BB5; color: #fff; border-radius: 99px;
|
||
font-size: 0.6rem; font-weight: 800; padding: 1px 4px; min-width: 15px; text-align: center;
|
||
line-height: 1.4;
|
||
}
|
||
/* 4-tab compact mode: hide labels, icons only */
|
||
.cr-panel-tabs-inner.tabs-4 .cr-tab-label { display: none; }
|
||
.cr-panel-tabs-inner.tabs-4 .cr-tab { padding: 7px 6px; }
|
||
.cr-panel-tabs-inner.tabs-4 .cr-tab svg,
|
||
.cr-panel-tabs-inner.tabs-4 .cr-tab i { width: 15px; height: 15px; }
|
||
/* divider below tabs */
|
||
.cr-panel-tabs::after {
|
||
content: ''; display: block; position: absolute; left: 0; right: 0;
|
||
height: 1px; background: rgba(255,255,255,0.05);
|
||
}
|
||
.cr-panel-tabs { position: relative; padding-bottom: 10px; }
|
||
|
||
/* ── Participants list ── */
|
||
.cr-participants {
|
||
flex: 1; overflow-y: auto; padding: 6px 8px;
|
||
display: flex; flex-direction: column; gap: 2px;
|
||
}
|
||
.cr-participants::-webkit-scrollbar { width: 3px; }
|
||
.cr-participants::-webkit-scrollbar-track { background: transparent; }
|
||
.cr-participants::-webkit-scrollbar-thumb { background: rgba(155,93,229,0.2); border-radius: 2px; }
|
||
|
||
.cr-participant {
|
||
display: flex; align-items: center; gap: 9px;
|
||
padding: 7px 10px; border-radius: 10px;
|
||
border: 1px solid transparent;
|
||
transition: background .15s, border-color .15s;
|
||
position: relative;
|
||
}
|
||
.cr-participant:hover { background: rgba(255,255,255,0.03); border-color: rgba(255,255,255,0.05); }
|
||
.cr-participant.speaking { background: rgba(6,214,160,0.05); border-color: rgba(6,214,160,0.18); }
|
||
|
||
/* avatar with online ring */
|
||
.cr-p-avatar-wrap { position: relative; flex-shrink: 0; }
|
||
.cr-p-avatar {
|
||
width: 32px; height: 32px; border-radius: 50%;
|
||
background: linear-gradient(135deg, #9B5DE5, #06D6E0);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 0.68rem; font-weight: 800; color: #fff;
|
||
font-family: 'Unbounded', sans-serif;
|
||
transition: box-shadow .15s;
|
||
}
|
||
.cr-p-online-dot {
|
||
position: absolute; bottom: 1px; right: 1px;
|
||
width: 8px; height: 8px; border-radius: 50%;
|
||
background: #22c55e; border: 2px solid #0c0e1a;
|
||
}
|
||
.cr-p-avatar.speaking {
|
||
box-shadow: 0 0 0 2.5px #06D6A0, 0 0 10px rgba(6,214,160,.4);
|
||
animation: speaking-pulse 0.8s ease-in-out infinite;
|
||
}
|
||
@keyframes speaking-pulse {
|
||
0%, 100% { box-shadow: 0 0 0 2px #06D6A0, 0 0 8px rgba(6,214,160,.35); }
|
||
50% { box-shadow: 0 0 0 3.5px #06D6A0, 0 0 14px rgba(6,214,160,.6); }
|
||
}
|
||
.cr-p-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||
.cr-p-name {
|
||
font-size: 0.8rem; font-weight: 600; color: #d1d5db;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.cr-p-sub { display: flex; align-items: center; gap: 4px; }
|
||
.cr-p-you {
|
||
font-size: 0.62rem; color: rgba(155,93,229,0.8); font-weight: 700;
|
||
background: rgba(155,93,229,0.1); border: 1px solid rgba(155,93,229,0.25);
|
||
border-radius: 4px; padding: 0px 4px;
|
||
}
|
||
.cr-p-role-tag {
|
||
font-size: 0.6rem; font-weight: 700; border-radius: 4px; padding: 0px 4px;
|
||
}
|
||
.cr-p-role-tag.teacher { color: #06D6A0; background: rgba(6,214,160,0.1); border: 1px solid rgba(6,214,160,0.2); }
|
||
.cr-p-role-tag.student { color: rgba(255,255,255,0.35); background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); }
|
||
.cr-p-status { display: flex; gap: 4px; align-items: center; flex-shrink: 0; }
|
||
.cr-p-status svg { width: 13px; height: 13px; }
|
||
.mic-on { color: #06D6A0; }
|
||
.mic-off { color: rgba(255,255,255,0.18); }
|
||
.cr-p-mute-btn {
|
||
background: none; border: none; cursor: pointer; padding: 2px; border-radius: 4px;
|
||
color: var(--text-3); display: flex; align-items: center; opacity: 0; transition: opacity .15s, color .15s;
|
||
}
|
||
.cr-participant:hover .cr-p-mute-btn { opacity: 1; }
|
||
|
||
/* ── Audio level bars ─────────────────────────────────────────────────── */
|
||
.cr-audio-bars {
|
||
display: none; align-items: flex-end; gap: 2px;
|
||
height: 14px; width: 16px; flex-shrink: 0;
|
||
}
|
||
.cr-participant.speaking .cr-audio-bars { display: flex; }
|
||
.cr-audio-bars span {
|
||
width: 3px; background: rgba(136,152,170,0.35); border-radius: 2px;
|
||
height: 2px; transition: height 0.1s ease, background 0.1s ease;
|
||
}
|
||
.cr-participant.speaking .cr-audio-bars span { background: #06D6A0; }
|
||
|
||
/* ── Floating speaker chip on board ──────────────────────────────────── */
|
||
.cr-speaker-chip {
|
||
position: absolute; bottom: 14px; left: 14px; z-index: 20;
|
||
display: flex; align-items: center; gap: 8px;
|
||
background: rgba(10,6,25,0.82); backdrop-filter: blur(12px);
|
||
border: 1px solid rgba(6,214,160,0.35); border-radius: 22px;
|
||
padding: 5px 13px 5px 5px;
|
||
pointer-events: none;
|
||
opacity: 0; transform: translateY(6px);
|
||
transition: opacity .22s ease, transform .22s ease;
|
||
}
|
||
.cr-speaker-chip.visible { opacity: 1; transform: translateY(0); }
|
||
.cr-speaker-chip-avatar {
|
||
width: 26px; height: 26px; border-radius: 50%; flex-shrink: 0;
|
||
background: linear-gradient(135deg, #9B5DE5, #06D6E0);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 0.6rem; font-weight: 800; color: #fff;
|
||
font-family: 'Unbounded', sans-serif;
|
||
box-shadow: 0 0 0 2px #06D6A0, 0 0 10px rgba(6,214,160,0.5);
|
||
animation: speaking-pulse 0.8s ease-in-out infinite;
|
||
}
|
||
.cr-speaker-chip-name {
|
||
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 600;
|
||
color: #e2e8f0; max-width: 160px; overflow: hidden;
|
||
text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
.cr-speaker-chip-wave {
|
||
display: flex; align-items: flex-end; gap: 2px; height: 14px;
|
||
}
|
||
.cr-speaker-chip-wave span {
|
||
width: 3px; background: #06D6A0; border-radius: 2px;
|
||
animation: wave-bar 0.7s ease-in-out infinite;
|
||
}
|
||
.cr-speaker-chip-wave span:nth-child(1) { animation-delay: 0s; }
|
||
.cr-speaker-chip-wave span:nth-child(2) { animation-delay: 0.12s; }
|
||
.cr-speaker-chip-wave span:nth-child(3) { animation-delay: 0.24s; }
|
||
@keyframes wave-bar {
|
||
0%, 100% { height: 3px; }
|
||
50% { height: 12px; }
|
||
}
|
||
/* Draw permission toggle — always visible for teacher */
|
||
.cr-p-draw-toggle {
|
||
display: flex; align-items: center; gap: 4px;
|
||
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
|
||
border-radius: 6px; padding: 2px 7px 2px 5px;
|
||
cursor: pointer; font-size: 0.67rem; font-weight: 600; color: var(--text-3);
|
||
transition: background .15s, border-color .15s, color .15s;
|
||
white-space: nowrap;
|
||
}
|
||
.cr-p-draw-toggle:hover { background: rgba(6,214,160,.12); border-color: rgba(6,214,160,.3); color: #06D6A0; }
|
||
.cr-p-draw-toggle.granted {
|
||
background: rgba(6,214,160,.15); border-color: rgba(6,214,160,.5); color: #06D6A0;
|
||
}
|
||
.cr-p-draw-toggle svg { flex-shrink: 0; }
|
||
/* Badge for student who can draw */
|
||
.cr-p-draw-badge {
|
||
display: inline-flex; align-items: center;
|
||
background: rgba(6,214,160,.15); border: 1px solid rgba(6,214,160,.4);
|
||
border-radius: 4px; padding: 1px 5px; font-size: 0.6rem; font-weight: 700; color: #06D6A0;
|
||
gap: 3px;
|
||
}
|
||
|
||
/* ── chat area ── */
|
||
.cr-chat-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||
.cr-messages {
|
||
flex: 1; overflow-y: auto; padding: 10px 10px 6px;
|
||
display: flex; flex-direction: column; gap: 4px;
|
||
}
|
||
.cr-messages::-webkit-scrollbar { width: 3px; }
|
||
.cr-messages::-webkit-scrollbar-track { background: transparent; }
|
||
.cr-messages::-webkit-scrollbar-thumb { background: rgba(155,93,229,0.2); border-radius: 2px; }
|
||
|
||
/* ── message row (wrapper for alignment) ── */
|
||
.cr-msg-row { display: flex; flex-direction: column; gap: 1px; }
|
||
.cr-msg-row.mine { align-items: flex-end; }
|
||
.cr-msg-row.theirs { align-items: flex-start; }
|
||
|
||
/* ── bubble ── */
|
||
.cr-msg {
|
||
display: flex; flex-direction: column; gap: 3px;
|
||
padding: 7px 10px; border-radius: 12px;
|
||
max-width: 88%; position: relative;
|
||
}
|
||
.cr-msg-row.mine .cr-msg {
|
||
background: rgba(155,93,229,0.22);
|
||
border: 1px solid rgba(155,93,229,0.3);
|
||
border-bottom-right-radius: 4px;
|
||
}
|
||
.cr-msg-row.theirs .cr-msg {
|
||
background: rgba(255,255,255,0.05);
|
||
border: 1px solid rgba(255,255,255,0.07);
|
||
border-bottom-left-radius: 4px;
|
||
}
|
||
.cr-msg-pinned {
|
||
background: rgba(155,93,229,.12) !important;
|
||
border-color: rgba(155,93,229,0.4) !important;
|
||
}
|
||
.cr-msg-header { display: flex; align-items: center; gap: 5px; }
|
||
.cr-msg-name { font-size: 0.7rem; font-weight: 700; color: #9B5DE5; }
|
||
.cr-msg-name.teacher-name { color: #06D6A0; }
|
||
.cr-msg-time { font-size: 0.63rem; color: rgba(255,255,255,0.28); flex: 1; }
|
||
.cr-msg-row.mine .cr-msg-time { text-align: right; }
|
||
.cr-msg-pin-badge { color: #9B5DE5; opacity: 0.8; display: flex; align-items: center; }
|
||
.cr-msg-pin-btn {
|
||
background: none; border: none; cursor: pointer; color: var(--text-3);
|
||
padding: 1px; border-radius: 4px; display: flex; align-items: center;
|
||
opacity: 0; transition: opacity .15s, color .15s;
|
||
}
|
||
.cr-msg:hover .cr-msg-pin-btn { opacity: 1; }
|
||
.cr-msg-pin-btn.active { color: #9B5DE5; opacity: 1; }
|
||
.cr-msg-pin-btn:hover { color: #9B5DE5; }
|
||
.cr-msg-text { font-size: 0.81rem; color: #d1d5db; line-height: 1.5; word-break: break-word; }
|
||
.cr-msg-row.mine .cr-msg-text { color: #e8e0f7; }
|
||
/* chat attachment */
|
||
.cr-msg-img { max-width: 100%; border-radius: 8px; margin-top: 4px; cursor: zoom-in; display: block; max-height: 200px; object-fit: contain; }
|
||
/* chat reactions */
|
||
.cr-msg-reactions { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 3px; }
|
||
.cr-msg-react-bar {
|
||
display: none; gap: 3px;
|
||
position: absolute; right: 4px; top: 4px;
|
||
background: rgba(20,14,40,0.95); border: 1px solid rgba(155,93,229,0.3);
|
||
border-radius: 8px; padding: 3px 5px;
|
||
}
|
||
.cr-msg:hover .cr-msg-react-bar { display: flex; }
|
||
.cr-msg { position: relative; }
|
||
.cr-react-pick {
|
||
background: none; border: none; cursor: pointer; padding: 3px 4px; border-radius: 5px;
|
||
color: rgba(255,255,255,0.45); font-size: 13px; line-height: 1;
|
||
transition: all .12s; display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.cr-react-pick:hover { background: rgba(155,93,229,0.2); color: #c4b5fd; }
|
||
.cr-react-chip {
|
||
display: inline-flex; align-items: center; gap: 3px;
|
||
padding: 1px 7px; border-radius: 99px; cursor: pointer;
|
||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
|
||
font-size: 0.72rem; color: rgba(255,255,255,0.6); transition: all .12s;
|
||
}
|
||
.cr-react-chip.mine { background: rgba(155,93,229,0.2); border-color: rgba(155,93,229,0.5); color: #c4b5fd; }
|
||
.cr-react-chip:hover { background: rgba(155,93,229,0.25); }
|
||
.cr-react-chip svg { flex-shrink: 0; }
|
||
/* chat attach preview */
|
||
.cr-chat-attach-preview {
|
||
display: none; align-items: center; gap: 8px;
|
||
padding: 6px 10px; background: rgba(155,93,229,0.1);
|
||
border: 1px solid rgba(155,93,229,0.3); border-radius: 8px; margin: 0 10px 4px;
|
||
}
|
||
.cr-chat-attach-preview.show { display: flex; }
|
||
.cr-attach-thumb { width: 40px; height: 40px; border-radius: 5px; object-fit: cover; }
|
||
.cr-attach-name { flex: 1; font-size: 0.72rem; color: rgba(255,255,255,0.6); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.cr-attach-rm { background: none; border: none; cursor: pointer; color: rgba(255,255,255,0.4); padding: 2px; border-radius: 4px; flex-shrink: 0; }
|
||
.cr-attach-rm:hover { color: #F15BB5; }
|
||
.cr-attach-btn {
|
||
width: 32px; height: 32px; border-radius: 8px; border: 1.5px solid rgba(255,255,255,0.1);
|
||
background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.45); cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all .12s;
|
||
}
|
||
.cr-attach-btn:hover { background: rgba(155,93,229,0.18); color: #c4b5fd; border-color: rgba(155,93,229,0.4); }
|
||
/* ── share library file button ── */
|
||
.cr-share-lib-btn {
|
||
width: 32px; height: 32px; border-radius: 8px; border: 1.5px solid rgba(255,255,255,0.1);
|
||
background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.45); cursor: pointer;
|
||
display: none; align-items: center; justify-content: center; flex-shrink: 0; transition: all .12s;
|
||
}
|
||
.cr-share-lib-btn:hover { background: rgba(6,214,224,0.14); color: #06D6E0; border-color: rgba(6,214,224,0.35); }
|
||
/* ── file picker modal ── */
|
||
.cr-file-picker-overlay {
|
||
position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,0.65);
|
||
display: flex; align-items: center; justify-content: center;
|
||
opacity: 0; pointer-events: none; transition: opacity .18s;
|
||
}
|
||
.cr-file-picker-overlay.open { opacity: 1; pointer-events: all; }
|
||
.cr-file-picker {
|
||
background: #1a1628; border: 1px solid rgba(155,93,229,0.25); border-radius: 16px;
|
||
padding: 0; width: 480px; max-width: calc(100vw - 24px); max-height: 70vh;
|
||
display: flex; flex-direction: column; overflow: hidden;
|
||
transform: translateY(14px); transition: transform .18s;
|
||
}
|
||
.cr-file-picker-overlay.open .cr-file-picker { transform: translateY(0); }
|
||
.cr-file-picker-head {
|
||
padding: 18px 20px 0; display: flex; align-items: center; gap: 10px; flex-shrink: 0;
|
||
}
|
||
.cr-file-picker-title { flex: 1; font-size: 0.92rem; font-weight: 700; color: #fff; }
|
||
.cr-file-picker-close {
|
||
background: none; border: none; cursor: pointer; color: rgba(255,255,255,0.4);
|
||
padding: 4px; border-radius: 6px; transition: color .12s; display: flex;
|
||
}
|
||
.cr-file-picker-close:hover { color: #fff; }
|
||
.cr-file-picker-search {
|
||
margin: 12px 20px 0; padding: 8px 12px; border-radius: 10px;
|
||
border: 1.5px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.04);
|
||
color: #fff; font-size: 0.78rem; width: calc(100% - 40px); box-sizing: border-box;
|
||
outline: none; transition: border-color .12s;
|
||
}
|
||
.cr-file-picker-search::placeholder { color: rgba(255,255,255,0.25); }
|
||
.cr-file-picker-search:focus { border-color: rgba(155,93,229,0.45); }
|
||
.cr-file-picker-list {
|
||
flex: 1; overflow-y: auto; padding: 10px 12px 14px;
|
||
display: flex; flex-direction: column; gap: 4px;
|
||
}
|
||
.cr-file-picker-list::-webkit-scrollbar { width: 4px; }
|
||
.cr-file-picker-list::-webkit-scrollbar-track { background: transparent; }
|
||
.cr-file-picker-list::-webkit-scrollbar-thumb { background: rgba(155,93,229,0.3); border-radius: 2px; }
|
||
.cr-file-picker-empty { text-align: center; color: rgba(255,255,255,0.3); font-size: 0.75rem; padding: 24px 0; }
|
||
.cr-file-item {
|
||
display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: 10px;
|
||
cursor: default; transition: background .12s;
|
||
}
|
||
.cr-file-item:hover { background: rgba(255,255,255,0.05); }
|
||
.cr-file-icon {
|
||
width: 34px; height: 34px; border-radius: 8px; flex-shrink: 0;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 0.58rem; font-weight: 900; letter-spacing: 0.02em; text-transform: uppercase;
|
||
}
|
||
.cr-file-icon.pdf { background: rgba(239,71,111,0.18); color: #EF476F; }
|
||
.cr-file-icon.img { background: rgba(6,214,160,0.18); color: #06D6A0; }
|
||
.cr-file-icon.doc { background: rgba(67,97,238,0.2); color: #7B9FFF; }
|
||
.cr-file-icon.xls { background: rgba(6,214,160,0.15); color: #06D6A0; }
|
||
.cr-file-icon.ppt { background: rgba(255,159,67,0.2); color: #FF9F43; }
|
||
.cr-file-icon.vid { background: rgba(155,93,229,0.2); color: #c4b5fd; }
|
||
.cr-file-icon.other { background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.45); }
|
||
.cr-file-info { flex: 1; min-width: 0; }
|
||
.cr-file-name { font-size: 0.78rem; font-weight: 600; color: rgba(255,255,255,0.88); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.cr-file-meta { font-size: 0.66rem; color: rgba(255,255,255,0.3); margin-top: 1px; }
|
||
.cr-file-pick-btn {
|
||
flex-shrink: 0; padding: 5px 12px; border-radius: 8px; font-size: 0.7rem; font-weight: 700;
|
||
border: 1.5px solid rgba(6,214,224,0.35); background: rgba(6,214,224,0.08); color: #06D6E0;
|
||
cursor: pointer; transition: all .12s; white-space: nowrap;
|
||
}
|
||
.cr-file-pick-btn:hover { background: rgba(6,214,224,0.18); }
|
||
/* ── library file message card ── */
|
||
.cr-msg-file-card {
|
||
display: flex; align-items: center; gap: 10px; margin-top: 4px;
|
||
padding: 9px 12px; border-radius: 10px; background: rgba(255,255,255,0.05);
|
||
border: 1px solid rgba(255,255,255,0.09); cursor: pointer; transition: background .12s;
|
||
max-width: 260px;
|
||
}
|
||
.cr-msg-file-card:hover { background: rgba(6,214,224,0.1); border-color: rgba(6,214,224,0.25); }
|
||
.cr-msg-file-card .cr-file-icon { width: 30px; height: 30px; border-radius: 7px; }
|
||
.cr-msg-file-card .cr-file-name { font-size: 0.74rem; }
|
||
.cr-msg-file-card .cr-file-meta { font-size: 0.62rem; }
|
||
.cr-msg-shared-label {
|
||
font-size: 0.6rem; color: rgba(6,214,224,0.55); font-weight: 600;
|
||
letter-spacing: 0.04em; text-transform: uppercase; margin-top: 3px;
|
||
}
|
||
/* ── notes panel ── */
|
||
.cr-notes-panel { flex: 1; display: flex; flex-direction: column; padding: 10px; gap: 8px; overflow: hidden; }
|
||
.cr-notes-header { display: flex; align-items: center; justify-content: space-between; padding: 0 2px; }
|
||
.cr-notes-label {
|
||
font-size: 0.7rem; color: rgba(255,255,255,0.3); display: flex; align-items: center; gap: 5px;
|
||
}
|
||
.cr-notes-label svg { width: 12px; height: 12px; color: rgba(155,93,229,0.5); }
|
||
.cr-notes-status { font-size: 0.65rem; color: rgba(6,214,224,0.45); display: flex; align-items: center; gap: 3px; }
|
||
.cr-notes-wordcount { font-size: 0.63rem; color: rgba(255,255,255,0.2); }
|
||
.cr-notes-ta {
|
||
flex: 1; resize: none; outline: none;
|
||
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
|
||
border-radius: 12px; padding: 12px 13px; color: #e8e0f7;
|
||
font-size: 0.82rem; font-family: 'Manrope',sans-serif; line-height: 1.6;
|
||
caret-color: #9B5DE5; transition: border-color .15s, background .15s;
|
||
scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.2) transparent;
|
||
}
|
||
.cr-notes-ta:focus {
|
||
border-color: rgba(155,93,229,0.35);
|
||
background: rgba(155,93,229,0.04);
|
||
}
|
||
.cr-notes-ta::placeholder { color: rgba(255,255,255,0.15); }
|
||
/* templates modal */
|
||
.cr-tpl-modal-overlay {
|
||
display: none; position: fixed; inset: 0; z-index: 200; background: rgba(0,0,0,0.7);
|
||
align-items: center; justify-content: center;
|
||
}
|
||
.cr-tpl-modal-overlay.open { display: flex; }
|
||
.cr-tpl-modal {
|
||
background: #15102a; border: 1.5px solid rgba(155,93,229,0.35);
|
||
border-radius: 16px; padding: 20px; width: min(480px, calc(100vw - 24px));
|
||
max-height: 80vh; display: flex; flex-direction: column; gap: 12px;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.7);
|
||
}
|
||
.cr-tpl-hdr { display: flex; align-items: center; justify-content: space-between; }
|
||
.cr-tpl-title { font-family:'Unbounded',sans-serif; font-size:0.82rem; font-weight:800; color:#fff; }
|
||
.cr-tpl-close { background:none;border:none;cursor:pointer;color:rgba(255,255,255,0.4);font-size:18px;padding:0 4px; }
|
||
.cr-tpl-list { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:6px; min-height:80px; }
|
||
.cr-tpl-item {
|
||
display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:8px;
|
||
background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);cursor:pointer;
|
||
transition:all .12s;
|
||
}
|
||
.cr-tpl-item:hover { background:rgba(155,93,229,0.12);border-color:rgba(155,93,229,0.35); }
|
||
.cr-tpl-item-name { flex:1;font-size:0.82rem;color:#e8e0f7; }
|
||
.cr-tpl-item-meta { font-size:0.68rem;color:rgba(255,255,255,0.3); }
|
||
.cr-tpl-item-del { background:none;border:none;cursor:pointer;color:rgba(255,255,255,0.2);padding:2px;border-radius:4px; }
|
||
.cr-tpl-item-del:hover { color:#F15BB5; }
|
||
.cr-tpl-save-area { display:flex;gap:8px;border-top:1px solid rgba(255,255,255,0.08);padding-top:12px; }
|
||
.cr-tpl-name-input {
|
||
flex:1;background:rgba(255,255,255,0.06);border:1.5px solid rgba(255,255,255,0.12);border-radius:8px;
|
||
padding:7px 10px;color:#e8e0f7;font-size:0.82rem;font-family:'Manrope',sans-serif;outline:none;
|
||
}
|
||
.cr-tpl-name-input:focus { border-color:rgba(155,93,229,0.6); }
|
||
.cr-tpl-save-btn {
|
||
padding:7px 16px;border-radius:8px;border:none;background:linear-gradient(135deg,#9B5DE5,#7B3FC5);
|
||
color:#fff;font-size:0.8rem;font-weight:700;font-family:'Manrope',sans-serif;cursor:pointer;
|
||
white-space:nowrap;transition:all .15s;
|
||
}
|
||
.cr-tpl-save-btn:hover { transform:translateY(-1px);box-shadow:0 4px 14px rgba(155,93,229,0.4); }
|
||
.cr-tpl-empty { font-size:0.8rem;color:rgba(255,255,255,0.25);text-align:center;padding:16px; }
|
||
|
||
.cr-chat-input-wrap {
|
||
padding: 8px 10px 10px; border-top: 1px solid rgba(255,255,255,0.05);
|
||
background: rgba(0,0,0,0.15);
|
||
display: flex; flex-direction: column; gap: 6px; flex-shrink: 0;
|
||
}
|
||
.cr-chat-input-row { display: flex; gap: 6px; align-items: flex-end; }
|
||
.cr-chat-input {
|
||
flex: 1; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09);
|
||
border-radius: 12px; padding: 8px 12px; color: #fff; font-family: 'Manrope',sans-serif;
|
||
font-size: 0.82rem; resize: none; outline: none; min-height: 36px; max-height: 90px;
|
||
transition: border-color .15s, background .15s; line-height: 1.45;
|
||
}
|
||
.cr-chat-input:focus {
|
||
border-color: rgba(155,93,229,0.45);
|
||
background: rgba(155,93,229,0.04);
|
||
}
|
||
.cr-chat-input::placeholder { color: rgba(255,255,255,0.25); }
|
||
.cr-chat-send {
|
||
width: 34px; height: 34px; border-radius: 10px;
|
||
background: linear-gradient(135deg, #9B5DE5, #7B3FC5);
|
||
border: none; cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: #fff; transition: all .15s; flex-shrink: 0;
|
||
box-shadow: 0 2px 10px rgba(155,93,229,0.3);
|
||
}
|
||
.cr-chat-send:hover { transform: translateY(-1px); box-shadow: 0 4px 14px rgba(155,93,229,0.5); }
|
||
.cr-chat-send:active { transform: translateY(0); }
|
||
.cr-chat-send svg { width: 14px; height: 14px; }
|
||
|
||
/* no session placeholder in chat */
|
||
.cr-no-session {
|
||
flex: 1; display: flex; flex-direction: column;
|
||
align-items: center; justify-content: center; gap: 10px;
|
||
padding: 20px; text-align: center;
|
||
}
|
||
.cr-no-session svg { width: 32px; height: 32px; stroke: #8898AA; }
|
||
.cr-no-session p { font-size: 0.78rem; color: var(--text-3); line-height: 1.5; margin: 0; }
|
||
|
||
/* ── Modal: start session ── */
|
||
.cr-modal-overlay {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
|
||
display: flex; align-items: center; justify-content: center;
|
||
z-index: 1000; opacity: 0; pointer-events: none; transition: opacity .2s;
|
||
}
|
||
.cr-modal-overlay.open { opacity: 1; pointer-events: all; }
|
||
.cr-modal {
|
||
background: #1a1f2e; border-radius: 18px;
|
||
border: 1.5px solid rgba(255,255,255,0.1);
|
||
padding: 28px; width: 440px; max-width: 90vw;
|
||
transform: translateY(10px); transition: transform .2s;
|
||
}
|
||
.cr-modal-overlay.open .cr-modal { transform: translateY(0); }
|
||
.cr-modal-title {
|
||
font-family: 'Unbounded',sans-serif; font-size: 1rem; font-weight: 800;
|
||
color: #fff; margin-bottom: 6px;
|
||
}
|
||
.cr-modal-sub { font-size: 0.8rem; color: var(--text-3); margin-bottom: 20px; }
|
||
|
||
.cr-mode-tabs { display: flex; gap: 8px; margin-bottom: 16px; }
|
||
.cr-mode-tab {
|
||
flex: 1; padding: 9px; border-radius: 10px;
|
||
border: 1.5px solid rgba(255,255,255,0.1); background: transparent;
|
||
color: var(--text-3); font-family: 'Manrope',sans-serif; font-size: 0.78rem;
|
||
font-weight: 700; cursor: pointer; transition: all .15s;
|
||
}
|
||
.cr-mode-tab.active {
|
||
border-color: #9B5DE5; background: rgba(155,93,229,0.12); color: #c4b5fd;
|
||
}
|
||
|
||
.cr-field { margin-bottom: 14px; }
|
||
.cr-label { font-size: 0.75rem; font-weight: 700; color: var(--text-3); margin-bottom: 6px; display: block; }
|
||
.cr-input {
|
||
width: 100%; background: rgba(255,255,255,0.06);
|
||
border: 1.5px solid rgba(255,255,255,0.1); border-radius: 10px;
|
||
padding: 10px 12px; color: #fff; font-family: 'Manrope',sans-serif;
|
||
font-size: 0.85rem; outline: none; transition: border-color .15s; box-sizing: border-box;
|
||
}
|
||
.cr-input:focus { border-color: rgba(155,93,229,0.5); }
|
||
.cr-select {
|
||
width: 100%; background: rgba(255,255,255,0.06);
|
||
border: 1.5px solid rgba(255,255,255,0.1); border-radius: 10px;
|
||
padding: 10px 12px; color: #fff; font-family: 'Manrope',sans-serif;
|
||
font-size: 0.85rem; outline: none; cursor: pointer; appearance: none;
|
||
transition: border-color .15s;
|
||
}
|
||
.cr-select:focus { border-color: rgba(155,93,229,0.5); }
|
||
.cr-select option { background: #1a1f2e; }
|
||
|
||
/* online students list */
|
||
.cr-online-list {
|
||
max-height: 200px; overflow-y: auto; border: 1.5px solid rgba(255,255,255,0.1);
|
||
border-radius: 10px; background: rgba(255,255,255,0.03);
|
||
}
|
||
.cr-online-item {
|
||
display: flex; align-items: center; gap: 10px; padding: 9px 12px;
|
||
cursor: pointer; transition: background .12s;
|
||
}
|
||
.cr-online-item:hover { background: rgba(155,93,229,0.12); }
|
||
.cr-online-item.selected { background: rgba(155,93,229,0.18); }
|
||
.cr-online-dot {
|
||
width: 7px; height: 7px; border-radius: 50%; background: #22c55e; flex-shrink: 0;
|
||
}
|
||
.cr-online-avatar {
|
||
width: 28px; height: 28px; border-radius: 50%;
|
||
background: linear-gradient(135deg, #9B5DE5, #06D6E0);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 0.62rem; font-weight: 800; color: #fff; flex-shrink: 0;
|
||
}
|
||
.cr-online-name { font-size: 0.83rem; color: #d1d5db; flex: 1; }
|
||
.cr-online-check {
|
||
width: 16px; height: 16px; border-radius: 4px;
|
||
border: 1.5px solid rgba(155,93,229,0.4); flex-shrink: 0; transition: all .12s;
|
||
}
|
||
.cr-online-item.selected .cr-online-check {
|
||
background: #9B5DE5; border-color: #9B5DE5;
|
||
}
|
||
.cr-online-empty { padding: 16px 12px; text-align: center; font-size: .8rem; color: #475569; }
|
||
.cr-online-refresh {
|
||
display: flex; align-items: center; gap: 6px; margin-bottom: 8px;
|
||
font-size: .72rem; font-weight: 700; color: var(--text-3); cursor: pointer;
|
||
background: none; border: none; padding: 0; font-family: 'Manrope',sans-serif;
|
||
}
|
||
.cr-online-refresh:hover { color: #c4b5fd; }
|
||
.cr-selected-users { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||
.cr-selected-user {
|
||
display: flex; align-items: center; gap: 5px;
|
||
padding: 4px 10px; border-radius: 99px;
|
||
background: rgba(155,93,229,0.15); border: 1.5px solid rgba(155,93,229,0.3);
|
||
font-size: 0.75rem; font-weight: 600; color: #c4b5fd;
|
||
}
|
||
.cr-selected-user button {
|
||
background: none; border: none; cursor: pointer; color: #c4b5fd;
|
||
padding: 0; display: flex; align-items: center; opacity: .7;
|
||
}
|
||
.cr-selected-user button:hover { opacity: 1; }
|
||
.cr-selected-user button svg { width: 11px; height: 11px; }
|
||
|
||
.cr-modal-actions { display: flex; gap: 10px; margin-top: 20px; }
|
||
.cr-modal-cancel {
|
||
flex: 1; padding: 11px; border-radius: 10px;
|
||
border: 1.5px solid rgba(255,255,255,0.1); background: transparent;
|
||
color: var(--text-3); font-family: 'Manrope',sans-serif; font-size: 0.85rem;
|
||
font-weight: 700; cursor: pointer; transition: all .15s;
|
||
}
|
||
.cr-modal-cancel:hover { border-color: rgba(255,255,255,0.25); color: #fff; }
|
||
.cr-modal-confirm {
|
||
flex: 2; padding: 11px; border-radius: 10px;
|
||
background: linear-gradient(135deg, #9B5DE5, #7B3FC5);
|
||
border: none; color: #fff; font-family: 'Manrope',sans-serif;
|
||
font-size: 0.85rem; font-weight: 700; cursor: pointer; transition: all .15s;
|
||
}
|
||
.cr-modal-confirm:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(155,93,229,0.4); }
|
||
.cr-modal-confirm:disabled { opacity: .5; cursor: not-allowed; transform: none; }
|
||
|
||
/* ── guest link modal ── */
|
||
.cr-guest-link-wrap {
|
||
display: flex; align-items: center; gap: 8px;
|
||
background: rgba(255,255,255,0.04); border: 1px solid rgba(155,93,229,0.2);
|
||
border-radius: 10px; padding: 10px 12px; margin: 16px 0 8px;
|
||
}
|
||
.cr-guest-link-url {
|
||
flex: 1; font-size: 0.72rem; color: rgba(232,224,247,0.75);
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
font-family: 'Manrope', monospace;
|
||
}
|
||
.cr-guest-copy-btn {
|
||
flex-shrink: 0; padding: 5px 12px; border: 1px solid rgba(155,93,229,0.35);
|
||
border-radius: 7px; background: none; color: #c4a8f5; font-size: 0.7rem;
|
||
font-weight: 700; cursor: pointer; transition: all .15s; white-space: nowrap;
|
||
}
|
||
.cr-guest-copy-btn:hover { background: rgba(155,93,229,0.12); color: #fff; }
|
||
.cr-guest-copy-btn.copied { color: #06D6A0; border-color: rgba(6,214,160,0.4); }
|
||
.cr-guest-note { font-size: 0.7rem; color: rgba(255,255,255,0.3); line-height: 1.6; }
|
||
.cr-guest-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||
.cr-guest-create-btn {
|
||
flex: 1; padding: 10px; border: none; border-radius: 10px;
|
||
background: linear-gradient(135deg, #9B5DE5, #5e2fb5); color: #fff;
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.7rem; font-weight: 800;
|
||
cursor: pointer; transition: opacity .15s;
|
||
}
|
||
.cr-guest-create-btn:hover { opacity: .88; }
|
||
.cr-guest-revoke-btn {
|
||
padding: 10px 14px; border: 1px solid rgba(239,71,111,0.3); border-radius: 10px;
|
||
background: none; color: #EF476F; font-size: 0.72rem; font-weight: 700;
|
||
cursor: pointer; transition: all .15s; white-space: nowrap;
|
||
}
|
||
.cr-guest-revoke-btn:hover { background: rgba(239,71,111,0.08); border-color: rgba(239,71,111,0.55); }
|
||
/* guest badge in participants list */
|
||
.cr-p-guest-badge {
|
||
font-size: 0.55rem; font-weight: 800; padding: 1px 5px; border-radius: 4px;
|
||
background: rgba(155,93,229,0.15); color: #c4a8f5; border: 1px solid rgba(155,93,229,0.25);
|
||
text-transform: uppercase; letter-spacing: 0.04em; flex-shrink: 0;
|
||
}
|
||
|
||
/* join banner (student) */
|
||
.cr-join-banner {
|
||
background: linear-gradient(135deg, rgba(155,93,229,0.15), rgba(6,214,224,0.08));
|
||
border: 1.5px solid rgba(155,93,229,0.3); border-radius: 14px;
|
||
padding: 24px 28px; max-width: 380px; text-align: center;
|
||
display: flex; flex-direction: column; align-items: center; gap: 14px;
|
||
}
|
||
.cr-join-banner h3 {
|
||
font-family: 'Unbounded',sans-serif; font-size: 1rem; font-weight: 800;
|
||
color: #fff; margin: 0;
|
||
}
|
||
.cr-join-banner p { font-size: 0.82rem; color: var(--text-3); margin: 0; line-height: 1.6; }
|
||
.cr-join-btn {
|
||
padding: 11px 32px; border-radius: 99px;
|
||
background: linear-gradient(135deg, #9B5DE5, #7B3FC5);
|
||
border: none; color: #fff; font-family: 'Manrope',sans-serif;
|
||
font-size: 0.88rem; font-weight: 700; cursor: pointer; transition: all .2s;
|
||
}
|
||
.cr-join-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 20px rgba(155,93,229,0.4); }
|
||
|
||
/* ── Session timer ── */
|
||
.cr-timer {
|
||
font-family: 'Manrope', monospace; font-size: 0.72rem; font-weight: 700;
|
||
color: rgba(255,255,255,0.35); letter-spacing: .04em; padding: 0 2px;
|
||
}
|
||
/* ── Teacher badge in participants ── */
|
||
.cr-p-teacher {
|
||
font-size: 0.6rem; font-weight: 700; color: #06D6A0;
|
||
background: rgba(6,214,160,0.12); border-radius: 4px; padding: 1px 5px;
|
||
white-space: nowrap; flex-shrink: 0;
|
||
}
|
||
/* ── Follow teacher toggle (student) ── */
|
||
.cr-follow-btn {
|
||
display: flex; align-items: center; gap: 5px;
|
||
padding: 3px 10px; border-radius: 99px; font-family: 'Manrope',sans-serif;
|
||
font-size: 0.7rem; font-weight: 700; white-space: nowrap;
|
||
border: 1.5px solid rgba(255,255,255,0.1); background: transparent;
|
||
color: rgba(255,255,255,0.35); cursor: pointer; transition: all .15s; flex-shrink: 0;
|
||
}
|
||
.cr-follow-btn.active {
|
||
border-color: rgba(6,214,224,0.4); background: rgba(6,214,224,0.08); color: #06D6E0;
|
||
}
|
||
.cr-follow-btn svg { width: 11px; height: 11px; }
|
||
/* ── Custom color input ── */
|
||
.cr-color-custom {
|
||
width: 20px; height: 20px; border-radius: 50%; cursor: pointer; flex-shrink: 0;
|
||
border: 2px solid rgba(255,255,255,0.2); padding: 0; overflow: hidden;
|
||
background: none; transition: transform .12s;
|
||
}
|
||
.cr-color-custom:hover { transform: scale(1.15); }
|
||
.cr-color-custom::-webkit-color-swatch-wrapper { padding: 0; }
|
||
.cr-color-custom::-webkit-color-swatch { border: none; border-radius: 50%; }
|
||
/* ── Student minimal nav ── */
|
||
.cr-student-nav {
|
||
flex-shrink: 0; height: 44px; background: #12161f;
|
||
border-top: 1.5px solid rgba(255,255,255,0.06);
|
||
display: flex; align-items: center; gap: 4px; padding: 0 12px;
|
||
}
|
||
|
||
/* ── Phase 4: screen share video overlay ── */
|
||
#cr-screen-video {
|
||
position: absolute; inset: 0; width: 100%; height: 100%;
|
||
object-fit: contain; background: #0e1117; display: none; z-index: 2;
|
||
}
|
||
.cr-screen-label {
|
||
position: absolute; top: 10px; left: 50%; transform: translateX(-50%);
|
||
background: rgba(155,93,229,0.25); border: 1.5px solid rgba(155,93,229,0.4);
|
||
border-radius: 99px; padding: 4px 14px;
|
||
font-family: 'Manrope',sans-serif; font-size: 0.72rem; color: #c4b5fd;
|
||
pointer-events: none; white-space: nowrap; z-index: 3; display: none;
|
||
}
|
||
/* header button states */
|
||
.cr-header-btn.cr-btn-sharing {
|
||
background: rgba(155,93,229,0.18); border-color: rgba(155,93,229,0.5); color: #c4b5fd;
|
||
}
|
||
/* ── screen picker modal v2 (Discord-style with live preview) ── */
|
||
.cr-screen-picker-overlay {
|
||
position: fixed; inset: 0; z-index: 10000;
|
||
background: rgba(0,0,0,0.75); backdrop-filter: blur(8px);
|
||
display: flex; align-items: center; justify-content: center;
|
||
opacity: 0; pointer-events: none; transition: opacity .18s;
|
||
}
|
||
.cr-screen-picker-overlay.open { opacity: 1; pointer-events: all; }
|
||
.cr-screen-picker {
|
||
background: #18131f; border: 1px solid rgba(155,93,229,0.22);
|
||
border-radius: 20px; width: 580px; max-width: calc(100vw - 24px);
|
||
max-height: 92vh; overflow-y: auto; overflow-x: hidden;
|
||
transform: scale(.95) translateY(16px);
|
||
transition: transform .24s cubic-bezier(.34,1.35,.64,1);
|
||
box-shadow: 0 40px 100px rgba(0,0,0,0.65), 0 0 0 1px rgba(255,255,255,0.04);
|
||
}
|
||
.cr-screen-picker::-webkit-scrollbar { width: 4px; }
|
||
.cr-screen-picker::-webkit-scrollbar-track { background: transparent; }
|
||
.cr-screen-picker::-webkit-scrollbar-thumb { background: rgba(155,93,229,0.3); border-radius: 2px; }
|
||
.cr-screen-picker-overlay.open .cr-screen-picker { transform: scale(1) translateY(0); }
|
||
/* header */
|
||
.cr-sp-head { padding: 20px 22px 0; display: flex; align-items: center; gap: 12px; }
|
||
.cr-sp-icon {
|
||
width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0;
|
||
background: rgba(155,93,229,0.2); display: flex; align-items: center; justify-content: center; color: #c4b5fd;
|
||
}
|
||
.cr-sp-titles { flex: 1; }
|
||
.cr-sp-title { font-size: 0.96rem; font-weight: 800; color: #fff; line-height: 1.2; }
|
||
.cr-sp-sub { font-size: 0.68rem; color: rgba(255,255,255,0.32); margin-top: 2px; }
|
||
.cr-sp-close {
|
||
background: rgba(255,255,255,0.06); border: none; cursor: pointer;
|
||
color: rgba(255,255,255,0.4); width: 30px; height: 30px; border-radius: 8px;
|
||
display: flex; align-items: center; justify-content: center; transition: all .12s; flex-shrink: 0;
|
||
}
|
||
.cr-sp-close:hover { background: rgba(239,71,111,0.15); color: #EF476F; }
|
||
/* ── tab bar ── */
|
||
.cr-sp-tabs {
|
||
display: flex; margin: 16px 22px 0; gap: 4px;
|
||
background: rgba(255,255,255,0.04); border-radius: 12px; padding: 4px;
|
||
}
|
||
.cr-sp-tab {
|
||
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
|
||
padding: 8px 10px; border-radius: 9px; border: none; cursor: pointer;
|
||
font-size: 0.72rem; font-weight: 700; color: rgba(255,255,255,0.4);
|
||
background: none; transition: all .14s; white-space: nowrap;
|
||
}
|
||
.cr-sp-tab svg { flex-shrink: 0; transition: color .14s; }
|
||
.cr-sp-tab:hover { color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.05); }
|
||
.cr-sp-tab.active { background: rgba(155,93,229,0.2); color: #c4b5fd; }
|
||
.cr-sp-tab.active svg { color: #9B5DE5; }
|
||
/* ── preview area ── */
|
||
.cr-sp-preview-wrap {
|
||
margin: 14px 22px 0; border-radius: 14px; overflow: hidden;
|
||
background: #0d0a12; border: 1px solid rgba(255,255,255,0.08);
|
||
position: relative;
|
||
}
|
||
.cr-sp-preview-wrap::before { content: ''; display: block; padding-top: 56.25%; } /* 16:9 */
|
||
.cr-sp-preview-inner {
|
||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||
}
|
||
/* empty state */
|
||
.cr-sp-empty {
|
||
display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px;
|
||
animation: crSpFadeIn .2s ease;
|
||
}
|
||
@keyframes crSpFadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||
.cr-sp-empty-icon {
|
||
width: 60px; height: 60px; border-radius: 16px;
|
||
background: rgba(155,93,229,0.1); border: 1px solid rgba(155,93,229,0.2);
|
||
display: flex; align-items: center; justify-content: center; color: rgba(155,93,229,0.6);
|
||
}
|
||
.cr-sp-empty-title { font-size: 0.82rem; font-weight: 700; color: rgba(255,255,255,0.5); }
|
||
.cr-sp-empty-hint { font-size: 0.66rem; color: rgba(255,255,255,0.25); text-align: center; max-width: 220px; line-height: 1.5; }
|
||
.cr-sp-pick-btn {
|
||
margin-top: 4px; padding: 9px 20px; border-radius: 10px; font-size: 0.76rem; font-weight: 800;
|
||
border: 1.5px solid rgba(155,93,229,0.45); background: rgba(155,93,229,0.12); color: #c4b5fd;
|
||
cursor: pointer; transition: all .14s; display: flex; align-items: center; gap: 7px;
|
||
}
|
||
.cr-sp-pick-btn:hover { background: rgba(155,93,229,0.22); border-color: rgba(155,93,229,0.7); color: #fff; }
|
||
/* live video preview */
|
||
.cr-sp-video-wrap {
|
||
position: absolute; inset: 0; display: none;
|
||
flex-direction: column; animation: crSpFadeIn .2s ease;
|
||
}
|
||
.cr-sp-video-wrap.visible { display: flex; }
|
||
.cr-sp-video {
|
||
width: 100%; height: 100%; object-fit: contain;
|
||
background: #000;
|
||
}
|
||
.cr-sp-preview-bar {
|
||
position: absolute; bottom: 0; left: 0; right: 0;
|
||
padding: 20px 14px 12px;
|
||
background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, transparent 100%);
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.cr-sp-src-icon-sm {
|
||
width: 22px; height: 22px; border-radius: 6px;
|
||
background: rgba(155,93,229,0.25); display: flex; align-items: center; justify-content: center;
|
||
color: #c4b5fd; flex-shrink: 0;
|
||
}
|
||
.cr-sp-preview-name {
|
||
flex: 1; font-size: 0.72rem; font-weight: 700; color: #fff;
|
||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||
}
|
||
.cr-sp-res-badge {
|
||
font-size: 0.58rem; font-weight: 800; padding: 2px 7px; border-radius: 5px;
|
||
background: rgba(155,93,229,0.3); color: #c4b5fd; flex-shrink: 0; letter-spacing: 0.02em;
|
||
}
|
||
.cr-sp-change-btn {
|
||
padding: 5px 12px; border-radius: 7px; font-size: 0.66rem; font-weight: 700;
|
||
border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.8);
|
||
cursor: pointer; flex-shrink: 0; transition: all .12s;
|
||
}
|
||
.cr-sp-change-btn:hover { background: rgba(255,255,255,0.18); color: #fff; }
|
||
/* ── divider ── */
|
||
.cr-sp-divider { margin: 14px 22px 0; border: none; border-top: 1px solid rgba(255,255,255,0.07); }
|
||
/* ── settings section ── */
|
||
.cr-sp-settings { padding: 14px 22px; display: flex; flex-direction: column; gap: 12px; }
|
||
.cr-sp-q-row { display: flex; align-items: center; gap: 10px; }
|
||
.cr-sp-q-label {
|
||
font-size: 0.64rem; font-weight: 800; color: rgba(255,255,255,0.35);
|
||
text-transform: uppercase; letter-spacing: 0.07em; width: 108px; flex-shrink: 0;
|
||
}
|
||
.cr-sp-q-opts { display: flex; gap: 5px; flex-wrap: wrap; }
|
||
.cr-sp-qbtn {
|
||
padding: 5px 13px; border-radius: 8px; font-size: 0.7rem; font-weight: 700;
|
||
border: 1.5px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.04);
|
||
color: rgba(255,255,255,0.45); cursor: pointer; transition: all .12s;
|
||
}
|
||
.cr-sp-qbtn:hover { border-color: rgba(155,93,229,0.4); color: rgba(255,255,255,0.75); }
|
||
.cr-sp-qbtn.active { border-color: rgba(155,93,229,0.65); background: rgba(155,93,229,0.16); color: #c4b5fd; }
|
||
/* ── option cards (2-col grid) ── */
|
||
.cr-sp-opts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||
.cr-sp-opt {
|
||
display: flex; align-items: center; gap: 9px; padding: 10px 12px; border-radius: 11px;
|
||
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.07);
|
||
cursor: pointer; transition: all .12s; user-select: none;
|
||
}
|
||
.cr-sp-opt:hover { background: rgba(255,255,255,0.06); border-color: rgba(255,255,255,0.12); }
|
||
.cr-sp-opt.on { background: rgba(6,214,224,0.07); border-color: rgba(6,214,224,0.25); }
|
||
.cr-sp-opt-ic { color: rgba(255,255,255,0.3); flex-shrink: 0; transition: color .12s; }
|
||
.cr-sp-opt.on .cr-sp-opt-ic { color: #06D6E0; }
|
||
.cr-sp-opt-text { flex: 1; min-width: 0; }
|
||
.cr-sp-opt-title { font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.6); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.cr-sp-opt.on .cr-sp-opt-title { color: rgba(255,255,255,0.85); }
|
||
.cr-sp-opt-hint { font-size: 0.58rem; color: rgba(255,255,255,0.25); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.cr-sp-toggle {
|
||
width: 34px; height: 18px; border-radius: 9px; border: none; cursor: pointer; flex-shrink: 0;
|
||
background: rgba(255,255,255,0.1); position: relative; transition: background .15s;
|
||
}
|
||
.cr-sp-toggle::after {
|
||
content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px;
|
||
border-radius: 50%; background: rgba(255,255,255,0.5); transition: transform .15s, background .15s;
|
||
}
|
||
.cr-sp-opt.on .cr-sp-toggle { background: #06D6E0; }
|
||
.cr-sp-opt.on .cr-sp-toggle::after { background: #fff; transform: translateX(16px); }
|
||
/* ── footer ── */
|
||
.cr-sp-footer {
|
||
padding: 12px 22px 18px; display: flex; align-items: center; justify-content: flex-end; gap: 10px;
|
||
border-top: 1px solid rgba(255,255,255,0.07);
|
||
}
|
||
.cr-sp-cancel {
|
||
padding: 9px 18px; border-radius: 10px; font-size: 0.76rem; font-weight: 700;
|
||
border: 1.5px solid rgba(255,255,255,0.1); background: none; color: rgba(255,255,255,0.45);
|
||
cursor: pointer; transition: all .12s;
|
||
}
|
||
.cr-sp-cancel:hover { border-color: rgba(255,255,255,0.25); color: rgba(255,255,255,0.75); }
|
||
.cr-sp-start {
|
||
padding: 9px 22px; border-radius: 10px; font-size: 0.76rem; font-weight: 800;
|
||
border: none; background: linear-gradient(135deg, #9B5DE5, #7B3DBF); color: #fff;
|
||
cursor: pointer; transition: all .15s; display: flex; align-items: center; gap: 8px;
|
||
box-shadow: 0 4px 14px rgba(155,93,229,0.35);
|
||
}
|
||
.cr-sp-start:hover { box-shadow: 0 6px 20px rgba(155,93,229,0.5); transform: translateY(-1px); }
|
||
.cr-sp-start:active { transform: translateY(0); }
|
||
.cr-sp-start:disabled { opacity: .4; cursor: not-allowed; transform: none; box-shadow: none; }
|
||
/* ── student status notification bar ── */
|
||
#cr-status-bar {
|
||
position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%);
|
||
z-index: 30; pointer-events: none;
|
||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||
}
|
||
.cr-status-msg {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 8px 16px; border-radius: 99px;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700;
|
||
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||
animation: cr-status-in .25s cubic-bezier(.34,1.4,.64,1) forwards;
|
||
white-space: nowrap;
|
||
}
|
||
.cr-status-msg.cr-status-out {
|
||
animation: cr-status-out .2s ease-in forwards;
|
||
}
|
||
.cr-status-msg svg { flex-shrink: 0; }
|
||
.cr-status-msg.type-success { background: rgba(6,214,160,.2); border: 1.5px solid rgba(6,214,160,.5); color: #06D6A0; }
|
||
.cr-status-msg.type-warn { background: rgba(241,91,181,.18); border: 1.5px solid rgba(241,91,181,.5); color: #F15BB5; }
|
||
.cr-status-msg.type-info { background: rgba(155,93,229,.2); border: 1.5px solid rgba(155,93,229,.5); color: #c4b5fd; }
|
||
@keyframes cr-status-in {
|
||
from { opacity: 0; transform: translateY(10px) scale(.92); }
|
||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||
}
|
||
@keyframes cr-status-out {
|
||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||
to { opacity: 0; transform: translateY(-6px) scale(.94); }
|
||
}
|
||
|
||
/* mic ON: green */
|
||
.cr-header-btn.cr-btn-mic-on {
|
||
background: rgba(6,214,160,.12); border-color: rgba(6,214,160,.5); color: #06D6A0;
|
||
}
|
||
.cr-header-btn.cr-btn-mic-on:hover {
|
||
background: rgba(6,214,160,.22); border-color: #06D6A0;
|
||
}
|
||
/* mic OFF: red */
|
||
.cr-header-btn.cr-btn-mic-off {
|
||
background: rgba(241,91,181,.12); border-color: rgba(241,91,181,.5); color: #F15BB5;
|
||
}
|
||
.cr-header-btn.cr-btn-mic-off:hover {
|
||
background: rgba(241,91,181,.22); border-color: #F15BB5;
|
||
}
|
||
/* per-participant mute button (teacher view) — pill style, always visible */
|
||
.cr-p-mute-btn {
|
||
display: flex; align-items: center; gap: 3px;
|
||
padding: 2px 7px 2px 5px; border-radius: 6px; border: 1px solid transparent;
|
||
font-family: 'Manrope',sans-serif; font-size: 0.65rem; font-weight: 700;
|
||
cursor: pointer; transition: all .15s; white-space: nowrap; flex-shrink: 0;
|
||
}
|
||
/* mic ON state for participant button */
|
||
.cr-p-mute-btn.mic-active {
|
||
background: rgba(6,214,160,.1); border-color: rgba(6,214,160,.35); color: #06D6A0;
|
||
}
|
||
.cr-p-mute-btn.mic-active:hover {
|
||
background: rgba(241,91,181,.15); border-color: rgba(241,91,181,.5); color: #F15BB5;
|
||
}
|
||
/* mic OFF state for participant button */
|
||
.cr-p-mute-btn.mic-muted {
|
||
background: rgba(241,91,181,.1); border-color: rgba(241,91,181,.35); color: #F15BB5;
|
||
opacity: 1 !important;
|
||
}
|
||
.cr-p-mute-btn.mic-muted:hover {
|
||
background: rgba(6,214,160,.1); border-color: rgba(6,214,160,.4); color: #06D6A0;
|
||
}
|
||
|
||
/* ── Quiz panel (teacher) ── */
|
||
.cr-quiz-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||
.cr-quiz-start-btn {
|
||
width: 100%; padding: 10px; border: none; border-radius: 10px;
|
||
background: linear-gradient(135deg, #06D6E0, #9B5DE5);
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.72rem; font-weight: 800;
|
||
color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 7px;
|
||
transition: transform 0.12s, box-shadow 0.12s;
|
||
}
|
||
.cr-quiz-start-btn:hover { transform: translateY(-1px); box-shadow: 0 5px 18px rgba(155,93,229,0.4); }
|
||
.cr-quiz-start-btn:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: none; }
|
||
.cr-quiz-status-bar {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 7px 10px; background: rgba(6,214,160,0.07); border: 1px solid rgba(6,214,160,0.18);
|
||
border-radius: 10px; margin-bottom: 8px;
|
||
}
|
||
.cr-quiz-status-dot { width: 6px; height: 6px; border-radius: 50%; background: #06D6A0; animation: pulse-dot 1.5s ease infinite; flex-shrink: 0; }
|
||
.cr-quiz-status-text { font-size: 0.7rem; font-weight: 700; color: #059652; }
|
||
.cr-quiz-end-btn { font-size: 0.66rem; font-weight: 700; color: #EF476F; background: none; border: none; cursor: pointer; padding: 3px 8px; border-radius: 6px; transition: background 0.12s; white-space: nowrap; flex-shrink: 0; }
|
||
.cr-quiz-end-btn:hover { background: rgba(239,71,111,0.12); }
|
||
.cr-quiz-active-card { background: rgba(155,93,229,0.08); border: 1px solid rgba(155,93,229,0.22); border-radius: 12px; padding: 10px 12px; margin-top: 8px; }
|
||
.cr-quiz-active-label { font-size: 0.6rem; font-weight: 800; color: #9B5DE5; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 5px; display: flex; align-items: center; gap: 5px; }
|
||
.cr-quiz-active-text { font-size: 0.8rem; font-weight: 600; color: #e8e0f7; line-height: 1.5; margin-bottom: 9px; }
|
||
.cr-quiz-counter-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||
.cr-quiz-counter-val { font-family: 'Unbounded', sans-serif; font-size: 0.96rem; font-weight: 900; color: #9B5DE5; flex-shrink: 0; }
|
||
.cr-quiz-counter-lbl { font-size: 0.68rem; color: rgba(255,255,255,0.35); font-weight: 600; }
|
||
.cr-quiz-counter-bar-wrap { flex: 1; height: 5px; background: rgba(155,93,229,0.14); border-radius: 99px; overflow: hidden; }
|
||
.cr-quiz-counter-bar { height: 100%; background: linear-gradient(90deg, #9B5DE5, #06D6E0); border-radius: 99px; transition: width 0.4s ease; }
|
||
.cr-quiz-show-results-btn {
|
||
width: 100%; padding: 7px; border: none; border-radius: 8px;
|
||
background: linear-gradient(135deg, #06D6E0, #9B5DE5);
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.66rem; font-weight: 800;
|
||
color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 5px; transition: transform 0.12s;
|
||
}
|
||
.cr-quiz-show-results-btn:hover { transform: translateY(-1px); }
|
||
.cr-quiz-result-stats { display: flex; flex-direction: column; gap: 5px; margin: 4px 0; overflow-y: auto; max-height: 200px; }
|
||
.cr-quiz-result-stat { flex: 1; padding: 5px 6px; border-radius: 7px; background: rgba(255,255,255,0.04); text-align: center; }
|
||
.cr-quiz-result-stat-val { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 900; color: #e8e0f7; }
|
||
.cr-quiz-result-stat-lbl { font-size: 0.56rem; color: rgba(255,255,255,0.3); font-weight: 700; text-transform: uppercase; }
|
||
.cr-quiz-result-stat.rs-ok .cr-quiz-result-stat-val { color: #06D6A0; }
|
||
.cr-quiz-result-stat.rs-bad .cr-quiz-result-stat-val { color: #EF476F; }
|
||
.cr-quiz-result-bars { display: flex; flex-direction: column; gap: 5px; }
|
||
.cr-quiz-result-row { display: flex; align-items: center; gap: 6px; }
|
||
.cr-quiz-result-key { width: 18px; height: 18px; border-radius: 5px; background: rgba(255,255,255,0.07); flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 0.58rem; font-weight: 800; color: rgba(255,255,255,0.4); }
|
||
.cr-quiz-result-key.correct { background: #06D6A0; color: #0e0824; }
|
||
.cr-quiz-result-track { flex: 1; height: 12px; background: rgba(255,255,255,0.05); border-radius: 99px; overflow: hidden; }
|
||
.cr-quiz-result-fill { height: 100%; border-radius: 99px; background: rgba(155,93,229,0.45); transition: width 0.5s ease; }
|
||
.cr-quiz-result-fill.correct-fill { background: linear-gradient(90deg, #06D6A0, #06D6E0); }
|
||
.cr-quiz-result-count { font-size: 0.64rem; font-weight: 800; color: rgba(255,255,255,0.5); min-width: 18px; text-align: right; }
|
||
.cr-quiz-filters { padding: 6px 10px 4px; display: flex; flex-direction: column; gap: 5px; }
|
||
.cr-quiz-search-wrap { position: relative; }
|
||
.cr-quiz-search-input { width: 100%; padding: 7px 10px 7px 28px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #e8e0f7; font-family: 'Manrope', sans-serif; font-size: 0.76rem; outline: none; box-sizing: border-box; transition: border-color 0.15s; }
|
||
.cr-quiz-search-input::placeholder { color: rgba(255,255,255,0.22); }
|
||
.cr-quiz-search-input:focus { border-color: rgba(155,93,229,0.5); }
|
||
.cr-quiz-search-icon { position: absolute; left: 8px; top: 50%; transform: translateY(-50%); width: 12px; height: 12px; color: rgba(255,255,255,0.22); pointer-events: none; }
|
||
.cr-quiz-filter-row { display: flex; gap: 5px; }
|
||
.cr-quiz-filter-sel { flex: 1; padding: 5px 7px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #e8e0f7; font-family: 'Manrope', sans-serif; font-size: 0.72rem; outline: none; cursor: pointer; appearance: none; }
|
||
.cr-quiz-filter-sel option { background: #1a0a3c; color: #e8e0f7; }
|
||
.cr-quiz-q-scroll { flex: 1; overflow-y: auto; padding: 2px 0; }
|
||
.cr-quiz-q-scroll::-webkit-scrollbar { width: 3px; }
|
||
.cr-quiz-q-scroll::-webkit-scrollbar-thumb { background: rgba(155,93,229,0.22); border-radius: 3px; }
|
||
.cr-quiz-q-item { display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px; border-radius: 9px; border: 1px solid transparent; margin: 1px 2px; transition: background 0.12s; }
|
||
.cr-quiz-q-item:hover { background: rgba(255,255,255,0.03); border-color: rgba(255,255,255,0.06); }
|
||
.cr-quiz-q-item.launched { background: rgba(6,214,160,0.06); border-color: rgba(6,214,160,0.18); }
|
||
.cr-quiz-q-body { flex: 1; min-width: 0; }
|
||
.cr-quiz-q-text { font-size: 0.76rem; font-weight: 600; color: rgba(232,224,247,0.85); line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||
.cr-quiz-q-meta { margin-top: 3px; display: flex; gap: 5px; flex-wrap: wrap; }
|
||
.cr-quiz-launch-btn { padding: 5px 9px; border: none; border-radius: 99px; background: rgba(155,93,229,0.16); color: #c4a8f5; font-family: 'Manrope', sans-serif; font-size: 0.68rem; font-weight: 800; cursor: pointer; flex-shrink: 0; transition: all 0.12s; margin-top: 1px; display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||
.cr-quiz-launch-btn:hover { background: rgba(155,93,229,0.32); color: #fff; }
|
||
.cr-quiz-launch-btn:disabled { opacity: 0.38; cursor: default; }
|
||
.cr-quiz-count { font-size: 0.62rem; color: rgba(255,255,255,0.22); padding: 2px 10px 3px; }
|
||
.cr-quiz-load-more { width: calc(100% - 20px); margin: 2px 10px 8px; padding: 7px; background: none; border: 1px dashed rgba(155,93,229,0.22); border-radius: 8px; color: rgba(155,93,229,0.65); font-size: 0.7rem; font-weight: 700; cursor: pointer; transition: all 0.12s; }
|
||
.cr-quiz-load-more:hover { border-color: rgba(155,93,229,0.45); color: #c4a8f5; }
|
||
.cr-quiz-load-more:disabled { opacity: 0.38; cursor: default; }
|
||
.cr-quiz-no-session-msg { text-align: center; padding: 28px 14px; color: rgba(255,255,255,0.22); font-size: 0.78rem; line-height: 1.7; }
|
||
|
||
/* ── Student quiz widget ── */
|
||
#cr-sq-widget { position: fixed; bottom: 24px; right: 24px; z-index: 7500; width: 310px;
|
||
background: #0e0824; border: 1.5px solid rgba(155,93,229,0.35); border-radius: 20px;
|
||
box-shadow: 0 16px 48px rgba(0,0,0,0.65); display: none; flex-direction: column; overflow: hidden; }
|
||
#cr-sq-widget.open { display: flex; animation: crSqIn .28s cubic-bezier(.34,1.56,.64,1); }
|
||
@keyframes crSqIn { from { opacity:0; transform:translateY(12px) scale(.96) } to { opacity:1; transform:none } }
|
||
#cr-sq-widget.sq-min .cr-sq-body { display: none; }
|
||
#cr-sq-widget.sq-min { border-bottom-left-radius: 20px; border-bottom-right-radius: 20px; }
|
||
.cr-sq-head { display: flex; align-items: center; justify-content: space-between; padding: 11px 14px 9px; border-bottom: 1px solid rgba(255,255,255,0.06); }
|
||
.cr-sq-badge { display: flex; align-items: center; gap: 7px; font-size: 0.68rem; font-weight: 800; color: #9B5DE5; text-transform: uppercase; letter-spacing: 0.06em; }
|
||
.cr-sq-badge-dot { width: 6px; height: 6px; border-radius: 50%; background: #9B5DE5; animation: pulse-dot 1.2s ease infinite; flex-shrink: 0; }
|
||
.cr-sq-collapse { background: none; border: none; color: rgba(255,255,255,0.35); cursor: pointer; padding: 4px; border-radius: 6px; transition: color .15s, background .15s; line-height: 1; }
|
||
.cr-sq-collapse:hover { color: rgba(255,255,255,0.75); background: rgba(255,255,255,0.07); }
|
||
.cr-sq-body { padding: 12px 14px 14px; overflow-y: auto; max-height: 380px; }
|
||
.cr-sq-q { font-size: 0.86rem; font-weight: 600; color: #e8e0f7; line-height: 1.6; margin-bottom: 12px; }
|
||
.cr-sq-opts { display: flex; flex-direction: column; gap: 7px; margin-bottom: 10px; }
|
||
.cr-sq-opt { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1.5px solid rgba(255,255,255,0.1); border-radius: 11px; cursor: pointer; transition: all .15s; background: transparent; }
|
||
.cr-sq-opt:hover { border-color: rgba(155,93,229,0.5); background: rgba(155,93,229,0.06); }
|
||
.cr-sq-opt.selected { border-color: #06D6E0; background: rgba(6,214,224,0.08); }
|
||
.cr-sq-opt.correct { border-color: #06D6A0 !important; background: rgba(6,214,160,0.1) !important; }
|
||
.cr-sq-opt.wrong { border-color: #EF476F !important; background: rgba(239,71,111,0.07) !important; }
|
||
.cr-sq-key { width: 26px; height: 26px; border-radius: 7px; background: rgba(255,255,255,0.07); flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 0.66rem; font-weight: 800; color: rgba(255,255,255,0.45); transition: all .15s; }
|
||
.cr-sq-opt.selected .cr-sq-key { background: #06D6E0; color: #0e0824; }
|
||
.cr-sq-opt.correct .cr-sq-key { background: #06D6A0; color: #0e0824; }
|
||
.cr-sq-opt.wrong .cr-sq-key { background: #EF476F; color: #fff; }
|
||
.cr-sq-opt-text { font-size: 0.8rem; color: #e8e0f7; flex: 1; line-height: 1.4; }
|
||
.cr-sq-status { text-align: center; font-size: 0.76rem; color: rgba(255,255,255,0.38); padding: 4px 0 0; }
|
||
.cr-sq-res-stats { display: flex; gap: 6px; margin-bottom: 9px; }
|
||
.cr-sq-res-stat { flex: 1; padding: 6px 7px; border-radius: 8px; background: rgba(255,255,255,0.04); text-align: center; }
|
||
.cr-sq-res-stat-val { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 900; color: #e8e0f7; }
|
||
.cr-sq-res-stat.ok .cr-sq-res-stat-val { color: #06D6A0; }
|
||
.cr-sq-res-stat.bad .cr-sq-res-stat-val { color: #EF476F; }
|
||
.cr-sq-res-stat-lbl { font-size: 0.56rem; color: rgba(255,255,255,0.28); font-weight: 700; text-transform: uppercase; }
|
||
.cr-sq-res-row { display: flex; align-items: center; gap: 7px; margin-bottom: 5px; }
|
||
.cr-sq-res-key { width: 20px; height: 20px; border-radius: 5px; background: rgba(255,255,255,0.07); flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 0.6rem; font-weight: 800; color: rgba(255,255,255,0.38); }
|
||
.cr-sq-res-key.ok { background: #06D6A0; color: #0e0824; }
|
||
.cr-sq-res-bar { flex: 1; height: 12px; background: rgba(255,255,255,0.06); border-radius: 99px; overflow: hidden; }
|
||
.cr-sq-res-fill { height: 100%; border-radius: 99px; background: rgba(155,93,229,0.45); transition: width .5s ease; }
|
||
.cr-sq-res-fill.ok { background: linear-gradient(90deg, #06D6A0, #06D6E0); }
|
||
.cr-sq-res-cnt { font-size: 0.64rem; font-weight: 800; color: rgba(255,255,255,0.45); min-width: 16px; text-align: right; }
|
||
|
||
@media (max-width: 768px) {
|
||
.cr-right { width: 100%; border-left: none; border-top: 1.5px solid rgba(255,255,255,0.06); max-height: 260px; }
|
||
.cr-body { flex-direction: column; }
|
||
.cr-main { min-height: 200px; }
|
||
}
|
||
|
||
/* ── Formula visual editor modal ────────────────────────────────────── */
|
||
.wbfm-overlay {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,0.75);
|
||
z-index: 190; cursor: pointer;
|
||
}
|
||
.wbfm-card {
|
||
position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%);
|
||
z-index: 191;
|
||
width: min(700px, calc(100vw - 24px));
|
||
background: #15102a;
|
||
border: 1.5px solid rgba(155,93,229,0.38);
|
||
border-radius: 18px;
|
||
box-shadow: 0 24px 70px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.04);
|
||
font-family: 'Manrope', sans-serif;
|
||
display: flex; flex-direction: column;
|
||
max-height: calc(100vh - 32px);
|
||
overflow: hidden;
|
||
}
|
||
/* header */
|
||
.wbfm-hdr {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 14px 18px 12px;
|
||
border-bottom: 1px solid rgba(255,255,255,0.07);
|
||
flex-shrink: 0;
|
||
}
|
||
.wbfm-title {
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800;
|
||
color: #fff; display: flex; align-items: center; gap: 8px;
|
||
}
|
||
.wbfm-close {
|
||
width: 28px; height: 28px; border-radius: 7px; border: none;
|
||
background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5);
|
||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||
transition: all .12s;
|
||
}
|
||
.wbfm-close:hover { background: rgba(241,91,181,0.15); color: #F15BB5; }
|
||
/* big preview */
|
||
.wbfm-preview {
|
||
min-height: 72px; max-height: 110px; overflow: auto;
|
||
background: rgba(255,255,255,0.05);
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
margin: 10px 16px 0;
|
||
border-radius: 12px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
padding: 10px 20px;
|
||
color: rgba(255,255,255,0.85);
|
||
font-size: 1.15rem;
|
||
flex-shrink: 0;
|
||
position: relative;
|
||
}
|
||
.wbfm-preview .katex { color: #fff; }
|
||
.wbfm-prev-hint {
|
||
position: absolute; bottom: 4px; right: 10px;
|
||
font-size: 0.67rem; color: rgba(255,255,255,0.18);
|
||
pointer-events: none;
|
||
}
|
||
/* category tabs */
|
||
.wbfm-tabs {
|
||
display: flex; gap: 2px;
|
||
padding: 8px 16px 0;
|
||
overflow-x: auto; flex-shrink: 0;
|
||
scrollbar-width: none;
|
||
}
|
||
.wbfm-tabs::-webkit-scrollbar { display: none; }
|
||
.wbfm-tab {
|
||
padding: 5px 13px; border-radius: 8px 8px 0 0;
|
||
border: 1px solid rgba(255,255,255,0.08); border-bottom: none;
|
||
background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.45);
|
||
font-size: 0.75rem; font-family: 'Manrope',sans-serif;
|
||
cursor: pointer; transition: all .13s; white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
.wbfm-tab:hover { background: rgba(155,93,229,0.14); color: rgba(255,255,255,0.75); }
|
||
.wbfm-tab.active {
|
||
background: rgba(155,93,229,0.22); color: #c4b5fd;
|
||
border-color: rgba(155,93,229,0.45); font-weight: 700;
|
||
}
|
||
/* button grid */
|
||
.wbfm-grid-wrap {
|
||
flex: 0 0 auto;
|
||
height: 210px;
|
||
overflow-y: auto;
|
||
padding: 8px 16px 6px;
|
||
border-top: 1px solid rgba(155,93,229,0.2);
|
||
scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.2) transparent;
|
||
}
|
||
.wbfm-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
|
||
gap: 5px;
|
||
}
|
||
.wbfm-vbtn {
|
||
height: 56px; border-radius: 9px;
|
||
border: 1.5px solid rgba(255,255,255,0.08);
|
||
background: rgba(255,255,255,0.04);
|
||
color: #e8e0f7; cursor: pointer;
|
||
transition: all .13s;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 0.95rem; padding: 4px 2px;
|
||
overflow: hidden; position: relative;
|
||
}
|
||
.wbfm-vbtn:hover {
|
||
background: rgba(155,93,229,0.2); border-color: rgba(155,93,229,0.55);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 14px rgba(155,93,229,0.25);
|
||
}
|
||
.wbfm-vbtn:active { transform: translateY(0); }
|
||
.wbfm-vbtn .katex { color: #e8e0f7; font-size: 1em; pointer-events: none; }
|
||
.wbfm-vbtn .katex-display { margin: 0 !important; }
|
||
/* LaTeX code toggle */
|
||
.wbfm-latex-toggle {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 6px 16px 4px; flex-shrink: 0;
|
||
}
|
||
.wbfm-lt-btn {
|
||
background: none; border: none;
|
||
color: rgba(255,255,255,0.32); font-size: 0.73rem;
|
||
font-family: 'Manrope',sans-serif; cursor: pointer;
|
||
display: flex; align-items: center; gap: 5px;
|
||
padding: 2px 4px; border-radius: 5px; transition: color .13s;
|
||
}
|
||
.wbfm-lt-btn:hover { color: rgba(255,255,255,0.6); }
|
||
.wbfm-lt-arrow { font-size: 0.65rem; transition: transform .2s; }
|
||
.wbfm-lt-arrow.open { transform: rotate(180deg); }
|
||
.wbfm-tab-hint {
|
||
font-size: 0.67rem; color: rgba(6,214,224,0.45); pointer-events: none;
|
||
}
|
||
.wbfm-latex-area {
|
||
padding: 0 16px 4px; flex-shrink: 0;
|
||
}
|
||
.wbfm-input {
|
||
background: rgba(255,255,255,0.05);
|
||
border: 1.5px solid rgba(155,93,229,0.4);
|
||
border-radius: 9px; outline: none;
|
||
color: #e8e0f7; caret-color: #9B5DE5;
|
||
font-size: 0.88rem; font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||
padding: 8px 12px; resize: none; width: 100%;
|
||
line-height: 1.55; box-sizing: border-box;
|
||
transition: border-color .15s;
|
||
}
|
||
.wbfm-input:focus { border-color: rgba(155,93,229,0.8); }
|
||
/* bottom bar */
|
||
.wbfm-bottom {
|
||
display: flex; align-items: center; gap: 0;
|
||
padding: 8px 16px 13px; flex-shrink: 0;
|
||
border-top: 1px solid rgba(255,255,255,0.07);
|
||
}
|
||
.wbfm-size-row {
|
||
display: flex; align-items: center; gap: 6px; flex: 1;
|
||
font-size: 0.75rem; color: rgba(255,255,255,0.35);
|
||
}
|
||
.wbfm-sz {
|
||
padding: 3px 10px; border-radius: 6px; border: 1.5px solid rgba(255,255,255,0.12);
|
||
background: transparent; color: rgba(255,255,255,0.4);
|
||
font-size: 0.75rem; cursor: pointer; transition: all .12s;
|
||
}
|
||
.wbfm-sz.active { border-color: rgba(155,93,229,0.6); background: rgba(155,93,229,0.15); color: #c084fc; }
|
||
.wbfm-actions { display: flex; gap: 8px; }
|
||
.wbfm-cancel {
|
||
padding: 8px 18px; border-radius: 8px; border: 1.5px solid rgba(255,255,255,0.12);
|
||
background: transparent; color: rgba(255,255,255,0.5);
|
||
font-size: 0.82rem; font-family: 'Manrope',sans-serif;
|
||
cursor: pointer; transition: all .12s;
|
||
}
|
||
.wbfm-cancel:hover { border-color: rgba(255,255,255,0.3); color: #fff; }
|
||
.wbfm-insert {
|
||
padding: 8px 22px; border-radius: 8px; border: none;
|
||
background: linear-gradient(135deg, #9B5DE5, #7B3FC5);
|
||
color: #fff; font-size: 0.85rem; font-weight: 700;
|
||
font-family: 'Manrope',sans-serif; cursor: pointer;
|
||
transition: all .15s; box-shadow: 0 3px 14px rgba(155,93,229,0.4);
|
||
}
|
||
.wbfm-insert:hover { transform: translateY(-1px); box-shadow: 0 5px 20px rgba(155,93,229,0.55); }
|
||
.wbfm-insert:disabled { opacity: 0.4; pointer-events: none; }
|
||
|
||
/* ── Confirm/Alert dialog ───────────────────────────────────────────── */
|
||
.cr-dlg-overlay {
|
||
position: fixed; inset: 0; z-index: 9000;
|
||
background: rgba(10,7,20,0.72); backdrop-filter: blur(6px);
|
||
display: flex; align-items: center; justify-content: center;
|
||
padding: 20px;
|
||
animation: crDlgFadeIn .15s ease;
|
||
}
|
||
@keyframes crDlgFadeIn { from { opacity:0 } to { opacity:1 } }
|
||
.cr-dlg {
|
||
background: linear-gradient(155deg, rgba(30,22,48,0.98), rgba(22,15,38,0.98));
|
||
border: 1.5px solid rgba(155,93,229,0.25); border-radius: 18px;
|
||
padding: 28px 28px 24px; max-width: 400px; width: 100%;
|
||
box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04);
|
||
animation: crDlgSlideIn .18s cubic-bezier(.34,1.56,.64,1);
|
||
display: flex; flex-direction: column; gap: 0;
|
||
}
|
||
@keyframes crDlgSlideIn { from { transform: scale(.92) translateY(10px); opacity:0 } to { transform: none; opacity:1 } }
|
||
.cr-dlg-icon {
|
||
width: 48px; height: 48px; border-radius: 14px; margin-bottom: 16px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.cr-dlg-icon.danger { background: rgba(241,91,181,0.15); border: 1.5px solid rgba(241,91,181,0.25); }
|
||
.cr-dlg-icon.warn { background: rgba(255,159,67,0.15); border: 1.5px solid rgba(255,159,67,0.25); }
|
||
.cr-dlg-icon.info { background: rgba(6,214,224,0.12); border: 1.5px solid rgba(6,214,224,0.22); }
|
||
.cr-dlg-title {
|
||
font-family: 'Unbounded',sans-serif; font-size: 0.95rem; font-weight: 800;
|
||
color: #fff; margin: 0 0 8px;
|
||
}
|
||
.cr-dlg-msg {
|
||
font-size: 0.83rem; color: var(--text-3); margin: 0 0 24px; line-height: 1.65;
|
||
}
|
||
.cr-dlg-actions { display: flex; gap: 10px; }
|
||
.cr-dlg-cancel {
|
||
flex: 1; padding: 10px; border-radius: 10px;
|
||
border: 1.5px solid rgba(255,255,255,0.1); background: transparent;
|
||
color: var(--text-3); font-family: 'Manrope',sans-serif; font-size: 0.85rem;
|
||
font-weight: 700; cursor: pointer; transition: all .15s;
|
||
}
|
||
.cr-dlg-cancel:hover { border-color: rgba(255,255,255,0.28); color: #fff; }
|
||
.cr-dlg-ok {
|
||
flex: 2; padding: 10px; border-radius: 10px; border: none;
|
||
color: #fff; font-family: 'Manrope',sans-serif;
|
||
font-size: 0.85rem; font-weight: 700; cursor: pointer; transition: all .15s;
|
||
}
|
||
.cr-dlg-ok.danger { background: linear-gradient(135deg, #F15BB5, #c43a94); }
|
||
.cr-dlg-ok.danger:hover { box-shadow: 0 4px 20px rgba(241,91,181,0.45); transform: translateY(-1px); }
|
||
.cr-dlg-ok.warn { background: linear-gradient(135deg, #FF9F43, #e07a20); }
|
||
.cr-dlg-ok.warn:hover { box-shadow: 0 4px 20px rgba(255,159,67,0.45); transform: translateY(-1px); }
|
||
.cr-dlg-ok.info { background: linear-gradient(135deg, #9B5DE5, #7B3FC5); }
|
||
.cr-dlg-ok.info:hover { box-shadow: 0 4px 20px rgba(155,93,229,0.45); transform: translateY(-1px); }
|
||
|
||
/* ── Settings side panel ───────────────────────────────────────────────── */
|
||
.cr-settings-overlay {
|
||
position: fixed; inset: 0; z-index: 3000;
|
||
background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);
|
||
display: flex; justify-content: flex-end;
|
||
opacity: 0; pointer-events: none;
|
||
transition: opacity .22s ease;
|
||
}
|
||
.cr-settings-overlay.open { opacity: 1; pointer-events: all; }
|
||
.cr-settings-panel {
|
||
width: 380px; max-width: 100vw;
|
||
background: #13101e; border-left: 1.5px solid rgba(155,93,229,0.25);
|
||
display: flex; flex-direction: column;
|
||
transform: translateX(30px);
|
||
transition: transform .22s ease;
|
||
overflow: hidden;
|
||
}
|
||
.cr-settings-overlay.open .cr-settings-panel { transform: translateX(0); }
|
||
.cr-sp-head {
|
||
padding: 18px 20px 12px; border-bottom: 1px solid rgba(255,255,255,0.07);
|
||
display: flex; align-items: center; gap: 12px; flex-shrink: 0;
|
||
}
|
||
.cr-sp-title {
|
||
flex: 1; font-family: 'Unbounded',sans-serif; font-size: 0.88rem;
|
||
font-weight: 800; color: #fff;
|
||
}
|
||
.cr-sp-close {
|
||
background: none; border: none; cursor: pointer; padding: 6px;
|
||
border-radius: 8px; color: var(--text-3); transition: color .15s, background .15s;
|
||
display: flex; align-items: center;
|
||
}
|
||
.cr-sp-close:hover { background: rgba(255,255,255,0.07); color: #fff; }
|
||
.cr-sp-tabs {
|
||
display: flex; border-bottom: 1px solid rgba(255,255,255,0.07); flex-shrink: 0;
|
||
}
|
||
.cr-sp-tab {
|
||
flex: 1; padding: 10px 6px; font-family: 'Manrope',sans-serif;
|
||
font-size: 0.7rem; font-weight: 700; color: var(--text-3);
|
||
background: none; border: none; cursor: pointer;
|
||
border-bottom: 2px solid transparent; transition: color .15s;
|
||
}
|
||
.cr-sp-tab.active { color: #9B5DE5; border-bottom-color: #9B5DE5; }
|
||
.cr-sp-body {
|
||
flex: 1; overflow-y: auto; padding: 20px;
|
||
scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.3) transparent;
|
||
}
|
||
.cr-sp-pane { display: none; flex-direction: column; gap: 22px; }
|
||
.cr-sp-pane.active { display: flex; }
|
||
.cr-sp-section { display: flex; flex-direction: column; gap: 10px; }
|
||
.cr-sp-section-label {
|
||
font-family: 'Manrope',sans-serif; font-size: 0.68rem; font-weight: 700;
|
||
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em;
|
||
}
|
||
.cr-sp-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||
.cr-sp-row-lbl { font-family: 'Manrope',sans-serif; font-size: 0.82rem; font-weight: 600; color: #c8d0db; flex: 1; line-height: 1.4; }
|
||
.cr-sp-row-sub { font-size: 0.69rem; color: var(--text-3); margin-top: 2px; }
|
||
/* Toggle */
|
||
.cr-toggle { position: relative; width: 38px; height: 22px; flex-shrink: 0; }
|
||
.cr-toggle input { opacity: 0; width: 0; height: 0; }
|
||
.cr-toggle-track {
|
||
position: absolute; inset: 0; cursor: pointer;
|
||
background: rgba(255,255,255,0.12); border-radius: 22px; transition: background .2s;
|
||
}
|
||
.cr-toggle input:checked + .cr-toggle-track { background: #9B5DE5; }
|
||
.cr-toggle-track::before {
|
||
content: ''; position: absolute; width: 16px; height: 16px;
|
||
left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: transform .2s;
|
||
}
|
||
.cr-toggle input:checked + .cr-toggle-track::before { transform: translateX(16px); }
|
||
/* Segmented control */
|
||
.cr-seg { display: flex; background: rgba(255,255,255,0.06); border-radius: 8px; overflow: hidden; }
|
||
.cr-seg button {
|
||
flex: 1; padding: 7px 8px; border: none; background: none; cursor: pointer;
|
||
font-family: 'Manrope',sans-serif; font-size: 0.72rem; font-weight: 700;
|
||
color: var(--text-3); transition: all .15s;
|
||
}
|
||
.cr-seg button.active { background: #9B5DE5; color: #fff; border-radius: 6px; }
|
||
/* Mic test */
|
||
.cr-mic-bar { height: 6px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; }
|
||
.cr-mic-fill { height: 100%; background: linear-gradient(90deg, #06D6A0, #9B5DE5); border-radius: 3px; width: 0%; transition: width 0.08s ease; }
|
||
.cr-sp-btn {
|
||
padding: 8px 16px; border-radius: 8px; border: 1px solid rgba(155,93,229,0.4);
|
||
background: rgba(155,93,229,0.1); color: #9B5DE5; font-family: 'Manrope',sans-serif;
|
||
font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: all .15s; align-self: flex-start;
|
||
}
|
||
.cr-sp-btn:hover { background: rgba(155,93,229,0.2); }
|
||
.cr-sp-btn.cyan { border-color: rgba(6,214,160,0.4); background: rgba(6,214,160,0.08); color: #06D6A0; }
|
||
.cr-sp-btn.cyan:hover { background: rgba(6,214,160,0.16); }
|
||
.cr-sp-btn.cyan.granted { opacity: 0.6; cursor: default; pointer-events: none; }
|
||
.cr-sp-note { font-family: 'Manrope',sans-serif; font-size: 0.72rem; color: var(--text-3); line-height: 1.5; }
|
||
/* Chat font size */
|
||
#cr-messages .cr-msg-text { font-size: 0.82rem; }
|
||
.cr-chat-fs-small #cr-messages .cr-msg-text { font-size: 0.74rem; }
|
||
.cr-chat-fs-large #cr-messages .cr-msg-text { font-size: 0.92rem; }
|
||
/* Left-hand mode: sidebar on left */
|
||
.cr-body.left-hand { flex-direction: row-reverse; }
|
||
|
||
/* ── Simulation panel ───────────────────────────────────────────────── */
|
||
.cr-sim-panel {
|
||
position: absolute; inset: 0; z-index: 40;
|
||
display: none; flex-direction: column;
|
||
background: #0A0814;
|
||
}
|
||
.cr-sim-panel.open { display: flex; }
|
||
.cr-sim-bar {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 0 14px; height: 40px; flex-shrink: 0;
|
||
background: rgba(14,10,28,0.97);
|
||
border-bottom: 1px solid rgba(155,93,229,0.2);
|
||
}
|
||
.cr-sim-bar-icon {
|
||
width: 22px; height: 22px; flex-shrink: 0;
|
||
background: linear-gradient(135deg, rgba(155,93,229,.35), rgba(6,214,224,.2));
|
||
border-radius: 6px; border: 1px solid rgba(155,93,229,0.3);
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.cr-sim-bar-icon svg { width: 12px; height: 12px; stroke: #9B5DE5; }
|
||
.cr-sim-bar-title { font-size: 0.82rem; font-weight: 700; color: var(--text-1, #f0e8ff); flex: 1; }
|
||
.cr-sim-bar-close {
|
||
width: 28px; height: 28px; border-radius: 7px; border: none;
|
||
background: rgba(255,255,255,0.06); color: var(--text-2, #aaa);
|
||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||
transition: background .15s;
|
||
}
|
||
.cr-sim-bar-close:hover { background: rgba(241,91,181,0.18); color: #F15BB5; }
|
||
.cr-sim-bar-close svg { width: 14px; height: 14px; }
|
||
/* Mode toggle (teacher only) */
|
||
.cr-sim-mode {
|
||
display: flex; gap: 3px; align-items: center; margin-right: 4px;
|
||
}
|
||
.cr-sim-mode-btn {
|
||
padding: 3px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1);
|
||
background: transparent; color: rgba(255,255,255,0.45);
|
||
font-family: 'Manrope',sans-serif; font-size: 0.7rem; font-weight: 700;
|
||
cursor: pointer; transition: all .15s;
|
||
}
|
||
.cr-sim-mode-btn:hover { color: rgba(255,255,255,0.7); }
|
||
.cr-sim-mode-btn.active { background: rgba(155,93,229,0.2); border-color: rgba(155,93,229,0.5); color: #d4b8ff; }
|
||
/* Student interaction blocker overlay (demo mode) */
|
||
.cr-sim-blocker {
|
||
position: absolute; inset: 40px 0 0 0; z-index: 6;
|
||
background: transparent; cursor: not-allowed;
|
||
display: none;
|
||
}
|
||
.cr-sim-blocker.active { display: block; }
|
||
.cr-sim-frame {
|
||
flex: 1; border: none; width: 100%; min-height: 0;
|
||
background: #0D0D1A;
|
||
}
|
||
|
||
/* ── Annotate-over-sim mode ───────────────────────────────────────────── */
|
||
/* Board floats above the sim panel (sim visible behind transparent canvas) */
|
||
.cr-board-area.annotate-active .cr-sim-panel,
|
||
.cr-board-area.annotate-active .cr-tb-panel { z-index: 1; }
|
||
.cr-board-area.annotate-active .cr-board-wrap {
|
||
z-index: 45;
|
||
background: transparent !important;
|
||
border-color: transparent !important;
|
||
box-shadow: none !important;
|
||
}
|
||
.cr-board-area.annotate-active .cr-board-wrap::before,
|
||
.cr-board-area.annotate-active .cr-board-wrap::after { display: none !important; }
|
||
/* Floating bar shown while annotating */
|
||
.cr-annotate-bar {
|
||
display: none;
|
||
position: absolute; top: 0; left: 0; right: 0; z-index: 60;
|
||
height: 40px; flex-shrink: 0;
|
||
background: rgba(10,6,22,0.92);
|
||
border-bottom: 1.5px solid rgba(241,91,181,0.4);
|
||
backdrop-filter: blur(6px);
|
||
align-items: center; gap: 8px; padding: 0 12px;
|
||
}
|
||
.cr-board-area.annotate-active .cr-annotate-bar { display: flex; }
|
||
.cr-annotate-bar-label {
|
||
font-size: 0.75rem; font-weight: 700; color: #F15BB5;
|
||
display: flex; align-items: center; gap: 5px; flex: 1;
|
||
}
|
||
.cr-annotate-bar-label svg { width: 14px; height: 14px; stroke: #F15BB5; }
|
||
.cr-annotate-exit {
|
||
padding: 4px 12px; border-radius: 7px; border: 1px solid rgba(241,91,181,0.4);
|
||
background: rgba(241,91,181,0.1); color: #F15BB5;
|
||
font-family: 'Manrope',sans-serif; font-size: 0.72rem; font-weight: 700;
|
||
cursor: pointer; transition: all .15s;
|
||
}
|
||
.cr-annotate-exit:hover { background: rgba(241,91,181,0.22); }
|
||
/* "Draw" button in sim bar */
|
||
.cr-sim-annotate-btn {
|
||
display: none;
|
||
padding: 3px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.12);
|
||
background: transparent; color: rgba(255,255,255,0.5);
|
||
font-family: 'Manrope',sans-serif; font-size: 0.7rem; font-weight: 700;
|
||
cursor: pointer; transition: all .15s; align-items: center; gap: 4px;
|
||
margin-right: 4px;
|
||
}
|
||
.cr-sim-annotate-btn svg { width: 12px; height: 12px; flex-shrink: 0; }
|
||
.cr-sim-annotate-btn:hover { color: rgba(255,255,255,0.8); border-color: rgba(241,91,181,0.4); }
|
||
.cr-sim-annotate-btn.active { background: rgba(241,91,181,0.18); border-color: rgba(241,91,181,0.5); color: #F15BB5; }
|
||
/* Show draw button only for teachers when sim is open */
|
||
.cr-sim-panel.open .cr-sim-annotate-btn.teacher-ctrl { display: flex; }
|
||
|
||
/* ── Simulation picker modal ────────────────────────────────────────── */
|
||
.cr-sim-picker-overlay {
|
||
position: fixed; inset: 0; z-index: 200;
|
||
background: rgba(0,0,0,0.72); backdrop-filter: blur(6px);
|
||
display: none; align-items: center; justify-content: center;
|
||
}
|
||
.cr-sim-picker-overlay.open { display: flex; }
|
||
.cr-sim-picker-modal {
|
||
background: #110a20; border-radius: 18px;
|
||
border: 1.5px solid rgba(155,93,229,0.25);
|
||
box-shadow: 0 24px 80px rgba(0,0,0,0.7);
|
||
width: min(720px, 96vw); max-height: 80vh;
|
||
display: flex; flex-direction: column; overflow: hidden;
|
||
}
|
||
.cr-sim-picker-head {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 20px 24px 0; flex-shrink: 0;
|
||
}
|
||
.cr-sim-picker-head h3 { flex: 1; font-family: 'Unbounded',sans-serif; font-size: 1.05rem; font-weight: 700; margin: 0; }
|
||
.cr-sim-picker-close {
|
||
width: 32px; height: 32px; border-radius: 9px; border: none;
|
||
background: rgba(255,255,255,0.06); color: var(--text-2, #aaa);
|
||
cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .15s;
|
||
}
|
||
.cr-sim-picker-close:hover { background: rgba(241,91,181,0.18); color: #F15BB5; }
|
||
.cr-sim-picker-close svg { width: 16px; height: 16px; }
|
||
.cr-sim-picker-cats {
|
||
display: flex; gap: 6px; padding: 14px 24px 10px; flex-shrink: 0; flex-wrap: wrap;
|
||
}
|
||
.cr-sim-cat-btn {
|
||
padding: 5px 14px; border-radius: 99px; border: 1.5px solid rgba(255,255,255,0.1);
|
||
background: transparent; color: rgba(255,255,255,0.55);
|
||
font-family: 'Manrope',sans-serif; font-size: 0.77rem; font-weight: 700;
|
||
cursor: pointer; transition: all .15s;
|
||
}
|
||
.cr-sim-cat-btn:hover { border-color: rgba(155,93,229,0.4); color: #c9b8ee; }
|
||
.cr-sim-cat-btn.active { background: rgba(155,93,229,0.15); border-color: rgba(155,93,229,0.5); color: #d4b8ff; }
|
||
.cr-sim-picker-body { flex: 1; overflow-y: auto; padding: 4px 24px 24px; scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.3) transparent; }
|
||
.cr-sim-picker-grid {
|
||
display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; margin-top: 10px;
|
||
}
|
||
.cr-sim-picker-card {
|
||
border-radius: 12px; border: 1.5px solid rgba(255,255,255,0.08);
|
||
background: rgba(255,255,255,0.04); padding: 14px 14px 12px;
|
||
cursor: pointer; transition: border-color .15s, background .15s, transform .15s;
|
||
display: flex; flex-direction: column; gap: 5px;
|
||
}
|
||
.cr-sim-picker-card:hover {
|
||
border-color: rgba(155,93,229,0.55); background: rgba(155,93,229,0.08);
|
||
transform: translateY(-1px);
|
||
}
|
||
.cr-sim-picker-card-title { font-size: 0.82rem; font-weight: 700; color: #e8e0f7; line-height: 1.3; }
|
||
.cr-sim-picker-card-cat {
|
||
font-size: 0.68rem; font-weight: 600;
|
||
padding: 2px 7px; border-radius: 99px; align-self: flex-start;
|
||
border: 1px solid transparent;
|
||
}
|
||
.cr-sim-picker-card-cat.math { background: rgba(155,93,229,0.15); border-color: rgba(155,93,229,0.3); color: #c9a0ff; }
|
||
.cr-sim-picker-card-cat.phys { background: rgba(6,214,224,0.1); border-color: rgba(6,214,224,0.3); color: #06D6E0; }
|
||
.cr-sim-picker-card-cat.chem { background: rgba(241,91,181,0.1); border-color: rgba(241,91,181,0.3); color: #F15BB5; }
|
||
.cr-sim-picker-card-cat.bio { background: rgba(168,224,99,0.1); border-color: rgba(168,224,99,0.3); color: #A8E063; }
|
||
.cr-sim-picker-card-cat.game { background: rgba(255,159,67,0.1); border-color: rgba(255,159,67,0.3); color: #FF9F43; }
|
||
|
||
/* ── Textbook panel (mirrors sim panel) ─────────────────────────────── */
|
||
.cr-tb-panel {
|
||
position: absolute; inset: 0; z-index: 5;
|
||
background: #fff; display: none; flex-direction: column;
|
||
border-left: 1px solid rgba(155,93,229,0.2);
|
||
}
|
||
.cr-tb-panel.open { display: flex; }
|
||
.cr-tb-bar {
|
||
display: flex; align-items: center; gap: 10px;
|
||
padding: 8px 14px; background: #1a0f2e; flex-shrink: 0;
|
||
border-bottom: 1px solid rgba(155,93,229,0.2);
|
||
}
|
||
.cr-tb-bar-icon {
|
||
width: 24px; height: 24px; border-radius: 7px;
|
||
background: rgba(6,214,224,0.15); border: 1px solid rgba(6,214,224,0.3);
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.cr-tb-bar-icon svg { width: 12px; height: 12px; stroke: #06D6E0; }
|
||
.cr-tb-bar-title { font-size: 0.82rem; font-weight: 700; color: #f0e8ff; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.cr-tb-bar-close {
|
||
width: 26px; height: 26px; border-radius: 7px; border: none;
|
||
background: rgba(241,91,181,0.1); color: rgba(255,255,255,0.7);
|
||
cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .15s, color .15s;
|
||
}
|
||
.cr-tb-bar-close:hover { background: rgba(241,91,181,0.18); color: #F15BB5; }
|
||
.cr-tb-bar-close svg { width: 14px; height: 14px; }
|
||
.cr-tb-mode {
|
||
display: flex; gap: 4px; padding: 3px; background: rgba(255,255,255,0.04);
|
||
border-radius: 99px; border: 1px solid rgba(255,255,255,0.06);
|
||
}
|
||
.cr-tb-mode-btn {
|
||
padding: 4px 12px; border-radius: 99px; border: 1.5px solid transparent;
|
||
background: transparent; color: rgba(255,255,255,0.5);
|
||
font-family: 'Manrope',sans-serif; font-size: 0.72rem; font-weight: 700; cursor: pointer; transition: all .15s;
|
||
}
|
||
.cr-tb-mode-btn:hover { color: rgba(255,255,255,0.7); }
|
||
.cr-tb-mode-btn.active { background: rgba(6,214,224,0.18); border-color: rgba(6,214,224,0.45); color: #06D6E0; }
|
||
.cr-tb-frame {
|
||
flex: 1; width: 100%; border: 0; background: #fff;
|
||
}
|
||
.cr-tb-blocker {
|
||
position: absolute; inset: 38px 0 0 0;
|
||
display: none; background: transparent; cursor: not-allowed; z-index: 10;
|
||
}
|
||
.cr-tb-blocker.active { display: block; }
|
||
|
||
/* Draw-over button (in tb-bar; only visible when textbook open) */
|
||
.cr-tb-annotate-btn {
|
||
display: none; padding: 5px 10px; border-radius: 99px;
|
||
border: 1.5px solid rgba(241,91,181,0.3); background: transparent;
|
||
color: rgba(255,255,255,0.7); font-family: 'Manrope',sans-serif;
|
||
font-size: 0.72rem; font-weight: 700; cursor: pointer;
|
||
transition: all .15s; align-items: center; gap: 4px; margin-right: 4px;
|
||
}
|
||
.cr-tb-annotate-btn svg { width: 12px; height: 12px; flex-shrink: 0; }
|
||
.cr-tb-annotate-btn:hover { color: rgba(255,255,255,0.85); border-color: rgba(241,91,181,0.55); }
|
||
.cr-tb-annotate-btn.active { background: rgba(241,91,181,0.18); border-color: rgba(241,91,181,0.55); color: #F15BB5; }
|
||
.cr-tb-panel.open .cr-tb-annotate-btn.teacher-ctrl { display: flex; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app-layout">
|
||
<aside class="sidebar" id="app-sidebar"></aside>
|
||
<div class="notif-drop" id="notif-drop"></div>
|
||
|
||
<div class="sb-content">
|
||
<!-- Header -->
|
||
<div class="cr-header">
|
||
<div class="cr-header-dots"></div>
|
||
<div class="cr-header-inner">
|
||
<div class="cr-title">
|
||
<i data-lucide="presentation" style="width:18px;height:18px;opacity:.6;flex-shrink:0"></i>
|
||
<span id="cr-session-title">Онлайн-урок</span>
|
||
</div>
|
||
<div id="cr-session-chip" style="display:none" class="cr-session-chip">
|
||
<span class="dot"></span> Идёт урок
|
||
</div>
|
||
<span class="cr-timer" id="cr-timer" style="display:none">00:00</span>
|
||
<div id="cr-header-actions" style="display:none; align-items:center; gap:8px;">
|
||
<button class="cr-header-btn" id="cr-mute-btn" onclick="crToggleMute()" style="display:none">
|
||
</button>
|
||
<button class="cr-header-btn" id="cr-screen-btn" onclick="crToggleScreen()" style="display:none">
|
||
<i data-lucide="monitor" style="width:14px;height:14px"></i>
|
||
<span>Экран</span>
|
||
</button>
|
||
<button class="cr-header-btn" id="cr-sim-btn" onclick="crOpenSimPicker()" style="display:none" title="Открыть симуляцию">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"/></svg>
|
||
<span>Симуляция</span>
|
||
</button>
|
||
<button class="cr-header-btn" id="cr-tb-btn" onclick="crOpenTbPicker()" style="display:none" title="Открыть учебник">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||
<span>Учебник</span>
|
||
</button>
|
||
<!-- hand raise: student only -->
|
||
<button class="cr-hand-btn" id="cr-hand-btn" onclick="crToggleHand()" style="display:none">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M18 11V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0"/><path d="M14 10V4a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>
|
||
<span id="cr-hand-label">Поднять руку</span>
|
||
</button>
|
||
<!-- guest link button: teacher only -->
|
||
<button class="cr-header-btn" id="cr-guest-btn" onclick="crGuestOpen()" style="display:none" title="Гостевая ссылка">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||
<span>Гостям</span>
|
||
</button>
|
||
<button class="cr-header-btn danger" id="cr-end-btn" onclick="crEndSession()" style="display:none">
|
||
<i data-lucide="x" style="width:14px;height:14px"></i>
|
||
<span>Завершить</span>
|
||
</button>
|
||
<button class="cr-header-btn" id="cr-leave-btn" onclick="crLeaveSession()" style="display:none">
|
||
<i data-lucide="log-out" style="width:14px;height:14px"></i>
|
||
<span>Покинуть</span>
|
||
</button>
|
||
</div>
|
||
<button class="cr-header-btn" onclick="openSettings()" title="Настройки урока" style="margin-left:4px">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Body -->
|
||
<div class="cr-body" id="cr-body">
|
||
<!-- Main area -->
|
||
<div class="cr-main" id="cr-main">
|
||
<!-- idle state (teacher) -->
|
||
<div class="cr-idle" id="cr-idle-teacher" style="display:none">
|
||
<div class="cr-idle-icon">
|
||
<i data-lucide="presentation" style="width:36px;height:36px;stroke:#9B5DE5"></i>
|
||
</div>
|
||
<h2>Начните онлайн-урок</h2>
|
||
<p>Интерактивная доска, голосовой чат и трансляция экрана для вашего класса</p>
|
||
<button class="cr-start-btn" onclick="crOpenStartModal()">
|
||
<i data-lucide="play" style="width:16px;height:16px"></i>
|
||
Начать урок
|
||
</button>
|
||
</div>
|
||
|
||
<!-- idle state (student) -->
|
||
<div class="cr-idle" id="cr-idle-student" style="display:none">
|
||
<div class="cr-idle-icon">
|
||
<i data-lucide="clock" style="width:36px;height:36px;stroke:#06D6E0"></i>
|
||
</div>
|
||
<h2>Ожидание урока</h2>
|
||
<p>Когда учитель начнёт урок — вы получите уведомление</p>
|
||
</div>
|
||
|
||
<!-- join banner (student got notification) -->
|
||
<div id="cr-join-banner" style="display:none">
|
||
<div class="cr-join-banner">
|
||
<i data-lucide="presentation" style="width:32px;height:32px;stroke:#9B5DE5"></i>
|
||
<h3 id="cr-join-title">Урок начался!</h3>
|
||
<p id="cr-join-sub">Нажмите кнопку чтобы войти</p>
|
||
<button class="cr-join-btn" onclick="crJoinSession()">Войти в урок</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- active session main — whiteboard -->
|
||
<div class="cr-active-main" id="cr-active-main" style="display:none">
|
||
<!-- board area: thumbs panel + board wrap side by side -->
|
||
<div class="cr-board-area" id="cr-board-area">
|
||
<!-- thumbnail panel (teacher only) -->
|
||
<div class="wb-thumbs-panel" id="wb-thumbs-panel" style="display:none">
|
||
<div id="wb-thumbs-list"></div>
|
||
<div class="wb-thumbs-add">
|
||
<button class="wb-thumbs-add-btn" onclick="wbAddPage()" title="Добавить страницу">+</button>
|
||
</div>
|
||
</div>
|
||
<div class="cr-board-wrap" id="cr-board-wrap">
|
||
<canvas id="cr-canvas"></canvas>
|
||
<video id="cr-screen-video" autoplay playsinline></video>
|
||
<div class="cr-screen-label" id="cr-screen-label">Трансляция экрана</div>
|
||
<div class="cr-board-readonly" id="cr-board-readonly" style="display:none">Только просмотр</div>
|
||
<!-- Student status notification bar -->
|
||
<div id="cr-status-bar"></div>
|
||
<!-- Teacher cursor overlay (students see teacher's mouse) -->
|
||
<div id="cr-teacher-cursor" style="display:none;"></div>
|
||
<!-- Active speaker chip -->
|
||
<div class="cr-speaker-chip" id="cr-speaker-chip">
|
||
<div class="cr-speaker-chip-avatar" id="cr-speaker-chip-avatar"></div>
|
||
<span class="cr-speaker-chip-name" id="cr-speaker-chip-name"></span>
|
||
<div class="cr-speaker-chip-wave">
|
||
<span></span><span></span><span></span>
|
||
</div>
|
||
</div>
|
||
<!-- Fullscreen exit overlay (visible only when in fullscreen — for touchpad users) -->
|
||
<button id="cr-fs-exit-overlay" onclick="wbToggleFullscreen()" title="Выйти из полноэкранного режима">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px"><polyline points="8 3 3 3 3 8"/><polyline points="21 8 21 3 16 3"/><polyline points="3 16 3 21 8 21"/><polyline points="16 21 21 21 21 16"/></svg>
|
||
Свернуть
|
||
</button>
|
||
</div>
|
||
<!-- Simulation iframe overlay (shown when teacher opens a sim) -->
|
||
<div class="cr-sim-panel" id="cr-sim-panel">
|
||
<div class="cr-sim-bar">
|
||
<div class="cr-sim-bar-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"/></svg>
|
||
</div>
|
||
<span class="cr-sim-bar-title" id="cr-sim-bar-title">Симуляция</span>
|
||
<!-- Draw-over button (teacher only) -->
|
||
<button class="cr-sim-annotate-btn teacher-ctrl" id="cr-sim-annotate-btn" onclick="crToggleAnnotate()" title="Рисовать поверх симуляции">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||
Рисовать
|
||
</button>
|
||
<!-- Mode toggle (teacher only) -->
|
||
<div class="cr-sim-mode" id="cr-sim-mode-toggle" style="display:none">
|
||
<button class="cr-sim-mode-btn active" id="cr-sim-mode-demo" onclick="crSetSimMode('demo')" title="Все видят одно и то же">Демо</button>
|
||
<button class="cr-sim-mode-btn" id="cr-sim-mode-free" onclick="crSetSimMode('free')" title="Каждый работает самостоятельно">Свободно</button>
|
||
</div>
|
||
<button class="cr-sim-bar-close" id="cr-sim-bar-close" onclick="crTeacherCloseSim()" title="Закрыть симуляцию" style="display:none">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||
</button>
|
||
</div>
|
||
<iframe class="cr-sim-frame" id="cr-sim-frame" src="about:blank" allow="fullscreen"></iframe>
|
||
<!-- Blocks student interaction in demo mode -->
|
||
<div class="cr-sim-blocker" id="cr-sim-blocker"></div>
|
||
</div>
|
||
<!-- Textbook iframe overlay (shown when teacher opens a textbook) -->
|
||
<div class="cr-tb-panel" id="cr-tb-panel">
|
||
<div class="cr-tb-bar">
|
||
<div class="cr-tb-bar-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
||
</div>
|
||
<span class="cr-tb-bar-title" id="cr-tb-bar-title">Учебник</span>
|
||
<button class="cr-tb-annotate-btn teacher-ctrl" id="cr-tb-annotate-btn" onclick="crToggleAnnotate()" title="Рисовать поверх учебника">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||
Рисовать
|
||
</button>
|
||
<div class="cr-tb-mode" id="cr-tb-mode-toggle" style="display:none">
|
||
<button class="cr-tb-mode-btn active" id="cr-tb-mode-demo" onclick="crSetTbMode('demo')" title="Все видят то же, что и учитель">Демо</button>
|
||
<button class="cr-tb-mode-btn" id="cr-tb-mode-free" onclick="crSetTbMode('free')" title="Каждый листает самостоятельно">Свободно</button>
|
||
</div>
|
||
<button class="cr-tb-bar-close" id="cr-tb-bar-close" onclick="crTeacherCloseTb()" title="Закрыть учебник" style="display:none">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||
</button>
|
||
</div>
|
||
<iframe class="cr-tb-frame" id="cr-tb-frame" src="about:blank"></iframe>
|
||
<div class="cr-tb-blocker" id="cr-tb-blocker"></div>
|
||
</div>
|
||
<!-- Annotate-mode floating bar (shown on top of sim when drawing) -->
|
||
<div class="cr-annotate-bar" id="cr-annotate-bar">
|
||
<span class="cr-annotate-bar-label">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||
Режим аннотации
|
||
</span>
|
||
<button class="cr-annotate-exit" id="cr-annotate-exit-btn" onclick="crToggleAnnotate()">Закончить рисование</button>
|
||
</div>
|
||
</div><!-- /.cr-board-area -->
|
||
<!-- student nav: page nav + follow toggle -->
|
||
<div class="cr-student-nav" id="cr-student-nav" style="display:none">
|
||
<button class="cr-tool-btn" onclick="wbStudentPrevPage()" title="Предыдущая страница">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||
</button>
|
||
<span class="cr-page-label" id="wb-student-page-label">1/1</span>
|
||
<button class="cr-tool-btn" onclick="wbStudentNextPage()" title="Следующая страница">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||
</button>
|
||
<button class="cr-follow-btn active" id="cr-follow-btn" onclick="crToggleFollow()" title="Следовать за учителем">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
|
||
<span id="cr-follow-label">Авто</span>
|
||
</button>
|
||
<button class="cr-fullscreen-btn" id="cr-student-fs-btn" onclick="wbToggleFullscreen()" title="На весь экран (F11)">
|
||
<svg class="fs-enter" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
||
<svg class="fs-exit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="10" y1="14" x2="3" y2="21"/><line x1="21" y1="3" x2="14" y2="10"/></svg>
|
||
<span id="cr-fs-label">На весь экран</span>
|
||
</button>
|
||
<button class="cr-tool-btn" onclick="crSaveBoardRegion(this)" title="Сохранить часть доски в «Мои материалы»">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2v14a2 2 0 0 0 2 2h14"/><path d="M18 22V8a2 2 0 0 0-2-2H2"/></svg>
|
||
</button>
|
||
<button class="cr-tool-btn" onclick="crSaveBoardPage(this)" title="Сохранить страницу доски в «Мои материалы»">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||
</button>
|
||
</div>
|
||
<!-- toolbar: teacher only -->
|
||
<div class="cr-toolbar" id="cr-toolbar" style="display:none">
|
||
|
||
<!-- ── ROW 1: drawing tools ─────────────────────────────────── -->
|
||
<div class="cr-tb-row">
|
||
<!-- select -->
|
||
<button class="cr-tool-btn" id="wb-tool-select" onclick="wbSetTool('select')" title="Выделить / переместить">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3l14 9-7 1-4 7L5 3z"/></svg>
|
||
</button>
|
||
<div class="cr-tool-sep"></div>
|
||
<!-- draw tools -->
|
||
<button class="cr-tool-btn active" id="wb-tool-pencil" onclick="wbSetTool('pencil')" title="Карандаш"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-highlighter" onclick="wbSetTool('highlighter')" title="Маркер"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l-6 6v3h3l6-6"/><path d="m22 2-3 3-4-4 3-3 4 4zm-7 3 4 4"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-laser" onclick="wbSetTool('laser')" title="Лазер"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-eraser" onclick="wbSetTool('eraser')" title="Ластик"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20H7L3 16l11-11 7 7-1 8z"/><path d="M6.0001 13.9999 10 18"/></svg></button>
|
||
<div class="cr-tool-sep"></div>
|
||
<!-- shapes dropdown -->
|
||
<div class="cr-drop" id="cr-shape-drop">
|
||
<button class="cr-tool-btn" id="wb-shape-picker-btn" onclick="wbToggleShapePicker()" title="Фигуры">
|
||
<span id="wb-shape-icon-slot"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px"><rect x="3" y="3" width="18" height="18" rx="2"/></svg></span>
|
||
<svg class="cr-drop-chevron" viewBox="0 0 8 5"><path d="M0 0l4 5 4-5z"/></svg>
|
||
</button>
|
||
<div class="cr-drop-popup cr-shape-popup-grid" id="cr-shape-popup">
|
||
<button class="cr-tool-btn" id="wb-tool-rect" onclick="wbPickShape('rect')" title="Прямоугольник"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-ellipse" onclick="wbPickShape('ellipse')" title="Эллипс"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="12" rx="10" ry="6"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-line" onclick="wbPickShape('line')" title="Линия"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="20" x2="20" y2="4"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-arrow" onclick="wbPickShape('arrow')" title="Стрелка"><svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M3 11h12.5l-4-4 1.5-1.5 6 6-6 6L11.5 16 15.5 12H3z"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-triangle" onclick="wbPickShape('triangle')" title="Треугольник"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 3 22 21 2 21"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-diamond" onclick="wbPickShape('diamond')" title="Ромб"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 22 12 12 22 2 12"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-hexagon" onclick="wbPickShape('hexagon')" title="Шестиугольник"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 20.66 7 20.66 17 12 22 3.34 17 3.34 7"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-star" onclick="wbPickShape('star')" title="Звезда"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-roundedrect" onclick="wbPickShape('roundedrect')" title="Скруглённый прямоугольник"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="5"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-callout" onclick="wbPickShape('callout')" title="Облачко"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
|
||
<div class="cr-pop-sep"></div>
|
||
<div class="cr-pop-full">
|
||
<button class="cr-pop-fill-btn" id="wb-fill-btn" onclick="wbToggleFill(this)" title="Заливка фигур">
|
||
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M16.56 8.94L7.62 0 6.21 1.41l2.38 2.38-5.15 5.15a1.49 1.49 0 0 0 0 2.12l5.5 5.5c.29.29.68.44 1.06.44s.77-.15 1.06-.44l5.5-5.5c.59-.58.59-1.53 0-2.12zM5.21 10 10 5.21 14.79 10H5.21zM19 11.5s-2 2.17-2 3.5c0 1.1.9 2 2 2s2-.9 2-2c0-1.33-2-3.5-2-3.5z"/></svg>
|
||
Заливка
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="cr-tool-sep"></div>
|
||
<!-- objects -->
|
||
<button class="cr-tool-btn" id="wb-tool-connector" onclick="wbSetTool('connector')" title="Коннектор"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-sticky" onclick="wbSetTool('sticky')" title="Стикер"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8z"/><polyline points="16 3 16 8 21 8"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-formula" onclick="wbSetTool('formula')" title="Формула LaTeX"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19c2 0 3-1 3-3V8c0-2 1-3 3-3"/><path d="M20 19c-2 0-3-1-3-3V8c0-2-1-3-3-3"/></svg></button>
|
||
<div class="wb-tbl-drop cr-drop" id="wb-tbl-drop">
|
||
<button class="cr-tool-btn" id="wb-tool-table" onclick="wbToggleTablePicker()" title="Таблица (выбор размера)">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
|
||
<svg class="cr-drop-chevron" viewBox="0 0 8 5"><path d="M0 0l4 5 4-5z"/></svg>
|
||
</button>
|
||
<div class="wb-tbl-popup" id="wb-tbl-popup">
|
||
<div class="wb-tbl-grid" id="wb-tbl-grid"></div>
|
||
<div class="wb-tbl-label" id="wb-tbl-label">1 × 1</div>
|
||
</div>
|
||
</div>
|
||
<button class="cr-tool-btn" id="wb-tool-coordinate" onclick="wbSetTool('coordinate')" title="Система координат"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="12" y1="3" x2="12" y2="21"/><polyline points="18 8 21 12 18 16"/><polyline points="8 18 12 21 16 18"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-numberline" onclick="wbSetTool('numberline')" title="Числовая ось"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="22" y2="12"/><polyline points="18 8 22 12 18 16"/><line x1="6" y1="9" x2="6" y2="15"/><line x1="10" y1="10" x2="10" y2="14"/><line x1="14" y1="10" x2="14" y2="14"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-compass" onclick="wbSetTool('compass')" title="Циркуль"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v8"/><path d="m8.93 12.36-5.2 9"/><path d="m15.07 12.36 5.2 9"/><circle cx="12" cy="10" r="2"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-text" onclick="wbSetTool('text')" title="Текст"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-mindmap" onclick="wbSetTool('mindmap')" title="Интеллект-карта"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><line x1="12" y1="9" x2="12" y2="3"/><circle cx="12" cy="3" r="1.5"/><line x1="14.6" y1="13.4" x2="19" y2="17"/><circle cx="20" cy="18" r="1.5"/><line x1="9.4" y1="13.4" x2="5" y2="17"/><circle cx="4" cy="18" r="1.5"/><line x1="15" y1="11" x2="21" y2="9"/><circle cx="22" cy="8.5" r="1.5"/><line x1="9" y1="11" x2="3" y2="9"/><circle cx="2" cy="8.5" r="1.5"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-image" onclick="wbOpenImagePicker()" title="Изображение"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-tool-image-ai" onclick="wbGenerateImage()" title="Сгенерировать картинку (ИИ)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13 3l2.2 6.3L22 12l-6.8 2.7L13 21l-2.2-6.3L4 12l6.8-2.7z"/><path d="M5 4v3M3.5 5.5h3"/></svg></button>
|
||
<input type="file" id="wb-image-input" accept="image/*" style="display:none" onchange="wbImageSelected(this)">
|
||
<div class="cr-tool-sep"></div>
|
||
<!-- undo / redo / clear -->
|
||
<button class="cr-tool-btn" onclick="wbUndo()" title="Отменить (Ctrl+Z)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/></svg></button>
|
||
<button class="cr-tool-btn" onclick="wbRedo()" title="Повторить (Ctrl+Y)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"/></svg></button>
|
||
<button class="cr-tool-btn" onclick="wbClear()" title="Очистить страницу"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
|
||
<!-- page nav pushed right -->
|
||
<div class="cr-page-nav">
|
||
<button class="cr-tool-btn" onclick="wbPrevPage()" title="Предыдущая страница"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg></button>
|
||
<span class="cr-page-label" id="wb-page-label">1/1</span>
|
||
<button class="cr-tool-btn" onclick="wbNextPage()" title="Следующая страница"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></button>
|
||
<button class="cr-tool-btn" onclick="wbAddPage()" title="Добавить страницу"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── ROW 2: style + actions ───────────────────────────────── -->
|
||
<div class="cr-tb-row">
|
||
<!-- colors (12 + custom) -->
|
||
<button class="cr-color-btn active" data-color="#ffffff" style="background:#ffffff" onclick="wbSetColor(this)" title="Белый"></button>
|
||
<button class="cr-color-btn" data-color="#e8e0f7" style="background:#e8e0f7" onclick="wbSetColor(this)" title="Лаванда"></button>
|
||
<button class="cr-color-btn" data-color="#FFE066" style="background:#FFE066" onclick="wbSetColor(this)" title="Жёлтый"></button>
|
||
<button class="cr-color-btn" data-color="#06D6E0" style="background:#06D6E0" onclick="wbSetColor(this)" title="Голубой"></button>
|
||
<button class="cr-color-btn" data-color="#FF6B6B" style="background:#FF6B6B" onclick="wbSetColor(this)" title="Красный"></button>
|
||
<button class="cr-color-btn" data-color="#4361EE" style="background:#4361EE" onclick="wbSetColor(this)" title="Синий"></button>
|
||
<button class="cr-color-btn" data-color="#A8E063" style="background:#A8E063" onclick="wbSetColor(this)" title="Зелёный"></button>
|
||
<button class="cr-color-btn" data-color="#F15BB5" style="background:#F15BB5" onclick="wbSetColor(this)" title="Розовый"></button>
|
||
<button class="cr-color-btn" data-color="#9B5DE5" style="background:#9B5DE5" onclick="wbSetColor(this)" title="Фиолетовый"></button>
|
||
<button class="cr-color-btn" data-color="#FF9F43" style="background:#FF9F43" onclick="wbSetColor(this)" title="Оранжевый"></button>
|
||
<button class="cr-color-btn" data-color="#8B6F47" style="background:#8B6F47" onclick="wbSetColor(this)" title="Коричневый"></button>
|
||
<button class="cr-color-btn" data-color="#9CA3AF" style="background:#9CA3AF" onclick="wbSetColor(this)" title="Серый"></button>
|
||
<input type="color" class="cr-color-custom" id="wb-color-custom" value="#ffffff"
|
||
onchange="wbSetCustomColor(this)" title="Свой цвет" />
|
||
<div class="cr-tool-sep"></div>
|
||
<!-- widths -->
|
||
<button class="cr-width-btn" id="wb-w1" onclick="wbSetWidth(1,this)" title="1px"><span class="cr-width-dot" style="width:2px;height:2px"></span></button>
|
||
<button class="cr-width-btn" id="wb-w2" onclick="wbSetWidth(2,this)" title="2px"><span class="cr-width-dot" style="width:3px;height:3px"></span></button>
|
||
<button class="cr-width-btn active" id="wb-w4" onclick="wbSetWidth(4,this)" title="4px"><span class="cr-width-dot" style="width:6px;height:6px"></span></button>
|
||
<button class="cr-width-btn" id="wb-w8" onclick="wbSetWidth(8,this)" title="8px"><span class="cr-width-dot" style="width:11px;height:11px"></span></button>
|
||
<button class="cr-width-btn" id="wb-w16" onclick="wbSetWidth(16,this)" title="16px"><span class="cr-width-dot" style="width:16px;height:16px"></span></button>
|
||
<div class="cr-tool-sep"></div>
|
||
<!-- line styles -->
|
||
<button class="cr-linestyle-btn active" id="wb-ls-solid" onclick="wbSetLineStyle('solid',this)" title="Сплошная">—</button>
|
||
<button class="cr-linestyle-btn" id="wb-ls-dashed" onclick="wbSetLineStyle('dashed',this)" title="Пунктир">╌</button>
|
||
<button class="cr-linestyle-btn" id="wb-ls-dotted" onclick="wbSetLineStyle('dotted',this)" title="Точки">···</button>
|
||
<div class="cr-tool-sep"></div>
|
||
<!-- opacity -->
|
||
<div class="cr-opacity-row">
|
||
<label>α</label>
|
||
<input type="range" id="wb-opacity-slider" min="10" max="100" value="100"
|
||
oninput="wbSetOpacity(this.value)" title="Непрозрачность">
|
||
</div>
|
||
<div class="cr-tool-sep"></div>
|
||
<!-- object actions -->
|
||
<button class="cr-tool-btn" onclick="wbCopySelected()" title="Копировать (Ctrl+C)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||
<button class="cr-tool-btn" onclick="wbPasteSelected()" title="Вставить (Ctrl+V)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/></svg></button>
|
||
<button class="cr-tool-btn" onclick="wbDeleteSelected()" title="Удалить (Del)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>
|
||
<button class="cr-tool-btn" onclick="wbBringToFront()" title="На передний план"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M4 16V4h12" opacity=".4"/></svg></button>
|
||
<button class="cr-tool-btn" onclick="wbSendToBack()" title="На задний план"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="12" height="12" rx="2"/><path d="M8 16v4h12V8h-4" opacity=".4"/></svg></button>
|
||
<div class="cr-tool-sep"></div>
|
||
<!-- overlay properties (shown when ruler/protractor selected) -->
|
||
<div class="wb-ov-props" id="wb-ov-props">
|
||
<span class="wb-ov-props-type" id="wb-ov-type">Линейка</span>
|
||
<label>Угол°<input type="number" id="wb-ov-angle" min="-360" max="360" step="1" oninput="wbOvApply()"></label>
|
||
<label id="wb-ov-size-lbl">Длина<input type="number" id="wb-ov-size" min="50" max="1800" step="10" oninput="wbOvApply()"></label>
|
||
<button class="cr-tool-btn" onclick="wbOvHide()" title="Скрыть" style="width:20px;height:20px;font-size:10px;padding:0">✕</button>
|
||
</div>
|
||
<div class="cr-tool-sep"></div>
|
||
<!-- zoom controls -->
|
||
<div class="wb-zoom-row">
|
||
<button class="cr-tool-btn" onclick="wbZoomIn()" title="Приблизить (Ctrl+)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg></button>
|
||
<span class="wb-zoom-label" id="wb-zoom-label" onclick="wbResetZoom()" title="Сбросить масштаб (Ctrl+0)" style="cursor:pointer">100%</span>
|
||
<button class="cr-tool-btn" onclick="wbZoomOut()" title="Отдалить (Ctrl-)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg></button>
|
||
</div>
|
||
<div class="cr-tool-sep"></div>
|
||
<!-- template picker -->
|
||
<div class="wb-tpl-picker">
|
||
<label>Шаблон:</label>
|
||
<select id="wb-tpl-select" onchange="wbSetPageTemplate(this.value)" title="Шаблон страницы">
|
||
<option value="blank">Чистая</option>
|
||
<option value="grid">Сетка</option>
|
||
<option value="lined">Линейки</option>
|
||
<option value="dots">Точки</option>
|
||
<option value="coordinate">Оси</option>
|
||
</select>
|
||
</div>
|
||
<div class="cr-tool-sep"></div>
|
||
<!-- board theme picker -->
|
||
<div class="wb-tpl-picker">
|
||
<label>Доска:</label>
|
||
<select id="wb-theme-select" onchange="wbSetBoardTheme(this.value)" title="Тема доски">
|
||
<option value="chalkboard">Мел</option>
|
||
<option value="blackboard">Классная</option>
|
||
<option value="corkboard">Пробка</option>
|
||
<option value="whiteboard">Белая</option>
|
||
</select>
|
||
</div>
|
||
<div class="cr-tool-sep"></div>
|
||
<!-- content templates picker -->
|
||
<div class="cr-drop" id="cr-tpl-drop">
|
||
<button class="cr-tool-btn" id="wb-tpl-picker-btn" onclick="wbToggleTplPicker()" title="Готовые шаблоны">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>
|
||
<svg class="cr-drop-chevron" viewBox="0 0 8 5"><path d="M0 0l4 5 4-5z"/></svg>
|
||
</button>
|
||
<div class="wb-tpl-popup-grid" id="wb-content-tpl-popup">
|
||
<div class="tpl-grid">
|
||
<button class="wb-tpl-item" onclick="wbInsertTemplate('venn2')">
|
||
<svg viewBox="0 0 40 28"><ellipse cx="14" cy="14" rx="12" ry="10" fill="none" stroke="#06D6E0" stroke-width="1.5"/><ellipse cx="26" cy="14" rx="12" ry="10" fill="none" stroke="#F15BB5" stroke-width="1.5"/></svg>
|
||
Венн 2
|
||
</button>
|
||
<button class="wb-tpl-item" onclick="wbInsertTemplate('venn3')">
|
||
<svg viewBox="0 0 40 28"><ellipse cx="14" cy="10" rx="11" ry="8" fill="none" stroke="#06D6E0" stroke-width="1.5"/><ellipse cx="26" cy="10" rx="11" ry="8" fill="none" stroke="#F15BB5" stroke-width="1.5"/><ellipse cx="20" cy="20" rx="11" ry="8" fill="none" stroke="#FFE066" stroke-width="1.5"/></svg>
|
||
Венн 3
|
||
</button>
|
||
<button class="wb-tpl-item" onclick="wbInsertTemplate('tchart')">
|
||
<svg viewBox="0 0 40 28"><rect x="2" y="2" width="36" height="24" fill="none" stroke="#9B5DE5" stroke-width="1.5"/><line x1="20" y1="2" x2="20" y2="26" stroke="#9B5DE5" stroke-width="1.5"/><line x1="2" y1="9" x2="38" y2="9" stroke="#9B5DE5" stroke-width="1.5"/></svg>
|
||
T-chart
|
||
</button>
|
||
<button class="wb-tpl-item" onclick="wbInsertTemplate('timeline')">
|
||
<svg viewBox="0 0 40 28"><line x1="3" y1="14" x2="37" y2="14" stroke="#06D6E0" stroke-width="1.5"/><polyline points="34,11 37,14 34,17" fill="none" stroke="#06D6E0" stroke-width="1.5"/><line x1="10" y1="10" x2="10" y2="18" stroke="#06D6E0" stroke-width="1.2"/><line x1="20" y1="10" x2="20" y2="18" stroke="#06D6E0" stroke-width="1.2"/><line x1="30" y1="10" x2="30" y2="18" stroke="#06D6E0" stroke-width="1.2"/></svg>
|
||
Шкала
|
||
</button>
|
||
<button class="wb-tpl-item" onclick="wbInsertTemplate('quadrant')">
|
||
<svg viewBox="0 0 40 28"><line x1="2" y1="14" x2="38" y2="14" stroke="#9B5DE5" stroke-width="1.5"/><line x1="20" y1="2" x2="20" y2="26" stroke="#9B5DE5" stroke-width="1.5"/></svg>
|
||
Квадрант
|
||
</button>
|
||
<button class="wb-tpl-item" onclick="wbInsertTemplate('pyramid')">
|
||
<svg viewBox="0 0 40 28"><polygon points="20,2 38,26 2,26" fill="none" stroke="#06D6E0" stroke-width="1.5"/><line x1="8" y1="17" x2="32" y2="17" stroke="#06D6E0" stroke-width="1"/><line x1="13" y1="11" x2="27" y2="11" stroke="#06D6E0" stroke-width="1"/></svg>
|
||
Пирамида
|
||
</button>
|
||
<button class="wb-tpl-item" onclick="wbInsertTemplate('swot')">
|
||
<svg viewBox="0 0 40 28"><rect x="2" y="2" width="36" height="24" fill="none" stroke="#9B5DE5" stroke-width="1.5"/><line x1="20" y1="2" x2="20" y2="26" stroke="#9B5DE5" stroke-width="1.5"/><line x1="2" y1="14" x2="38" y2="14" stroke="#9B5DE5" stroke-width="1.5"/></svg>
|
||
SWOT
|
||
</button>
|
||
</div>
|
||
<div class="wb-bg-section">
|
||
<div class="wb-bg-row">
|
||
<button class="wb-bg-btn" onclick="wbPickBgImage()" title="Изображение или PDF как фон страницы">
|
||
<svg style="width:11px;height:11px;vertical-align:middle;margin-right:3px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||
Фон страницы
|
||
</button>
|
||
<button class="wb-bg-btn danger" id="wb-bg-remove-btn" onclick="wbRemoveBgImage()" style="display:none" title="Убрать фон">
|
||
<svg style="width:11px;height:11px;vertical-align:middle;margin-right:3px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>
|
||
Удалить
|
||
</button>
|
||
</div>
|
||
<input type="file" id="wb-bg-file-input" accept="image/*,.pdf" style="display:none" onchange="wbBgFileSelected(this)">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="cr-tool-sep"></div>
|
||
<!-- utils dropdown: fullscreen, export, templates, ruler, protractor, measurements -->
|
||
<div class="cr-drop" id="cr-utils-drop">
|
||
<button class="cr-tool-btn" id="wb-utils-picker-btn" onclick="wbToggleUtilsPicker()" title="Инструменты">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M4.93 4.93a10 10 0 0 0 0 14.14"/></svg>
|
||
<svg class="cr-drop-chevron" viewBox="0 0 8 5"><path d="M0 0l4 5 4-5z"/></svg>
|
||
</button>
|
||
<div class="cr-drop-popup cr-utils-popup-grid" id="cr-utils-popup">
|
||
<button class="cr-tool-btn" onclick="wbToggleFullscreen()" title="Полный экран"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg></button>
|
||
<button class="cr-tool-btn" onclick="_wb && _wb.exportPNG()" title="Экспорт PNG"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
|
||
<button class="cr-tool-btn" id="btn-templates" onclick="crShowTemplates()" title="Шаблоны урока" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg></button>
|
||
<div class="cr-pop-sep"></div>
|
||
<button class="cr-tool-btn active" id="wb-btn-snap" onclick="wbToggleSnap(this)" title="Полоски выравнивания"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-btn-measurements" onclick="wbToggleMeasurements(this)" title="Авто-измерения фигур"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 6H3"/><path d="M10 12H3"/><path d="M10 18H3"/><polyline points="15 12 18 15 21 12"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-btn-ruler" onclick="wbToggleRuler()" title="Линейка"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="8" width="20" height="8" rx="1"/><line x1="6" y1="12" x2="6" y2="16"/><line x1="10" y1="12" x2="10" y2="16"/><line x1="14" y1="12" x2="14" y2="16"/><line x1="18" y1="12" x2="18" y2="16"/></svg></button>
|
||
<button class="cr-tool-btn" id="wb-btn-protractor" onclick="wbToggleProtractor()" title="Транспортир"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 20 A9 9 0 0 1 21 20"/><line x1="12" y1="11" x2="12" y2="20"/><line x1="3" y1="20" x2="21" y2="20"/></svg></button>
|
||
</div>
|
||
</div>
|
||
</div><!-- /.cr-tb-row row2 -->
|
||
|
||
<!-- ── ROW 3: text options (visible only when text tool active) ── -->
|
||
<div class="cr-text-row" id="cr-text-row">
|
||
<label>Шрифт</label>
|
||
<select class="cr-text-font-sel" id="wb-text-font" onchange="wbSetTextFont(this.value)">
|
||
<option value="Manrope">Manrope</option>
|
||
<option value="Unbounded">Unbounded</option>
|
||
<option value="Caveat">Caveat</option>
|
||
<option value="Georgia">Georgia</option>
|
||
<option value="Arial">Arial</option>
|
||
<option value="Courier New">Courier New</option>
|
||
<option value="Impact">Impact</option>
|
||
</select>
|
||
<div class="cr-tool-sep"></div>
|
||
<label>Размер</label>
|
||
<input type="number" class="cr-text-size-inp" id="wb-text-size"
|
||
value="22" min="8" max="120" step="2"
|
||
onchange="wbSetTextSize(this.value)" oninput="wbSetTextSize(this.value)">
|
||
<div class="cr-tool-sep"></div>
|
||
<button class="cr-text-fmt-btn" id="wb-text-bold" onclick="wbToggleTextBold()" title="Жирный (Ctrl+B)" style="font-family:Georgia,serif">B</button>
|
||
<button class="cr-text-fmt-btn" id="wb-text-italic" onclick="wbToggleTextItalic()" title="Курсив (Ctrl+I)" style="font-family:Georgia,serif;font-style:italic">I</button>
|
||
<div class="cr-tool-sep"></div>
|
||
<button class="cr-text-fmt-btn active" id="wb-text-al-left" onclick="wbSetTextAlign('left')" title="По левому краю"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="2" y1="4" x2="14" y2="4"/><line x1="2" y1="8" x2="10" y2="8"/><line x1="2" y1="12" x2="12" y2="12"/></svg></button>
|
||
<button class="cr-text-fmt-btn" id="wb-text-al-center" onclick="wbSetTextAlign('center')" title="По центру"> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="2" y1="4" x2="14" y2="4"/><line x1="4" y1="8" x2="12" y2="8"/><line x1="3" y1="12" x2="13" y2="12"/></svg></button>
|
||
<button class="cr-text-fmt-btn" id="wb-text-al-right" onclick="wbSetTextAlign('right')" title="По правому краю"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="2" y1="4" x2="14" y2="4"/><line x1="6" y1="8" x2="14" y2="8"/><line x1="4" y1="12" x2="14" y2="12"/></svg></button>
|
||
</div>
|
||
|
||
<!-- ── Context row: sticky color presets ───────────────────── -->
|
||
<div class="wb-ctx-row" id="wb-sticky-row">
|
||
<span class="wb-ctx-lbl">Цвет:</span>
|
||
<button class="wb-sticky-col active" data-sc="#FFE066" style="background:#FFE066" onclick="wbSetStickyColor(this)" title="Жёлтый"></button>
|
||
<button class="wb-sticky-col" data-sc="#FF9F7F" style="background:#FF9F7F" onclick="wbSetStickyColor(this)" title="Персиковый"></button>
|
||
<button class="wb-sticky-col" data-sc="#B5EAD7" style="background:#B5EAD7" onclick="wbSetStickyColor(this)" title="Мятный"></button>
|
||
<button class="wb-sticky-col" data-sc="#C7CEEA" style="background:#C7CEEA" onclick="wbSetStickyColor(this)" title="Лавандовый"></button>
|
||
<button class="wb-sticky-col" data-sc="#FFDAC1" style="background:#FFDAC1" onclick="wbSetStickyColor(this)" title="Персиковый 2"></button>
|
||
<button class="wb-sticky-col" data-sc="#E2B7F5" style="background:#E2B7F5" onclick="wbSetStickyColor(this)" title="Сиреневый"></button>
|
||
</div>
|
||
|
||
<!-- ── Context row: connector arrows (shown when connector selected) -->
|
||
<div class="wb-ctx-row" id="wb-connector-row">
|
||
<span class="wb-ctx-lbl">Стрелки:</span>
|
||
<button class="cr-text-fmt-btn" id="wb-arr-start" onclick="wbToggleArrow('arrowStart')" title="Стрелка в начале">
|
||
<svg viewBox="0 0 20 14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="7" x2="16" y2="7"/><polyline points="8,3 4,7 8,11"/></svg>
|
||
</button>
|
||
<button class="cr-text-fmt-btn active" id="wb-arr-end" onclick="wbToggleArrow('arrowEnd')" title="Стрелка в конце">
|
||
<svg viewBox="0 0 20 14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="7" x2="16" y2="7"/><polyline points="12,3 16,7 12,11"/></svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- ── Context row: multi-select alignment ──────────────────── -->
|
||
<div class="wb-ctx-row" id="wb-align-row">
|
||
<span class="wb-ctx-lbl">Выровнять:</span>
|
||
<button class="cr-tool-btn" onclick="wbAlign('left')" title="По левому краю"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="3" x2="4" y2="21"/><rect x="4" y="5" width="12" height="4" rx="1"/><rect x="4" y="14" width="8" height="4" rx="1"/></svg></button>
|
||
<button class="cr-tool-btn" onclick="wbAlign('centerH')" title="По центру (гориз)"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="3" x2="12" y2="21"/><rect x="5" y="5" width="14" height="4" rx="1"/><rect x="7" y="14" width="10" height="4" rx="1"/></svg></button>
|
||
<button class="cr-tool-btn" onclick="wbAlign('right')" title="По правому краю"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="20" y1="3" x2="20" y2="21"/><rect x="8" y="5" width="12" height="4" rx="1"/><rect x="12" y="14" width="8" height="4" rx="1"/></svg></button>
|
||
<div class="cr-tool-sep"></div>
|
||
<button class="cr-tool-btn" onclick="wbAlign('top')" title="По верхнему краю"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="4" x2="21" y2="4"/><rect x="5" y="4" width="4" height="12" rx="1"/><rect x="14" y="4" width="4" height="8" rx="1"/></svg></button>
|
||
<button class="cr-tool-btn" onclick="wbAlign('centerV')" title="По центру (верт)"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="12" x2="21" y2="12"/><rect x="5" y="5" width="4" height="14" rx="1"/><rect x="14" y="7" width="4" height="10" rx="1"/></svg></button>
|
||
<button class="cr-tool-btn" onclick="wbAlign('bottom')" title="По нижнему краю"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="20" x2="21" y2="20"/><rect x="5" y="8" width="4" height="12" rx="1"/><rect x="14" y="12" width="4" height="8" rx="1"/></svg></button>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right panel -->
|
||
<div class="cr-right" id="cr-right">
|
||
<div class="cr-panel-tabs">
|
||
<div class="cr-panel-tabs-inner" id="cr-tabs-inner">
|
||
<button class="cr-tab active" id="tab-participants" onclick="crSwitchTab('participants')" title="Участники">
|
||
<i data-lucide="users" style="width:13px;height:13px"></i>
|
||
<span class="cr-tab-label">Участники</span>
|
||
<span class="cr-tab-badge" id="participants-count">0</span>
|
||
</button>
|
||
<button class="cr-tab" id="tab-chat" onclick="crSwitchTab('chat')" title="Чат">
|
||
<i data-lucide="message-circle" style="width:13px;height:13px"></i>
|
||
<span class="cr-tab-label">Чат</span>
|
||
<span class="cr-tab-badge" id="chat-unread" style="display:none">0</span>
|
||
</button>
|
||
<button class="cr-tab" id="tab-notes" onclick="crSwitchTab('notes')" title="Заметки">
|
||
<i data-lucide="notebook-pen" style="width:13px;height:13px"></i>
|
||
<span class="cr-tab-label">Заметки</span>
|
||
</button>
|
||
<button class="cr-tab" id="tab-quiz" onclick="crSwitchTab('quiz')" style="display:none" title="Квиз">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||
<span class="cr-tab-label">Квиз</span>
|
||
<span class="cr-tab-badge" id="quiz-badge" style="display:none">●</span>
|
||
</button>
|
||
</div>
|
||
<button class="cr-panel-collapse-btn" onclick="crToggleRightPanel()" title="Свернуть панель">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="cr-panel-content" id="cr-panel-content">
|
||
<!-- Participants panel -->
|
||
<div id="panel-participants" style="flex:1;overflow-y:auto;display:flex;flex-direction:column;">
|
||
<!-- raised hands (teacher only) -->
|
||
<div id="cr-hands-section" class="cr-hands-section" style="display:none">
|
||
<div class="cr-hands-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0"/><path d="M14 10V4a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>
|
||
Поднятые руки
|
||
</div>
|
||
<div id="cr-hands-list"></div>
|
||
</div>
|
||
<div class="cr-participants" id="cr-participants-list">
|
||
<div class="cr-no-session" id="participants-no-session">
|
||
<i data-lucide="users" style="width:32px;height:32px;stroke:#8898AA"></i>
|
||
<p>Участники появятся когда урок начнётся</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Notes panel -->
|
||
<div id="panel-notes" style="display:none; flex:1; flex-direction:column; overflow:hidden;">
|
||
<div class="cr-notes-panel">
|
||
<div class="cr-notes-header">
|
||
<span class="cr-notes-label">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||
Личные заметки
|
||
</span>
|
||
<span class="cr-notes-status" id="notes-status"></span>
|
||
</div>
|
||
<textarea class="cr-notes-ta" id="cr-notes-ta"
|
||
placeholder="Пишите заметки по уроку…"
|
||
oninput="crNotesOnInput()"></textarea>
|
||
<div style="display:flex;align-items:center;justify-content:space-between;padding:0 2px;">
|
||
<span class="cr-notes-wordcount" id="notes-wordcount">0 слов</span>
|
||
<span style="font-size:0.6rem;color:rgba(255,255,255,0.15)">Только вам</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quiz panel (teacher only) -->
|
||
<div id="panel-quiz" style="display:none; flex:1; flex-direction:column; overflow:hidden;">
|
||
<!-- No session msg -->
|
||
<div class="cr-quiz-no-session-msg" id="cr-quiz-no-session">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:32px;height:32px;stroke:#8898AA"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||
<p>Квиз доступен во время урока</p>
|
||
</div>
|
||
<!-- Quiz active state -->
|
||
<div id="cr-quiz-session" style="display:none;flex:1;flex-direction:column;overflow:hidden;">
|
||
<!-- Personal session warning (no class) -->
|
||
<div id="cr-quiz-no-class" style="display:none;padding:10px 12px 0">
|
||
<div style="background:rgba(255,183,0,0.08);border:1px solid rgba(255,183,0,0.2);border-radius:8px;padding:8px 10px;font-size:0.7rem;color:rgba(255,220,100,0.75);line-height:1.5">
|
||
Квиз доступен только в классных сессиях. Создайте урок с выбором класса.
|
||
</div>
|
||
</div>
|
||
<!-- Start button (no live quiz running) -->
|
||
<div id="cr-quiz-start-area" style="padding:10px 10px 6px">
|
||
<button class="cr-quiz-start-btn" id="cr-quiz-start-btn" onclick="crQuizStart()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||
Запустить квиз
|
||
</button>
|
||
</div>
|
||
<!-- Status bar (quiz running) -->
|
||
<div class="cr-quiz-status-bar" id="cr-quiz-status-bar" style="display:none">
|
||
<span class="cr-quiz-status-dot"></span>
|
||
<span class="cr-quiz-status-text" id="cr-quiz-status-text">Вопрос активен</span>
|
||
<button class="cr-quiz-end-btn" onclick="crQuizEnd()">Завершить</button>
|
||
</div>
|
||
<!-- Active question card -->
|
||
<div class="cr-quiz-active-card" id="cr-quiz-active-card" style="display:none">
|
||
<div class="cr-quiz-active-label">Текущий вопрос</div>
|
||
<div class="cr-quiz-active-text" id="cr-quiz-active-text"></div>
|
||
<div class="cr-quiz-counter-row">
|
||
<span class="cr-quiz-counter-val" id="cr-quiz-ans-count">0</span>
|
||
<span class="cr-quiz-counter-lbl">ответов</span>
|
||
<button class="cr-quiz-show-results-btn" id="cr-quiz-show-results-btn" onclick="crQuizShowResults()">Результаты</button>
|
||
</div>
|
||
</div>
|
||
<!-- Results display -->
|
||
<div class="cr-quiz-result-stats" id="cr-quiz-result-stats" style="display:none"></div>
|
||
<!-- Filters -->
|
||
<div class="cr-quiz-filters" id="cr-quiz-filters">
|
||
<div class="cr-quiz-search-wrap">
|
||
<svg class="cr-quiz-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||
<input class="cr-quiz-search-input" id="cr-quiz-search" type="text" placeholder="Поиск вопросов…" oninput="crQuizOnSearch(this.value)">
|
||
</div>
|
||
<div class="cr-quiz-filter-row">
|
||
<select class="cr-quiz-filter-sel" id="cr-quiz-topic-sel" onchange="crQuizOnTopicFilter()">
|
||
<option value="">Все темы</option>
|
||
</select>
|
||
<select class="cr-quiz-filter-sel" id="cr-quiz-diff-sel" onchange="crQuizOnDiffFilter()">
|
||
<option value="">Любой</option>
|
||
<option value="1">Лёгкий</option>
|
||
<option value="2">Средний</option>
|
||
<option value="3">Сложный</option>
|
||
</select>
|
||
</div>
|
||
<div class="cr-quiz-count" id="cr-quiz-count" style="display:none"></div>
|
||
</div>
|
||
<!-- Question list -->
|
||
<div class="cr-quiz-q-scroll" id="cr-quiz-q-scroll"></div>
|
||
<button class="cr-quiz-load-more" id="cr-quiz-load-more" style="display:none" onclick="crQuizLoadMore()">Загрузить ещё</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Chat panel -->
|
||
<div id="panel-chat" style="display:none; flex:1; flex-direction:column; overflow:hidden;">
|
||
<div class="cr-no-session" id="chat-no-session">
|
||
<i data-lucide="message-circle" style="width:32px;height:32px;stroke:#8898AA"></i>
|
||
<p>Чат доступен во время урока</p>
|
||
</div>
|
||
<div class="cr-chat-wrap" id="chat-active" style="display:none">
|
||
<div class="cr-messages" id="cr-messages"></div>
|
||
<!-- attach preview -->
|
||
<div class="cr-chat-attach-preview" id="cr-attach-preview">
|
||
<img id="cr-attach-thumb" class="cr-attach-thumb" src="" alt="">
|
||
<span class="cr-attach-name" id="cr-attach-name"></span>
|
||
<button class="cr-attach-rm" onclick="crClearAttach()" title="Удалить">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="cr-chat-input-wrap">
|
||
<div class="cr-chat-input-row">
|
||
<button class="cr-attach-btn" onclick="document.getElementById('cr-file-input').click()" title="Прикрепить изображение">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 0 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||
</button>
|
||
<input type="file" id="cr-file-input" accept="image/*" style="display:none" onchange="crAttachFile(this)">
|
||
<button class="cr-share-lib-btn" id="cr-share-lib-btn" onclick="crOpenFilePicker()" title="Поделиться файлом из библиотеки">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
|
||
</button>
|
||
<textarea class="cr-chat-input" id="cr-chat-input" placeholder="Сообщение..." rows="1"
|
||
onkeydown="crChatKeyDown(event)"></textarea>
|
||
<button class="cr-chat-send" onclick="crSendChat()">
|
||
<i data-lucide="send" style="width:14px;height:14px"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div><!-- /.cr-panel-content -->
|
||
</div><!-- /.cr-right -->
|
||
</div><!-- /.cr-body -->
|
||
</div><!-- /.sb-content -->
|
||
</div><!-- /.app-layout -->
|
||
|
||
<!-- Start Session Modal -->
|
||
<div class="cr-modal-overlay" id="cr-modal">
|
||
<div class="cr-modal">
|
||
<div class="cr-modal-title">Начать онлайн-урок</div>
|
||
<div class="cr-modal-sub">Выберите аудиторию и введите тему</div>
|
||
|
||
<div class="cr-mode-tabs">
|
||
<button class="cr-mode-tab active" id="mode-class" onclick="crSetMode('class')">
|
||
<i data-lucide="graduation-cap" style="width:13px;height:13px;display:inline;vertical-align:middle;margin-right:4px"></i>
|
||
Класс
|
||
</button>
|
||
<button class="cr-mode-tab" id="mode-personal" onclick="crSetMode('personal')">
|
||
<i data-lucide="user" style="width:13px;height:13px;display:inline;vertical-align:middle;margin-right:4px"></i>
|
||
Личная сессия
|
||
</button>
|
||
</div>
|
||
|
||
<div class="cr-field">
|
||
<label class="cr-label">Тема урока (необязательно)</label>
|
||
<input type="text" class="cr-input" id="cr-title-input" placeholder="Например: Тема 5 — Фотосинтез" maxlength="100" />
|
||
</div>
|
||
|
||
<!-- class mode -->
|
||
<div id="field-class" class="cr-field">
|
||
<label class="cr-label">Класс</label>
|
||
<select class="cr-select" id="cr-class-select">
|
||
<option value="">Выберите класс...</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- personal mode -->
|
||
<div id="field-personal" class="cr-field" style="display:none">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
|
||
<label class="cr-label" style="margin:0">Онлайн-ученики</label>
|
||
<button class="cr-online-refresh" onclick="crLoadOnlineStudents()">
|
||
<i data-lucide="refresh-cw" style="width:11px;height:11px"></i>Обновить
|
||
</button>
|
||
</div>
|
||
<div class="cr-online-list" id="cr-online-list">
|
||
<div class="cr-online-empty">Загрузка...</div>
|
||
</div>
|
||
<div class="cr-selected-users" id="cr-selected-users"></div>
|
||
</div>
|
||
|
||
<div class="cr-modal-actions">
|
||
<button class="cr-modal-cancel" onclick="crCloseStartModal()">Отмена</button>
|
||
<button class="cr-modal-confirm" id="cr-confirm-btn" onclick="crStartSession()">
|
||
<i data-lucide="play" style="width:14px;height:14px;display:inline;vertical-align:middle;margin-right:4px"></i>
|
||
Начать урок
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Guest link modal ── -->
|
||
<div class="cr-modal-overlay" id="cr-guest-modal" onclick="if(event.target===this)crGuestClose()">
|
||
<div class="cr-modal" style="max-width:400px">
|
||
<div class="cr-modal-title">Гостевая ссылка</div>
|
||
<div class="cr-modal-sub">Поделитесь ссылкой — гость увидит доску без регистрации (только просмотр)</div>
|
||
|
||
<!-- state: no token yet -->
|
||
<div id="cr-guest-no-token">
|
||
<div class="cr-guest-actions" style="margin-top:20px">
|
||
<button class="cr-guest-create-btn" onclick="crGuestCreate()">
|
||
Создать ссылку
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- state: token exists -->
|
||
<div id="cr-guest-has-token" style="display:none">
|
||
<div class="cr-guest-link-wrap">
|
||
<span class="cr-guest-link-url" id="cr-guest-url"></span>
|
||
<button class="cr-guest-copy-btn" id="cr-guest-copy-btn" onclick="crGuestCopy()">Копировать</button>
|
||
</div>
|
||
<div class="cr-guest-note">Гости видят только доску. Чат, квизы и другие функции — недоступны.</div>
|
||
<div class="cr-guest-actions">
|
||
<button class="cr-guest-revoke-btn" onclick="crGuestRevoke()">Отозвать ссылку</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cr-modal-actions" style="margin-top:16px;justify-content:flex-end">
|
||
<button class="cr-modal-cancel" onclick="crGuestClose()">Закрыть</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Screen Picker Modal v2 -->
|
||
<div class="cr-screen-picker-overlay" id="cr-screen-picker-overlay" onclick="if(event.target===this)crCloseScreenPicker()">
|
||
<div class="cr-screen-picker">
|
||
|
||
<!-- Header -->
|
||
<div class="cr-sp-head">
|
||
<div class="cr-sp-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:18px;height:18px"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||
</div>
|
||
<div class="cr-sp-titles">
|
||
<div class="cr-sp-title">Трансляция экрана</div>
|
||
<div class="cr-sp-sub">Ученики увидят выбранное содержимое в реальном времени</div>
|
||
</div>
|
||
<button class="cr-sp-close" onclick="crCloseScreenPicker()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Source type tabs -->
|
||
<div class="cr-sp-tabs">
|
||
<button class="cr-sp-tab active" id="cr-sptab-monitor" onclick="crSpSwitchTab('monitor')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||
Экраны
|
||
</button>
|
||
<button class="cr-sp-tab" id="cr-sptab-window" onclick="crSpSwitchTab('window')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
|
||
Окна
|
||
</button>
|
||
<button class="cr-sp-tab" id="cr-sptab-browser" onclick="crSpSwitchTab('browser')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||
Вкладки браузера
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Live preview area -->
|
||
<div class="cr-sp-preview-wrap">
|
||
<div class="cr-sp-preview-inner">
|
||
|
||
<!-- Empty state -->
|
||
<div class="cr-sp-empty" id="cr-sp-empty">
|
||
<div class="cr-sp-empty-icon" id="cr-sp-empty-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:26px;height:26px"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||
</div>
|
||
<div class="cr-sp-empty-title" id="cr-sp-empty-title">Экран не выбран</div>
|
||
<div class="cr-sp-empty-hint" id="cr-sp-empty-hint">Нажмите кнопку ниже — откроется диалог выбора источника</div>
|
||
<button class="cr-sp-pick-btn" id="cr-sp-pick-btn" onclick="crSpPickSource()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||
Выбрать экран
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Video preview (shown after source selected) -->
|
||
<div class="cr-sp-video-wrap" id="cr-sp-video-wrap">
|
||
<video class="cr-sp-video" id="cr-sp-video" autoplay muted playsinline></video>
|
||
<div class="cr-sp-preview-bar">
|
||
<div class="cr-sp-src-icon-sm" id="cr-sp-bar-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||
</div>
|
||
<span class="cr-sp-preview-name" id="cr-sp-preview-name">Экран 1</span>
|
||
<span class="cr-sp-res-badge" id="cr-sp-res-badge"></span>
|
||
<button class="cr-sp-change-btn" onclick="crSpPickSource()">Сменить</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="cr-sp-divider">
|
||
|
||
<!-- Settings -->
|
||
<div class="cr-sp-settings">
|
||
<div class="cr-sp-q-row">
|
||
<span class="cr-sp-q-label">Разрешение</span>
|
||
<div class="cr-sp-q-opts">
|
||
<button class="cr-sp-qbtn" data-q="720" onclick="crSpSetQ(this,'res')">720p</button>
|
||
<button class="cr-sp-qbtn active" data-q="1080" onclick="crSpSetQ(this,'res')">1080p</button>
|
||
<button class="cr-sp-qbtn" data-q="0" onclick="crSpSetQ(this,'res')">Источник</button>
|
||
</div>
|
||
</div>
|
||
<div class="cr-sp-q-row">
|
||
<span class="cr-sp-q-label">Частота кадров</span>
|
||
<div class="cr-sp-q-opts">
|
||
<button class="cr-sp-qbtn" data-q="15" onclick="crSpSetQ(this,'fps')">15 fps</button>
|
||
<button class="cr-sp-qbtn active" data-q="30" onclick="crSpSetQ(this,'fps')">30 fps</button>
|
||
<button class="cr-sp-qbtn" data-q="60" onclick="crSpSetQ(this,'fps')">60 fps</button>
|
||
</div>
|
||
</div>
|
||
<!-- Option cards grid -->
|
||
<div class="cr-sp-opts-grid">
|
||
<!-- Audio -->
|
||
<div class="cr-sp-opt" id="cr-sp-opt-audio" onclick="crSpToggleAudio()">
|
||
<span class="cr-sp-opt-ic">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
|
||
</span>
|
||
<div class="cr-sp-opt-text">
|
||
<div class="cr-sp-opt-title">Звук системы</div>
|
||
<div class="cr-sp-opt-hint">Передавать системный звук</div>
|
||
</div>
|
||
<button class="cr-sp-toggle" onclick="event.stopPropagation()"></button>
|
||
</div>
|
||
<!-- Cursor -->
|
||
<div class="cr-sp-opt on" id="cr-sp-opt-cursor" onclick="crSpToggleCursor()">
|
||
<span class="cr-sp-opt-ic">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px"><path d="m4 4 7.07 17 2.51-7.39L21 11.07z"/></svg>
|
||
</span>
|
||
<div class="cr-sp-opt-text">
|
||
<div class="cr-sp-opt-title">Курсор</div>
|
||
<div class="cr-sp-opt-hint">Показывать указатель мыши</div>
|
||
</div>
|
||
<button class="cr-sp-toggle" onclick="event.stopPropagation()"></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Footer -->
|
||
<div class="cr-sp-footer">
|
||
<button class="cr-sp-cancel" onclick="crCloseScreenPicker()">Отмена</button>
|
||
<button class="cr-sp-start" id="cr-sp-start-btn" onclick="crSpStartShare()" disabled>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||
Поделиться
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- File Picker Modal -->
|
||
<div class="cr-file-picker-overlay" id="cr-file-picker-overlay" onclick="if(event.target===this)crCloseFilePicker()">
|
||
<div class="cr-file-picker">
|
||
<div class="cr-file-picker-head">
|
||
<span class="cr-file-picker-title">Файлы из библиотеки</span>
|
||
<button class="cr-file-picker-close" onclick="crCloseFilePicker()" title="Закрыть">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||
</button>
|
||
</div>
|
||
<input class="cr-file-picker-search" id="cr-file-picker-search" type="text" placeholder="Поиск по названию..." oninput="crFilePickerSearch(this.value)">
|
||
<div class="cr-file-picker-list" id="cr-file-picker-list">
|
||
<div class="cr-file-picker-empty">Загрузка...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js" crossorigin="anonymous"></script>
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" crossorigin="anonymous" />
|
||
<script src="/js/whiteboard.js"></script>
|
||
<script src="/js/board-clip.js"></script>
|
||
<script src="/js/classroom-rtc.js"></script>
|
||
<script src="/js/api.js"></script>
|
||
<script src="/js/imggen.js"></script>
|
||
<script src="/js/sound.js"></script>
|
||
<script src="/js/sidebar.js"></script>
|
||
<script src="/js/notifications.js"></script>
|
||
<script>
|
||
/* ── User prefs ─────────────────────────────────────────────────────────── */
|
||
const CR_PREFS_KEY = 'ls_cr_prefs';
|
||
const CR_PREFS_DEFAULT = {
|
||
chatFontSize: 'medium', // 'small' | 'medium' | 'large'
|
||
mutedOnJoin: false,
|
||
vadSensitivity: 'medium', // 'low' | 'medium' | 'high' → VAD threshold
|
||
chatSound: true,
|
||
handSound: true,
|
||
defaultTool: 'pencil', // 'pencil' | 'select'
|
||
leftHand: false,
|
||
stylusMultiplier: 0, // 0=off, 0.5, 1.0, 2.0
|
||
lessonPushNotif: false,
|
||
};
|
||
const VAD_THRESHOLDS = { low: 22, medium: 12, high: 5 };
|
||
let _prefs = { ...CR_PREFS_DEFAULT };
|
||
|
||
function crLoadPrefs() {
|
||
try { _prefs = { ...CR_PREFS_DEFAULT, ...JSON.parse(localStorage.getItem(CR_PREFS_KEY) || '{}') }; }
|
||
catch { _prefs = { ...CR_PREFS_DEFAULT }; }
|
||
}
|
||
function crSavePrefs() {
|
||
try { localStorage.setItem(CR_PREFS_KEY, JSON.stringify(_prefs)); } catch {}
|
||
}
|
||
function crApplyPrefs() {
|
||
// Right panel collapsed state
|
||
if (localStorage.getItem('cr_right_collapsed') === '1')
|
||
document.getElementById('cr-right')?.classList.add('collapsed');
|
||
// Chat font size
|
||
const chatWrap = document.getElementById('chat-active');
|
||
if (chatWrap) {
|
||
chatWrap.classList.remove('cr-chat-fs-small','cr-chat-fs-large');
|
||
if (_prefs.chatFontSize === 'small') chatWrap.classList.add('cr-chat-fs-small');
|
||
if (_prefs.chatFontSize === 'large') chatWrap.classList.add('cr-chat-fs-large');
|
||
}
|
||
// Left-hand mode
|
||
const bodyEl = document.getElementById('cr-body');
|
||
if (bodyEl) bodyEl.classList.toggle('left-hand', !!_prefs.leftHand);
|
||
// Whiteboard
|
||
if (window._wb) {
|
||
_wb.setStylusMultiplier(_prefs.stylusMultiplier);
|
||
}
|
||
}
|
||
function crSetPref(key, value) {
|
||
_prefs[key] = value;
|
||
crSavePrefs();
|
||
crApplyPrefs();
|
||
_crSyncSettingsUI();
|
||
}
|
||
|
||
/* ── Settings modal ───────────────────────────────────────────────────── */
|
||
let _micTestStream = null, _micTestACtx = null, _micTestTimer = null;
|
||
|
||
function openSettings() {
|
||
document.getElementById('cr-settings-overlay').classList.add('open');
|
||
_crSyncSettingsUI();
|
||
}
|
||
function closeSettings() {
|
||
document.getElementById('cr-settings-overlay').classList.remove('open');
|
||
_crMicTestStop();
|
||
}
|
||
function settingsTab(name) {
|
||
document.querySelectorAll('.cr-sp-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
||
document.querySelectorAll('.cr-sp-pane').forEach(p => p.classList.toggle('active', p.id === 'crs-' + name));
|
||
}
|
||
function _crSyncSettingsUI() {
|
||
_crSegActive('crs-chat-size', _prefs.chatFontSize);
|
||
_crCheckSync('crs-muted-join', _prefs.mutedOnJoin);
|
||
_crSegActive('crs-vad', _prefs.vadSensitivity);
|
||
_crCheckSync('crs-chat-sound', _prefs.chatSound);
|
||
_crCheckSync('crs-hand-sound', _prefs.handSound);
|
||
if (window.LS && LS.sfx) {
|
||
_crCheckSync('crs-sfx-enabled', LS.sfx.enabled);
|
||
const vol = document.getElementById('crs-sfx-volume');
|
||
if (vol) vol.value = Math.round(LS.sfx.volume * 100);
|
||
}
|
||
_crSegActive('crs-def-tool', _prefs.defaultTool);
|
||
_crSegActive('crs-hand', _prefs.leftHand ? 'left' : 'right');
|
||
_crSegActive('crs-stylus', String(_prefs.stylusMultiplier));
|
||
const pushBtn = document.getElementById('crs-push-btn');
|
||
if (pushBtn && Notification.permission === 'granted') {
|
||
pushBtn.textContent = 'Уведомления включены'; pushBtn.classList.add('granted');
|
||
}
|
||
}
|
||
function _crSegActive(id, val) {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.val === String(val)));
|
||
}
|
||
function _crCheckSync(id, val) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.checked = !!val;
|
||
}
|
||
|
||
/* Mic test */
|
||
async function crMicTestStart() {
|
||
_crMicTestStop();
|
||
const fill = document.getElementById('crs-mic-fill');
|
||
const status = document.getElementById('crs-mic-status');
|
||
const btn = document.getElementById('crs-mic-btn');
|
||
try {
|
||
_micTestStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||
_micTestACtx = new AudioContext();
|
||
const src = _micTestACtx.createMediaStreamSource(_micTestStream);
|
||
const analyser = _micTestACtx.createAnalyser();
|
||
analyser.fftSize = 256;
|
||
src.connect(analyser);
|
||
const buf = new Uint8Array(analyser.frequencyBinCount);
|
||
if (status) status.textContent = 'Говорите в микрофон…';
|
||
if (btn) { btn.textContent = 'Остановить'; btn.onclick = _crMicTestStop; }
|
||
_micTestTimer = setInterval(() => {
|
||
analyser.getByteFrequencyData(buf);
|
||
const avg = buf.reduce((a,b) => a+b, 0) / buf.length;
|
||
if (fill) fill.style.width = Math.min(100, Math.round((avg/64)*100)) + '%';
|
||
}, 80);
|
||
} catch {
|
||
if (status) status.textContent = 'Нет доступа к микрофону';
|
||
}
|
||
}
|
||
function _crMicTestStop() {
|
||
if (_micTestTimer) { clearInterval(_micTestTimer); _micTestTimer = null; }
|
||
if (_micTestACtx) { try { _micTestACtx.close(); } catch {} _micTestACtx = null; }
|
||
if (_micTestStream) { _micTestStream.getTracks().forEach(t => t.stop()); _micTestStream = null; }
|
||
const fill = document.getElementById('crs-mic-fill');
|
||
const status = document.getElementById('crs-mic-status');
|
||
const btn = document.getElementById('crs-mic-btn');
|
||
if (fill) fill.style.width = '0%';
|
||
if (status) status.textContent = '';
|
||
if (btn) { btn.textContent = 'Начать тест'; btn.onclick = crMicTestStart; }
|
||
}
|
||
|
||
/* Push notifications */
|
||
async function crRequestPush() {
|
||
if (!('Notification' in window)) return;
|
||
const perm = await Notification.requestPermission();
|
||
if (perm === 'granted') {
|
||
_prefs.lessonPushNotif = true; crSavePrefs();
|
||
const btn = document.getElementById('crs-push-btn');
|
||
if (btn) { btn.textContent = 'Уведомления включены'; btn.classList.add('granted'); }
|
||
}
|
||
}
|
||
|
||
/* Notification sounds — delegated to LS.sfx */
|
||
function _crSfx(name) {
|
||
try { if (window.LS && LS.sfx) LS.sfx.play(name); } catch {}
|
||
}
|
||
function crSfxSetEnabled(v) {
|
||
if (window.LS && LS.sfx) { LS.sfx.setEnabled(v); if (v) LS.sfx.play('click'); }
|
||
}
|
||
function crSfxSetVolume(v) {
|
||
if (window.LS && LS.sfx) { LS.sfx.setVolume(v / 100); LS.sfx.play('click'); }
|
||
}
|
||
|
||
/* ── state ── */
|
||
let _me = null;
|
||
let _session = null;
|
||
let _sessionId = null;
|
||
let _sessionBoardTheme = null; // pending theme from session data, applied in initWhiteboard
|
||
let _sseHandle = null;
|
||
let _participants = {}; // userId -> {name, micMuted}
|
||
let _raisedHands = {}; // userId -> userName
|
||
let _handRaised = false;// student: own hand state
|
||
let _totalPages = 1; // total page count for current session
|
||
let _pageNames = {}; // {pageNum: name|null}
|
||
let _wbMenuPage = 1; // page targeted by context menu (global for onclick in menu HTML)
|
||
let _followTeacher = true; // student: follow teacher's page
|
||
let _mode = 'class';
|
||
let _selectedUsers = []; // [{id, name}] for personal session
|
||
|
||
/* ── whiteboard state ── */
|
||
let _wb = null; // Whiteboard instance
|
||
let _wbBatch = []; // strokes pending send
|
||
let _wbBatchTimer = null; // flush timer
|
||
let _wbCurrentPage = 1;
|
||
let _wbInitializing = false; // true while loading initial strokes from server
|
||
let _wbPendingSSE = []; // SSE strokes buffered during initialization
|
||
const _wbOwnIds = new Set(); // server-assigned IDs of our own sent strokes (to skip in SSE)
|
||
const _WB_OWN_IDS_MAX = 2000; // cap to prevent unbounded growth on long lessons
|
||
|
||
/* ── WebSocket (low-latency cursor + preview) ── */
|
||
let _crWs = null; // WebSocket instance
|
||
let _crWsReady = false; // true when WS is open
|
||
|
||
/* ── WebRTC state ── */
|
||
let _rtc = null; // ClassroomRTC instance
|
||
|
||
/* ── UI state ── */
|
||
let _chatUnread = 0; // unread messages (when chat tab not active)
|
||
let _activeTab = 'participants'; // current right panel tab
|
||
let _timerHandle = null; // session timer interval
|
||
let _simActive = null; // simId of currently open simulation (null = none)
|
||
let _simMode = 'demo'; // 'demo' (teacher controls, students watch) | 'free' (independent)
|
||
let _simStateThrottle = null; // timer for state relay throttle
|
||
let _sessionStartTime = null; // Date when session was created
|
||
|
||
/* ── polling ── */
|
||
let _pollPart = null; // setInterval handle (participants backup)
|
||
let _pollChatTimer = null; // setInterval handle (chat backup poll)
|
||
let _lastChatId = 0; // last rendered message id
|
||
const _renderedMsgIds = new Set(); // dedup
|
||
|
||
/* ── live drawing ── */
|
||
let _wbLiveTimer = null; // throttle timer for stroke-preview POST
|
||
let _wbMaxSeq = 0; // highest seq received (for polling fallback)
|
||
let _wbClearGen = 0; // incremented on clear/page-switch to discard stale poll responses
|
||
let _strokePollTimer = null;// polling interval for student stroke fallback
|
||
let _teacherPollTimer = null;// incremental stroke poll for teacher (SSE fallback)
|
||
let _drawPermPollTimer = null;// periodic draw permission check for student
|
||
|
||
/* ── teacher cursor ── */
|
||
let _cursorThrottle = null; // setTimeout handle for cursor POST throttle
|
||
let _cursorHideTimer = null;// setTimeout to hide cursor after inactivity
|
||
|
||
/* ── collaborative drawing ── */
|
||
let _canDraw = false; // student: has teacher granted draw permission?
|
||
const _permittedStudents = new Set(); // teacher: set of student user IDs with draw permission
|
||
|
||
/** Enable draw permission for the current student (shared logic for SSE + REST) */
|
||
function enableDrawPermission(silent) {
|
||
if (_canDraw) return; // already enabled
|
||
_canDraw = true;
|
||
if (_wb) {
|
||
_wb.setReadOnly(false);
|
||
_wb._onStrokeDone = wbOnLocalStroke;
|
||
_wb._onStrokeUndo = wbOnLocalUndo;
|
||
_wb._onStrokeProgress = wbOnStrokeProgress;
|
||
_wb._onStrokeUpdated = wbOnLocalStrokeUpdated;
|
||
_wb._onFormulaInsert = showFormulaModal;
|
||
_wb._onCoordEdit = showCoordModal;
|
||
_wb._onNumberLineEdit = showNumLineModal;
|
||
_wb._onObjectCreated = () => wbSetTool('select');
|
||
}
|
||
document.getElementById('cr-toolbar').style.display = 'flex';
|
||
document.getElementById('cr-board-readonly').style.display = 'none';
|
||
if (!_wbBatchTimer) _wbBatchTimer = setInterval(wbFlushBatch, 80);
|
||
// Stop the 2s full-replace poll, switch to full-sync-with-local-preservation poll.
|
||
wbStopPoll();
|
||
if (!_teacherPollTimer) _teacherPollTimer = setInterval(_drawingStudentSyncPoll, 2000);
|
||
if (!silent) crStatusNotify('success',
|
||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>`,
|
||
'Вам разрешено рисовать');
|
||
}
|
||
|
||
/** Disable draw permission for the current student */
|
||
function disableDrawPermission() {
|
||
if (!_canDraw) return; // already disabled
|
||
_canDraw = false;
|
||
if (_wb) {
|
||
_wb.setReadOnly(true);
|
||
_wb._onStrokeDone = null;
|
||
_wb._onStrokeUndo = null;
|
||
_wb._onStrokeProgress = null;
|
||
_wb._onStrokeUpdated = null;
|
||
_wb._onFormulaInsert = null;
|
||
_wb._onCoordEdit = null;
|
||
_wb._onObjectCreated = null;
|
||
}
|
||
document.getElementById('cr-toolbar').style.display = 'none';
|
||
document.getElementById('cr-board-readonly').style.display = 'block';
|
||
if (_wbBatchTimer) { clearInterval(_wbBatchTimer); _wbBatchTimer = null; }
|
||
if (_teacherPollTimer){ clearInterval(_teacherPollTimer);_teacherPollTimer= null; }
|
||
// Resume the 2s full-replace poll so the student sees teacher strokes
|
||
wbStartPoll();
|
||
crStatusNotify('warn',
|
||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>`,
|
||
'Рисование запрещено');
|
||
}
|
||
|
||
/* ── Student status notification bar ── */
|
||
const _statusTimers = new Map();
|
||
function crStatusNotify(type, iconSvg, text, duration = 4000) {
|
||
const isStudent = _me?.role !== 'teacher' && _me?.role !== 'admin';
|
||
if (!isStudent) return;
|
||
const bar = document.getElementById('cr-status-bar');
|
||
if (!bar) return;
|
||
const id = type + '_' + text; // deduplicate same message
|
||
// remove old same message if exists
|
||
const old = bar.querySelector(`[data-sid="${CSS.escape(id)}"]`);
|
||
if (old) { clearTimeout(_statusTimers.get(id)); old.remove(); }
|
||
const el = document.createElement('div');
|
||
el.className = `cr-status-msg type-${type}`;
|
||
el.dataset.sid = id;
|
||
el.innerHTML = iconSvg + `<span>${text}</span>`;
|
||
bar.appendChild(el);
|
||
const timer = setTimeout(() => {
|
||
el.classList.add('cr-status-out');
|
||
el.addEventListener('animationend', () => el.remove(), { once: true });
|
||
_statusTimers.delete(id);
|
||
}, duration);
|
||
_statusTimers.set(id, timer);
|
||
}
|
||
|
||
function startPolling() { /* backup polls removed — WS delivers all events with SSE fallback */ }
|
||
function stopPolling() { /* no-op */ }
|
||
|
||
async function _pollChat() {
|
||
if (!_sessionId) return;
|
||
try {
|
||
const data = await LS.get(`/api/classroom/${_sessionId}/chat?since_id=${_lastChatId}`);
|
||
(data.messages || []).forEach(m => appendChatMessage({
|
||
id: m.id, userId: m.user_id, userName: m.user_name,
|
||
message: m.message, createdAt: m.created_at, pinned: !!m.pinned,
|
||
attachmentUrl: m.attachment_url, attachmentType: m.attachment_type,
|
||
reactions: m.reactions || {},
|
||
}));
|
||
} catch {}
|
||
}
|
||
|
||
async function pollParticipants() {
|
||
if (!_sessionId) return;
|
||
try {
|
||
const data = await LS.get(`/api/classroom/${_sessionId}/participants`);
|
||
const fresh = {};
|
||
for (const p of (data.participants || [])) {
|
||
fresh[p.user_id] = { name: p.name, micMuted: _participants[p.user_id]?.micMuted || false };
|
||
}
|
||
_participants = fresh;
|
||
updateParticipantsList();
|
||
} catch {}
|
||
}
|
||
|
||
/* ── cursor colors per user ── */
|
||
const _CURSOR_COLORS = ['#06D6E0','#FF6B6B','#A8E063','#FF9F43','#F15BB5','#4361EE','#FFE066','#9B5DE5'];
|
||
function _getUserColor(userId) {
|
||
return _CURSOR_COLORS[Math.abs(userId) % _CURSOR_COLORS.length];
|
||
}
|
||
|
||
/* ── init ── */
|
||
/* ── Universal confirm dialog ─────────────────────────────────────── */
|
||
let _dlgResolve = null;
|
||
function crConfirm(title, msg, { okText = 'Подтвердить', type = 'danger', iconSvg = null } = {}) {
|
||
return new Promise(resolve => {
|
||
const overlay = document.getElementById('cr-dlg');
|
||
const iconEl = document.getElementById('cr-dlg-icon');
|
||
const titleEl = document.getElementById('cr-dlg-title');
|
||
const msgEl = document.getElementById('cr-dlg-msg');
|
||
const okBtn = document.getElementById('cr-dlg-ok');
|
||
const cancelBtn = document.getElementById('cr-dlg-cancel');
|
||
|
||
titleEl.textContent = title;
|
||
msgEl.textContent = msg;
|
||
okBtn.textContent = okText;
|
||
okBtn.className = `cr-dlg-ok ${type}`;
|
||
iconEl.className = `cr-dlg-icon ${type}`;
|
||
|
||
const icons = {
|
||
danger: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:22px;height:22px;stroke:#F15BB5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><circle cx="12" cy="16" r=".5" fill="currentColor"/></svg>`,
|
||
warn: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:22px;height:22px;stroke:#FF9F43"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><circle cx="12" cy="17" r=".5" fill="currentColor"/></svg>`,
|
||
info: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:22px;height:22px;stroke:#06D6E0"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><circle cx="12" cy="8" r=".5" fill="currentColor"/></svg>`,
|
||
};
|
||
iconEl.innerHTML = iconSvg || icons[type] || icons.info;
|
||
|
||
if (_dlgResolve) _dlgResolve(false); // dismiss any previous dialog
|
||
_dlgResolve = resolve;
|
||
overlay.style.display = 'flex';
|
||
|
||
const onOk = () => { overlay.style.display = 'none'; _dlgResolve = null; resolve(true); cleanup(); };
|
||
const onCancel = () => { overlay.style.display = 'none'; _dlgResolve = null; resolve(false); cleanup(); };
|
||
const onKey = (e) => { if (e.key === 'Escape') onCancel(); if (e.key === 'Enter') onOk(); };
|
||
|
||
function cleanup() {
|
||
okBtn.removeEventListener('click', onOk);
|
||
cancelBtn.removeEventListener('click', onCancel);
|
||
document.removeEventListener('keydown', onKey);
|
||
}
|
||
okBtn.addEventListener('click', onOk, { once: true });
|
||
cancelBtn.addEventListener('click', onCancel, { once: true });
|
||
document.addEventListener('keydown', onKey, { once: true });
|
||
});
|
||
}
|
||
|
||
function _showClassroomDisabled() {
|
||
document.getElementById('cr-board-wrap').innerHTML = `
|
||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:20px;padding:40px;text-align:center">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width:64px;height:64px;color:rgba(155,93,229,0.4)"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><line x1="1" y1="1" x2="23" y2="23" stroke="#F15BB5"/></svg>
|
||
<div style="font-family:'Unbounded',sans-serif;font-size:1.1rem;font-weight:800;color:var(--text,#e2d9f3)">Онлайн-уроки отключены</div>
|
||
<div style="font-size:0.9rem;color:rgba(255,255,255,0.5);max-width:340px;line-height:1.6">Администратор отключил модуль онлайн-уроков. Обратитесь к администратору платформы.</div>
|
||
</div>`;
|
||
document.getElementById('cr-toolbar')?.remove();
|
||
document.getElementById('cr-student-nav')?.remove();
|
||
}
|
||
|
||
async function init() {
|
||
if (!LS.isLoggedIn()) { window.location.href = '/login'; return; }
|
||
crLoadPrefs();
|
||
crApplyPrefs();
|
||
_me = LS.getUser();
|
||
if (!_me) {
|
||
try { _me = await LS.get('/api/auth/me'); LS.setUser(_me); } catch { window.location.href = '/login'; return; }
|
||
}
|
||
|
||
// setup nav
|
||
LS.renderNavAvatar(document.getElementById('nav-avatar'), _me);
|
||
document.getElementById('nav-user').textContent = _me.name || _me.email;
|
||
if (_me.role === 'admin') document.getElementById('btn-admin').style.display = '';
|
||
if (['teacher', 'admin'].includes(_me.role)) {
|
||
document.querySelectorAll('.sb-teacher-only').forEach(el => el.style.display = '');
|
||
document.getElementById('btn-classes').style.display = '';
|
||
}
|
||
if (window.lucide) lucide.createIcons();
|
||
|
||
// sidebar collapse
|
||
if (localStorage.getItem('ls_sb_collapsed') === '1') {
|
||
document.querySelector('.app-layout')?.classList.add('sb-collapsed');
|
||
}
|
||
|
||
// Check classroom module enabled (teacher sees disabled screen if off)
|
||
if (_me.role === 'teacher') {
|
||
try {
|
||
const features = await LS.get('/api/admin/features').catch(() => ({}));
|
||
if (features.classroom === false) {
|
||
_showClassroomDisabled();
|
||
return;
|
||
}
|
||
} catch { /* if endpoint fails — allow access */ }
|
||
}
|
||
|
||
// SSE for classroom events
|
||
_sseHandle = LS.connectSSE(handleSSE);
|
||
|
||
// check for active session
|
||
await checkActiveSession();
|
||
|
||
// load classes for modal (teacher only)
|
||
if (_me.role === 'teacher' || _me.role === 'admin') {
|
||
loadClassesForModal();
|
||
}
|
||
}
|
||
|
||
async function checkActiveSession() {
|
||
const isTeacher = _me.role === 'teacher' || _me.role === 'admin';
|
||
|
||
try {
|
||
const data = await LS.get('/api/classroom/my/session');
|
||
if (data.session) {
|
||
_session = data.session;
|
||
_sessionId = data.session.id;
|
||
|
||
if (isTeacher) {
|
||
// Teacher: always reconnect — rejoin to refresh attendance, then fetch fresh data
|
||
await LS.post(`/api/classroom/${_sessionId}/join`).catch(() => {});
|
||
const fresh = await LS.get(`/api/classroom/${_sessionId}`).catch(() => data.session);
|
||
_session = fresh;
|
||
enterActiveState(fresh);
|
||
loadChat();
|
||
return;
|
||
} else {
|
||
// Student: auto-reconnect if was already in session, else show banner
|
||
if (data.wasJoined) {
|
||
const joinRes = await LS.post(`/api/classroom/${_sessionId}/join`).catch(() => ({}));
|
||
const fresh = await LS.get(`/api/classroom/${_sessionId}`).catch(() => data.session);
|
||
_session = fresh;
|
||
// Set _canDraw BEFORE enterActiveState so initWhiteboard sees it
|
||
if (fresh.canDraw || joinRes.canDraw) _canDraw = true;
|
||
enterActiveState(fresh);
|
||
loadChat();
|
||
// Apply toolbar/readonly state if draw was pre-set
|
||
if (_canDraw) enableDrawPermission(true);
|
||
} else {
|
||
showJoinBanner(data.session);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
} catch {}
|
||
|
||
// No active session — show idle state
|
||
if (isTeacher) {
|
||
document.getElementById('cr-idle-teacher').style.display = 'flex';
|
||
} else {
|
||
document.getElementById('cr-idle-student').style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
function showJoinBanner(session) {
|
||
_session = session;
|
||
_sessionId = session.id;
|
||
// Hide board area if it was left visible from a previous session
|
||
document.getElementById('cr-active-main').style.display = 'none';
|
||
document.getElementById('cr-idle-teacher').style.display = 'none';
|
||
document.getElementById('cr-idle-student').style.display = 'none';
|
||
document.getElementById('cr-join-banner').style.display = 'flex';
|
||
document.getElementById('cr-join-title').textContent = session.title || 'Урок начался!';
|
||
document.getElementById('cr-join-sub').textContent = `Нажмите чтобы войти`;
|
||
}
|
||
|
||
/* ── SSE handler ── */
|
||
/* eslint-disable eqeqeq */
|
||
function handleSSE(data, fromWS = false) {
|
||
// When WS is active, classroom events are delivered via WS.
|
||
// Ignore classroom_ events that arrive via SSE to avoid duplicates.
|
||
// Events coming from WS (fromWS=true) are always processed.
|
||
if (!fromWS && _crWsReady && data.type?.startsWith('classroom_') && data.type !== '_sse_reconnect') return;
|
||
|
||
// Use == for sessionId comparison throughout — guards against string/number mismatch
|
||
// when _sessionId comes from JSON (number) vs SSE data (could be string in edge cases).
|
||
if (data.type === 'classroom_started') {
|
||
onClassroomStarted(data);
|
||
_crSfx('lesson_start');
|
||
} else if (data.type === 'classroom_ended') {
|
||
if (_sessionId == data.sessionId) { onClassroomEnded(true); _crSfx('lesson_end'); }
|
||
} else if (data.type === 'classroom_user_joined') {
|
||
if (_sessionId == data.sessionId) { onUserJoined(data); _crSfx('user_joined'); }
|
||
} else if (data.type === 'classroom_user_left') {
|
||
if (_sessionId == data.sessionId) { onUserLeft(data); _crSfx('user_left'); }
|
||
} else if (data.type === 'classroom_chat') {
|
||
if (_sessionId == data.sessionId) appendChatMessage(data);
|
||
} else if (data.type === 'classroom_strokes') {
|
||
if (_sessionId == data.sessionId && _wb && data.pageNum == _wbCurrentPage) {
|
||
// Skip our own strokes — server now includes userId.
|
||
// _wbOwnIds is a backup for strokes already confirmed locally.
|
||
if (data.userId == _me?.id) return;
|
||
const incoming = (data.strokes || []).filter(s => !_wbOwnIds.has(s.id));
|
||
// Confirmed strokes replace live preview
|
||
if (incoming.length > 0) _wb.clearAllLiveStrokes();
|
||
if (incoming.length === 0) return;
|
||
if (_wbInitializing) {
|
||
// Buffer until loadStrokes completes to avoid race condition
|
||
_wbPendingSSE.push(...incoming);
|
||
} else {
|
||
_wbUpdateMaxSeq(incoming);
|
||
_wb.addStrokes(incoming);
|
||
}
|
||
}
|
||
} else if (data.type === 'classroom_stroke_preview') {
|
||
if (_sessionId == data.sessionId && _wb && data.pageNum == _wbCurrentPage) {
|
||
// Skip our own preview echo — we already render it locally via _curPts/_shapeEnd
|
||
if (data.userId == _me?.id) return;
|
||
if (data.cancel) {
|
||
_wb.removeLiveStroke(data.liveId);
|
||
} else {
|
||
// Assign stable color per userId
|
||
const color = _getUserColor(data.userId);
|
||
_wb.setLiveStroke(data.liveId, data.tool, data.data, data.userName, color);
|
||
}
|
||
}
|
||
} else if (data.type === 'classroom_stroke_deleted') {
|
||
if (_sessionId == data.sessionId && _wb) _wb.removeStroke(data.strokeId);
|
||
} else if (data.type === 'classroom_stroke_updated') {
|
||
if (_sessionId == data.sessionId && _wb && data.pageNum == _wbCurrentPage)
|
||
_wb.updateStroke(data.strokeId, data.data);
|
||
} else if (data.type === 'classroom_page_added') {
|
||
if (_sessionId == data.sessionId) onPageAdded(data.pageNum);
|
||
} else if (data.type === 'classroom_page_changed') {
|
||
if (_sessionId == data.sessionId) onPageChanged(data.pageNum);
|
||
} else if (data.type === 'classroom_template_changed') {
|
||
if (_sessionId == data.sessionId && _wb && data.pageNum == _wbCurrentPage) {
|
||
_wb.setTemplate(data.template);
|
||
const sel = document.getElementById('wb-tpl-select');
|
||
if (sel) sel.value = data.template;
|
||
wbUpdateThumbnail(_wbCurrentPage);
|
||
}
|
||
} else if (data.type === 'classroom_board_theme') {
|
||
if (_sessionId == data.sessionId && _wb) {
|
||
_wb.setBoardTheme(data.theme);
|
||
const sel = document.getElementById('wb-theme-select');
|
||
if (sel) sel.value = data.theme;
|
||
}
|
||
} else if (data.type === 'classroom_hand_raised') {
|
||
if (_sessionId == data.sessionId) onHandRaised(data.userId, data.userName);
|
||
} else if (data.type === 'classroom_hand_lowered') {
|
||
if (_sessionId == data.sessionId) onHandLowered(data.userId);
|
||
} else if (data.type === 'classroom_signal') {
|
||
if (_rtc && _sessionId == data.sessionId) _rtc.handleSignal(data.from, data.payload);
|
||
} else if (data.type === 'classroom_screen_started') {
|
||
if (_sessionId == data.sessionId) {
|
||
document.getElementById('cr-screen-label').style.display = 'block';
|
||
}
|
||
} else if (data.type === 'classroom_screen_stopped') {
|
||
if (_sessionId == data.sessionId) {
|
||
const video = document.getElementById('cr-screen-video');
|
||
video.srcObject = null; video.style.display = 'none';
|
||
document.getElementById('cr-screen-label').style.display = 'none';
|
||
document.getElementById('cr-screen-btn').classList.remove('cr-btn-sharing');
|
||
}
|
||
} else if (data.type === 'classroom_muted') {
|
||
if (_sessionId == data.sessionId && _rtc) {
|
||
_crSfx('muted');
|
||
_rtc.forceMute();
|
||
if (_participants[_me.id]) _participants[_me.id].micMuted = true;
|
||
updateParticipantsList();
|
||
updateMuteBtn();
|
||
crStatusNotify('warn',
|
||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`,
|
||
'Учитель выключил ваш микрофон');
|
||
}
|
||
} else if (data.type === 'classroom_draw_permitted') {
|
||
if (_sessionId == data.sessionId) { enableDrawPermission(); _crSfx('draw_permitted'); }
|
||
} else if (data.type === 'classroom_draw_revoked') {
|
||
if (_sessionId == data.sessionId) disableDrawPermission();
|
||
} else if (data.type === 'classroom_reaction') {
|
||
if (_sessionId == data.sessionId) {
|
||
const el = document.getElementById(`msg-reactions-${data.msgId}`);
|
||
if (el) el.innerHTML = _renderReactions(data.reactions || {}, data.msgId);
|
||
}
|
||
} else if (data.type === 'classroom_template_loaded') {
|
||
if (_sessionId == data.sessionId && _wb) {
|
||
_totalPages = data.pages || 1;
|
||
_wbCurrentPage = 1;
|
||
wbRebuildThumbnails();
|
||
_wbOwnIds.clear();
|
||
_wbMaxSeq = 0;
|
||
LS.get(`/api/classroom/${_sessionId}/strokes?page_num=1`).then(strokesData => {
|
||
_wbUpdateMaxSeq(strokesData.strokes || []);
|
||
_wb.loadStrokes(strokesData.strokes || []);
|
||
wbUpdateThumbnail(1);
|
||
}).catch(() => {});
|
||
}
|
||
} else if (data.type === 'classroom_message_pinned') {
|
||
if (_sessionId == data.sessionId) _updatePinnedMsg(data.msgId, data.pinned);
|
||
} else if (data.type === 'classroom_cursor') {
|
||
if (_sessionId == data.sessionId && data.pageNum == _wbCurrentPage && data.userId != _me?.id) {
|
||
_showRemoteCursor(data.userId, data.userName, data.x, data.y);
|
||
}
|
||
} else if (data.type === 'classroom_page_cleared') {
|
||
if (_sessionId == data.sessionId && _wb && data.pageNum == _wbCurrentPage) {
|
||
_wbClearGen++; // discard any in-flight poll response that has pre-clear strokes
|
||
_wbMaxSeq = 0; // seq restarts from 0 after DB delete, so reset polling cursor
|
||
_wb.clearPage();
|
||
}
|
||
} else if (data.type === 'classroom_page_renamed') {
|
||
if (_sessionId == data.sessionId) {
|
||
_pageNames[data.pageNum] = data.name || null;
|
||
wbRebuildThumbnails();
|
||
}
|
||
} else if (data.type === 'classroom_page_duplicated') {
|
||
if (_sessionId == data.sessionId) {
|
||
if (data.name) _pageNames[data.newPage] = data.name;
|
||
onPageAdded(data.newPage);
|
||
}
|
||
} else if (data.type === 'classroom_page_deleted') {
|
||
if (_sessionId == data.sessionId) {
|
||
const del = data.pageNum;
|
||
const newNames = {};
|
||
Object.keys(_pageNames).forEach(k => {
|
||
const n = parseInt(k);
|
||
if (n < del) newNames[n] = _pageNames[n];
|
||
else if (n > del) newNames[n - 1] = _pageNames[n];
|
||
});
|
||
_pageNames = newNames;
|
||
_totalPages = Math.max(1, _totalPages - 1);
|
||
if (!isTeacher && _followTeacher) {
|
||
// navigate to teacher's current page
|
||
_wbCurrentPage = data.newCurrent;
|
||
updatePageLabel();
|
||
if (_wb) {
|
||
_wb.clearPage();
|
||
LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${_wbCurrentPage}`).then(d => {
|
||
_wb.loadStrokes(d.strokes || []);
|
||
if (d.template) _wb.setTemplate(d.template);
|
||
_wbUpdateMaxSeq(d.strokes || []); _wbMaxSeq = 0; _wbClearGen++;
|
||
}).catch(() => {});
|
||
}
|
||
}
|
||
wbRebuildThumbnails();
|
||
}
|
||
} else if (data.type === 'classroom_sim_open') {
|
||
if (_sessionId == data.sessionId) onSimOpen(data.simId, data.title);
|
||
} else if (data.type === 'classroom_sim_close') {
|
||
if (_sessionId == data.sessionId) onSimClose();
|
||
} else if (data.type === 'classroom_sim_state') {
|
||
if (_sessionId == data.sessionId && _simActive && _simMode === 'demo') {
|
||
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
|
||
if (!isTeacher) {
|
||
// Student: apply teacher's state to own iframe
|
||
document.getElementById('cr-sim-frame')?.contentWindow?.postMessage(
|
||
{ type: 'apply_sim_state', state: data.state }, '*'
|
||
);
|
||
}
|
||
}
|
||
} else if (data.type === 'classroom_sim_mode') {
|
||
if (_sessionId == data.sessionId) onSimModeChange(data.mode);
|
||
} else if (data.type === 'classroom_sim_annotate') {
|
||
if (_sessionId == data.sessionId) _crApplyAnnotate(data.active);
|
||
} else if (data.type === 'classroom_textbook_open') {
|
||
if (_sessionId == data.sessionId) onTbOpen(data);
|
||
} else if (data.type === 'classroom_textbook_close') {
|
||
if (_sessionId == data.sessionId) onTbClose();
|
||
} else if (data.type === 'classroom_textbook_nav') {
|
||
if (_sessionId == data.sessionId) onTbNav(data);
|
||
} else if (data.type === 'classroom_textbook_mode') {
|
||
if (_sessionId == data.sessionId) onTbModeChange(data.mode);
|
||
} else if (data.type === '_sse_reconnect') {
|
||
// SSE reconnected after a drop — re-sync all real-time state to fill the gap
|
||
if (_sessionId) resyncAfterReconnect();
|
||
} else if (data.type === 'live_question') {
|
||
// live_* events are class-scoped, no sessionId field
|
||
const _iqTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
|
||
if (_iqTeacher) {
|
||
if (_quizLiveId == null) _quizLiveId = data.liveId;
|
||
crQuizShowActiveCard(data.question);
|
||
} else {
|
||
crStudentOnQuestion(data.question, data.liveId);
|
||
}
|
||
} else if (data.type === 'live_answer_count') {
|
||
if (_me && (_me.role === 'teacher' || _me.role === 'admin')) crQuizUpdateCounter(data.count);
|
||
} else if (data.type === 'live_results') {
|
||
const _irTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
|
||
if (_irTeacher) crQuizRenderResults(data);
|
||
else crStudentOnResults(data);
|
||
} else if (data.type === 'live_ended') {
|
||
const _ieTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
|
||
if (_ieTeacher) {
|
||
_quizLiveId = null;
|
||
document.getElementById('cr-quiz-status-bar').style.display = 'none';
|
||
document.getElementById('cr-quiz-start-area').style.display = 'block';
|
||
document.getElementById('cr-quiz-active-card').style.display = 'none';
|
||
document.getElementById('cr-quiz-result-stats').style.display = 'none';
|
||
} else {
|
||
crStudentOnEnded();
|
||
}
|
||
} else if (data.type === 'classroom_guest_joined') {
|
||
if (_sessionId == data.sessionId) {
|
||
_participants[data.guestId] = { name: data.guestName || 'Гость', role: 'guest', micMuted: false };
|
||
updateParticipantsList();
|
||
crStatusNotify('info',
|
||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
|
||
`Гость «${LS.escapeHtml(data.guestName || 'Гость')}» присоединился`);
|
||
}
|
||
} else if (data.type === 'classroom_guest_left') {
|
||
if (_sessionId == data.sessionId) {
|
||
delete _participants[data.guestId];
|
||
updateParticipantsList();
|
||
}
|
||
}
|
||
}
|
||
/* eslint-enable eqeqeq */
|
||
|
||
function onClassroomStarted(data) {
|
||
const isTeacher = _me.role === 'teacher' || _me.role === 'admin';
|
||
if (!isTeacher) {
|
||
showJoinBanner({ id: data.sessionId, title: data.title });
|
||
// Push notification if enabled and page not focused
|
||
if (_prefs.lessonPushNotif && Notification.permission === 'granted' && document.hidden) {
|
||
try {
|
||
new Notification('Урок начался', {
|
||
body: data.title || 'Учитель начал урок. Присоединяйтесь!',
|
||
icon: '/favicon.svg',
|
||
});
|
||
} catch {}
|
||
}
|
||
}
|
||
}
|
||
|
||
function onClassroomEnded(showToast = true) {
|
||
if (!_sessionId) return; // guard against double-call (direct + WS event)
|
||
stopPolling();
|
||
wbStopBatch();
|
||
_crWsClose();
|
||
// Fully destroy whiteboard (not just clear) so the board area is gone
|
||
if (_wb) { _wb.destroy(); _wb = null; }
|
||
if (_rtc) { _rtc.destroy(); _rtc = null; }
|
||
if (_timerHandle) { clearInterval(_timerHandle); _timerHandle = null; }
|
||
_session = null;
|
||
_sessionId = null;
|
||
_sessionStartTime = null;
|
||
_participants = {};
|
||
document.getElementById('cr-speaker-chip')?.classList.remove('visible');
|
||
_raisedHands = {};
|
||
_handRaised = false;
|
||
_wbCurrentPage = 1;
|
||
_totalPages = 1;
|
||
_canDraw = false;
|
||
_permittedStudents.clear();
|
||
_lastChatId = 0;
|
||
_chatUnread = 0;
|
||
_renderedMsgIds.clear();
|
||
_notesLoaded = false;
|
||
document.getElementById('cr-hands-section').style.display = 'none';
|
||
document.getElementById('cr-hand-btn').style.display = 'none';
|
||
document.getElementById('cr-mute-btn').style.display = 'none';
|
||
document.getElementById('cr-share-lib-btn').style.display = 'none';
|
||
const _tabQuizEnd = document.getElementById('tab-quiz');
|
||
if (_tabQuizEnd) _tabQuizEnd.style.display = 'none';
|
||
document.getElementById('cr-tabs-inner')?.classList.remove('tabs-4');
|
||
if (_activeTab === 'quiz') crSwitchTab('participants');
|
||
document.getElementById('cr-student-nav').style.display = 'none';
|
||
document.getElementById('cr-timer').style.display = 'none';
|
||
document.getElementById('cr-timer').textContent = '00:00';
|
||
document.getElementById('chat-unread').style.display = 'none';
|
||
// reset screen video
|
||
const video = document.getElementById('cr-screen-video');
|
||
video.srcObject = null; video.style.display = 'none';
|
||
document.getElementById('cr-screen-label').style.display = 'none';
|
||
setActiveState(false);
|
||
document.getElementById('cr-session-title').textContent = 'Онлайн-урок';
|
||
document.getElementById('cr-session-chip').style.display = 'none';
|
||
document.getElementById('cr-header-actions').style.display = 'none';
|
||
_cleanupRemoteCursors();
|
||
// Hide active board area immediately so the empty board doesn't stay on screen
|
||
document.getElementById('cr-active-main').style.display = 'none';
|
||
document.getElementById('cr-join-banner').style.display = 'none';
|
||
// Show correct idle state right away (don't wait for checkActiveSession)
|
||
const isTeacher = _me?.role === 'teacher' || _me?.role === 'admin';
|
||
document.getElementById('cr-idle-teacher').style.display = isTeacher ? 'flex' : 'none';
|
||
document.getElementById('cr-idle-student').style.display = isTeacher ? 'none' : 'flex';
|
||
document.getElementById('cr-toolbar').style.display = 'none';
|
||
document.getElementById('wb-thumbs-panel').style.display = 'none';
|
||
// Close sim panel if open
|
||
if (_simActive) onSimClose();
|
||
updateParticipantsList();
|
||
if (showToast) LS.toast('Урок завершён', 'info');
|
||
}
|
||
|
||
function onUserJoined(data) {
|
||
_participants[data.userId] = { name: data.userName, micMuted: false };
|
||
updateParticipantsList();
|
||
// If teacher is sharing screen, add the new peer
|
||
if (_rtc) _rtc.addPeerForScreenShare(data.userId);
|
||
}
|
||
|
||
function onUserLeft(data) {
|
||
delete _participants[data.userId];
|
||
updateParticipantsList();
|
||
_updateSpeakerChip();
|
||
if (_rtc) _rtc.removePeer(data.userId);
|
||
}
|
||
|
||
/* ── start session modal ── */
|
||
function crOpenStartModal() {
|
||
_selectedUsers = [];
|
||
renderSelectedUsers();
|
||
document.getElementById('cr-modal').classList.add('open');
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
function crCloseStartModal() {
|
||
document.getElementById('cr-modal').classList.remove('open');
|
||
}
|
||
|
||
function crSetMode(mode) {
|
||
_mode = mode;
|
||
document.getElementById('mode-class').classList.toggle('active', mode === 'class');
|
||
document.getElementById('mode-personal').classList.toggle('active', mode === 'personal');
|
||
document.getElementById('field-class').style.display = mode === 'class' ? '' : 'none';
|
||
document.getElementById('field-personal').style.display = mode === 'personal' ? '' : 'none';
|
||
if (mode === 'personal') crLoadOnlineStudents();
|
||
}
|
||
|
||
async function loadClassesForModal() {
|
||
try {
|
||
const classes = await LS.get('/api/classes');
|
||
const sel = document.getElementById('cr-class-select');
|
||
sel.innerHTML = '<option value="">Выберите класс...</option>';
|
||
(classes || []).forEach(c => {
|
||
sel.innerHTML += `<option value="${c.id}">${c.name}</option>`;
|
||
});
|
||
} catch {}
|
||
}
|
||
|
||
async function crLoadOnlineStudents() {
|
||
const list = document.getElementById('cr-online-list');
|
||
list.innerHTML = '<div class="cr-online-empty">Загрузка...</div>';
|
||
try {
|
||
const data = await LS.get('/api/classroom/online-students');
|
||
const students = data.students || [];
|
||
if (!students.length) {
|
||
list.innerHTML = '<div class="cr-online-empty">Нет учеников онлайн</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = students.map(u => {
|
||
const initials = (u.name || '?').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('') || '?';
|
||
const sel = _selectedUsers.find(s => s.id === u.id) ? 'selected' : '';
|
||
return `<div class="cr-online-item ${sel}" id="cr-oi-${u.id}" data-uid="${u.id}" data-uname="${LS.esc(u.name || '')}">
|
||
<span class="cr-online-dot"></span>
|
||
<div class="cr-online-avatar">${initials}</div>
|
||
<span class="cr-online-name">${LS.esc(u.name || u.email)}</span>
|
||
<span class="cr-online-check"></span>
|
||
</div>`;
|
||
}).join('');
|
||
list.querySelectorAll('.cr-online-item').forEach(el => {
|
||
el.addEventListener('click', () => crToggleOnlineUser(Number(el.dataset.uid), el.dataset.uname));
|
||
});
|
||
if (window.lucide) lucide.createIcons();
|
||
} catch { list.innerHTML = '<div class="cr-online-empty">Ошибка загрузки</div>'; }
|
||
}
|
||
|
||
function crToggleOnlineUser(id, name) {
|
||
const idx = _selectedUsers.findIndex(u => u.id === id);
|
||
if (idx >= 0) {
|
||
_selectedUsers.splice(idx, 1);
|
||
} else {
|
||
_selectedUsers.push({ id, name });
|
||
}
|
||
// toggle selected class on item
|
||
const item = document.getElementById(`cr-oi-${id}`);
|
||
if (item) item.classList.toggle('selected', _selectedUsers.some(u => u.id === id));
|
||
renderSelectedUsers();
|
||
}
|
||
|
||
function crRemoveUser(id) {
|
||
_selectedUsers = _selectedUsers.filter(u => u.id !== id);
|
||
// un-select in online list if visible
|
||
const item = document.getElementById(`cr-oi-${id}`);
|
||
if (item) item.classList.remove('selected');
|
||
renderSelectedUsers();
|
||
}
|
||
|
||
function renderSelectedUsers() {
|
||
const wrap = document.getElementById('cr-selected-users');
|
||
wrap.innerHTML = _selectedUsers.map(u =>
|
||
`<div class="cr-selected-user">
|
||
${u.name}
|
||
<button onclick="crRemoveUser(${u.id})"><i data-lucide="x" style="width:11px;height:11px"></i></button>
|
||
</div>`
|
||
).join('');
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
async function crStartSession() {
|
||
const title = document.getElementById('cr-title-input').value.trim();
|
||
const btn = document.getElementById('cr-confirm-btn');
|
||
btn.disabled = true;
|
||
|
||
try {
|
||
let body;
|
||
if (_mode === 'class') {
|
||
const classId = Number(document.getElementById('cr-class-select').value);
|
||
if (!classId) { LS.toast('Выберите класс', 'error'); btn.disabled = false; return; }
|
||
body = { class_id: classId, title };
|
||
} else {
|
||
if (!_selectedUsers.length) { LS.toast('Добавьте хотя бы одного ученика', 'error'); btn.disabled = false; return; }
|
||
body = { user_ids: _selectedUsers.map(u => u.id), title };
|
||
}
|
||
|
||
const session = await LS.post('/api/classroom', body);
|
||
_session = session;
|
||
_sessionId = session.id;
|
||
crCloseStartModal();
|
||
// teacher joins their own session (adds to attendance + fires classroom_user_joined SSE)
|
||
await LS.post(`/api/classroom/${session.id}/join`).catch(() => {});
|
||
const fullSession = await LS.get(`/api/classroom/${session.id}`).catch(() => session);
|
||
_session = fullSession;
|
||
enterActiveState(fullSession);
|
||
loadChat();
|
||
} catch (e) {
|
||
LS.toast(e.message || 'Ошибка', 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
/* ── join session (student) ── */
|
||
async function crJoinSession() {
|
||
if (!_sessionId) return;
|
||
try {
|
||
const joinRes = await LS.post(`/api/classroom/${_sessionId}/join`);
|
||
const session = await LS.get(`/api/classroom/${_sessionId}`);
|
||
_session = session;
|
||
// Set _canDraw BEFORE enterActiveState so initWhiteboard sees it
|
||
if (session.canDraw || joinRes.canDraw) _canDraw = true;
|
||
enterActiveState(session);
|
||
// load existing chat
|
||
loadChat();
|
||
// Apply toolbar/readonly state if draw was pre-set
|
||
if (_canDraw) enableDrawPermission();
|
||
} catch (e) {
|
||
LS.toast(e.message || 'Ошибка входа', 'error');
|
||
}
|
||
}
|
||
|
||
/* ── active state ── */
|
||
function enterActiveState(session) {
|
||
// reset dedup state for fresh session
|
||
_lastChatId = 0;
|
||
_renderedMsgIds.clear();
|
||
_notesLoaded = false;
|
||
document.getElementById('cr-messages').innerHTML = '';
|
||
document.getElementById('cr-notes-ta').value = '';
|
||
startPolling();
|
||
setActiveState(true);
|
||
document.getElementById('cr-idle-teacher').style.display = 'none';
|
||
document.getElementById('cr-idle-student').style.display = 'none';
|
||
document.getElementById('cr-join-banner').style.display = 'none';
|
||
document.getElementById('cr-active-main').style.display = 'flex';
|
||
document.getElementById('cr-session-title').textContent = session.title || 'Онлайн-урок';
|
||
document.getElementById('cr-session-chip').style.display = 'inline-flex';
|
||
document.getElementById('cr-header-actions').style.display = 'flex';
|
||
|
||
const isTeacher = _me.role === 'teacher' || _me.role === 'admin';
|
||
document.getElementById('cr-end-btn').style.display = isTeacher ? 'flex' : 'none';
|
||
document.getElementById('cr-leave-btn').style.display = isTeacher ? 'none' : 'flex';
|
||
document.getElementById('cr-screen-btn').style.display = isTeacher ? 'flex' : 'none';
|
||
document.getElementById('cr-sim-btn').style.display = isTeacher ? 'flex' : 'none';
|
||
document.getElementById('cr-tb-btn').style.display = isTeacher ? 'flex' : 'none';
|
||
document.getElementById('cr-guest-btn').style.display = isTeacher ? 'flex' : 'none';
|
||
document.getElementById('cr-share-lib-btn').style.display = isTeacher ? 'flex' : 'none';
|
||
document.getElementById('cr-hand-btn').style.display = isTeacher ? 'none' : 'flex';
|
||
document.getElementById('btn-templates').style.display = isTeacher ? 'flex' : 'none';
|
||
// Teacher: show raised hands panel
|
||
document.getElementById('cr-hands-section').style.display = isTeacher ? 'block' : 'none';
|
||
// Quiz panel
|
||
crQuizOnSessionActive(isTeacher);
|
||
|
||
// page state
|
||
_wbCurrentPage = session.current_page || 1;
|
||
_totalPages = session.pageCount || 1;
|
||
// Remember board theme from session — will be applied in initWhiteboard
|
||
if (session.board_theme) _sessionBoardTheme = session.board_theme;
|
||
_raisedHands = {};
|
||
_handRaised = false;
|
||
_followTeacher = true;
|
||
updatePageLabel();
|
||
|
||
// populate participants from attendance
|
||
if (session.attendance) {
|
||
session.attendance.forEach(a => {
|
||
if (!a.left_at) _participants[a.user_id] = { name: a.name || a.user_name, micMuted: false };
|
||
});
|
||
}
|
||
if (_me?.id) _participants[_me.id] = { name: _me.name, micMuted: false };
|
||
updateParticipantsList();
|
||
if (window.lucide) lucide.createIcons();
|
||
|
||
// session timer
|
||
// SQLite stores UTC without 'T'/'Z' — force UTC parse
|
||
_sessionStartTime = session.created_at
|
||
? new Date(session.created_at.replace(' ', 'T') + 'Z')
|
||
: new Date();
|
||
if (_timerHandle) clearInterval(_timerHandle);
|
||
_timerHandle = setInterval(updateTimer, 1000);
|
||
document.getElementById('cr-timer').style.display = 'inline';
|
||
updateTimer();
|
||
|
||
// show correct nav (teacher toolbar vs student nav)
|
||
document.getElementById('cr-student-nav').style.display = isTeacher ? 'none' : 'flex';
|
||
|
||
// load raised hands (non-blocking)
|
||
_loadHandsAsync(session.id);
|
||
|
||
// init whiteboard
|
||
initWhiteboard(isTeacher, session.id);
|
||
|
||
// open WebSocket for low-latency cursor + preview
|
||
_crWsConnect();
|
||
|
||
// init WebRTC (async - don't block UI)
|
||
initRTC(session);
|
||
|
||
// Welcome status for student
|
||
if (!isTeacher) {
|
||
setTimeout(() => crStatusNotify('info',
|
||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
|
||
`Урок начался: ${session.title || 'Онлайн-урок'}`, 5000), 600);
|
||
}
|
||
}
|
||
|
||
function setActiveState(active) {
|
||
document.getElementById('chat-no-session').style.display = active ? 'none' : 'flex';
|
||
document.getElementById('chat-active').style.display = active ? 'flex' : 'none';
|
||
document.getElementById('participants-no-session').style.display = active ? 'none' : 'flex';
|
||
// Hide the right panel entirely when no session is active (avoids empty strip on mobile)
|
||
document.getElementById('cr-right').style.display = active ? '' : 'none';
|
||
}
|
||
|
||
/* ── whiteboard ── */
|
||
async function initWhiteboard(isTeacher, sessionId) {
|
||
const canvas = document.getElementById('cr-canvas');
|
||
if (!canvas) return;
|
||
|
||
// destroy previous instance
|
||
if (_wb) { _wb.destroy(); _wb = null; }
|
||
wbStopBatch();
|
||
_wbPendingSSE = [];
|
||
_wbOwnIds.clear();
|
||
|
||
// Mark as initializing — SSE strokes will be buffered into _wbPendingSSE
|
||
_wbInitializing = true;
|
||
|
||
const canEdit = isTeacher || _canDraw;
|
||
_wb = new Whiteboard(canvas, {
|
||
readOnly: !canEdit,
|
||
onStrokeDone: canEdit ? wbOnLocalStroke : null,
|
||
onStrokeUndo: canEdit ? wbOnLocalUndo : null,
|
||
onStrokeProgress: canEdit ? wbOnStrokeProgress : null,
|
||
onStrokeUpdated: canEdit ? wbOnLocalStrokeUpdated : null,
|
||
onCursorMove: wbOnCursorMove,
|
||
onFormulaInsert: canEdit ? showFormulaModal : null,
|
||
onCoordEdit: canEdit ? showCoordModal : null,
|
||
onNumberLineEdit: canEdit ? showNumLineModal : null,
|
||
onObjectCreated: canEdit ? () => wbSetTool('select') : null,
|
||
onToolSwitch: canEdit ? tool => wbSetTool(tool) : null,
|
||
onZoomChange: z => { const el = document.getElementById('wb-zoom-label'); if (el) el.textContent = Math.round(z * 100) + '%'; },
|
||
onOverlayChange: wbOvChange,
|
||
stylusMultiplier: _prefs.stylusMultiplier,
|
||
});
|
||
|
||
// Apply session board theme (set by teacher, synced for all participants)
|
||
if (_sessionBoardTheme && _sessionBoardTheme !== 'chalkboard') {
|
||
_wb.setBoardTheme(_sessionBoardTheme);
|
||
}
|
||
{ const sel = document.getElementById('wb-theme-select'); if (sel) sel.value = _sessionBoardTheme || 'chalkboard'; }
|
||
_sessionBoardTheme = null; // consumed
|
||
|
||
// Apply saved whiteboard defaults from user preferences
|
||
if (canEdit && LS.prefs) {
|
||
LS.prefs.init().then(() => {
|
||
const wb = LS.prefs.get('wb', {});
|
||
if (wb.color) {
|
||
_wb.setColor(wb.color);
|
||
document.querySelectorAll('.cr-color-btn').forEach(b => b.classList.remove('active'));
|
||
const match = document.querySelector(`.cr-color-btn[data-color="${wb.color}"]`);
|
||
if (match) match.classList.add('active');
|
||
}
|
||
if (wb.width) {
|
||
_wb.setWidth(wb.width);
|
||
document.querySelectorAll('.cr-width-btn').forEach(b => b.classList.remove('active'));
|
||
const wBtn = document.getElementById(`wb-w${wb.width}`);
|
||
if (wBtn) wBtn.classList.add('active');
|
||
}
|
||
if (wb.lineStyle) {
|
||
_wb.setLineStyle(wb.lineStyle);
|
||
document.querySelectorAll('.cr-linestyle-btn').forEach(b => b.classList.remove('active'));
|
||
const lBtn = document.getElementById(`wb-ls-${wb.lineStyle}`);
|
||
if (lBtn) lBtn.classList.add('active');
|
||
}
|
||
if (wb.theme) {
|
||
_wb.setBoardTheme(wb.theme);
|
||
const sel = document.getElementById('wb-theme-select');
|
||
if (sel) sel.value = wb.theme;
|
||
}
|
||
}).catch(() => {});
|
||
}
|
||
|
||
// show/hide toolbar, thumbs panel, and readonly label
|
||
document.getElementById('cr-toolbar').style.display = canEdit ? 'flex' : 'none';
|
||
document.getElementById('wb-thumbs-panel').style.display = isTeacher ? 'flex' : 'none';
|
||
document.getElementById('cr-board-readonly').style.display = canEdit ? 'none' : 'block';
|
||
|
||
// Apply default tool preference
|
||
if (canEdit && _prefs.defaultTool && _prefs.defaultTool !== 'pencil') {
|
||
wbSetTool(_prefs.defaultTool);
|
||
}
|
||
|
||
// load page names
|
||
try {
|
||
const pagesRes = await LS.get(`/api/classroom/${sessionId}/pages`);
|
||
_pageNames = {};
|
||
(pagesRes.pages || []).forEach(p => { if (p.name) _pageNames[p.page_num] = p.name; });
|
||
} catch {}
|
||
|
||
// load existing strokes — done before unblocking SSE to avoid duplicates
|
||
try {
|
||
const res = await LS.get(`/api/classroom/${sessionId}/strokes?page_num=1`);
|
||
const initialStrokes = res.strokes || [];
|
||
_wbUpdateMaxSeq(initialStrokes);
|
||
_wb.loadStrokes(initialStrokes);
|
||
if (res.template) { _wb.setTemplate(res.template); const sel = document.getElementById('wb-tpl-select'); if (sel) sel.value = res.template; }
|
||
if (res.name !== undefined) _pageNames[1] = res.name || null;
|
||
_wbUpdateBgBtn();
|
||
wbUpdateThumbnail(1);
|
||
} catch {}
|
||
|
||
// Unlock SSE processing and apply any buffered strokes
|
||
_wbInitializing = false;
|
||
if (_wbPendingSSE.length > 0) {
|
||
_wbUpdateMaxSeq(_wbPendingSSE);
|
||
_wb.addStrokes(_wbPendingSSE);
|
||
_wbPendingSSE = [];
|
||
}
|
||
|
||
// apply initial cursor style based on active tool
|
||
wbUpdateCursorStyle();
|
||
|
||
// Selection-based context UI (connector arrows, multi-select alignment)
|
||
canvas.addEventListener('pointerup', () => setTimeout(wbCheckSelectionUI, 20));
|
||
|
||
if (isTeacher) {
|
||
// start batch flush every 80ms
|
||
_wbBatchTimer = setInterval(wbFlushBatch, 80);
|
||
// Incremental poll every 4s as SSE fallback — fetches only strokes newer than _wbMaxSeq
|
||
_teacherPollTimer = setInterval(_wbTeacherIncrementalPoll, 4000);
|
||
// cursor is now broadcast via whiteboard's onCursorMove callback
|
||
} else {
|
||
// Start batch flush if student has draw permission
|
||
if (_canDraw) {
|
||
if (!_wbBatchTimer) _wbBatchTimer = setInterval(wbFlushBatch, 80);
|
||
// Full-sync every 2s (preserves local unconfirmed strokes, guarantees teacher strokes appear)
|
||
if (!_teacherPollTimer) _teacherPollTimer = setInterval(_drawingStudentSyncPoll, 2000);
|
||
} else {
|
||
// Read-only student: poll every 2s as a fallback in case SSE events are missed
|
||
wbStartPoll();
|
||
}
|
||
// Draw permission delivered via WS — no periodic poll needed
|
||
}
|
||
}
|
||
|
||
/** Teacher: fetch strokes newer than _wbMaxSeq to catch any missed SSE events */
|
||
async function _wbTeacherIncrementalPoll() {
|
||
if (!_sessionId || !_wb || _wbInitializing) return;
|
||
const since = _wbMaxSeq;
|
||
const page = _wbCurrentPage;
|
||
try {
|
||
const res = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${page}&since_seq=${since}`);
|
||
if (_wbCurrentPage !== page) return; // page switched while fetching
|
||
const strokes = res.strokes || [];
|
||
if (strokes.length === 0) return;
|
||
const newStrokes = strokes.filter(s => !_wbOwnIds.has(s.id));
|
||
if (newStrokes.length === 0) return;
|
||
_wbUpdateMaxSeq(newStrokes);
|
||
_wb.addStrokes(newStrokes);
|
||
} catch {}
|
||
}
|
||
|
||
/** Drawing student: full-sync poll that preserves locally-drawn (unconfirmed) strokes.
|
||
* Guarantees teacher strokes appear even if SSE is missed. Runs every 2s. */
|
||
async function _drawingStudentSyncPoll() {
|
||
if (!_sessionId || !_wb || _wbInitializing) return;
|
||
const page = _wbCurrentPage;
|
||
const gen = _wbClearGen;
|
||
try {
|
||
const res = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${page}`);
|
||
if (_wbClearGen !== gen || _wbCurrentPage !== page) return;
|
||
const serverStrokes = res.strokes || [];
|
||
// Collect strokes with negative IDs (locally drawn, not yet saved to server)
|
||
// Snapshot taken AFTER fetch so we don't re-add strokes that were confirmed during await
|
||
const localUnconfirmed = _wb.getLocalStrokes();
|
||
_wbMaxSeq = 0;
|
||
_wbUpdateMaxSeq(serverStrokes);
|
||
_wb.loadStrokes(serverStrokes); // full replace — includes all teacher strokes
|
||
// Re-add unconfirmed local strokes on top (addStrokes deduplicates by ID)
|
||
if (localUnconfirmed.length > 0) _wb.addStrokes(localUnconfirmed);
|
||
if (res.template) _wb.setTemplate(res.template);
|
||
wbUpdateThumbnail(page);
|
||
} catch {}
|
||
}
|
||
|
||
/** Student: check draw permission state from server (SSE fallback) */
|
||
async function _checkDrawPermission() {
|
||
if (!_sessionId || !_wb) return;
|
||
const isTeacher = _me?.role === 'teacher' || _me?.role === 'admin';
|
||
if (isTeacher) return;
|
||
try {
|
||
const session = await LS.get(`/api/classroom/${_sessionId}`);
|
||
if (session.canDraw && !_canDraw) {
|
||
enableDrawPermission(true); // silent — catching up with missed SSE event
|
||
} else if (!session.canDraw && _canDraw) {
|
||
disableDrawPermission();
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
/* Remote cursors: one DOM element per userId */
|
||
const _remoteCursorEls = new Map(); // userId → {el, hideTimer}
|
||
const _CURSOR_COLORS_DOM = ['#9B5DE5','#06D6E0','#FF6B6B','#A8E063','#FF9F43','#F15BB5','#4361EE','#FFE066'];
|
||
|
||
function _getOrCreateCursorEl(userId, userName, color) {
|
||
if (_remoteCursorEls.has(userId)) return _remoteCursorEls.get(userId);
|
||
const wrap = document.getElementById('cr-board-wrap');
|
||
if (!wrap) return null;
|
||
const el = document.createElement('div');
|
||
el.style.cssText = 'position:absolute;pointer-events:none;z-index:20;display:none;transform:translate(-4px,-4px);transition:left 100ms linear,top 100ms linear;';
|
||
el.innerHTML = `
|
||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M2 2L12 6L7 7.5L5.5 12L2 2Z" fill="${color}" stroke="#fff" stroke-width="1"/>
|
||
</svg>
|
||
<span style="position:absolute;left:14px;top:0;background:${color};color:#fff;font-size:0.6rem;font-weight:700;padding:1px 5px;border-radius:4px;white-space:nowrap;">${userName || ''}</span>`;
|
||
wrap.appendChild(el);
|
||
_remoteCursorEls.set(userId, { el, hideTimer: null });
|
||
return _remoteCursorEls.get(userId);
|
||
}
|
||
|
||
function _showRemoteCursor(userId, userName, vx, vy) {
|
||
const wrap = document.getElementById('cr-board-wrap');
|
||
if (!wrap) return;
|
||
const color = _CURSOR_COLORS_DOM[Math.abs(userId) % _CURSOR_COLORS_DOM.length];
|
||
const entry = _getOrCreateCursorEl(userId, userName, color);
|
||
if (!entry) return;
|
||
const { el } = entry;
|
||
const cssW = wrap.clientWidth || 1;
|
||
const cssH = wrap.clientHeight || 1;
|
||
el.style.left = ((vx / 1920) * cssW) + 'px';
|
||
el.style.top = ((vy / 1080) * cssH) + 'px';
|
||
el.style.display = 'block';
|
||
if (entry.hideTimer) clearTimeout(entry.hideTimer);
|
||
entry.hideTimer = setTimeout(() => { el.style.display = 'none'; }, 3000);
|
||
}
|
||
|
||
function _cleanupRemoteCursors() {
|
||
for (const { el } of _remoteCursorEls.values()) el.remove();
|
||
_remoteCursorEls.clear();
|
||
}
|
||
|
||
// Keep legacy function name for backward compat with old SSE events
|
||
function _showTeacherCursor(vx, vy) {
|
||
if (_session?.teacher_id) _showRemoteCursor(_session.teacher_id, 'Учитель', vx, vy);
|
||
}
|
||
|
||
/* Teacher cursor: throttled broadcast */
|
||
function _onTeacherMouseMove(e) {
|
||
if (!_sessionId || !_wb) return;
|
||
if (_cursorThrottle) return; // already scheduled
|
||
_cursorThrottle = setTimeout(() => {
|
||
_cursorThrottle = null;
|
||
const rect = e.target.getBoundingClientRect();
|
||
const cssW = e.target.clientWidth || 1;
|
||
const cssH = e.target.clientHeight || 1;
|
||
// Normalize to virtual 1920x1080
|
||
const vx = ((e.clientX - rect.left) / cssW) * 1920;
|
||
const vy = ((e.clientY - rect.top) / cssH) * 1080;
|
||
LS.post(`/api/classroom/${_sessionId}/cursor`, { x: vx, y: vy, page_num: _wbCurrentPage }).catch(() => {});
|
||
}, 150); // ~6fps throttle — enough for cursor indicator, stays within rate limits
|
||
}
|
||
|
||
function _onTeacherMouseLeave() {
|
||
// Send off-screen cursor position to hide it on student side
|
||
if (_sessionId) {
|
||
LS.post(`/api/classroom/${_sessionId}/cursor`, { x: -1, y: -1, page_num: _wbCurrentPage }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
function wbOnLocalStroke(stroke) {
|
||
_wbBatch.push(stroke);
|
||
wbUpdateThumbnail(_wbCurrentPage);
|
||
}
|
||
|
||
/* Called when teacher moves/resizes an image object on the board */
|
||
function wbOnLocalStrokeUpdated(stroke) {
|
||
if (!_sessionId || stroke.id < 0) return; // not yet saved to server — skip
|
||
LS.patch(`/api/classroom/${_sessionId}/strokes/${stroke.id}`, { data: stroke.data }).catch(() => {});
|
||
}
|
||
|
||
async function wbOnLocalUndo(id) {
|
||
// if it's a server-confirmed id (positive), delete from server
|
||
if (id > 0) {
|
||
try { await LS.del(`/api/classroom/${_sessionId}/strokes/${id}`); } catch {}
|
||
}
|
||
// if negative (local only, not yet sent), remove from batch queue
|
||
_wbBatch = _wbBatch.filter(s => s.id !== id);
|
||
}
|
||
|
||
/* ── WebSocket connect/close (classroom session scope) ── */
|
||
function _crWsConnect() {
|
||
if (_crWs && (_crWs.readyState === WebSocket.OPEN || _crWs.readyState === WebSocket.CONNECTING)) return;
|
||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
_crWs = new WebSocket(`${proto}//${location.host}/ws`);
|
||
_crWs.onopen = () => {
|
||
// Authenticate via first message — token never appears in URL or proxy logs
|
||
const token = LS.getToken() || '';
|
||
_crWs.send(JSON.stringify({ type: 'auth', token }));
|
||
};
|
||
_crWs.onclose = () => {
|
||
_crWsReady = false;
|
||
// Auto-reconnect after 2s (SSE stays active as fallback during gap)
|
||
if (_sessionId) setTimeout(() => { if (_sessionId) _crWsConnect(); }, 2000);
|
||
};
|
||
_crWs.onerror = () => { _crWsReady = false; };
|
||
_crWs.onmessage = e => {
|
||
let msg;
|
||
try { msg = JSON.parse(e.data); } catch { return; }
|
||
if (msg.type === 'auth_ok') {
|
||
_crWsReady = true;
|
||
// Register in session so server delivers events via WS instead of SSE
|
||
if (_sessionId) _crWs.send(JSON.stringify({ type: 'classroom_join', sessionId: _sessionId }));
|
||
return;
|
||
}
|
||
// All other classroom server→client events
|
||
try { handleSSE(msg, true); } catch {}
|
||
};
|
||
}
|
||
|
||
function _crWsClose() {
|
||
_crWsReady = false;
|
||
if (_crWs) { try { _crWs.close(); } catch {} _crWs = null; }
|
||
}
|
||
|
||
function _crWsSend(obj) {
|
||
if (_crWsReady && _crWs?.readyState === WebSocket.OPEN) {
|
||
try { _crWs.send(JSON.stringify(obj)); return true; } catch {}
|
||
}
|
||
return false; // fallback to HTTP
|
||
}
|
||
|
||
/* Called by Whiteboard during drawing (throttled ~20ms inside the class).
|
||
Sends preview via WebSocket (fast path); falls back to HTTP if WS not ready. */
|
||
function wbOnStrokeProgress(p) {
|
||
if (!_sessionId) return;
|
||
if (p.cancel) {
|
||
if (!_crWsSend({ type: 'preview', sessionId: _sessionId, liveId: p.liveId, pageNum: _wbCurrentPage, cancel: true })) {
|
||
LS.post(`/api/classroom/${_sessionId}/stroke-preview`, {
|
||
live_id: p.liveId, cancel: true, page_num: _wbCurrentPage,
|
||
}).catch(() => {});
|
||
}
|
||
return;
|
||
}
|
||
if (!_crWsSend({ type: 'preview', sessionId: _sessionId, liveId: p.liveId, tool: p.tool, data: p.data, pageNum: _wbCurrentPage })) {
|
||
LS.post(`/api/classroom/${_sessionId}/stroke-preview`, {
|
||
live_id: p.liveId, tool: p.tool, data: p.data, page_num: _wbCurrentPage,
|
||
}).catch(() => {});
|
||
}
|
||
}
|
||
|
||
function wbOnCursorMove(vx, vy) {
|
||
if (!_sessionId) return;
|
||
if (!_crWsSend({ type: 'cursor', sessionId: _sessionId, x: vx, y: vy, pageNum: _wbCurrentPage })) {
|
||
LS.post(`/api/classroom/${_sessionId}/cursor`, {
|
||
x: vx, y: vy, page_num: _wbCurrentPage,
|
||
}).catch(() => {});
|
||
}
|
||
}
|
||
|
||
let _wbFlushFails = 0;
|
||
async function wbFlushBatch() {
|
||
if (!_sessionId || _wbBatch.length === 0) return;
|
||
// Backoff: skip ticks after consecutive failures (max ~5s pause)
|
||
if (_wbFlushFails > 0) {
|
||
const skipTicks = Math.min(60, Math.pow(2, _wbFlushFails)); // 2,4,8,16...60
|
||
if (Math.random() > 1 / skipTicks) return;
|
||
}
|
||
const toSend = _wbBatch.splice(0, _wbBatch.length); // drain queue
|
||
try {
|
||
const res = await LS.post(`/api/classroom/${_sessionId}/strokes`, {
|
||
page_num: _wbCurrentPage,
|
||
strokes: toSend.map(s => ({ tool: s.tool, data: s.data })),
|
||
});
|
||
_wbFlushFails = 0;
|
||
// update local (negative) ids to server-assigned ids; track own ids to skip in SSE
|
||
if (res.strokes && _wb) {
|
||
res.strokes.forEach((saved, i) => {
|
||
if (toSend[i]) {
|
||
_wbOwnIds.add(saved.id); // register BEFORE confirmStroke calls render
|
||
if (_wbOwnIds.size > _WB_OWN_IDS_MAX) { const it = _wbOwnIds.values().next().value; _wbOwnIds.delete(it); }
|
||
_wb.confirmStroke(toSend[i].id, saved.id);
|
||
}
|
||
});
|
||
}
|
||
} catch {
|
||
_wbFlushFails++;
|
||
_wbBatch.unshift(...toSend);
|
||
}
|
||
}
|
||
|
||
function wbStopBatch() {
|
||
if (_wbBatchTimer) { clearInterval(_wbBatchTimer); _wbBatchTimer = null; }
|
||
if (_wbLiveTimer) { clearTimeout(_wbLiveTimer); _wbLiveTimer = null; }
|
||
if (_teacherPollTimer){ clearInterval(_teacherPollTimer);_teacherPollTimer= null; }
|
||
if (_drawPermPollTimer){ clearInterval(_drawPermPollTimer);_drawPermPollTimer= null; }
|
||
wbStopPoll();
|
||
_wbBatch = [];
|
||
_wbOwnIds.clear();
|
||
_wbInitializing = false;
|
||
_wbPendingSSE = [];
|
||
_wbMaxSeq = 0;
|
||
}
|
||
|
||
/* Track max stroke seq (for efficient polling with since_seq) */
|
||
function _wbUpdateMaxSeq(strokes) {
|
||
for (const s of strokes) {
|
||
if (s.seq != null && s.seq > _wbMaxSeq) _wbMaxSeq = s.seq;
|
||
}
|
||
}
|
||
|
||
/* Кол-во подтверждённых сервером штрихов на доске: у них положительный id
|
||
(локальные, ещё не отправленные — отрицательный). */
|
||
function _wbLoadedServerCount() {
|
||
try { return (_wb && _wb._strokes) ? _wb._strokes.filter(s => s.id > 0).length : 0; }
|
||
catch (e) { return 0; }
|
||
}
|
||
|
||
/* Student-side poll (2s, страховка к WS/SSE). Сначала тянет лёгкую сигнатуру
|
||
страницы (maxSeq + count); если доска уже совпадает с сервером (обычный случай
|
||
при живом WS) — НИЧЕГО не грузит. Полная перезагрузка — только при расхождении
|
||
(пропущенные при обрыве события, клиры, удаления). */
|
||
function wbStartPoll() {
|
||
wbStopPoll();
|
||
_strokePollTimer = setInterval(async () => {
|
||
if (!_sessionId || !_wb || _wbInitializing) return;
|
||
const page = _wbCurrentPage;
|
||
const gen = _wbClearGen;
|
||
try {
|
||
const sig = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${page}&meta=1`);
|
||
if (_wbClearGen !== gen || _wbCurrentPage !== page) return;
|
||
// Доска идентична серверу → пропускаем тяжёлую перезагрузку
|
||
if (sig && sig.maxSeq === _wbMaxSeq && sig.count === _wbLoadedServerCount()) return;
|
||
// Расхождение → полная перезагрузка (проверенный путь: корректно отражает клиры/undo/пропуски)
|
||
const res = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${page}`);
|
||
if (_wbClearGen !== gen || _wbCurrentPage !== page) return;
|
||
const strokes = res.strokes || [];
|
||
_wbMaxSeq = 0;
|
||
_wbUpdateMaxSeq(strokes);
|
||
_wb.loadStrokes(strokes);
|
||
if (res.template) _wb.setTemplate(res.template);
|
||
wbUpdateThumbnail(page);
|
||
} catch {}
|
||
}, 2000);
|
||
}
|
||
|
||
function wbStopPoll() {
|
||
if (_strokePollTimer) { clearInterval(_strokePollTimer); _strokePollTimer = null; }
|
||
}
|
||
|
||
// zoom controls
|
||
function wbZoomIn() { if (_wb) _wb.zoomTo(_wb._zoom * 1.25); }
|
||
function wbZoomOut() { if (_wb) _wb.zoomTo(_wb._zoom / 1.25); }
|
||
function wbResetZoom() { if (_wb) _wb.resetView(); }
|
||
|
||
// toolbar handlers
|
||
const WB_TOOLS = ['pencil','eraser','highlighter','laser','select',
|
||
'rect','ellipse','line','arrow',
|
||
'triangle','diamond','hexagon','star','roundedrect','callout',
|
||
'text','sticky','formula','table','connector','coordinate','numberline','compass'];
|
||
/* ── Custom cursors ── */
|
||
function _makePencilCursor(color, lineW) {
|
||
const r = Math.max(2, Math.min(8, lineW * 0.8));
|
||
const sz = 32;
|
||
const tip = sz - 4;
|
||
const enc = encodeURIComponent(
|
||
`<svg xmlns="http://www.w3.org/2000/svg" width="${sz}" height="${sz}" viewBox="0 0 ${sz} ${sz}">` +
|
||
`<circle cx="${tip}" cy="${tip}" r="${r}" fill="${color}" opacity="0.85" stroke="rgba(0,0,0,0.5)" stroke-width="1"/>` +
|
||
`<line x1="0" y1="0" x2="${tip - r}" y2="${tip - r}" stroke="rgba(0,0,0,0.4)" stroke-width="0.7"/>` +
|
||
`</svg>`
|
||
);
|
||
return `url("data:image/svg+xml,${enc}") ${tip} ${tip}, crosshair`;
|
||
}
|
||
|
||
function _makeEraserCursor(lineW) {
|
||
const s = Math.max(8, Math.min(28, lineW * 2));
|
||
const sz = s + 6;
|
||
const off = 3;
|
||
const enc = encodeURIComponent(
|
||
`<svg xmlns="http://www.w3.org/2000/svg" width="${sz}" height="${sz}" viewBox="0 0 ${sz} ${sz}">` +
|
||
`<rect x="${off}" y="${off}" width="${s}" height="${s}" rx="2" fill="rgba(255,255,255,0.15)" stroke="rgba(255,255,255,0.7)" stroke-width="1.2"/>` +
|
||
`</svg>`
|
||
);
|
||
const hot = Math.round(sz / 2);
|
||
return `url("data:image/svg+xml,${enc}") ${hot} ${hot}, cell`;
|
||
}
|
||
|
||
function _makeHighlighterCursor(color) {
|
||
const enc = encodeURIComponent(
|
||
`<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">` +
|
||
`<rect x="8" y="14" width="12" height="10" rx="1" fill="${color}" opacity="0.55" stroke="rgba(0,0,0,0.3)" stroke-width="1"/>` +
|
||
`<line x1="14" y1="0" x2="14" y2="28" stroke="rgba(0,0,0,0.25)" stroke-width="0.5"/>` +
|
||
`<line x1="0" y1="14" x2="28" y2="14" stroke="rgba(0,0,0,0.25)" stroke-width="0.5"/>` +
|
||
`</svg>`
|
||
);
|
||
return `url("data:image/svg+xml,${enc}") 14 14, crosshair`;
|
||
}
|
||
|
||
function wbUpdateCursorStyle() {
|
||
if (!_wb) return;
|
||
const tool = _wb._tool;
|
||
const color = _wb._color || '#ffffff';
|
||
const lineW = _wb._lineWidth || 4;
|
||
let cursor;
|
||
if (tool === 'pencil') cursor = _makePencilCursor(color, lineW);
|
||
else if (tool === 'highlighter') cursor = _makeHighlighterCursor(color);
|
||
else if (tool === 'eraser') cursor = _makeEraserCursor(lineW);
|
||
else {
|
||
const map = { text: 'text', select: 'default', sticky: 'copy', formula: 'copy',
|
||
table: 'crosshair', connector: 'crosshair', laser: 'none',
|
||
coordinate: 'copy', numberline: 'copy', compass: 'copy' };
|
||
cursor = map[tool] || 'crosshair';
|
||
}
|
||
document.getElementById('cr-canvas').style.cursor = cursor;
|
||
}
|
||
|
||
const WB_SHAPE_TOOLS = ['rect','ellipse','line','arrow','triangle','diamond','hexagon','star','roundedrect','callout'];
|
||
|
||
function wbSetTool(tool) {
|
||
if (!_wb) return;
|
||
_wb.setTool(tool);
|
||
WB_TOOLS.forEach(t => {
|
||
const el = document.getElementById(`wb-tool-${t}`);
|
||
if (el) el.classList.toggle('active', t === tool);
|
||
});
|
||
// shape picker trigger: highlight if a shape tool is active, update icon
|
||
const isShape = WB_SHAPE_TOOLS.includes(tool);
|
||
const shapeTrigger = document.getElementById('wb-shape-picker-btn');
|
||
if (shapeTrigger) shapeTrigger.classList.toggle('active', isShape);
|
||
if (isShape) {
|
||
const srcBtn = document.getElementById(`wb-tool-${tool}`);
|
||
const slot = document.getElementById('wb-shape-icon-slot');
|
||
if (srcBtn && slot) slot.innerHTML = srcBtn.querySelector('svg').outerHTML;
|
||
}
|
||
// text row visibility
|
||
document.getElementById('cr-text-row')?.classList.toggle('visible', tool === 'text');
|
||
// sticky color row visibility
|
||
document.getElementById('wb-sticky-row')?.classList.toggle('visible', tool === 'sticky');
|
||
// hide connector/align rows when switching tools
|
||
document.getElementById('wb-connector-row')?.classList.remove('visible');
|
||
document.getElementById('wb-align-row')?.classList.remove('visible');
|
||
wbUpdateCursorStyle();
|
||
}
|
||
|
||
// Position a fixed popup above its trigger button (no inline display — CSS class controls visibility)
|
||
function _positionDropPopup(popup, btn, approxW) {
|
||
const r = btn.getBoundingClientRect();
|
||
let left = r.left + r.width / 2 - approxW / 2;
|
||
left = Math.max(4, Math.min(left, window.innerWidth - approxW - 4));
|
||
popup.style.left = left + 'px';
|
||
popup.style.top = 'auto';
|
||
popup.style.bottom = (window.innerHeight - r.top + 8) + 'px';
|
||
}
|
||
|
||
// Shape picker dropdown (~182px wide: 5×32 + 4×3gap + 2×5pad)
|
||
function wbToggleShapePicker() {
|
||
const popup = document.getElementById('cr-shape-popup');
|
||
const btn = document.getElementById('wb-shape-picker-btn');
|
||
const wasOpen = popup.classList.contains('open');
|
||
document.getElementById('cr-utils-popup')?.classList.remove('open');
|
||
if (wasOpen) { popup.classList.remove('open'); return; }
|
||
_positionDropPopup(popup, btn, 182);
|
||
popup.classList.add('open');
|
||
setTimeout(() => document.addEventListener('click', _closeShapePicker, { once: true }), 0);
|
||
}
|
||
function _closeShapePicker(e) {
|
||
if (!document.getElementById('cr-shape-drop')?.contains(e.target))
|
||
document.getElementById('cr-shape-popup')?.classList.remove('open');
|
||
else if (document.getElementById('cr-shape-popup')?.classList.contains('open'))
|
||
document.addEventListener('click', _closeShapePicker, { once: true });
|
||
}
|
||
function wbPickShape(tool) {
|
||
document.getElementById('cr-shape-popup')?.classList.remove('open');
|
||
wbSetTool(tool);
|
||
}
|
||
|
||
// Text alignment
|
||
function wbSetTextAlign(align) {
|
||
if (!_wb) return;
|
||
_wb.setTextAlign(align);
|
||
['left','center','right'].forEach(a => {
|
||
document.getElementById(`wb-text-al-${a}`)?.classList.toggle('active', a === align);
|
||
});
|
||
}
|
||
|
||
// Text tool options
|
||
function wbSetTextFont(family) {
|
||
if (!_wb) return;
|
||
_wb.setTextFontFamily(family);
|
||
// Also update selected text/sticky strokes
|
||
if (_wb._selectedIds.size > 0) {
|
||
for (const id of _wb._selectedIds) {
|
||
const s = _wb._strokes.find(x => x.id === id);
|
||
if (s && (s.tool === 'text' || s.tool === 'sticky')) {
|
||
s.data.fontFamily = family;
|
||
if (_wb._onStrokeUpdated) _wb._onStrokeUpdated(s);
|
||
}
|
||
}
|
||
_wb._staticDirty = true;
|
||
_wb.render();
|
||
}
|
||
}
|
||
function wbSetTextSize(v) {
|
||
const n = parseInt(v);
|
||
if (!_wb || isNaN(n)) return;
|
||
_wb.setTextFontSize(n);
|
||
const inp = document.getElementById('wb-text-size');
|
||
if (inp) inp.value = Math.max(8, Math.min(120, n));
|
||
}
|
||
function wbToggleTextBold() {
|
||
if (!_wb) return;
|
||
_wb._textBold = !_wb._textBold;
|
||
_wb.setTextBold(_wb._textBold);
|
||
document.getElementById('wb-text-bold')?.classList.toggle('active', _wb._textBold);
|
||
}
|
||
function wbToggleTextItalic() {
|
||
if (!_wb) return;
|
||
_wb._textItalic = !_wb._textItalic;
|
||
_wb.setTextItalic(_wb._textItalic);
|
||
document.getElementById('wb-text-italic')?.classList.toggle('active', _wb._textItalic);
|
||
}
|
||
|
||
// Sticky color presets
|
||
function wbSetStickyColor(btn) {
|
||
if (!_wb) return;
|
||
const c = btn.dataset.sc;
|
||
_wb.setStickyColor(c);
|
||
document.querySelectorAll('.wb-sticky-col').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
}
|
||
|
||
// Connector arrow toggles
|
||
function wbToggleArrow(which) {
|
||
if (!_wb) return;
|
||
_wb.toggleConnectorArrow(which);
|
||
// Update button state
|
||
const sel = _wb._strokes.find(s => s.id === _wb._selectedId);
|
||
if (sel && sel.tool === 'connector') {
|
||
document.getElementById('wb-arr-start')?.classList.toggle('active', !!sel.data.arrowStart);
|
||
document.getElementById('wb-arr-end')?.classList.toggle('active', !!sel.data.arrowEnd);
|
||
}
|
||
}
|
||
|
||
// Multi-select alignment
|
||
function wbAlign(dir) {
|
||
if (_wb) _wb.alignStrokes(dir);
|
||
}
|
||
|
||
// Table grid picker
|
||
function _buildTableGrid() {
|
||
const grid = document.getElementById('wb-tbl-grid');
|
||
const label = document.getElementById('wb-tbl-label');
|
||
if (!grid) return;
|
||
grid.innerHTML = '';
|
||
const ROWS = 6, COLS = 8;
|
||
let hlR = 0, hlC = 0;
|
||
for (let r = 1; r <= ROWS; r++) {
|
||
const row = document.createElement('div');
|
||
row.className = 'wb-tbl-row';
|
||
for (let c = 1; c <= COLS; c++) {
|
||
const cell = document.createElement('div');
|
||
cell.className = 'wb-tbl-cell';
|
||
cell.dataset.r = r; cell.dataset.c = c;
|
||
cell.addEventListener('mouseover', () => {
|
||
hlR = r; hlC = c;
|
||
label.textContent = `${r} × ${c}`;
|
||
grid.querySelectorAll('.wb-tbl-cell').forEach(el => {
|
||
el.classList.toggle('hl', +el.dataset.r <= hlR && +el.dataset.c <= hlC);
|
||
});
|
||
});
|
||
cell.addEventListener('click', () => {
|
||
if (_wb) { _wb.setTableSize(r, c); wbSetTool('table'); }
|
||
document.getElementById('wb-tbl-popup')?.classList.remove('open');
|
||
});
|
||
row.appendChild(cell);
|
||
}
|
||
grid.appendChild(row);
|
||
}
|
||
}
|
||
|
||
function wbToggleTablePicker() {
|
||
const popup = document.getElementById('wb-tbl-popup');
|
||
const btn = document.getElementById('wb-tool-table');
|
||
if (!popup || !btn) return;
|
||
const wasOpen = popup.classList.contains('open');
|
||
document.getElementById('cr-shape-popup')?.classList.remove('open');
|
||
document.getElementById('cr-utils-popup')?.classList.remove('open');
|
||
if (wasOpen) { popup.classList.remove('open'); return; }
|
||
if (!popup.dataset.built) { _buildTableGrid(); popup.dataset.built = '1'; }
|
||
// Reset highlight
|
||
document.getElementById('wb-tbl-label').textContent = '1 × 1';
|
||
popup.querySelectorAll('.wb-tbl-cell').forEach(el => el.classList.remove('hl'));
|
||
_positionDropPopup(popup, btn, 190);
|
||
popup.classList.add('open');
|
||
setTimeout(() => document.addEventListener('click', _closeTablePicker, { once: true }), 0);
|
||
}
|
||
function _closeTablePicker(e) {
|
||
if (!document.getElementById('wb-tbl-drop')?.contains(e.target))
|
||
document.getElementById('wb-tbl-popup')?.classList.remove('open');
|
||
else if (document.getElementById('wb-tbl-popup')?.classList.contains('open'))
|
||
document.addEventListener('click', _closeTablePicker, { once: true });
|
||
}
|
||
|
||
// ── Content templates picker ────────────────────────────────────────
|
||
function wbToggleTplPicker() {
|
||
const popup = document.getElementById('wb-content-tpl-popup');
|
||
const btn = document.getElementById('wb-tpl-picker-btn');
|
||
const wasOpen = popup.classList.contains('open');
|
||
document.getElementById('wb-tpl-popup')?.classList.remove('open');
|
||
document.getElementById('cr-utils-popup')?.classList.remove('open');
|
||
if (wasOpen) { popup.classList.remove('open'); return; }
|
||
_positionDropPopup(popup, btn, 230);
|
||
popup.classList.add('open');
|
||
setTimeout(() => document.addEventListener('click', _closeTplPicker, { once: true }), 0);
|
||
}
|
||
function _closeTplPicker(e) {
|
||
if (!document.getElementById('cr-tpl-drop')?.contains(e.target))
|
||
document.getElementById('wb-content-tpl-popup')?.classList.remove('open');
|
||
else if (document.getElementById('wb-content-tpl-popup')?.classList.contains('open'))
|
||
document.addEventListener('click', _closeTplPicker, { once: true });
|
||
}
|
||
function wbInsertTemplate(name) {
|
||
if (!_wb) return;
|
||
document.getElementById('wb-content-tpl-popup')?.classList.remove('open');
|
||
_wb.insertTemplate(name);
|
||
wbSetTool('select');
|
||
}
|
||
|
||
// ── Page background image ────────────────────────────────────────────
|
||
function wbPickBgImage() {
|
||
document.getElementById('wb-content-tpl-popup')?.classList.remove('open');
|
||
document.getElementById('wb-bg-file-input')?.click();
|
||
}
|
||
function wbBgFileSelected(input) {
|
||
const file = input.files?.[0];
|
||
if (!file || !_wb) return;
|
||
input.value = '';
|
||
if (file.type === 'application/pdf') {
|
||
_wbLoadPdfAsBackground(file);
|
||
} else {
|
||
_wb.pasteImageAsBackground(file);
|
||
_wbUpdateBgBtn();
|
||
}
|
||
}
|
||
function wbRemoveBgImage() {
|
||
if (!_wb) return;
|
||
_wb.removePageBackground();
|
||
_wbUpdateBgBtn();
|
||
}
|
||
function _wbUpdateBgBtn() {
|
||
const btn = document.getElementById('wb-bg-remove-btn');
|
||
if (btn) btn.style.display = _wb?.hasPageBackground() ? '' : 'none';
|
||
}
|
||
async function _wbLoadPdfAsBackground(file) {
|
||
try {
|
||
// Dynamically load PDF.js if not already loaded
|
||
if (!window.pdfjsLib) {
|
||
await new Promise((res, rej) => {
|
||
const s = document.createElement('script');
|
||
s.src = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@4.4.168/build/pdf.min.mjs';
|
||
s.type = 'module';
|
||
s.onload = res; s.onerror = rej;
|
||
document.head.appendChild(s);
|
||
});
|
||
if (!window.pdfjsLib) throw new Error('PDF.js не загрузился');
|
||
}
|
||
const ab = await file.arrayBuffer();
|
||
const pdf = await window.pdfjsLib.getDocument({ data: ab }).promise;
|
||
const page = await pdf.getPage(1);
|
||
const vp = page.getViewport({ scale: 1 });
|
||
// Render at full resolution, capped at 1920 wide
|
||
const scale = Math.min(1, 1920 / vp.width);
|
||
const viewport = page.getViewport({ scale });
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = Math.round(viewport.width);
|
||
canvas.height = Math.round(viewport.height);
|
||
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
|
||
canvas.toBlob(blob => {
|
||
if (blob) { _wb.pasteImageAsBackground(blob); _wbUpdateBgBtn(); }
|
||
}, 'image/jpeg', 0.85);
|
||
} catch (err) {
|
||
LS.toast('Ошибка загрузки PDF: ' + (err.message || err), 'error');
|
||
}
|
||
}
|
||
|
||
// Selection UI: show connector/align context rows based on current selection
|
||
function wbCheckSelectionUI() {
|
||
if (!_wb || _wb._tool !== 'select') {
|
||
document.getElementById('wb-connector-row')?.classList.remove('visible');
|
||
document.getElementById('wb-align-row')?.classList.remove('visible');
|
||
return;
|
||
}
|
||
const ids = [..._wb._selectedIds];
|
||
const cnt = ids.length;
|
||
// Multi-select alignment row
|
||
document.getElementById('wb-align-row')?.classList.toggle('visible', cnt > 1);
|
||
// Connector row (single connector selected)
|
||
let showConn = false;
|
||
if (cnt === 1) {
|
||
const sel = _wb._strokes.find(s => s.id === ids[0]);
|
||
if (sel && sel.tool === 'connector') {
|
||
showConn = true;
|
||
document.getElementById('wb-arr-start')?.classList.toggle('active', !!sel.data.arrowStart);
|
||
document.getElementById('wb-arr-end')?.classList.toggle('active', !!sel.data.arrowEnd);
|
||
}
|
||
}
|
||
document.getElementById('wb-connector-row')?.classList.toggle('visible', showConn);
|
||
|
||
// Text/sticky font row
|
||
let showTextRow = false;
|
||
if (cnt === 1) {
|
||
const sel = _wb._strokes.find(s => s.id === ids[0]);
|
||
if (sel && (sel.tool === 'text' || sel.tool === 'sticky')) {
|
||
showTextRow = true;
|
||
const ff = sel.data.fontFamily || 'Manrope';
|
||
const fSel = document.getElementById('wb-text-font');
|
||
if (fSel && fSel.value !== ff) fSel.value = ff;
|
||
}
|
||
}
|
||
const textRow = document.getElementById('cr-text-row');
|
||
if (textRow && _wb._tool === 'select') {
|
||
textRow.classList.toggle('visible', showTextRow);
|
||
}
|
||
}
|
||
|
||
// Utils picker dropdown (~112px wide: 3×32 + 2×3gap + 2×5pad)
|
||
function wbToggleUtilsPicker() {
|
||
const popup = document.getElementById('cr-utils-popup');
|
||
const btn = document.getElementById('wb-utils-picker-btn');
|
||
const wasOpen = popup.classList.contains('open');
|
||
document.getElementById('cr-shape-popup')?.classList.remove('open');
|
||
if (wasOpen) { popup.classList.remove('open'); return; }
|
||
_positionDropPopup(popup, btn, 112);
|
||
popup.classList.add('open');
|
||
setTimeout(() => document.addEventListener('click', _closeUtilsPicker, { once: true }), 0);
|
||
}
|
||
function _closeUtilsPicker(e) {
|
||
if (!document.getElementById('cr-utils-drop')?.contains(e.target))
|
||
document.getElementById('cr-utils-popup')?.classList.remove('open');
|
||
else if (document.getElementById('cr-utils-popup')?.classList.contains('open'))
|
||
document.addEventListener('click', _closeUtilsPicker, { once: true });
|
||
}
|
||
|
||
function wbSetColor(btn) {
|
||
if (!_wb) return;
|
||
const color = btn.dataset.color;
|
||
_wb.setColor(color);
|
||
document.querySelectorAll('.cr-color-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
// switch to pencil if eraser or laser is active when color changes
|
||
const activeTools = ['eraser','laser'];
|
||
if (activeTools.some(t => document.getElementById(`wb-tool-${t}`)?.classList.contains('active')))
|
||
wbSetTool('pencil');
|
||
else wbUpdateCursorStyle();
|
||
if (LS.prefs) LS.prefs.set('wb.color', color);
|
||
}
|
||
|
||
function wbSetWidth(px, btn) {
|
||
if (!_wb) return;
|
||
_wb.setWidth(px);
|
||
document.querySelectorAll('.cr-width-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
wbUpdateCursorStyle();
|
||
if (LS.prefs) LS.prefs.set('wb.width', px);
|
||
}
|
||
|
||
function wbSetLineStyle(style, btn) {
|
||
if (!_wb) return;
|
||
_wb.setLineStyle(style);
|
||
document.querySelectorAll('.cr-linestyle-btn').forEach(b => b.classList.remove('active'));
|
||
if (btn) btn.classList.add('active');
|
||
if (LS.prefs) LS.prefs.set('wb.lineStyle', style);
|
||
}
|
||
|
||
function wbSetOpacity(val) {
|
||
if (!_wb) return;
|
||
const v = Number(val) / 100;
|
||
_wb.setOpacity(v);
|
||
// also update selected stroke opacity live
|
||
if (_wb._selectedId != null) _wb.updateOpacity(v);
|
||
}
|
||
|
||
function wbToggleRuler() {
|
||
if (!_wb) return;
|
||
_wb.toggleRuler();
|
||
const on = _wb._overlays.some(o => o.type === 'ruler');
|
||
document.getElementById('wb-btn-ruler')?.classList.toggle('active', on);
|
||
if (!on && _wbOvCurrent?.type === 'ruler') wbOvHide();
|
||
}
|
||
|
||
function wbToggleProtractor() {
|
||
if (!_wb) return;
|
||
_wb.toggleProtractor();
|
||
const on = _wb._overlays.some(o => o.type === 'protractor');
|
||
document.getElementById('wb-btn-protractor')?.classList.toggle('active', on);
|
||
if (!on && _wbOvCurrent?.type === 'protractor') wbOvHide();
|
||
}
|
||
|
||
function wbToggleSnap(btn) {
|
||
if (!_wb) return;
|
||
const on = !_wb._snapEnabled;
|
||
_wb.setSnapEnabled(on);
|
||
btn.classList.toggle('active', on);
|
||
}
|
||
|
||
function wbToggleMeasurements(btn) {
|
||
if (!_wb) return;
|
||
const on = _wb.toggleMeasurements();
|
||
btn.classList.toggle('active', on);
|
||
}
|
||
|
||
/* ── Overlay properties panel ─────────────────────────────────────── */
|
||
let _wbOvCurrent = null; // currently shown overlay object
|
||
|
||
function wbOvChange(ov) {
|
||
_wbOvCurrent = ov;
|
||
const panel = document.getElementById('wb-ov-props');
|
||
if (!panel) return;
|
||
const isRuler = ov.type === 'ruler';
|
||
document.getElementById('wb-ov-type').textContent = isRuler ? 'Линейка' : 'Транспортир';
|
||
document.getElementById('wb-ov-size-lbl').firstChild.textContent = isRuler ? 'Длина' : 'Радиус';
|
||
const angleDeg = Math.round(((ov.angle || 0) * 180 / Math.PI) * 10) / 10;
|
||
document.getElementById('wb-ov-angle').value = angleDeg;
|
||
const sizeVal = isRuler ? Math.round(ov.width || 400) : Math.round(ov.radius || 80);
|
||
document.getElementById('wb-ov-size').value = sizeVal;
|
||
panel.classList.add('visible');
|
||
}
|
||
|
||
function wbOvApply() {
|
||
if (!_wbOvCurrent || !_wb) return;
|
||
const ov = _wbOvCurrent;
|
||
const angleDeg = parseFloat(document.getElementById('wb-ov-angle').value) || 0;
|
||
ov.angle = angleDeg * Math.PI / 180;
|
||
const sizeVal = parseFloat(document.getElementById('wb-ov-size').value) || 100;
|
||
if (ov.type === 'ruler') ov.width = Math.max(50, sizeVal);
|
||
else ov.radius = Math.max(20, sizeVal);
|
||
_wb.render();
|
||
}
|
||
|
||
function wbOvHide() {
|
||
_wbOvCurrent = null;
|
||
document.getElementById('wb-ov-props')?.classList.remove('visible');
|
||
}
|
||
|
||
/* Coordinate system edit modal */
|
||
let _coordEditStroke = null;
|
||
function showCoordModal(stroke) {
|
||
_coordEditStroke = stroke;
|
||
const d = stroke.data;
|
||
document.getElementById('coord-xmin').value = d.xMin ?? -10;
|
||
document.getElementById('coord-xmax').value = d.xMax ?? 10;
|
||
document.getElementById('coord-ymin').value = d.yMin ?? -10;
|
||
document.getElementById('coord-ymax').value = d.yMax ?? 10;
|
||
document.getElementById('coord-step').value = d.gridStep ?? 1;
|
||
renderCoordFnList();
|
||
document.getElementById('wb-coord-modal').style.display = 'flex';
|
||
}
|
||
function closeCoordModal() { document.getElementById('wb-coord-modal').style.display = 'none'; }
|
||
function applyCoordSettings() {
|
||
if (!_coordEditStroke || !_wb) return;
|
||
const d = _coordEditStroke.data;
|
||
d.xMin = parseFloat(document.getElementById('coord-xmin').value) || -10;
|
||
d.xMax = parseFloat(document.getElementById('coord-xmax').value) || 10;
|
||
d.yMin = parseFloat(document.getElementById('coord-ymin').value) || -10;
|
||
d.yMax = parseFloat(document.getElementById('coord-ymax').value) || 10;
|
||
d.gridStep = parseFloat(document.getElementById('coord-step').value) || 1;
|
||
_wb._staticDirty = true; _wb.render();
|
||
if (_wb._onStrokeUpdated) _wb._onStrokeUpdated(_coordEditStroke);
|
||
}
|
||
function renderCoordFnList() {
|
||
if (!_coordEditStroke) return;
|
||
const list = document.getElementById('coord-fn-list');
|
||
list.innerHTML = '';
|
||
(_coordEditStroke.data.functions || []).forEach((fn, i) => {
|
||
const row = document.createElement('div');
|
||
row.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:6px;';
|
||
row.innerHTML = `<input type="color" value="${fn.color||'#06D6E0'}" style="width:28px;height:28px;border:none;border-radius:4px;cursor:pointer;padding:0" onchange="_coordEditStroke.data.functions[${i}].color=this.value; _wb&&(_wb._staticDirty=true,_wb.render())">
|
||
<input type="text" value="${fn.expr||''}" placeholder="y = sin(x)" style="flex:1;font-size:12px;background:rgba(255,255,255,0.07);border:1px solid rgba(155,93,229,0.3);border-radius:4px;color:#e8e0f7;padding:4px 8px" onchange="_coordEditStroke.data.functions[${i}].expr=this.value;_wb&&(_wb._staticDirty=true,_wb.render())">
|
||
<button onclick="coordRemoveFn(${i})" style="background:rgba(255,80,80,0.15);border:1px solid rgba(255,80,80,0.3);color:#ff6b6b;border-radius:4px;cursor:pointer;padding:3px 8px;font-size:11px">✕</button>`;
|
||
list.appendChild(row);
|
||
});
|
||
}
|
||
function coordAddFn() {
|
||
if (!_coordEditStroke) return;
|
||
_coordEditStroke.data.functions = _coordEditStroke.data.functions || [];
|
||
_coordEditStroke.data.functions.push({ expr: 'x', color: '#06D6E0' });
|
||
renderCoordFnList();
|
||
if (_wb) { _wb._staticDirty = true; _wb.render(); }
|
||
}
|
||
function coordRemoveFn(i) {
|
||
if (!_coordEditStroke) return;
|
||
_coordEditStroke.data.functions.splice(i, 1);
|
||
renderCoordFnList();
|
||
if (_wb) { _wb._staticDirty = true; _wb.render(); }
|
||
}
|
||
|
||
/* Number line edit modal */
|
||
let _nlEditStroke = null;
|
||
function showNumLineModal(stroke) {
|
||
_nlEditStroke = stroke;
|
||
const d = stroke.data;
|
||
document.getElementById('nl-min').value = d.min ?? -10;
|
||
document.getElementById('nl-max').value = d.max ?? 10;
|
||
document.getElementById('nl-step').value = d.step ?? 1;
|
||
nlRenderPoints(); nlRenderIntervals();
|
||
document.getElementById('wb-numline-modal').style.display = 'flex';
|
||
}
|
||
function closeNumLineModal() { document.getElementById('wb-numline-modal').style.display = 'none'; }
|
||
function applyNumLineSettings() {
|
||
if (!_nlEditStroke || !_wb) return;
|
||
const d = _nlEditStroke.data;
|
||
d.min = parseFloat(document.getElementById('nl-min').value) || -10;
|
||
d.max = parseFloat(document.getElementById('nl-max').value) || 10;
|
||
d.step = parseFloat(document.getElementById('nl-step').value) || 1;
|
||
_wb._staticDirty = true; _wb.render();
|
||
}
|
||
const _nlStyle = 'width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(6,214,224,0.3);border-radius:4px;color:#e8e0f7;padding:4px 7px;font-size:11px;';
|
||
function nlRenderPoints() {
|
||
const list = document.getElementById('nl-points-list');
|
||
if (!list || !_nlEditStroke) return;
|
||
list.innerHTML = '';
|
||
(_nlEditStroke.data.points || []).forEach((pt, i) => {
|
||
const row = document.createElement('div');
|
||
row.style.cssText = 'display:flex;gap:5px;align-items:center;margin-bottom:5px;';
|
||
row.innerHTML = `<input type="number" value="${pt.val??0}" placeholder="Значение" style="${_nlStyle}flex:1" oninput="_nlEditStroke.data.points[${i}].val=+this.value;_wb&&(_wb._staticDirty=true,_wb.render())">
|
||
<input type="text" value="${pt.label||''}" placeholder="Метка" style="${_nlStyle}width:60px" oninput="_nlEditStroke.data.points[${i}].label=this.value;_wb&&(_wb._staticDirty=true,_wb.render())">
|
||
<input type="color" value="${pt.color||'#06D6E0'}" style="width:24px;height:24px;border:none;border-radius:3px;cursor:pointer;padding:0" onchange="_nlEditStroke.data.points[${i}].color=this.value;_wb&&(_wb._staticDirty=true,_wb.render())">
|
||
<label style="font-size:10px;color:#aaa;white-space:nowrap;"><input type="checkbox" ${pt.open?'checked':''} onchange="_nlEditStroke.data.points[${i}].open=this.checked;_wb&&(_wb._staticDirty=true,_wb.render())"> ○</label>
|
||
<button onclick="nlRemovePoint(${i})" style="background:rgba(255,80,80,0.15);border:1px solid rgba(255,80,80,0.3);color:#ff6b6b;border-radius:3px;cursor:pointer;padding:2px 6px;font-size:10px">✕</button>`;
|
||
list.appendChild(row);
|
||
});
|
||
}
|
||
function nlRenderIntervals() {
|
||
const list = document.getElementById('nl-intervals-list');
|
||
if (!list || !_nlEditStroke) return;
|
||
list.innerHTML = '';
|
||
(_nlEditStroke.data.intervals || []).forEach((iv, i) => {
|
||
const row = document.createElement('div');
|
||
row.style.cssText = 'display:flex;gap:5px;align-items:center;margin-bottom:5px;';
|
||
row.innerHTML = `<input type="number" value="${iv.from??0}" placeholder="От" style="${_nlStyle}flex:1" oninput="_nlEditStroke.data.intervals[${i}].from=+this.value;_wb&&(_wb._staticDirty=true,_wb.render())">
|
||
<input type="number" value="${iv.to??5}" placeholder="До" style="${_nlStyle}flex:1" oninput="_nlEditStroke.data.intervals[${i}].to=+this.value;_wb&&(_wb._staticDirty=true,_wb.render())">
|
||
<input type="color" value="${iv.color||'#9B5DE5'}" style="width:24px;height:24px;border:none;border-radius:3px;cursor:pointer;padding:0" onchange="_nlEditStroke.data.intervals[${i}].color=this.value;_wb&&(_wb._staticDirty=true,_wb.render())">
|
||
<button onclick="nlRemoveInterval(${i})" style="background:rgba(255,80,80,0.15);border:1px solid rgba(255,80,80,0.3);color:#ff6b6b;border-radius:3px;cursor:pointer;padding:2px 6px;font-size:10px">✕</button>`;
|
||
list.appendChild(row);
|
||
});
|
||
}
|
||
function nlAddPoint() {
|
||
if (!_nlEditStroke) return;
|
||
_nlEditStroke.data.points = _nlEditStroke.data.points || [];
|
||
_nlEditStroke.data.points.push({ val: 0, color: '#06D6E0', open: false });
|
||
nlRenderPoints();
|
||
if (_wb) { _wb._staticDirty = true; _wb.render(); }
|
||
}
|
||
function nlRemovePoint(i) {
|
||
if (!_nlEditStroke) return;
|
||
_nlEditStroke.data.points.splice(i, 1); nlRenderPoints();
|
||
if (_wb) { _wb._staticDirty = true; _wb.render(); }
|
||
}
|
||
function nlAddInterval() {
|
||
if (!_nlEditStroke) return;
|
||
_nlEditStroke.data.intervals = _nlEditStroke.data.intervals || [];
|
||
_nlEditStroke.data.intervals.push({ from: 0, to: 5, color: '#9B5DE5' });
|
||
nlRenderIntervals();
|
||
if (_wb) { _wb._staticDirty = true; _wb.render(); }
|
||
}
|
||
function nlRemoveInterval(i) {
|
||
if (!_nlEditStroke) return;
|
||
_nlEditStroke.data.intervals.splice(i, 1); nlRenderIntervals();
|
||
if (_wb) { _wb._staticDirty = true; _wb.render(); }
|
||
}
|
||
|
||
function wbUndo() {
|
||
if (!_wb) return;
|
||
_wb.undo();
|
||
setTimeout(wbCheckSelectionUI, 20);
|
||
}
|
||
|
||
async function wbClear() {
|
||
if (!_wb || !_sessionId) return;
|
||
const ok = await crConfirm('Очистить страницу?', 'Все рисунки на текущей странице будут удалены для всех участников урока.', { okText: 'Очистить', type: 'warn' });
|
||
if (!ok) return;
|
||
_wb.clearPage();
|
||
_wbBatch = [];
|
||
_crWsSend({ type: 'page_clear', sessionId: _sessionId, pageNum: _wbCurrentPage });
|
||
wbUpdateThumbnail(_wbCurrentPage);
|
||
}
|
||
|
||
/* ── end / leave session ── */
|
||
async function crEndSession() {
|
||
if (!_sessionId) return;
|
||
const ok = await crConfirm('Завершить урок?', 'Урок будет завершён для всех участников. Это действие нельзя отменить.', { okText: 'Завершить урок', type: 'danger' });
|
||
if (!ok) return;
|
||
try {
|
||
await LS.del(`/api/classroom/${_sessionId}`);
|
||
onClassroomEnded(false);
|
||
LS.toast('Урок завершён', 'info');
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
async function crLeaveSession() {
|
||
if (!_sessionId) return;
|
||
try {
|
||
await LS.post(`/api/classroom/${_sessionId}/leave`);
|
||
onClassroomEnded(false);
|
||
LS.toast('Вы вышли из урока', 'info');
|
||
// If the session is still active, show the join banner so student can rejoin without refresh
|
||
const data = await LS.get('/api/classroom/my/session').catch(() => null);
|
||
if (data?.session) showJoinBanner(data.session);
|
||
} catch {}
|
||
}
|
||
|
||
/* ── guest link ── */
|
||
function crGuestOpen() {
|
||
if (!_sessionId) return;
|
||
const overlay = document.getElementById('cr-guest-modal');
|
||
overlay.classList.add('open');
|
||
// Load current token state
|
||
LS.get(`/api/classroom/${_sessionId}/guest-token`).then(data => {
|
||
if (data.token) {
|
||
_crGuestShowToken(data.token);
|
||
} else {
|
||
document.getElementById('cr-guest-no-token').style.display = 'block';
|
||
document.getElementById('cr-guest-has-token').style.display = 'none';
|
||
}
|
||
}).catch(() => {});
|
||
}
|
||
|
||
function crGuestClose() {
|
||
document.getElementById('cr-guest-modal').classList.remove('open');
|
||
}
|
||
|
||
function _crGuestShowToken(token) {
|
||
const url = `${location.origin}/guest-board?token=${token}`;
|
||
document.getElementById('cr-guest-url').textContent = url;
|
||
document.getElementById('cr-guest-no-token').style.display = 'none';
|
||
document.getElementById('cr-guest-has-token').style.display = 'block';
|
||
const copyBtn = document.getElementById('cr-guest-copy-btn');
|
||
if (copyBtn) copyBtn.classList.remove('copied');
|
||
}
|
||
|
||
async function crGuestCreate() {
|
||
if (!_sessionId) return;
|
||
try {
|
||
const data = await LS.post(`/api/classroom/${_sessionId}/guest-token`);
|
||
_crGuestShowToken(data.token);
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
async function crGuestRevoke() {
|
||
if (!_sessionId) return;
|
||
const ok = await crConfirm('Отозвать ссылку?', 'Гости, перешедшие по старой ссылке, потеряют доступ.', { okText: 'Отозвать', type: 'danger' });
|
||
if (!ok) return;
|
||
try {
|
||
await LS.del(`/api/classroom/${_sessionId}/guest-token`);
|
||
document.getElementById('cr-guest-no-token').style.display = 'block';
|
||
document.getElementById('cr-guest-has-token').style.display = 'none';
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
function crGuestCopy() {
|
||
const url = document.getElementById('cr-guest-url')?.textContent || '';
|
||
if (!url) return;
|
||
navigator.clipboard.writeText(url).then(() => {
|
||
const btn = document.getElementById('cr-guest-copy-btn');
|
||
if (btn) {
|
||
btn.textContent = 'Скопировано';
|
||
btn.classList.add('copied');
|
||
setTimeout(() => { btn.textContent = 'Копировать'; btn.classList.remove('copied'); }, 2000);
|
||
}
|
||
}).catch(() => {
|
||
LS.toast('Не удалось скопировать', 'error');
|
||
});
|
||
}
|
||
|
||
/* ── file picker (share library file) ── */
|
||
let _crFilePickerFiles = [];
|
||
|
||
function crOpenFilePicker() {
|
||
document.getElementById('cr-file-picker-overlay').classList.add('open');
|
||
document.getElementById('cr-file-picker-search').value = '';
|
||
document.getElementById('cr-file-picker-list').innerHTML = '<div class="cr-file-picker-empty">Загрузка...</div>';
|
||
LS.get('/api/files').then(data => {
|
||
_crFilePickerFiles = (data.files || data || []);
|
||
crFilePickerSearch('');
|
||
}).catch(() => {
|
||
document.getElementById('cr-file-picker-list').innerHTML = '<div class="cr-file-picker-empty">Ошибка загрузки</div>';
|
||
});
|
||
}
|
||
|
||
function crCloseFilePicker() {
|
||
document.getElementById('cr-file-picker-overlay').classList.remove('open');
|
||
}
|
||
|
||
function crFilePickerSearch(q) {
|
||
const query = q.trim().toLowerCase();
|
||
const filtered = query
|
||
? _crFilePickerFiles.filter(f => (f.title || f.original_name || '').toLowerCase().includes(query))
|
||
: _crFilePickerFiles;
|
||
crFilePickerRender(filtered);
|
||
}
|
||
|
||
function crFilePickerRender(files) {
|
||
const list = document.getElementById('cr-file-picker-list');
|
||
if (!files.length) {
|
||
list.innerHTML = '<div class="cr-file-picker-empty">Файлы не найдены</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = files.map(f => {
|
||
const name = f.title || f.original_name || 'Файл';
|
||
const ext = _crFileExt(f.mimetype || '');
|
||
const size = f.size ? _crFmtSize(f.size) : '';
|
||
const sub = [f.subject_slug || '', size].filter(Boolean).join(' · ');
|
||
return `<div class="cr-file-item">
|
||
<div class="cr-file-icon ${ext}">${ext.toUpperCase()}</div>
|
||
<div class="cr-file-info">
|
||
<div class="cr-file-name" title="${LS.esc(name)}">${LS.esc(name)}</div>
|
||
${sub ? `<div class="cr-file-meta">${LS.esc(sub)}</div>` : ''}
|
||
</div>
|
||
<button class="cr-file-pick-btn" onclick="crShareLibraryFile(${f.id}, ${JSON.stringify(name)}, ${JSON.stringify(f.mimetype || '')})">
|
||
Поделиться
|
||
</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function crShareLibraryFile(fileId, title, mimetype) {
|
||
if (!_sessionId) return;
|
||
crCloseFilePicker();
|
||
try {
|
||
await LS.post(`/api/classroom/${_sessionId}/chat`, {
|
||
message: title,
|
||
attachment_url: fileId + '|' + (mimetype || ''),
|
||
attachment_type: 'library_file',
|
||
});
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
function crDownloadLibraryFile(fileId, name) {
|
||
const token = localStorage.getItem('ls_token');
|
||
fetch(`/api/files/${fileId}/download`, {
|
||
headers: { Authorization: 'Bearer ' + token },
|
||
})
|
||
.then(r => {
|
||
if (!r.ok) throw new Error('Ошибка загрузки');
|
||
return r.blob();
|
||
})
|
||
.then(blob => {
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = name;
|
||
document.body.appendChild(a); a.click();
|
||
document.body.removeChild(a);
|
||
setTimeout(() => URL.revokeObjectURL(url), 15000);
|
||
})
|
||
.catch(e => LS.toast(e.message || 'Ошибка загрузки', 'error'));
|
||
}
|
||
|
||
function _crFileExt(mimetype) {
|
||
if (!mimetype) return 'other';
|
||
if (mimetype === 'application/pdf') return 'pdf';
|
||
if (mimetype.startsWith('image/')) return 'img';
|
||
if (mimetype.includes('word') || mimetype.includes('document') || mimetype === 'text/plain') return 'doc';
|
||
if (mimetype.includes('sheet') || mimetype.includes('excel') || mimetype.includes('csv')) return 'xls';
|
||
if (mimetype.includes('presentation') || mimetype.includes('powerpoint')) return 'ppt';
|
||
if (mimetype.startsWith('video/')) return 'vid';
|
||
return 'other';
|
||
}
|
||
|
||
function _crFmtSize(bytes) {
|
||
if (bytes < 1024) return bytes + ' Б';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' КБ';
|
||
return (bytes / (1024 * 1024)).toFixed(1) + ' МБ';
|
||
}
|
||
|
||
/* ── participants ── */
|
||
function updateParticipantsList() {
|
||
const list = document.getElementById('cr-participants-list');
|
||
const noSession = document.getElementById('participants-no-session');
|
||
const ids = Object.keys(_participants);
|
||
document.getElementById('participants-count').textContent = ids.length;
|
||
|
||
// Always clear previous DOM entries first
|
||
list.querySelectorAll('.cr-participant').forEach(el => el.remove());
|
||
|
||
if (!_sessionId) {
|
||
noSession.style.display = 'flex';
|
||
return;
|
||
}
|
||
noSession.style.display = ids.length ? 'none' : 'flex';
|
||
if (!ids.length) return;
|
||
|
||
const isTeacherView = _me?.role === 'teacher' || _me?.role === 'admin';
|
||
ids.forEach(uid => {
|
||
const p = _participants[uid];
|
||
const isGuest = p.role === 'guest';
|
||
const initials = (p.name || '?').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('') || '?';
|
||
const isMe = !isGuest && String(uid) === String(_me?.id);
|
||
const isSessionTeacher = !isGuest && _session && String(uid) === String(_session.teacher_id);
|
||
const hasHand = !isGuest && !!_raisedHands[uid];
|
||
const div = document.createElement('div');
|
||
div.className = 'cr-participant';
|
||
div.dataset.uid = uid;
|
||
const hasDrawPerm = !isGuest && _permittedStudents.has(Number(uid));
|
||
const svgPencil = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:10px;height:10px"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>`;
|
||
const svgMicOn = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:10px;height:10px"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`;
|
||
const svgMicOff = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:10px;height:10px"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`;
|
||
// Mute button for teacher: shows current state, action on hover (not for guests)
|
||
const muteBtnHtml = isTeacherView && !isMe && !isSessionTeacher && !isGuest ? (
|
||
p.micMuted
|
||
? `<button class="cr-p-mute-btn mic-muted" onclick="crMutePeer(${uid})" title="Включить микрофон">${svgMicOff} Выкл</button>`
|
||
: `<button class="cr-p-mute-btn mic-active" onclick="crMutePeer(${uid})" title="Заглушить">${svgMicOn} Вкл</button>`
|
||
) : '';
|
||
if (p.speaking) div.classList.add('speaking');
|
||
/* per-user avatar gradient from uid hash */
|
||
const avatarGrads = [
|
||
['#9B5DE5','#06D6E0'], ['#F15BB5','#9B5DE5'], ['#06D6A0','#2979FF'],
|
||
['#FF9F43','#F15BB5'], ['#4361EE','#06D6E0'], ['#A8E063','#06D6A0'],
|
||
['#9B5DE5','#F15BB5'], ['#06D6E0','#4361EE'],
|
||
];
|
||
const uidHash = isGuest
|
||
? uid.split('').reduce((a, c) => (a * 31 + c.charCodeAt(0)) & 0xffff, 0)
|
||
: (Number(uid) || 0);
|
||
const grad = avatarGrads[uidHash % avatarGrads.length];
|
||
div.innerHTML = `
|
||
<div class="cr-p-avatar-wrap">
|
||
<div class="cr-p-avatar${p.speaking ? ' speaking' : ''}"
|
||
style="background:linear-gradient(135deg,${grad[0]},${grad[1]})">${initials}</div>
|
||
<div class="cr-p-online-dot"></div>
|
||
</div>
|
||
<div class="cr-audio-bars" id="cr-bars-${uid}"><span></span><span></span><span></span></div>
|
||
<div class="cr-p-info">
|
||
<div class="cr-p-name">${LS.escapeHtml(p.name)}</div>
|
||
<div class="cr-p-sub">
|
||
${isGuest
|
||
? '<span class="cr-p-guest-badge">Гость</span>'
|
||
: isSessionTeacher ? '<span class="cr-p-role-tag teacher">Учитель</span>' : '<span class="cr-p-role-tag student">Ученик</span>'}
|
||
${isMe ? '<span class="cr-p-you">Вы</span>' : ''}
|
||
${!isTeacherView && hasDrawPerm ? `<span class="cr-p-draw-badge">${svgPencil} рисует</span>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="cr-p-status">
|
||
${hasHand ? `<svg viewBox="0 0 24 24" fill="none" stroke="#FFB347" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><path d="M18 11V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0"/><path d="M14 10V4a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>` : ''}
|
||
${muteBtnHtml}
|
||
</div>
|
||
${isTeacherView && !isMe && !isSessionTeacher && !isGuest ? `
|
||
<button class="cr-p-draw-toggle${hasDrawPerm ? ' granted' : ''}" onclick="crToggleDrawPermission(${uid})">
|
||
${svgPencil}
|
||
${hasDrawPerm ? 'Запретить' : 'Рисовать'}
|
||
</button>` : ''}`;
|
||
list.appendChild(div);
|
||
});
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
/* ── hand raise ── */
|
||
function crToggleHand() {
|
||
if (!_sessionId) return;
|
||
_handRaised = !_handRaised;
|
||
const btn = document.getElementById('cr-hand-btn');
|
||
const lbl = document.getElementById('cr-hand-label');
|
||
btn.classList.toggle('raised', _handRaised);
|
||
lbl.textContent = _handRaised ? 'Опустить руку' : 'Поднять руку';
|
||
_crWsSend({ type: _handRaised ? 'hand_raise' : 'hand_lower', sessionId: _sessionId });
|
||
}
|
||
|
||
function onHandRaised(userId, userName) {
|
||
_raisedHands[userId] = userName;
|
||
updateHandsList();
|
||
updateParticipantsList();
|
||
if (_prefs.handSound && String(userId) !== String(_me?.id))
|
||
_crSfx('hand_raise');
|
||
}
|
||
|
||
function onHandLowered(userId) {
|
||
delete _raisedHands[userId];
|
||
updateHandsList();
|
||
updateParticipantsList();
|
||
}
|
||
|
||
function updateHandsList() {
|
||
const list = document.getElementById('cr-hands-list');
|
||
if (!list) return;
|
||
const ids = Object.keys(_raisedHands);
|
||
list.innerHTML = ids.map(uid => `
|
||
<div class="cr-hand-item">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0"/><path d="M14 10V4a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>
|
||
${LS.escapeHtml(_raisedHands[uid])}
|
||
</div>`).join('');
|
||
}
|
||
|
||
/* ── page navigation ── */
|
||
function updatePageLabel() {
|
||
const text = `${_wbCurrentPage}/${_totalPages}`;
|
||
const lbl = document.getElementById('wb-page-label');
|
||
const slbl = document.getElementById('wb-student-page-label');
|
||
if (lbl) lbl.textContent = text;
|
||
if (slbl) slbl.textContent = text;
|
||
}
|
||
|
||
function onPageAdded(pageNum) {
|
||
_totalPages = Math.max(_totalPages, pageNum);
|
||
updatePageLabel();
|
||
const isTeacher = _me.role === 'teacher' || _me.role === 'admin';
|
||
if (!isTeacher && _followTeacher) wbGoToPage(pageNum);
|
||
else wbRebuildThumbnails();
|
||
}
|
||
|
||
function onPageChanged(pageNum) {
|
||
const isTeacher = _me.role === 'teacher' || _me.role === 'admin';
|
||
if (!isTeacher && _followTeacher) wbGoToPage(pageNum);
|
||
else if (isTeacher) {
|
||
_wbCurrentPage = pageNum;
|
||
updatePageLabel();
|
||
}
|
||
}
|
||
|
||
async function wbGoToPage(pageNum) {
|
||
if (!_wb || pageNum === _wbCurrentPage) return;
|
||
_wbCurrentPage = pageNum;
|
||
_wbMaxSeq = 0;
|
||
_wbClearGen++;
|
||
updatePageLabel();
|
||
_wb.clearPage();
|
||
_wbBatch = [];
|
||
try {
|
||
const res = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${pageNum}`);
|
||
const strokes = res.strokes || [];
|
||
_wbUpdateMaxSeq(strokes);
|
||
_wb.loadStrokes(strokes);
|
||
if (res.template) _wb.setTemplate(res.template);
|
||
_wb.setPageNum(pageNum);
|
||
_wbUpdateBgBtn();
|
||
wbRebuildThumbnails();
|
||
} catch {}
|
||
}
|
||
|
||
async function wbPrevPage() {
|
||
if (_wbCurrentPage <= 1) return;
|
||
await _wbChangePage(_wbCurrentPage - 1);
|
||
}
|
||
|
||
async function wbNextPage() {
|
||
if (_wbCurrentPage >= _totalPages) return;
|
||
await _wbChangePage(_wbCurrentPage + 1);
|
||
}
|
||
|
||
// Thumbnail rendering helpers
|
||
function wbUpdateThumbnail(pageNum) {
|
||
if (!_wb) return;
|
||
const list = document.getElementById('wb-thumbs-list');
|
||
if (!list) return;
|
||
// Rebuild if count changed
|
||
if (list.children.length !== _totalPages) { wbRebuildThumbnails(); return; }
|
||
// Update only the target page canvas
|
||
const item = list.querySelector(`.wb-thumb-item[data-page="${pageNum}"]`);
|
||
if (!item) return;
|
||
const cvs = item.querySelector('canvas');
|
||
if (!cvs) return;
|
||
if (pageNum === _wbCurrentPage) {
|
||
_wb.renderThumbnail(cvs);
|
||
}
|
||
// Highlight active
|
||
list.querySelectorAll('.wb-thumb-item').forEach(el => {
|
||
el.classList.toggle('active', parseInt(el.dataset.page) === _wbCurrentPage);
|
||
});
|
||
}
|
||
|
||
function wbRebuildThumbnails() {
|
||
const list = document.getElementById('wb-thumbs-list');
|
||
if (!list || !_wb) return;
|
||
list.innerHTML = '';
|
||
for (let i = 1; i <= _totalPages; i++) {
|
||
const item = document.createElement('div');
|
||
item.className = 'wb-thumb-item' + (i === _wbCurrentPage ? ' active' : '');
|
||
item.dataset.page = i;
|
||
const name = _pageNames[i];
|
||
item.title = name || `Страница ${i}`;
|
||
item.onclick = () => { if (i !== _wbCurrentPage) _wbChangePage(i); };
|
||
item.ondblclick = (e) => { e.stopPropagation(); wbStartRenaming(i); };
|
||
item.oncontextmenu = (e) => { e.preventDefault(); wbShowPageMenu(e, i); };
|
||
const cvs = document.createElement('canvas');
|
||
cvs.width = 192; cvs.height = 108;
|
||
item.appendChild(cvs);
|
||
const lbl = document.createElement('span');
|
||
lbl.className = 'wb-thumb-label';
|
||
lbl.textContent = name || '';
|
||
item.appendChild(lbl);
|
||
const num = document.createElement('span');
|
||
num.className = 'wb-thumb-num';
|
||
num.textContent = i;
|
||
item.appendChild(num);
|
||
list.appendChild(item);
|
||
if (i === _wbCurrentPage) _wb.renderThumbnail(cvs);
|
||
}
|
||
}
|
||
|
||
async function wbAddPage(template = 'blank') {
|
||
if (!_sessionId) return;
|
||
try {
|
||
const res = await LS.post(`/api/classroom/${_sessionId}/pages`, { template });
|
||
_totalPages = res.pageNum;
|
||
await _wbChangePage(res.pageNum);
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
function wbSetPageTemplate(template) {
|
||
if (!_sessionId || !_wb) return;
|
||
_wb.setTemplate(template);
|
||
_crWsSend({ type: 'template_change', sessionId: _sessionId, pageNum: _wbCurrentPage, template });
|
||
wbUpdateThumbnail(_wbCurrentPage);
|
||
}
|
||
|
||
function wbSetBoardTheme(theme) {
|
||
if (!_wb) return;
|
||
_wb.setBoardTheme(theme);
|
||
const sel = document.getElementById('wb-theme-select');
|
||
if (sel) sel.value = theme;
|
||
if (LS.prefs) LS.prefs.set('wb.theme', theme);
|
||
// Broadcast to students if in active session
|
||
if (_sessionId) {
|
||
LS.patch(`/api/classroom/${_sessionId}/board-theme`, { theme }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
/* ── Page context menu ── */
|
||
function wbShowPageMenu(e, pageNum) {
|
||
_wbMenuPage = pageNum;
|
||
const menu = document.getElementById('wb-page-menu');
|
||
menu.classList.add('open');
|
||
const x = Math.min(e.clientX, window.innerWidth - 155);
|
||
const y = Math.min(e.clientY, window.innerHeight - 140);
|
||
menu.style.left = x + 'px';
|
||
menu.style.top = y + 'px';
|
||
setTimeout(() => document.addEventListener('click', _wbMenuClose, { once: true }), 0);
|
||
}
|
||
function _wbMenuClose() { document.getElementById('wb-page-menu').classList.remove('open'); }
|
||
function wbHidePageMenu() { _wbMenuClose(); }
|
||
|
||
async function wbPageDuplicate(pageNum) {
|
||
wbHidePageMenu();
|
||
if (!_sessionId) return;
|
||
try {
|
||
const res = await LS.post(`/api/classroom/${_sessionId}/pages/${pageNum}/duplicate`);
|
||
// SSE handles page addition; set name if returned
|
||
if (res.name) _pageNames[res.newPage] = res.name;
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
async function wbPageDelete(pageNum) {
|
||
wbHidePageMenu();
|
||
if (!_sessionId || _totalPages <= 1) return;
|
||
try {
|
||
const res = await LS.del(`/api/classroom/${_sessionId}/pages/${pageNum}`);
|
||
// Renumber _pageNames
|
||
const newNames = {};
|
||
Object.keys(_pageNames).forEach(k => {
|
||
const n = parseInt(k);
|
||
if (n < pageNum) newNames[n] = _pageNames[n];
|
||
else if (n > pageNum) newNames[n - 1] = _pageNames[n];
|
||
});
|
||
_pageNames = newNames;
|
||
_totalPages = Math.max(1, _totalPages - 1);
|
||
if (_wbCurrentPage > pageNum) _wbCurrentPage = _wbCurrentPage - 1;
|
||
else if (_wbCurrentPage === pageNum) _wbCurrentPage = res.newCurrent;
|
||
updatePageLabel();
|
||
if (_wb) {
|
||
_wb.clearPage();
|
||
const d = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${_wbCurrentPage}`);
|
||
_wb.loadStrokes(d.strokes || []);
|
||
if (d.template) _wb.setTemplate(d.template);
|
||
_wbMaxSeq = 0; _wbClearGen++;
|
||
_wbUpdateMaxSeq(d.strokes || []);
|
||
_wbUpdateBgBtn();
|
||
}
|
||
wbRebuildThumbnails();
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
function wbPageClear(pageNum) {
|
||
wbHidePageMenu();
|
||
if (!_sessionId) return;
|
||
_crWsSend({ type: 'page_clear', sessionId: _sessionId, pageNum });
|
||
if (pageNum === _wbCurrentPage && _wb) { _wb.clearPage(); _wbMaxSeq = 0; _wbClearGen++; }
|
||
wbUpdateThumbnail(pageNum);
|
||
}
|
||
|
||
/* ── Page naming ── */
|
||
function wbStartRenaming(pageNum) {
|
||
const list = document.getElementById('wb-thumbs-list');
|
||
const item = list?.querySelector(`.wb-thumb-item[data-page="${pageNum}"]`);
|
||
if (!item) return;
|
||
// Remove existing input if any
|
||
item.querySelector('.wb-thumb-rename')?.remove();
|
||
const input = document.createElement('input');
|
||
input.className = 'wb-thumb-rename';
|
||
input.value = _pageNames[pageNum] || '';
|
||
input.placeholder = `Стр. ${pageNum}`;
|
||
item.appendChild(input);
|
||
input.focus(); input.select();
|
||
input.onkeydown = (e) => {
|
||
if (e.key === 'Enter') { e.preventDefault(); wbFinishRenaming(pageNum, input.value.trim()); }
|
||
if (e.key === 'Escape') { input.remove(); }
|
||
};
|
||
input.onblur = () => wbFinishRenaming(pageNum, input.value.trim());
|
||
}
|
||
|
||
async function wbFinishRenaming(pageNum, name) {
|
||
const list = document.getElementById('wb-thumbs-list');
|
||
const item = list?.querySelector(`.wb-thumb-item[data-page="${pageNum}"]`);
|
||
item?.querySelector('.wb-thumb-rename')?.remove();
|
||
const finalName = name || null;
|
||
_pageNames[pageNum] = finalName;
|
||
// Update label text
|
||
const lbl = item?.querySelector('.wb-thumb-label');
|
||
if (lbl) lbl.textContent = finalName || '';
|
||
if (item) item.title = finalName || `Страница ${pageNum}`;
|
||
if (!_sessionId) return;
|
||
try { await LS.patch(`/api/classroom/${_sessionId}/pages/${pageNum}/name`, { name: finalName }); } catch {}
|
||
}
|
||
|
||
async function _wbChangePage(pageNum) {
|
||
if (!_sessionId) return;
|
||
_wbBatch = []; // discard unsent strokes from old page
|
||
// Send via WS (server updates DB + broadcasts to all)
|
||
_crWsSend({ type: 'page_change', sessionId: _sessionId, pageNum });
|
||
// Navigate locally without waiting for echo
|
||
_wbCurrentPage = pageNum;
|
||
_wbMaxSeq = 0; _wbClearGen++;
|
||
updatePageLabel();
|
||
if (_wb) {
|
||
_wb.clearPage();
|
||
try {
|
||
const res = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${pageNum}`);
|
||
const strokes = res.strokes || [];
|
||
_wbUpdateMaxSeq(strokes);
|
||
_wb.loadStrokes(strokes);
|
||
if (res.template) {
|
||
_wb.setTemplate(res.template);
|
||
const sel = document.getElementById('wb-tpl-select');
|
||
if (sel) sel.value = res.template;
|
||
}
|
||
if (res.name !== undefined) _pageNames[pageNum] = res.name || null;
|
||
_wbUpdateBgBtn();
|
||
wbRebuildThumbnails();
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
}
|
||
|
||
/* ── tabs ── */
|
||
function crToggleRightPanel() {
|
||
const panel = document.getElementById('cr-right');
|
||
const collapsed = panel.classList.toggle('collapsed');
|
||
localStorage.setItem('cr_right_collapsed', collapsed ? '1' : '0');
|
||
// Trigger canvas resize after transition
|
||
setTimeout(() => { if (_wb) _wb.fit(); }, 240);
|
||
}
|
||
|
||
function crSwitchTab(tab) {
|
||
// Auto-expand if collapsed
|
||
const panel = document.getElementById('cr-right');
|
||
if (panel?.classList.contains('collapsed')) crToggleRightPanel();
|
||
|
||
_activeTab = tab;
|
||
document.getElementById('tab-participants').classList.toggle('active', tab === 'participants');
|
||
document.getElementById('tab-chat').classList.toggle('active', tab === 'chat');
|
||
document.getElementById('tab-notes').classList.toggle('active', tab === 'notes');
|
||
const tabQuiz = document.getElementById('tab-quiz');
|
||
if (tabQuiz) tabQuiz.classList.toggle('active', tab === 'quiz');
|
||
document.getElementById('panel-participants').style.display = tab === 'participants' ? 'flex' : 'none';
|
||
document.getElementById('panel-chat').style.display = tab === 'chat' ? 'flex' : 'none';
|
||
document.getElementById('panel-notes').style.display = tab === 'notes' ? 'flex' : 'none';
|
||
document.getElementById('panel-quiz').style.display = tab === 'quiz' ? 'flex' : 'none';
|
||
if (tab === 'quiz') {
|
||
document.getElementById('quiz-badge').style.display = 'none';
|
||
}
|
||
if (tab === 'chat') {
|
||
_chatUnread = 0;
|
||
document.getElementById('chat-unread').style.display = 'none';
|
||
}
|
||
if (tab === 'notes' && _sessionId) crNotesLoad();
|
||
}
|
||
|
||
/* ── chat ── */
|
||
async function loadChat() {
|
||
if (!_sessionId) return;
|
||
try {
|
||
const data = await LS.get(`/api/classroom/${_sessionId}/chat`);
|
||
(data.messages || []).forEach(m => appendChatMessage({
|
||
id: m.id, userId: m.user_id, userName: m.user_name,
|
||
message: m.message, createdAt: m.created_at, pinned: !!m.pinned,
|
||
attachmentUrl: m.attachment_url, attachmentType: m.attachment_type,
|
||
reactions: m.reactions || {},
|
||
}));
|
||
} catch {}
|
||
}
|
||
|
||
function appendChatMessage(data) {
|
||
// dedup
|
||
if (data.id && _renderedMsgIds.has(data.id)) return;
|
||
if (data.id) {
|
||
_renderedMsgIds.add(data.id);
|
||
if (data.id > _lastChatId) _lastChatId = data.id;
|
||
}
|
||
|
||
// track unread when chat tab not active
|
||
if (_activeTab !== 'chat' && data.userId !== _me?.id) {
|
||
_chatUnread++;
|
||
const badge = document.getElementById('chat-unread');
|
||
if (badge) { badge.textContent = _chatUnread; badge.style.display = 'inline'; }
|
||
if (_prefs.chatSound) _crSfx('chat_message');
|
||
}
|
||
|
||
const wrap = document.getElementById('cr-messages');
|
||
const isMsgTeacher = data.userId === _session?.teacher_id;
|
||
const isTeacherView = _me?.role === 'teacher' || _me?.role === 'admin';
|
||
const isOwnMsg = String(data.userId) === String(_me?.id);
|
||
const time = new Date(data.createdAt).toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
|
||
|
||
/* row wrapper for bubble alignment */
|
||
const row = document.createElement('div');
|
||
row.className = 'cr-msg-row ' + (isOwnMsg ? 'mine' : 'theirs');
|
||
|
||
const div = document.createElement('div');
|
||
div.className = 'cr-msg' + (data.pinned ? ' cr-msg-pinned' : '');
|
||
if (data.id) div.dataset.msgid = data.id;
|
||
const pinIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17H19V15L17 7V3H7V7L5 15V17Z"/><line x1="12" y1="3" x2="12" y2="7"/></svg>`;
|
||
|
||
// reaction icons (SVG, no emoji)
|
||
const REACT_ICONS = {
|
||
like: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>`,
|
||
heart: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/></svg>`,
|
||
question: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>`,
|
||
idea: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/></svg>`,
|
||
wow: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
|
||
};
|
||
const REACT_LABELS = { like:'Понятно', heart:'Класс!', question:'Вопрос', idea:'Интересно', wow:'Вау' };
|
||
|
||
const reactBar = `<div class="cr-msg-react-bar">${
|
||
Object.entries(REACT_ICONS).map(([k,ico]) =>
|
||
`<button class="cr-react-pick" onclick="crReact(${data.id},'${k}')" title="${REACT_LABELS[k]}">${ico}</button>`
|
||
).join('')
|
||
}</div>`;
|
||
|
||
let attachHtml = '';
|
||
if (data.attachmentUrl) {
|
||
if (data.attachmentType === 'image') {
|
||
attachHtml = `<img class="cr-msg-img" src="${LS.esc(data.attachmentUrl)}" alt="изображение" onclick="window.open('${LS.esc(data.attachmentUrl)}','_blank')">`;
|
||
} else if (data.attachmentType === 'library_file') {
|
||
const [fileId, ...mtParts] = (data.attachmentUrl || '').split('|');
|
||
const mime = mtParts.join('|') || '';
|
||
const ext = _crFileExt(mime);
|
||
const displayName = data.message || 'Файл';
|
||
attachHtml = `<div class="cr-msg-file-card" onclick="crDownloadLibraryFile(${parseInt(fileId,10)}, ${JSON.stringify(displayName)})">
|
||
<div class="cr-file-icon ${ext}">${ext.toUpperCase()}</div>
|
||
<div class="cr-file-info">
|
||
<div class="cr-file-name">${LS.esc(displayName)}</div>
|
||
<div class="cr-msg-shared-label">из библиотеки</div>
|
||
</div>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;flex-shrink:0;color:rgba(255,255,255,0.35)"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||
</div>`;
|
||
} else {
|
||
attachHtml = `<a href="${LS.esc(data.attachmentUrl)}" target="_blank" style="color:#9B5DE5;font-size:0.78rem;">Файл</a>`;
|
||
}
|
||
}
|
||
|
||
const reactionsHtml = _renderReactions(data.reactions || {}, data.id);
|
||
|
||
div.innerHTML = `
|
||
${isTeacherView && data.id ? reactBar : ''}
|
||
<div class="cr-msg-header">
|
||
${!isOwnMsg ? `<span class="cr-msg-name${isMsgTeacher ? ' teacher-name' : ''}">${LS.esc(data.userName || '')}</span>` : ''}
|
||
<span class="cr-msg-time">${time}</span>
|
||
${data.pinned ? `<span class="cr-msg-pin-badge">${pinIcon}</span>` : ''}
|
||
${isTeacherView && data.id ? `<button class="cr-msg-pin-btn${data.pinned ? ' active' : ''}" onclick="crPinMessage(${data.id}, this)" title="${data.pinned ? 'Открепить' : 'Закрепить'}">${pinIcon}</button>` : ''}
|
||
</div>
|
||
${data.message && data.attachmentType !== 'library_file' ? `<div class="cr-msg-text">${LS.esc(data.message)}</div>` : ''}
|
||
${attachHtml}
|
||
<div class="cr-msg-reactions" id="msg-reactions-${data.id}">${reactionsHtml}</div>`;
|
||
row.appendChild(div);
|
||
wrap.appendChild(row);
|
||
wrap.scrollTop = wrap.scrollHeight;
|
||
}
|
||
|
||
async function crPinMessage(msgId, btn) {
|
||
if (!_sessionId) return;
|
||
try {
|
||
const res = await LS.post(`/api/classroom/${_sessionId}/chat/${msgId}/pin`);
|
||
// SSE will update all clients; update local immediately
|
||
_updatePinnedMsg(msgId, res.pinned);
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
function _updatePinnedMsg(msgId, pinned) {
|
||
const div = document.querySelector(`.cr-msg[data-msgid="${msgId}"]`);
|
||
if (!div) return;
|
||
div.classList.toggle('cr-msg-pinned', pinned);
|
||
const badge = div.querySelector('.cr-msg-pin-badge');
|
||
const btn = div.querySelector('.cr-msg-pin-btn');
|
||
const pinIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17H19V15L17 7V3H7V7L5 15V17Z"/><line x1="12" y1="3" x2="12" y2="7"/></svg>`;
|
||
if (pinned && !badge) {
|
||
const s = document.createElement('span');
|
||
s.className = 'cr-msg-pin-badge';
|
||
s.innerHTML = pinIcon;
|
||
div.querySelector('.cr-msg-header')?.insertBefore(s, btn || null);
|
||
} else if (!pinned && badge) {
|
||
badge.remove();
|
||
}
|
||
if (btn) {
|
||
btn.classList.toggle('active', pinned);
|
||
btn.title = pinned ? 'Открепить' : 'Закрепить';
|
||
}
|
||
}
|
||
|
||
/* ── chat attach state ── */
|
||
let _pendingAttachUrl = null;
|
||
let _pendingAttachType = null;
|
||
let _pendingAttachName = null;
|
||
|
||
async function crAttachFile(input) {
|
||
const file = input.files[0];
|
||
if (!file || !_sessionId) return;
|
||
input.value = '';
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
const res = await fetch(`/api/classroom/${_sessionId}/chat/upload`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${LS.getToken()}` },
|
||
body: fd,
|
||
});
|
||
if (!res.ok) throw new Error('Ошибка загрузки');
|
||
const data = await res.json();
|
||
_pendingAttachUrl = data.url;
|
||
_pendingAttachType = data.type;
|
||
_pendingAttachName = data.name;
|
||
const prev = document.getElementById('cr-attach-preview');
|
||
const thumb = document.getElementById('cr-attach-thumb');
|
||
const name = document.getElementById('cr-attach-name');
|
||
if (data.type === 'image') {
|
||
thumb.src = data.url;
|
||
thumb.style.display = 'block';
|
||
} else {
|
||
thumb.style.display = 'none';
|
||
}
|
||
name.textContent = data.name;
|
||
prev.style.display = 'flex';
|
||
} catch (e) { LS.toast(e.message || 'Ошибка загрузки', 'error'); }
|
||
}
|
||
|
||
function crClearAttach() {
|
||
_pendingAttachUrl = null;
|
||
_pendingAttachType = null;
|
||
_pendingAttachName = null;
|
||
document.getElementById('cr-attach-preview').style.display = 'none';
|
||
document.getElementById('cr-attach-thumb').src = '';
|
||
document.getElementById('cr-attach-name').textContent = '';
|
||
}
|
||
|
||
async function crSendChat() {
|
||
const input = document.getElementById('cr-chat-input');
|
||
const message = input.value.trim();
|
||
const hasAttach = !!_pendingAttachUrl;
|
||
if (!message && !hasAttach) return;
|
||
if (!_sessionId) return;
|
||
input.value = '';
|
||
const body = { message };
|
||
if (hasAttach) {
|
||
body.attachment_url = _pendingAttachUrl;
|
||
body.attachment_type = _pendingAttachType;
|
||
crClearAttach();
|
||
}
|
||
try {
|
||
await LS.post(`/api/classroom/${_sessionId}/chat`, body);
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
function crChatKeyDown(e) {
|
||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); crSendChat(); }
|
||
}
|
||
|
||
/* ── reactions ── */
|
||
function _renderReactions(reactions, msgId) {
|
||
if (!reactions || !Object.keys(reactions).length) return '';
|
||
const ICONS = { like:'👍', heart:'❤', question:'❓', idea:'💡', wow:'⭐' };
|
||
const myId = String(_me?.id || '');
|
||
return Object.entries(reactions).map(([k, info]) => {
|
||
// getChat provides `mine` boolean; SSE/reactToMessage provides `uids` string
|
||
let isMine = !!info.mine;
|
||
if (!isMine && info.uids) isMine = myId && String(info.uids).split(',').includes(myId);
|
||
const mine = isMine ? ' cr-react-chip-mine' : '';
|
||
const label = ICONS[k] || k;
|
||
const count = info.count || info.cnt || 0;
|
||
return `<button class="cr-react-chip${mine}" onclick="crReact(${msgId},'${k}')" title="${k}">${label} <span>${count}</span></button>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function crReact(msgId, reaction) {
|
||
if (!_sessionId || !msgId) return;
|
||
try {
|
||
const res = await LS.post(`/api/classroom/${_sessionId}/chat/${msgId}/react`, { reaction });
|
||
// SSE will broadcast; update locally immediately
|
||
const el = document.getElementById(`msg-reactions-${msgId}`);
|
||
if (el) el.innerHTML = _renderReactions(res.reactions || {}, msgId);
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
/* ── notes ── */
|
||
let _notesSaveTimer = null;
|
||
let _notesLoaded = false;
|
||
|
||
async function crNotesLoad() {
|
||
if (!_sessionId || _notesLoaded) return;
|
||
_notesLoaded = true;
|
||
try {
|
||
const data = await LS.get(`/api/classroom/${_sessionId}/notes`);
|
||
const ta = document.getElementById('cr-notes-ta');
|
||
if (ta) ta.value = data.content || '';
|
||
const st = document.getElementById('notes-status');
|
||
if (st) st.textContent = '';
|
||
} catch {}
|
||
}
|
||
|
||
function crNotesOnInput() {
|
||
const st = document.getElementById('notes-status');
|
||
if (st) st.textContent = 'Изменено…';
|
||
/* word count */
|
||
const ta = document.getElementById('cr-notes-ta');
|
||
const wc = document.getElementById('notes-wordcount');
|
||
if (ta && wc) {
|
||
const words = ta.value.trim() ? ta.value.trim().split(/\s+/).length : 0;
|
||
wc.textContent = words + ' ' + (words === 1 ? 'слово' : words < 5 ? 'слова' : 'слов');
|
||
}
|
||
clearTimeout(_notesSaveTimer);
|
||
_notesSaveTimer = setTimeout(crNotesSave, 1500);
|
||
}
|
||
|
||
async function crNotesSave() {
|
||
if (!_sessionId) return;
|
||
const ta = document.getElementById('cr-notes-ta');
|
||
const content = ta ? ta.value : '';
|
||
try {
|
||
await LS.put(`/api/classroom/${_sessionId}/notes`, { content });
|
||
const st = document.getElementById('notes-status');
|
||
if (st) st.textContent = 'Сохранено';
|
||
setTimeout(() => { const s = document.getElementById('notes-status'); if (s) s.textContent = ''; }, 2000);
|
||
} catch {}
|
||
}
|
||
|
||
/* ── lesson templates ── */
|
||
function crShowTemplates() {
|
||
document.getElementById('cr-tpl-modal').classList.add('open');
|
||
crLoadTemplatesList();
|
||
}
|
||
|
||
function crHideTemplates() {
|
||
document.getElementById('cr-tpl-modal').classList.remove('open');
|
||
}
|
||
|
||
async function crLoadTemplatesList() {
|
||
try {
|
||
const data = await LS.get('/api/classroom/templates');
|
||
crRenderTemplates(data.templates || []);
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
function crRenderTemplates(list) {
|
||
const el = document.getElementById('cr-tpl-list');
|
||
if (!list.length) {
|
||
el.innerHTML = '<div class="cr-tpl-empty">Нет сохранённых шаблонов</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = list.map(t => `
|
||
<div class="cr-tpl-item">
|
||
<div style="flex:1;min-width:0">
|
||
<div class="cr-tpl-item-name">${LS.esc(t.title)}</div>
|
||
${t.description ? `<div class="cr-tpl-item-meta">${LS.esc(t.description)}</div>` : ''}
|
||
</div>
|
||
<button class="cr-tpl-save-btn" style="padding:5px 10px;font-size:0.74rem;flex-shrink:0" onclick="crLoadTemplate(${t.id})">Загрузить</button>
|
||
<button class="cr-tpl-item-del" onclick="crDeleteTemplate(${t.id})" title="Удалить">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
|
||
</button>
|
||
</div>`).join('');
|
||
}
|
||
|
||
async function crSaveTemplate() {
|
||
if (!_sessionId) return;
|
||
const name = document.getElementById('cr-tpl-name').value.trim();
|
||
if (!name) { LS.toast('Введите название шаблона', 'error'); return; }
|
||
try {
|
||
await LS.post(`/api/classroom/${_sessionId}/save-template`, { title: name });
|
||
document.getElementById('cr-tpl-name').value = '';
|
||
LS.toast('Шаблон сохранён', 'success');
|
||
crLoadTemplatesList();
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
async function crLoadTemplate(id) {
|
||
if (!_sessionId || !id) return;
|
||
const ok = await crConfirm('Загрузить шаблон?', 'Текущее содержимое всех страниц урока будет заменено содержимым шаблона.', { okText: 'Загрузить', type: 'warn' });
|
||
if (!ok) return;
|
||
try {
|
||
await LS.post(`/api/classroom/${_sessionId}/load-template`, { template_id: id });
|
||
crHideTemplates();
|
||
LS.toast('Шаблон загружен', 'success');
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
async function crDeleteTemplate(id) {
|
||
if (!id) return;
|
||
const ok = await crConfirm('Удалить шаблон?', 'Шаблон будет удалён без возможности восстановления.', { okText: 'Удалить', type: 'danger' });
|
||
if (!ok) return;
|
||
try {
|
||
await LS.del(`/api/classroom/templates/${id}`);
|
||
crLoadTemplatesList();
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
/* ── Helpers ── */
|
||
function _loadHandsAsync(sessionId) {
|
||
LS.get(`/api/classroom/${sessionId}/hands`)
|
||
.then(data => {
|
||
(data.hands || []).forEach(h => { _raisedHands[h.userId] = h.userName; });
|
||
updateHandsList();
|
||
updateParticipantsList();
|
||
}).catch(() => {});
|
||
}
|
||
|
||
/* ── SSE reconnect: re-sync all state missed during the gap ── */
|
||
async function resyncAfterReconnect() {
|
||
if (!_sessionId) return;
|
||
try {
|
||
// 1. Verify session is still active
|
||
const session = await LS.get(`/api/classroom/${_sessionId}`).catch(() => null);
|
||
if (!session || session.status !== 'active') {
|
||
onClassroomEnded();
|
||
return;
|
||
}
|
||
_session = session;
|
||
|
||
// 2. Re-sync participants
|
||
const partData = await LS.get(`/api/classroom/${_sessionId}/participants`).catch(() => null);
|
||
if (partData) {
|
||
const fresh = {};
|
||
for (const p of (partData.participants || [])) {
|
||
fresh[p.user_id] = {
|
||
name: p.name,
|
||
micMuted: _participants[p.user_id]?.micMuted || false,
|
||
};
|
||
}
|
||
// Keep our own entry with current mute state
|
||
if (_me?.id) fresh[_me.id] = { name: _me.name, micMuted: _rtc?.isMuted() || false };
|
||
_participants = fresh;
|
||
updateParticipantsList();
|
||
}
|
||
|
||
// 3. Re-sync raised hands
|
||
const handsData = await LS.get(`/api/classroom/${_sessionId}/hands`).catch(() => null);
|
||
if (handsData) {
|
||
_raisedHands = {};
|
||
(handsData.hands || []).forEach(h => { _raisedHands[h.userId] = h.userName; });
|
||
updateHandsList();
|
||
updateParticipantsList();
|
||
}
|
||
|
||
// 4. Re-sync whiteboard strokes for current page
|
||
if (_wb) {
|
||
// Clear own-id filter — strokes will be reloaded from server, so echoes no longer needed
|
||
_wbOwnIds.clear();
|
||
_wbMaxSeq = 0;
|
||
const strokesData = await LS.get(`/api/classroom/${_sessionId}/strokes?page_num=${_wbCurrentPage}`).catch(() => null);
|
||
if (strokesData) {
|
||
_wbUpdateMaxSeq(strokesData.strokes || []);
|
||
_wb.loadStrokes(strokesData.strokes || []);
|
||
}
|
||
}
|
||
|
||
// 5. Catch up on missed chat messages using since_id for efficiency
|
||
const chatData = await LS.get(`/api/classroom/${_sessionId}/chat?since_id=${_lastChatId}`).catch(() => null);
|
||
if (chatData) {
|
||
(chatData.messages || []).forEach(m => appendChatMessage({
|
||
id: m.id, userId: m.user_id, userName: m.user_name,
|
||
message: m.message, createdAt: m.created_at,
|
||
}));
|
||
}
|
||
|
||
// 6. Recover WebRTC peers that failed during the SSE gap
|
||
if (_rtc) {
|
||
const peerIds = Object.keys(_participants).map(Number).filter(id => id !== _me?.id);
|
||
_rtc.recoverPeers(peerIds).catch(() => {});
|
||
}
|
||
|
||
// 7. Follow teacher's current page if follow mode is on
|
||
const isTeacher = _me?.role === 'teacher' || _me?.role === 'admin';
|
||
if (!isTeacher && _followTeacher && session.current_page && session.current_page !== _wbCurrentPage) {
|
||
wbGoToPage(session.current_page);
|
||
}
|
||
|
||
// 8. Re-check draw permission (SSE event may have been missed during the gap)
|
||
if (!isTeacher && session.canDraw && !_canDraw) {
|
||
enableDrawPermission(true); // silent — just re-syncing after SSE gap
|
||
} else if (!isTeacher && !session.canDraw && _canDraw) {
|
||
disableDrawPermission();
|
||
}
|
||
|
||
} catch (e) {
|
||
console.warn('[Classroom] resync error', e);
|
||
}
|
||
}
|
||
|
||
function updateTimer() {
|
||
if (!_sessionStartTime) return;
|
||
const s = Math.floor((Date.now() - _sessionStartTime.getTime()) / 1000);
|
||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
||
const t = h > 0
|
||
? `${h}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`
|
||
: `${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
|
||
const el = document.getElementById('cr-timer');
|
||
if (el) el.textContent = t;
|
||
}
|
||
|
||
/* ── Whiteboard extra ── */
|
||
function wbRedo() { if (_wb) { _wb.redo(); setTimeout(wbCheckSelectionUI, 20); } }
|
||
|
||
let _wbFill = false;
|
||
function wbToggleFill(btn) {
|
||
if (!_wb) return;
|
||
_wbFill = !_wbFill;
|
||
_wb.setFill(_wbFill);
|
||
btn.classList.toggle('active', _wbFill);
|
||
}
|
||
|
||
function wbCopySelected() { if (_wb) _wb.copy(); }
|
||
function wbPasteSelected() { if (_wb) _wb.paste(); }
|
||
function wbDeleteSelected() { if (_wb) { _wb.deleteSelected(); setTimeout(wbCheckSelectionUI, 20); } }
|
||
function wbBringToFront() { if (_wb) _wb.bringToFront(); }
|
||
function wbSendToBack() { if (_wb) _wb.sendToBack(); }
|
||
|
||
/* ── Formula visual editor ── */
|
||
let _wbFmVx = 960, _wbFmVy = 540, _wbFmFontSize = 32, _wbFmPrevTimer = null;
|
||
let _wbFmCurrentTab = 'basic';
|
||
|
||
/* ---- button data for all categories ---- */
|
||
const WB_FM_CATS = {
|
||
basic: { name: 'Основные', items: [
|
||
{ label:'Плюс-минус ±', latex:'\\pm', display:'\\pm' },
|
||
{ label:'Умножить ×', latex:'\\times', display:'\\times' },
|
||
{ label:'Делить ÷', latex:'\\div', display:'\\div' },
|
||
{ label:'Точка · (умножение)',latex:'\\cdot', display:'\\cdot' },
|
||
{ label:'Не равно ≠', latex:'\\neq', display:'\\neq' },
|
||
{ label:'Меньше или равно ≤', latex:'\\leq', display:'\\leq' },
|
||
{ label:'Больше или равно ≥', latex:'\\geq', display:'\\geq' },
|
||
{ label:'Примерно равно ≈', latex:'\\approx', display:'\\approx' },
|
||
{ label:'Бесконечность ∞', latex:'\\infty', display:'\\infty' },
|
||
{ label:'Многоточие …', latex:'\\ldots', display:'\\ldots' },
|
||
{ label:'Процент %', latex:'\\%', display:'\\%' },
|
||
{ label:'Градус °', latex:'^{\\circ}', display:'90^{\\circ}' },
|
||
{ label:'Модуль числа |x|', latex:'\\left|{}\\right|', display:'|x|', cursorBack:7 },
|
||
{ label:'Скобки ( )', latex:'\\left({}\\right)', display:'(a+b)', cursorBack:7 },
|
||
{ label:'Квадратные скобки', latex:'\\left[{}\\right]', display:'[x]', cursorBack:7 },
|
||
{ label:'Стрелка вправо →', latex:'\\rightarrow', display:'\\rightarrow' },
|
||
{ label:'Следовательно ⇒', latex:'\\Rightarrow', display:'\\Rightarrow' },
|
||
{ label:'Равносильно ⟺', latex:'\\Leftrightarrow', display:'\\Leftrightarrow' },
|
||
]},
|
||
frac: { name: 'Дроби · √', items: [
|
||
{ label:'Дробь a/b', latex:'\\frac{}{}', display:'\\frac{a}{b}', cursorBack:2 },
|
||
{ label:'Квадратный корень √', latex:'\\sqrt{}', display:'\\sqrt{a}', cursorBack:1 },
|
||
{ label:'Кубический корень ∛', latex:'\\sqrt[3]{}', display:'\\sqrt[3]{a}', cursorBack:1 },
|
||
{ label:'Корень n-й степени', latex:'\\sqrt[n]{}', display:'\\sqrt[n]{a}', cursorBack:1 },
|
||
{ label:'Дробь со скобками', latex:'\\frac{\\left({}\\right)}{}', display:'\\frac{(a+b)}{c}', cursorBack:9 },
|
||
{ label:'Корень из дроби', latex:'\\sqrt{\\frac{}{}}', display:'\\sqrt{\\frac{a}{b}}', cursorBack:3 },
|
||
{ label:'Дробь с корнем вверху',latex:'\\frac{\\sqrt{}}{{}}' ,display:'\\frac{\\sqrt{a}}{b}',cursorBack:2 },
|
||
{ label:'Дробь с корнем внизу', latex:'\\frac{{}}{\\sqrt{}}', display:'\\frac{a}{\\sqrt{b}}', cursorBack:8 },
|
||
]},
|
||
powers: { name: 'Степени', items: [
|
||
{ label:'В квадрате x²', latex:'^{2}', display:'x^{2}' },
|
||
{ label:'В кубе x³', latex:'^{3}', display:'x^{3}' },
|
||
{ label:'В степени n', latex:'^{}', display:'x^{n}', cursorBack:1 },
|
||
{ label:'Нижний индекс', latex:'_{}', display:'x_{n}', cursorBack:1 },
|
||
{ label:'Степень и индекс', latex:'_{}^{}', display:'x_{i}^{n}', cursorBack:1 },
|
||
{ label:'e в степени x', latex:'e^{}', display:'e^{x}', cursorBack:1 },
|
||
{ label:'10 в степени n', latex:'10^{}', display:'10^{n}', cursorBack:1 },
|
||
{ label:'Логарифм log_a(b)', latex:'\\log_{}{}' ,display:'\\log_{a}b', cursorBack:2 },
|
||
{ label:'Натуральный ln', latex:'\\ln{}', display:'\\ln x', cursorBack:1 },
|
||
{ label:'Десятичный lg', latex:'\\lg{}', display:'\\lg x', cursorBack:1 },
|
||
{ label:'Логарифм log₂', latex:'\\log_{2}{}', display:'\\log_{2}x', cursorBack:1 },
|
||
{ label:'Логарифм log₁₀', latex:'\\log_{10}{}',display:'\\log_{10}x', cursorBack:1 },
|
||
]},
|
||
sums: { name: 'Суммы · ∫', items: [
|
||
{ label:'Сумма Σ с пределами', latex:'\\sum_{i=1}^{n}{}', display:'\\sum_{i=1}^{n}a_i', cursorBack:2 },
|
||
{ label:'Интеграл от a до b', latex:'\\int_{a}^{b}{}\\,dx', display:'\\int_{a}^{b}f(x)\\,dx', cursorBack:4 },
|
||
{ label:'Неопределённый интеграл', latex:'\\int {}\\,dx', display:'\\int f(x)\\,dx', cursorBack:4 },
|
||
{ label:'Двойной интеграл', latex:'\\iint', display:'\\iint' },
|
||
{ label:'Криволинейный интеграл', latex:'\\oint', display:'\\oint' },
|
||
{ label:'Произведение Π', latex:'\\prod_{i=1}^{n}{}', display:'\\prod_{i=1}^{n}a_i', cursorBack:2 },
|
||
{ label:'Предел x → 0', latex:'\\lim_{x \\to {}}{}', display:'\\lim_{x\\to 0}f(x)', cursorBack:4 },
|
||
{ label:'Предел x → ∞', latex:'\\lim_{x \\to \\infty}{}', display:'\\lim_{x\\to\\infty}f(x)', cursorBack:2 },
|
||
{ label:'Производная dy/dx', latex:'\\frac{dy}{dx}', display:'\\frac{dy}{dx}' },
|
||
{ label:'Частная производная', latex:'\\frac{\\partial {}}{\\partial {}}',display:'\\frac{\\partial f}{\\partial x}',cursorBack:11 },
|
||
{ label:'Вторая производная d²y/dx²',latex:"\\frac{d^2y}{dx^2}", display:"\\frac{d^{2}y}{dx^{2}}" },
|
||
{ label:'f штрих f\'(x)', latex:"f'(x)", display:"f'(x)" },
|
||
{ label:'Вектор над буквой', latex:'\\vec{}', display:'\\vec{a}', cursorBack:1 },
|
||
{ label:'Вектор AB →', latex:'\\overrightarrow{}', display:'\\overrightarrow{AB}', cursorBack:1 },
|
||
]},
|
||
greek: { name: 'α β γ …', items: [
|
||
{ label:'Альфа α', latex:'\\alpha', display:'\\alpha' },
|
||
{ label:'Бета β', latex:'\\beta', display:'\\beta' },
|
||
{ label:'Гамма γ', latex:'\\gamma', display:'\\gamma' },
|
||
{ label:'Гамма Γ', latex:'\\Gamma', display:'\\Gamma' },
|
||
{ label:'Дельта δ', latex:'\\delta', display:'\\delta' },
|
||
{ label:'Дельта Δ', latex:'\\Delta', display:'\\Delta' },
|
||
{ label:'Эпсилон ε', latex:'\\varepsilon',display:'\\varepsilon' },
|
||
{ label:'Дзета ζ', latex:'\\zeta', display:'\\zeta' },
|
||
{ label:'Эта η', latex:'\\eta', display:'\\eta' },
|
||
{ label:'Тета θ', latex:'\\theta', display:'\\theta' },
|
||
{ label:'Тета Θ', latex:'\\Theta', display:'\\Theta' },
|
||
{ label:'Каппа κ', latex:'\\kappa', display:'\\kappa' },
|
||
{ label:'Лямбда λ', latex:'\\lambda', display:'\\lambda' },
|
||
{ label:'Лямбда Λ', latex:'\\Lambda', display:'\\Lambda' },
|
||
{ label:'Мю μ', latex:'\\mu', display:'\\mu' },
|
||
{ label:'Ню ν', latex:'\\nu', display:'\\nu' },
|
||
{ label:'Кси ξ', latex:'\\xi', display:'\\xi' },
|
||
{ label:'Пи π', latex:'\\pi', display:'\\pi' },
|
||
{ label:'Пи Π', latex:'\\Pi', display:'\\Pi' },
|
||
{ label:'Ро ρ', latex:'\\rho', display:'\\rho' },
|
||
{ label:'Сигма σ', latex:'\\sigma', display:'\\sigma' },
|
||
{ label:'Сигма Σ', latex:'\\Sigma', display:'\\Sigma' },
|
||
{ label:'Тау τ', latex:'\\tau', display:'\\tau' },
|
||
{ label:'Фи φ', latex:'\\varphi', display:'\\varphi' },
|
||
{ label:'Фи Φ', latex:'\\Phi', display:'\\Phi' },
|
||
{ label:'Хи χ', latex:'\\chi', display:'\\chi' },
|
||
{ label:'Пси ψ', latex:'\\psi', display:'\\psi' },
|
||
{ label:'Пси Ψ', latex:'\\Psi', display:'\\Psi' },
|
||
{ label:'Омега ω', latex:'\\omega', display:'\\omega' },
|
||
{ label:'Омега Ω', latex:'\\Omega', display:'\\Omega' },
|
||
]},
|
||
trig: { name: 'sin · cos', items: [
|
||
{ label:'Синус sin(x)', latex:'\\sin{}', display:'\\sin(x)', cursorBack:1 },
|
||
{ label:'Косинус cos(x)', latex:'\\cos{}', display:'\\cos(x)', cursorBack:1 },
|
||
{ label:'Тангенс tan(x)', latex:'\\tan{}', display:'\\tan(x)', cursorBack:1 },
|
||
{ label:'Котангенс cot(x)', latex:'\\cot{}', display:'\\cot(x)', cursorBack:1 },
|
||
{ label:'Арксинус arcsin', latex:'\\arcsin{}', display:'\\arcsin(x)', cursorBack:1 },
|
||
{ label:'Арккосинус arccos', latex:'\\arccos{}', display:'\\arccos(x)', cursorBack:1 },
|
||
{ label:'Арктангенс arctan', latex:'\\arctan{}', display:'\\arctan(x)', cursorBack:1 },
|
||
{ label:'sin² x', latex:'\\sin^{2}{}', display:'\\sin^{2}x', cursorBack:1 },
|
||
{ label:'cos² x', latex:'\\cos^{2}{}', display:'\\cos^{2}x', cursorBack:1 },
|
||
{ label:'sin²+cos²=1', latex:'\\sin^{2}x+\\cos^{2}x=1', display:'\\sin^{2}x+\\cos^{2}x=1' },
|
||
{ label:'sin(α+β)', latex:'\\sin(\\alpha+\\beta)', display:'\\sin(\\alpha+\\beta)' },
|
||
{ label:'cos(α+β)', latex:'\\cos(\\alpha+\\beta)', display:'\\cos(\\alpha+\\beta)' },
|
||
{ label:'sin(2α) = 2sin·cos', latex:'\\sin(2\\alpha)=2\\sin\\alpha\\cos\\alpha', display:'\\sin 2\\alpha' },
|
||
]},
|
||
geo: { name: 'Геометрия', items: [
|
||
{ label:'Угол ∠', latex:'\\angle', display:'\\angle' },
|
||
{ label:'Треугольник △', latex:'\\triangle', display:'\\triangle' },
|
||
{ label:'Перпендикулярно ⊥', latex:'\\perp', display:'\\perp' },
|
||
{ label:'Параллельно ∥', latex:'\\parallel', display:'\\parallel' },
|
||
{ label:'Отрезок AB с чёрточкой', latex:'\\overline{}', display:'\\overline{AB}', cursorBack:1 },
|
||
{ label:'Вектор AB →', latex:'\\overrightarrow{}', display:'\\overrightarrow{AB}',cursorBack:1 },
|
||
{ label:'Длина отрезка |AB|', latex:'|{}|', display:'|AB|', cursorBack:2 },
|
||
{ label:'Координаты (x; y)', latex:'({};\\ {})', display:'(x;\\ y)', cursorBack:5 },
|
||
{ label:'Подобие ~', latex:'\\sim', display:'\\sim' },
|
||
{ label:'Конгруэнтность ≅', latex:'\\cong', display:'\\cong' },
|
||
{ label:'Площадь S = ...', latex:'S={}', display:'S=\\ldots', cursorBack:2 },
|
||
{ label:'Периметр P = ...', latex:'P={}', display:'P=\\ldots', cursorBack:2 },
|
||
{ label:'Теорема Пифагора a²+b²=c²',latex:'a^{2}+b^{2}=c^{2}', display:'a^{2}+b^{2}=c^{2}' },
|
||
{ label:'Формула расстояния', latex:'\\sqrt{(x_2-x_1)^{2}+(y_2-y_1)^{2}}', display:'\\sqrt{\\Delta x^{2}+\\Delta y^{2}}' },
|
||
]},
|
||
matrix: { name: 'Матрицы', items: [
|
||
{ label:'Матрица 2×2', latex:'\\begin{pmatrix} {} & {} \\\\ {} & {} \\end{pmatrix}', display:'\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}', cursorBack:30 },
|
||
{ label:'Матрица 3×3', latex:'\\begin{pmatrix} {} & {} & {} \\\\ {} & {} & {} \\\\ {} & {} & {} \\end{pmatrix}', display:'\\begin{pmatrix}a&b&c\\\\d&e&f\\\\g&h&k\\end{pmatrix}', cursorBack:50 },
|
||
{ label:'Столбец-вектор', latex:'\\begin{pmatrix} {} \\\\ {} \\end{pmatrix}', display:'\\begin{pmatrix}x\\\\y\\end{pmatrix}', cursorBack:18 },
|
||
{ label:'Система 2 уравнений', latex:'\\begin{cases} {} \\\\ {} \\end{cases}', display:'\\begin{cases}ax=b\\\\cx=d\\end{cases}', cursorBack:18 },
|
||
{ label:'Система 3 уравнений', latex:'\\begin{cases} {} \\\\ {} \\\\ {} \\end{cases}', display:'\\begin{cases}a\\\\b\\\\c\\end{cases}', cursorBack:18 },
|
||
{ label:'Определитель |M| 2×2',latex:'\\begin{vmatrix} {} & {} \\\\ {} & {} \\end{vmatrix}', display:'\\begin{vmatrix}a&b\\\\c&d\\end{vmatrix}', cursorBack:30 },
|
||
]},
|
||
};
|
||
|
||
/* ---- render buttons for current tab ---- */
|
||
function wbFmRenderTab(tabId) {
|
||
const grid = document.getElementById('wbfm-grid');
|
||
grid.innerHTML = '';
|
||
const cat = WB_FM_CATS[tabId];
|
||
if (!cat) return;
|
||
cat.items.forEach(item => {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'wbfm-vbtn';
|
||
btn.title = item.label;
|
||
if (window.katex) {
|
||
try {
|
||
btn.innerHTML = window.katex.renderToString(item.display, { throwOnError: false, displayMode: false });
|
||
} catch(e) { btn.textContent = item.label.slice(0,6); }
|
||
} else {
|
||
btn.textContent = item.label.slice(0,8);
|
||
}
|
||
btn.addEventListener('click', () => wbFmInsertItem(item));
|
||
grid.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
function wbFmTab(btn, tabId) {
|
||
_wbFmCurrentTab = tabId;
|
||
document.querySelectorAll('.wbfm-tab').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
wbFmRenderTab(tabId);
|
||
}
|
||
|
||
/* ---- insert item into textarea and show LaTeX area ---- */
|
||
function wbFmInsertItem(item) {
|
||
// Auto-show LaTeX area so teacher sees what was generated
|
||
const ltArea = document.getElementById('wbfm-latex-area');
|
||
if (ltArea.style.display === 'none') {
|
||
ltArea.style.display = 'block';
|
||
document.getElementById('wbfm-lt-arrow').classList.add('open');
|
||
}
|
||
const ta = document.getElementById('wbfm-input');
|
||
const s = ta.selectionStart, e = ta.selectionEnd;
|
||
ta.value = ta.value.slice(0, s) + item.latex + ta.value.slice(e);
|
||
// place cursor inside first {} if template
|
||
let newPos = s + item.latex.length - (item.cursorBack || 0);
|
||
const braceIn = item.latex.indexOf('{}');
|
||
if (braceIn >= 0 && item.cursorBack > 0) {
|
||
newPos = s + braceIn + 1;
|
||
}
|
||
ta.focus();
|
||
ta.setSelectionRange(newPos, newPos);
|
||
// show hint about Tab key if template has holes
|
||
const holes = (item.latex.match(/\{\}/g) || []).length;
|
||
const hint = document.getElementById('wbfm-tab-hint');
|
||
hint.textContent = holes > 1 ? 'Tab — следующее поле' : holes === 1 ? 'Допишите значение' : '';
|
||
wbFmUpdatePreview();
|
||
}
|
||
|
||
/* ---- Tab key jumps between {} holes ---- */
|
||
function wbFmKeydown(event) {
|
||
if (event.key === 'Tab') {
|
||
event.preventDefault();
|
||
const ta = event.target;
|
||
const pos = ta.selectionStart;
|
||
const val = ta.value;
|
||
// find next {} after cursor
|
||
let next = val.indexOf('{}', pos);
|
||
if (next < 0) next = val.indexOf('{}'); // wrap around
|
||
if (next >= 0) { ta.setSelectionRange(next + 1, next + 1); return; }
|
||
}
|
||
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
||
event.preventDefault(); wbFmInsert();
|
||
}
|
||
if (event.key === 'Escape') { event.stopPropagation(); wbFmClose(); }
|
||
}
|
||
|
||
function wbFmToggleLatex() {
|
||
const area = document.getElementById('wbfm-latex-area');
|
||
const arrow = document.getElementById('wbfm-lt-arrow');
|
||
const open = area.style.display !== 'none';
|
||
area.style.display = open ? 'none' : 'block';
|
||
arrow.classList.toggle('open', !open);
|
||
if (!open) setTimeout(() => document.getElementById('wbfm-input').focus(), 50);
|
||
}
|
||
|
||
function showFormulaModal(vx, vy, existingLatex) {
|
||
_wbFmVx = vx; _wbFmVy = vy;
|
||
const modal = document.getElementById('wb-formula-modal');
|
||
modal.style.display = 'block';
|
||
// Reset tab to basic
|
||
wbFmRenderTab(_wbFmCurrentTab);
|
||
// Reset/load latex
|
||
const ta = document.getElementById('wbfm-input');
|
||
ta.value = existingLatex || '';
|
||
if (existingLatex) {
|
||
// Editing existing — show LaTeX field
|
||
document.getElementById('wbfm-latex-area').style.display = 'block';
|
||
document.getElementById('wbfm-lt-arrow').classList.add('open');
|
||
} else {
|
||
document.getElementById('wbfm-latex-area').style.display = 'none';
|
||
document.getElementById('wbfm-lt-arrow').classList.remove('open');
|
||
}
|
||
document.getElementById('wbfm-insert-btn').disabled = !ta.value.trim();
|
||
wbFmUpdatePreview();
|
||
}
|
||
|
||
function wbFmClose() {
|
||
document.getElementById('wb-formula-modal').style.display = 'none';
|
||
if (_wbFmPrevTimer) { clearTimeout(_wbFmPrevTimer); _wbFmPrevTimer = null; }
|
||
if (_wb) _wb._editingFormulaStroke = null;
|
||
document.getElementById('wbfm-tab-hint').textContent = '';
|
||
}
|
||
|
||
function wbFmUpdatePreview() {
|
||
if (_wbFmPrevTimer) clearTimeout(_wbFmPrevTimer);
|
||
_wbFmPrevTimer = setTimeout(() => {
|
||
const latex = document.getElementById('wbfm-input').value.trim();
|
||
const prev = document.getElementById('wbfm-preview');
|
||
const btn = document.getElementById('wbfm-insert-btn');
|
||
btn.disabled = !latex;
|
||
if (!latex) {
|
||
prev.innerHTML = '<span style="color:rgba(255,255,255,0.28);font-size:0.85rem">Нажмите любую кнопку — здесь появится формула</span><span class="wbfm-prev-hint">Ctrl+Enter — вставить</span>';
|
||
return;
|
||
}
|
||
if (!window.katex) { prev.textContent = latex; return; }
|
||
try {
|
||
prev.innerHTML = window.katex.renderToString(latex, { throwOnError: false, displayMode: true });
|
||
prev.innerHTML += '<span class="wbfm-prev-hint">Ctrl+Enter — вставить</span>';
|
||
} catch (e) {
|
||
prev.innerHTML = `<span style="color:#F15BB5;font-size:0.8rem">${e.message || 'Ошибка'}</span>`;
|
||
}
|
||
}, 160);
|
||
}
|
||
|
||
function wbFmSz(btn, fs) {
|
||
_wbFmFontSize = fs;
|
||
document.querySelectorAll('.wbfm-sz').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
}
|
||
|
||
function wbFmInsert() {
|
||
if (!_wb) return wbFmClose();
|
||
const latex = document.getElementById('wbfm-input').value.trim();
|
||
if (!latex) return;
|
||
const editStroke = _wb._editingFormulaStroke;
|
||
if (editStroke) {
|
||
_wb.updateFormula(editStroke, latex, _wbFmFontSize);
|
||
} else {
|
||
_wb.insertFormula(_wbFmVx, _wbFmVy, latex, _wbFmFontSize);
|
||
}
|
||
wbFmClose();
|
||
}
|
||
|
||
/* ── Image upload ── */
|
||
function wbOpenImagePicker() {
|
||
document.getElementById('wb-image-input')?.click();
|
||
}
|
||
|
||
// Поместить загруженный <img> на доску (ресайз до 800px, по центру)
|
||
function wbPlaceImageFromImg(img) {
|
||
if (!_wb || !_sessionId) return;
|
||
const maxPx = 800;
|
||
let w = img.naturalWidth, h = img.naturalHeight;
|
||
if (w > maxPx || h > maxPx) {
|
||
if (w >= h) { h = Math.round(h * maxPx / w); w = maxPx; }
|
||
else { w = Math.round(w * maxPx / h); h = maxPx; }
|
||
}
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = w; canvas.height = h;
|
||
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
||
const src = canvas.toDataURL('image/jpeg', 0.8);
|
||
const vw = (w / (img.naturalWidth || w)) * 800;
|
||
const vh = (h / (img.naturalHeight || h)) * 450;
|
||
const vx = (1920 - vw) / 2;
|
||
const vy = (1080 - vh) / 2;
|
||
const stroke = {
|
||
id: _wb._localIdCounter--,
|
||
tool: 'image',
|
||
data: { src, x: vx, y: vy, w: vw, h: vh },
|
||
};
|
||
_wb._strokes.push(stroke);
|
||
_wb._undoStack.push(stroke.id);
|
||
_wb.render();
|
||
if (_wb._onStrokeDone) _wb._onStrokeDone(stroke);
|
||
}
|
||
|
||
function wbImageSelected(input) {
|
||
const file = input.files?.[0];
|
||
if (!file || !_wb || !_sessionId) return;
|
||
input.value = '';
|
||
const reader = new FileReader();
|
||
reader.onload = e => {
|
||
const img = new Image();
|
||
img.onload = () => wbPlaceImageFromImg(img);
|
||
img.src = e.target.result;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
// Сгенерировать картинку ИИ и вставить на доску
|
||
function wbGenerateImage() {
|
||
if (!_wb || !_sessionId) return;
|
||
if (!LS.imagePromptModal) { LS.toast?.('Модуль генерации не загружен'); return; }
|
||
LS.imagePromptModal({
|
||
title: 'Картинка на доску (ИИ)',
|
||
placeholder: 'Опиши иллюстрацию: «схема круговорота воды, плоский стиль»',
|
||
useLabel: 'Вставить на доску',
|
||
onUse: (url) => {
|
||
const img = new Image();
|
||
img.onload = () => wbPlaceImageFromImg(img);
|
||
img.onerror = () => LS.toast?.('Не удалось загрузить картинку', 'error');
|
||
img.src = url;
|
||
},
|
||
});
|
||
}
|
||
|
||
function wbSetCustomColor(input) {
|
||
if (!_wb) return;
|
||
_wb.setColor(input.value);
|
||
document.querySelectorAll('.cr-color-btn').forEach(b => b.classList.remove('active'));
|
||
input.style.outline = '2px solid #fff';
|
||
setTimeout(() => { if (input) input.style.outline = ''; }, 300);
|
||
if (['eraser','laser'].some(t => document.getElementById(`wb-tool-${t}`)?.classList.contains('active')))
|
||
wbSetTool('pencil');
|
||
if (LS.prefs) LS.prefs.set('wb.color', input.value);
|
||
}
|
||
|
||
function wbToggleFullscreen() {
|
||
const el = document.getElementById('cr-board-wrap');
|
||
if (!document.fullscreenElement) {
|
||
el.requestFullscreen?.() || el.webkitRequestFullscreen?.();
|
||
} else {
|
||
document.exitFullscreen?.() || document.webkitExitFullscreen?.();
|
||
}
|
||
}
|
||
|
||
function _updateStudentFsBtn() {
|
||
const isFs = !!document.fullscreenElement;
|
||
const lbl = document.getElementById('cr-fs-label');
|
||
if (lbl) lbl.textContent = isFs ? 'Свернуть' : 'На весь экран';
|
||
}
|
||
document.addEventListener('fullscreenchange', _updateStudentFsBtn);
|
||
document.addEventListener('webkitfullscreenchange', _updateStudentFsBtn);
|
||
|
||
/* ── Сохранить доску/фрагмент в «Мои материалы» (ученик) ── */
|
||
function _crClipMeta() {
|
||
return {
|
||
sourceSessionId: _sessionId,
|
||
sourceTitle: (_session && _session.title) || 'Онлайн-урок',
|
||
pageNum: (typeof _wbCurrentPage !== 'undefined' ? _wbCurrentPage : null),
|
||
};
|
||
}
|
||
function crSaveBoardPage(btn) { if (window.BoardClip) BoardClip.savePage(_wb, _crClipMeta(), btn); }
|
||
function crSaveBoardRegion(btn) { if (window.BoardClip) BoardClip.saveRegion(_wb, _crClipMeta(), btn); }
|
||
|
||
/* ── Student page nav / follow ── */
|
||
async function wbStudentPrevPage() {
|
||
if (_wbCurrentPage <= 1) return;
|
||
_followTeacher = false; updateFollowBtn();
|
||
await wbGoToPage(_wbCurrentPage - 1);
|
||
}
|
||
|
||
async function wbStudentNextPage() {
|
||
if (_wbCurrentPage >= _totalPages) return;
|
||
_followTeacher = false; updateFollowBtn();
|
||
await wbGoToPage(_wbCurrentPage + 1);
|
||
}
|
||
|
||
function crToggleFollow() {
|
||
_followTeacher = !_followTeacher;
|
||
updateFollowBtn();
|
||
if (_followTeacher && _session?.current_page) wbGoToPage(_session.current_page);
|
||
}
|
||
|
||
function updateFollowBtn() {
|
||
const btn = document.getElementById('cr-follow-btn');
|
||
const lbl = document.getElementById('cr-follow-label');
|
||
if (!btn) return;
|
||
btn.classList.toggle('active', _followTeacher);
|
||
if (lbl) lbl.textContent = _followTeacher ? 'Авто' : 'Ручной';
|
||
}
|
||
|
||
/* ── Keyboard shortcuts (non-whiteboard-class shortcuts only) ── */
|
||
// Note: Ctrl+Z/Y, Ctrl+C/V, Delete, Escape are handled inside Whiteboard class.
|
||
document.addEventListener('keydown', e => {
|
||
if (!_wb || !_sessionId) return;
|
||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||
const isTeacher = _me?.role === 'teacher' || _me?.role === 'admin';
|
||
if (!isTeacher) return;
|
||
const toolMap = { '1': 'pencil', '2': 'eraser', '3': 'rect', '4': 'ellipse', '5': 'line', '6': 'text',
|
||
'7': 'connector', '8': 'sticky', '9': 'formula' };
|
||
if (toolMap[e.key] && !e.ctrlKey && !e.metaKey) { wbSetTool(toolMap[e.key]); return; }
|
||
});
|
||
|
||
/* ── WebRTC ── */
|
||
async function initRTC(session) {
|
||
if (_rtc) { _rtc.destroy(); _rtc = null; }
|
||
|
||
// peers already in session (excluding self)
|
||
const peerIds = Object.keys(_participants).map(Number).filter(id => id !== _me.id);
|
||
|
||
_rtc = new ClassroomRTC({
|
||
sessionId: session.id,
|
||
userId: _me.id,
|
||
onSignal: async (targetId, payload) => {
|
||
try {
|
||
await LS.post(`/api/classroom/${session.id}/signal`, { target_user_id: targetId, payload });
|
||
} catch {}
|
||
},
|
||
onScreenStream: (stream) => {
|
||
const video = document.getElementById('cr-screen-video');
|
||
const label = document.getElementById('cr-screen-label');
|
||
if (stream) {
|
||
video.srcObject = stream;
|
||
video.style.display = 'block';
|
||
label.style.display = 'block';
|
||
video.play().catch(() => {});
|
||
} else {
|
||
video.srcObject = null;
|
||
video.style.display = 'none';
|
||
label.style.display = 'none';
|
||
}
|
||
},
|
||
onMicActive: (uid, speaking) => {
|
||
if (_participants[uid]) {
|
||
_participants[uid].speaking = speaking;
|
||
// Update avatar and row without full re-render
|
||
const row = document.querySelector(`.cr-participant[data-uid="${uid}"]`);
|
||
if (row) {
|
||
row.classList.toggle('speaking', speaking);
|
||
row.querySelector('.cr-p-avatar')?.classList.toggle('speaking', speaking);
|
||
}
|
||
_updateSpeakerChip();
|
||
}
|
||
},
|
||
onMicLevel: (uid, level) => {
|
||
if (_participants[uid]) _participants[uid].micLevel = level;
|
||
const bars = document.getElementById(`cr-bars-${uid}`);
|
||
if (bars) {
|
||
const spans = bars.querySelectorAll('span');
|
||
// 3 bars: left/center/right with natural stagger heights
|
||
const h2 = Math.max(2, Math.round((level / 100) * 12));
|
||
const h1 = Math.max(2, Math.round(h2 * 0.65));
|
||
const h3 = Math.max(2, Math.round(h2 * 0.45));
|
||
if (spans[0]) spans[0].style.height = h1 + 'px';
|
||
if (spans[1]) spans[1].style.height = h2 + 'px';
|
||
if (spans[2]) spans[2].style.height = h3 + 'px';
|
||
}
|
||
},
|
||
vadThreshold: VAD_THRESHOLDS[_prefs.vadSensitivity] ?? 12,
|
||
});
|
||
|
||
const micOk = await _rtc.startAudio();
|
||
if (!micOk) LS.toast('Нет доступа к микрофону', 'warning');
|
||
|
||
// Apply muted-on-join preference
|
||
if (_prefs.mutedOnJoin && !_rtc.isMuted()) {
|
||
_rtc.forceMute();
|
||
if (_participants[_me?.id]) _participants[_me.id].micMuted = true;
|
||
}
|
||
|
||
document.getElementById('cr-mute-btn').style.display = 'flex';
|
||
updateMuteBtn();
|
||
|
||
if (peerIds.length > 0) await _rtc.connectTo(peerIds);
|
||
}
|
||
|
||
function _updateSpeakerChip() {
|
||
const chip = document.getElementById('cr-speaker-chip');
|
||
if (!chip) return;
|
||
// Find all currently speaking participants, pick the loudest
|
||
const speaking = Object.entries(_participants).filter(([, p]) => p.speaking);
|
||
if (!speaking.length) {
|
||
chip.classList.remove('visible');
|
||
return;
|
||
}
|
||
const [, p] = speaking.sort((a, b) => (b[1].micLevel || 0) - (a[1].micLevel || 0))[0];
|
||
const initials = (p.name || '?').split(' ').slice(0, 2).map(w => w[0]?.toUpperCase() || '').join('') || '?';
|
||
const avatarEl = document.getElementById('cr-speaker-chip-avatar');
|
||
const nameEl = document.getElementById('cr-speaker-chip-name');
|
||
if (avatarEl) avatarEl.textContent = initials;
|
||
if (nameEl) nameEl.textContent = p.name || '';
|
||
chip.classList.add('visible');
|
||
}
|
||
|
||
function crToggleMute() {
|
||
if (!_rtc) return;
|
||
const muted = _rtc.toggleMute();
|
||
if (_participants[_me?.id]) _participants[_me.id].micMuted = muted;
|
||
updateParticipantsList();
|
||
updateMuteBtn();
|
||
}
|
||
|
||
function updateMuteBtn() {
|
||
const btn = document.getElementById('cr-mute-btn');
|
||
if (!btn) return;
|
||
const muted = _rtc?.isMuted() ?? false;
|
||
btn.classList.toggle('cr-btn-mic-on', !muted);
|
||
btn.classList.toggle('cr-btn-mic-off', muted);
|
||
// Update icon via data attribute on a child span (no innerHTML overwrite — keeps onclick)
|
||
let ico = btn.querySelector('.cr-mute-ico');
|
||
let lbl = btn.querySelector('.cr-mute-lbl');
|
||
if (!ico) {
|
||
btn.innerHTML = '<span class="cr-mute-ico"></span><span class="cr-mute-lbl"></span>';
|
||
ico = btn.querySelector('.cr-mute-ico');
|
||
lbl = btn.querySelector('.cr-mute-lbl');
|
||
}
|
||
lbl.textContent = muted ? 'Выключен' : 'Включён';
|
||
ico.innerHTML = muted
|
||
? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;display:block"><line x1="1" y1="1" x2="23" y2="23"/><path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/><path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`
|
||
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;display:block"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`;
|
||
}
|
||
|
||
/* ── screen picker v2 ── */
|
||
let _crSpTab = 'monitor'; // 'monitor' | 'window' | 'browser'
|
||
let _crSpRes = 1080; // 720 | 1080 | 0 (native)
|
||
let _crSpFps = 30;
|
||
let _crSpAudio = false;
|
||
let _crSpCursor = true;
|
||
let _crSpTempStream = null; // preview stream, handed to RTC on confirm
|
||
|
||
/* Tab metadata */
|
||
const _CR_SP_TABS = {
|
||
monitor: {
|
||
label: 'Выбрать экран',
|
||
emptyTitle: 'Экран не выбран',
|
||
emptyHint: 'Нажмите кнопку ниже — откроется выбор монитора',
|
||
barSvg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>',
|
||
emptySvg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:26px;height:26px"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>',
|
||
},
|
||
window: {
|
||
label: 'Выбрать окно',
|
||
emptyTitle: 'Окно не выбрано',
|
||
emptyHint: 'Нажмите кнопку ниже — откроется выбор приложения',
|
||
barSvg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>',
|
||
emptySvg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:26px;height:26px"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>',
|
||
},
|
||
browser: {
|
||
label: 'Выбрать вкладку',
|
||
emptyTitle: 'Вкладка не выбрана',
|
||
emptyHint: 'Нажмите кнопку ниже — откроется выбор вкладки браузера',
|
||
barSvg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
|
||
emptySvg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:26px;height:26px"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
|
||
},
|
||
};
|
||
|
||
function crToggleScreen() {
|
||
if (!_rtc || !_sessionId) return;
|
||
if (_rtc.isSharing()) {
|
||
_rtc.stopScreenShare();
|
||
document.getElementById('cr-screen-btn').classList.remove('cr-btn-sharing');
|
||
_crWsSend({ type: 'screen_stop', sessionId: _sessionId });
|
||
} else {
|
||
crOpenScreenPicker();
|
||
}
|
||
}
|
||
|
||
function crOpenScreenPicker() {
|
||
// Reset preview state (but keep quality prefs)
|
||
_crSpTempStreamStop();
|
||
_crSpShowEmpty();
|
||
// Sync UI state
|
||
['monitor','window','browser'].forEach(t =>
|
||
document.getElementById(`cr-sptab-${t}`)?.classList.toggle('active', t === _crSpTab)
|
||
);
|
||
_crSpUpdateEmptyText();
|
||
document.getElementById('cr-screen-picker-overlay').classList.add('open');
|
||
}
|
||
|
||
function crCloseScreenPicker() {
|
||
document.getElementById('cr-screen-picker-overlay').classList.remove('open');
|
||
_crSpTempStreamStop(); // discard preview if not confirmed
|
||
}
|
||
|
||
function crSpSwitchTab(tab) {
|
||
if (_crSpTab === tab) return;
|
||
_crSpTab = tab;
|
||
['monitor','window','browser'].forEach(t =>
|
||
document.getElementById(`cr-sptab-${t}`)?.classList.toggle('active', t === tab)
|
||
);
|
||
// Reset preview — different surface type
|
||
_crSpTempStreamStop();
|
||
_crSpShowEmpty();
|
||
_crSpUpdateEmptyText();
|
||
}
|
||
|
||
function _crSpUpdateEmptyText() {
|
||
const meta = _CR_SP_TABS[_crSpTab];
|
||
const emptyIcon = document.getElementById('cr-sp-empty-icon');
|
||
const emptyTitle = document.getElementById('cr-sp-empty-title');
|
||
const emptyHint = document.getElementById('cr-sp-empty-hint');
|
||
const pickBtn = document.getElementById('cr-sp-pick-btn');
|
||
if (emptyIcon) emptyIcon.innerHTML = meta.emptySvg;
|
||
if (emptyTitle) emptyTitle.textContent = meta.emptyTitle;
|
||
if (emptyHint) emptyHint.textContent = meta.emptyHint;
|
||
if (pickBtn) {
|
||
// keep the SVG icon inside the button, just update text node
|
||
pickBtn.innerHTML = meta.barSvg + ' ' + meta.label;
|
||
}
|
||
}
|
||
|
||
function _crSpShowEmpty() {
|
||
document.getElementById('cr-sp-empty').style.display = 'flex';
|
||
document.getElementById('cr-sp-video-wrap').classList.remove('visible');
|
||
document.getElementById('cr-sp-start-btn').disabled = true;
|
||
const v = document.getElementById('cr-sp-video');
|
||
if (v) v.srcObject = null;
|
||
}
|
||
|
||
function _crSpShowPreview(stream) {
|
||
const meta = _CR_SP_TABS[_crSpTab];
|
||
const track = stream.getVideoTracks()[0];
|
||
const label = track?.label || meta.emptyTitle.replace('не выбран', '').trim() || 'Источник';
|
||
const settings = track?.getSettings?.() || {};
|
||
const w = settings.width || 0, h = settings.height || 0;
|
||
const resBadge = w && h ? `${w}×${h}` : '';
|
||
|
||
document.getElementById('cr-sp-bar-icon').innerHTML = meta.barSvg;
|
||
document.getElementById('cr-sp-preview-name').textContent = label;
|
||
document.getElementById('cr-sp-res-badge').textContent = resBadge;
|
||
document.getElementById('cr-sp-video').srcObject = stream;
|
||
document.getElementById('cr-sp-empty').style.display = 'none';
|
||
document.getElementById('cr-sp-video-wrap').classList.add('visible');
|
||
document.getElementById('cr-sp-start-btn').disabled = false;
|
||
}
|
||
|
||
function _crSpTempStreamStop() {
|
||
if (_crSpTempStream) {
|
||
_crSpTempStream.getTracks().forEach(t => t.stop());
|
||
_crSpTempStream = null;
|
||
}
|
||
}
|
||
|
||
async function crSpPickSource() {
|
||
_crSpTempStreamStop();
|
||
_crSpShowEmpty();
|
||
|
||
const vc = { displaySurface: _crSpTab, cursor: _crSpCursor ? 'always' : 'never' };
|
||
if (_crSpRes === 720) { vc.width = { ideal: 1280 }; vc.height = { ideal: 720 }; }
|
||
if (_crSpRes === 1080) { vc.width = { ideal: 1920 }; vc.height = { ideal: 1080 }; }
|
||
if (_crSpFps > 0) vc.frameRate = { ideal: _crSpFps };
|
||
|
||
let stream;
|
||
try {
|
||
stream = await navigator.mediaDevices.getDisplayMedia({ video: vc, audio: _crSpAudio });
|
||
} catch { return; } // user cancelled
|
||
|
||
_crSpTempStream = stream;
|
||
_crSpShowPreview(stream);
|
||
|
||
// If user hits browser's "Stop sharing" button while modal open → reset
|
||
const vt = stream.getVideoTracks()[0];
|
||
if (vt) vt.onended = () => {
|
||
if (_crSpTempStream === stream) { _crSpTempStream = null; _crSpShowEmpty(); }
|
||
};
|
||
}
|
||
|
||
function crSpSetQ(btn, type) {
|
||
const row = btn.closest('.cr-sp-q-opts');
|
||
row.querySelectorAll('.cr-sp-qbtn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
const val = Number(btn.dataset.q);
|
||
if (type === 'res') _crSpRes = val;
|
||
else _crSpFps = val;
|
||
}
|
||
|
||
function crSpToggleAudio() {
|
||
_crSpAudio = !_crSpAudio;
|
||
document.getElementById('cr-sp-opt-audio')?.classList.toggle('on', _crSpAudio);
|
||
}
|
||
|
||
function crSpToggleCursor() {
|
||
_crSpCursor = !_crSpCursor;
|
||
document.getElementById('cr-sp-opt-cursor')?.classList.toggle('on', _crSpCursor);
|
||
}
|
||
|
||
async function crSpStartShare() {
|
||
if (!_crSpTempStream || !_rtc || !_sessionId) return;
|
||
|
||
const stream = _crSpTempStream;
|
||
_crSpTempStream = null; // hand off ownership — don't stop it
|
||
|
||
document.getElementById('cr-screen-picker-overlay').classList.remove('open');
|
||
|
||
await _rtc.useExistingScreenStream(stream);
|
||
|
||
const vt = stream.getVideoTracks()[0];
|
||
if (vt) {
|
||
vt.onended = () => {
|
||
_rtc.stopScreenShare();
|
||
onScreenShareStopped();
|
||
};
|
||
}
|
||
document.getElementById('cr-screen-btn').classList.add('cr-btn-sharing');
|
||
_crWsSend({ type: 'screen_start', sessionId: _sessionId });
|
||
}
|
||
|
||
function onScreenShareStopped() {
|
||
document.getElementById('cr-screen-btn').classList.remove('cr-btn-sharing');
|
||
if (_sessionId) _crWsSend({ type: 'screen_stop', sessionId: _sessionId });
|
||
}
|
||
|
||
/* ── Simulation integration ──────────────────────────────────────────── */
|
||
|
||
// Compact sims catalogue (id + title + category) mirrored from lab.html
|
||
const CR_SIMS = [
|
||
{ id:'geometry', cat:'math', title:'Планиметрия' },
|
||
{ id:'graph', cat:'math', title:'График функции' },
|
||
{ id:'graphtransform',cat:'math', title:'Трансформации графиков' },
|
||
{ id:'triangle', cat:'math', title:'Геометрия треугольника' },
|
||
{ id:'quadratic', cat:'math', title:'Корни квадратного уравнения' },
|
||
{ id:'stereo', cat:'math', title:'Стереометрия 3D' },
|
||
{ id:'probability', cat:'math', title:'Теория вероятностей' },
|
||
{ id:'trigcircle', cat:'math', title:'Тригонометрическая окружность' },
|
||
{ id:'normaldist', cat:'math', title:'Нормальное распределение' },
|
||
{ id:'projectile', cat:'phys', title:'Бросок тела' },
|
||
{ id:'pendulum', cat:'phys', title:'Маятник' },
|
||
{ id:'collision', cat:'phys', title:'Столкновение шаров' },
|
||
{ id:'magnetic', cat:'phys', title:'Магнитное поле токов' },
|
||
{ id:'circuit', cat:'phys', title:'Электрические цепи' },
|
||
{ id:'coulomb', cat:'phys', title:'Закон Кулона' },
|
||
{ id:'dynamics', cat:'phys', title:'Динамика' },
|
||
{ id:'thinlens', cat:'phys', title:'Тонкая линза' },
|
||
{ id:'refraction', cat:'phys', title:'Преломление света' },
|
||
{ id:'mirrors', cat:'phys', title:'Зеркала' },
|
||
{ id:'isoprocess', cat:'phys', title:'Изопроцессы' },
|
||
{ id:'waves', cat:'phys', title:'Волны и звук' },
|
||
{ id:'molphys', cat:'chem', title:'Молекулярная физика' },
|
||
{ id:'chemistry', cat:'chem', title:'Химические реакции' },
|
||
{ id:'equilibrium', cat:'chem', title:'Химическое равновесие' },
|
||
{ id:'electrolysis', cat:'chem', title:'Электролиз' },
|
||
{ id:'bohratom', cat:'chem', title:'Атом Бора' },
|
||
{ id:'orbitals', cat:'chem', title:'Молекулярные орбитали' },
|
||
{ id:'titration', cat:'chem', title:'pH и кривая титрования' },
|
||
{ id:'chemsandbox', cat:'chem', title:'Химическая песочница' },
|
||
{ id:'crystal', cat:'chem', title:'Кристаллическая решётка' },
|
||
{ id:'celldivision', cat:'bio', title:'Деление клетки' },
|
||
{ id:'photosynthesis',cat:'bio', title:'Фотосинтез и дыхание' },
|
||
{ id:'angrybirds', cat:'game', title:'Angry Birds Physics' },
|
||
];
|
||
|
||
const CAT_LABELS = { math:'Математика', phys:'Физика', chem:'Химия', bio:'Биология', game:'Игра' };
|
||
|
||
let _simPickerCat = 'all'; // active filter in picker
|
||
// Конструктор симуляций (Фаза 7): свои + published custom-симуляции для доски.
|
||
let _crCustomSims = null; // [{ id, cat, title, _custom:true }] — кэш списка
|
||
|
||
async function _crLoadCustomSims() {
|
||
if (_crCustomSims) return _crCustomSims;
|
||
try {
|
||
const data = await LS.customSimsList();
|
||
const rows = (data && data.sims) || [];
|
||
_crCustomSims = rows.map(s => ({
|
||
id: 'custom:' + s.id,
|
||
cat: s.cat || 'phys',
|
||
title: s.title || ('Симуляция #' + s.id),
|
||
_custom: true,
|
||
}));
|
||
} catch (e) { _crCustomSims = []; }
|
||
return _crCustomSims;
|
||
}
|
||
|
||
async function crOpenSimPicker() {
|
||
if (_simActive) {
|
||
// If sim already open — clicking "Симуляция" closes it (teacher action)
|
||
crTeacherCloseSim();
|
||
return;
|
||
}
|
||
_simPickerCat = 'all';
|
||
await _crLoadCustomSims();
|
||
_crRenderSimGrid('all');
|
||
const overlay = document.getElementById('cr-sim-picker-overlay');
|
||
overlay.classList.add('open');
|
||
// reset cat buttons
|
||
overlay.querySelectorAll('.cr-sim-cat-btn').forEach((b, i) => b.classList.toggle('active', i === 0));
|
||
}
|
||
|
||
function crCloseSimPicker() {
|
||
document.getElementById('cr-sim-picker-overlay').classList.remove('open');
|
||
}
|
||
|
||
function crFilterSims(cat, btn) {
|
||
_simPickerCat = cat;
|
||
document.querySelectorAll('.cr-sim-cat-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
_crRenderSimGrid(cat);
|
||
}
|
||
|
||
function _crRenderSimGrid(cat) {
|
||
const grid = document.getElementById('cr-sim-picker-grid');
|
||
// Конструктор симуляций (Фаза 7): встроенные + свои/published custom-sims.
|
||
const all = CR_SIMS.concat(_crCustomSims || []);
|
||
const sims = cat === 'all' ? all : all.filter(s => s.cat === cat);
|
||
const esc = v => String(v == null ? '' : v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
grid.innerHTML = sims.map(s => `
|
||
<div class="cr-sim-picker-card" onclick="crPickSim('${String(s.id).replace(/'/g,"\\'")}','${esc(s.title).replace(/'/g,"\\'")}')" title="${esc(s.title)}">
|
||
<span class="cr-sim-picker-card-cat ${s.cat}">${s._custom ? 'Моя' : (CAT_LABELS[s.cat] || s.cat)}</span>
|
||
<span class="cr-sim-picker-card-title">${esc(s.title)}</span>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
async function crPickSim(simId, title) {
|
||
crCloseSimPicker();
|
||
if (!_sessionId) return;
|
||
try {
|
||
await LS.post(`/api/classroom/${_sessionId}/sim`, { simId, title });
|
||
// onSimOpen will be called via SSE echo (including own client)
|
||
} catch (e) {
|
||
LS.toast(e.message || 'Ошибка открытия симуляции', 'error');
|
||
}
|
||
}
|
||
|
||
async function crTeacherCloseSim() {
|
||
if (!_sessionId) return;
|
||
try {
|
||
await LS.del(`/api/classroom/${_sessionId}/sim`);
|
||
// onSimClose will be called via SSE echo
|
||
} catch (e) {
|
||
LS.toast(e.message || 'Ошибка', 'error');
|
||
}
|
||
}
|
||
|
||
function onSimOpen(simId, title) {
|
||
_simActive = simId;
|
||
const panel = document.getElementById('cr-sim-panel');
|
||
const frame = document.getElementById('cr-sim-frame');
|
||
const titleEl = document.getElementById('cr-sim-bar-title');
|
||
const closeBtn = document.getElementById('cr-sim-bar-close');
|
||
const modeToggle = document.getElementById('cr-sim-mode-toggle');
|
||
const headerBtn = document.getElementById('cr-sim-btn');
|
||
const blocker = document.getElementById('cr-sim-blocker');
|
||
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
|
||
|
||
titleEl.textContent = title || simId;
|
||
frame.src = `/lab?embed=1&sim=${encodeURIComponent(simId)}`;
|
||
panel.classList.add('open');
|
||
|
||
// Teacher: show close button + mode toggle + update header label
|
||
if (closeBtn) closeBtn.style.display = isTeacher ? 'flex' : 'none';
|
||
if (modeToggle) modeToggle.style.display = isTeacher ? 'flex' : 'none';
|
||
if (headerBtn && isTeacher) {
|
||
headerBtn.title = 'Закрыть симуляцию';
|
||
headerBtn.querySelector('span').textContent = 'Закрыть';
|
||
}
|
||
|
||
// Student in demo mode: block interaction
|
||
if (blocker) blocker.classList.toggle('active', !isTeacher && _simMode === 'demo');
|
||
|
||
// Sync mode buttons to current state
|
||
onSimModeChange(_simMode);
|
||
}
|
||
|
||
function onSimClose() {
|
||
_simActive = null;
|
||
const panel = document.getElementById('cr-sim-panel');
|
||
const frame = document.getElementById('cr-sim-frame');
|
||
const headerBtn = document.getElementById('cr-sim-btn');
|
||
const modeToggle = document.getElementById('cr-sim-mode-toggle');
|
||
const blocker = document.getElementById('cr-sim-blocker');
|
||
|
||
// Exit annotate mode when sim closes
|
||
if (_annotateActive) _crApplyAnnotate(false);
|
||
|
||
panel.classList.remove('open');
|
||
if (modeToggle) modeToggle.style.display = 'none';
|
||
if (blocker) blocker.classList.remove('active');
|
||
|
||
// Delay src reset so the iframe doesn't flash
|
||
setTimeout(() => { if (frame) frame.src = 'about:blank'; }, 300);
|
||
|
||
// Restore header button label
|
||
if (headerBtn) {
|
||
headerBtn.title = 'Открыть симуляцию';
|
||
const sp = headerBtn.querySelector('span');
|
||
if (sp) sp.textContent = 'Симуляция';
|
||
}
|
||
}
|
||
|
||
let _annotateActive = false;
|
||
let _annotateTool = 'pencil'; // saved tool before entering annotate mode
|
||
|
||
async function crToggleAnnotate() {
|
||
if (!_simActive && !_tbActive) return;
|
||
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
|
||
const newVal = !_annotateActive;
|
||
_crApplyAnnotate(newVal);
|
||
// Only teacher broadcasts to students (reuse sim/annotate channel — same payload)
|
||
if (isTeacher && _sessionId) {
|
||
try {
|
||
await LS.post(`/api/classroom/${_sessionId}/sim/annotate`, { active: newVal });
|
||
} catch (e) { /* non-critical */ }
|
||
}
|
||
}
|
||
|
||
function _crApplyAnnotate(active) {
|
||
_annotateActive = active;
|
||
const boardArea = document.getElementById('cr-board-area');
|
||
const simBtn = document.getElementById('cr-sim-annotate-btn');
|
||
const tbBtn = document.getElementById('cr-tb-annotate-btn');
|
||
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
|
||
|
||
boardArea?.classList.toggle('annotate-active', active);
|
||
if (simBtn) simBtn.classList.toggle('active', active);
|
||
if (tbBtn) tbBtn.classList.toggle('active', active);
|
||
|
||
if (!_wb) return;
|
||
_wb.setAnnotateMode(active);
|
||
if (active) {
|
||
// Remember current tool, switch to pencil for drawing
|
||
_annotateTool = _wb._currentTool || 'pencil';
|
||
if (isTeacher) {
|
||
_wb.setTool('pencil');
|
||
document.querySelectorAll('.cr-tool-btn').forEach(b => b.classList.remove('active'));
|
||
document.getElementById('cr-tool-pencil')?.classList.add('active');
|
||
}
|
||
} else {
|
||
// Restore previous tool
|
||
if (isTeacher) {
|
||
_wb.setTool(_annotateTool);
|
||
document.getElementById(`cr-tool-${_annotateTool}`)?.classList.add('active');
|
||
}
|
||
}
|
||
}
|
||
|
||
async function crSetSimMode(mode) {
|
||
if (!_sessionId) return;
|
||
try {
|
||
await LS.post(`/api/classroom/${_sessionId}/sim/mode`, { mode });
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
function onSimModeChange(mode) {
|
||
_simMode = mode;
|
||
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
|
||
const blocker = document.getElementById('cr-sim-blocker');
|
||
if (blocker) blocker.classList.toggle('active', !isTeacher && mode === 'demo');
|
||
// Update toggle buttons (teacher UI)
|
||
document.getElementById('cr-sim-mode-demo')?.classList.toggle('active', mode === 'demo');
|
||
document.getElementById('cr-sim-mode-free')?.classList.toggle('active', mode === 'free');
|
||
}
|
||
|
||
// Teacher: relay sim state from iframe to backend → SSE to students
|
||
window.addEventListener('message', e => {
|
||
if (!_simActive || !_sessionId) return;
|
||
const isTeacher = _me?.role === 'teacher' || _me?.role === 'admin';
|
||
if (!isTeacher) return;
|
||
if (e.data?.type !== 'sim_state') return;
|
||
clearTimeout(_simStateThrottle);
|
||
_simStateThrottle = setTimeout(async () => {
|
||
try { await LS.post(`/api/classroom/${_sessionId}/sim/state`, { state: e.data.state }); } catch {}
|
||
}, 300);
|
||
});
|
||
|
||
/* ════════════════════════════════════════════════════════════════════
|
||
TEXTBOOK in classroom — open any textbook in shared iframe
|
||
════════════════════════════════════════════════════════════════════ */
|
||
let _tbActive = null; // current slug, or null
|
||
let _tbMode = 'demo'; // 'demo' | 'free'
|
||
let _tbList = null; // cached catalog from GET /api/textbooks
|
||
let _tbPickerSubj = 'all';
|
||
let _tbNavThrottle = null;
|
||
|
||
async function crOpenTbPicker() {
|
||
if (_tbActive) { crTeacherCloseTb(); return; }
|
||
const overlay = document.getElementById('cr-tb-picker-overlay');
|
||
overlay.classList.add('open');
|
||
if (!_tbList) {
|
||
try {
|
||
const data = await LS.get('/api/textbooks');
|
||
_tbList = Array.isArray(data) ? data : (data?.textbooks || data?.items || []);
|
||
} catch (e) {
|
||
document.getElementById('cr-tb-picker-grid').innerHTML =
|
||
`<div style="grid-column:1/-1;color:#F15BB5;padding:24px;text-align:center;font-size:.85rem">Не удалось загрузить список учебников</div>`;
|
||
return;
|
||
}
|
||
_crRenderTbCats();
|
||
}
|
||
_crRenderTbGrid(_tbPickerSubj);
|
||
}
|
||
|
||
function crCloseTbPicker() {
|
||
document.getElementById('cr-tb-picker-overlay').classList.remove('open');
|
||
}
|
||
|
||
function crFilterTbs(subj, btn) {
|
||
_tbPickerSubj = subj;
|
||
document.querySelectorAll('#cr-tb-picker-cats .cr-sim-cat-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
_crRenderTbGrid(subj);
|
||
}
|
||
|
||
function _crRenderTbCats() {
|
||
const subjs = [...new Set((_tbList || []).map(t => t.subject).filter(Boolean))].sort();
|
||
const SUBJ_LABEL = {
|
||
math: 'Математика', algebra: 'Алгебра', geometry: 'Геометрия',
|
||
physics: 'Физика', chemistry: 'Химия', biology: 'Биология',
|
||
informatics: 'Информатика', russian: 'Русский', english: 'Английский',
|
||
};
|
||
const cats = document.getElementById('cr-tb-picker-cats');
|
||
cats.innerHTML = `<button class="cr-sim-cat-btn active" onclick="crFilterTbs('all', this)">Все</button>` +
|
||
subjs.map(s => `<button class="cr-sim-cat-btn" onclick="crFilterTbs('${s}', this)">${SUBJ_LABEL[s] || s}</button>`).join('');
|
||
}
|
||
|
||
function _crRenderTbGrid(subj) {
|
||
const grid = document.getElementById('cr-tb-picker-grid');
|
||
const list = (_tbList || []).filter(t => subj === 'all' || t.subject === subj);
|
||
if (!list.length) {
|
||
grid.innerHTML = `<div style="grid-column:1/-1;color:rgba(255,255,255,0.5);padding:24px;text-align:center;font-size:.85rem">Нет учебников</div>`;
|
||
return;
|
||
}
|
||
grid.innerHTML = list.map(t => {
|
||
const slugSafe = String(t.slug).replace(/[^a-z0-9_-]/gi, '');
|
||
const titleEsc = String(t.title || t.slug).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
const grade = t.grade ? `${t.grade} кл.` : '';
|
||
return `
|
||
<div class="cr-sim-picker-card" onclick="crPickTb('${slugSafe}')" title="${titleEsc}">
|
||
<span class="cr-sim-picker-card-cat math">${grade || (t.subject || '')}</span>
|
||
<span class="cr-sim-picker-card-title">${titleEsc}</span>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function crPickTb(slug) {
|
||
crCloseTbPicker();
|
||
if (!_sessionId) return;
|
||
try {
|
||
await LS.post(`/api/classroom/${_sessionId}/textbook`, { slug });
|
||
// onTbOpen will fire via SSE echo
|
||
} catch (e) {
|
||
LS.toast(e.message || 'Ошибка открытия учебника', 'error');
|
||
}
|
||
}
|
||
|
||
async function crTeacherCloseTb() {
|
||
if (!_sessionId) return;
|
||
try { await LS.del(`/api/classroom/${_sessionId}/textbook`); }
|
||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
async function crSetTbMode(mode) {
|
||
if (!_sessionId) return;
|
||
try { await LS.post(`/api/classroom/${_sessionId}/textbook/mode`, { mode }); }
|
||
catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
function onTbOpen(data) {
|
||
_tbActive = data.slug;
|
||
const panel = document.getElementById('cr-tb-panel');
|
||
const frame = document.getElementById('cr-tb-frame');
|
||
const titleEl = document.getElementById('cr-tb-bar-title');
|
||
const closeBtn = document.getElementById('cr-tb-bar-close');
|
||
const modeTog = document.getElementById('cr-tb-mode-toggle');
|
||
const headerBtn = document.getElementById('cr-tb-btn');
|
||
const blocker = document.getElementById('cr-tb-blocker');
|
||
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
|
||
|
||
titleEl.textContent = data.title || data.slug;
|
||
let src = `/textbook/${encodeURIComponent(data.slug)}?embed=1`;
|
||
if (data.hash) src += '#' + data.hash;
|
||
frame.src = src;
|
||
panel.classList.add('open');
|
||
|
||
if (closeBtn) closeBtn.style.display = isTeacher ? 'flex' : 'none';
|
||
if (modeTog) modeTog.style.display = isTeacher ? 'flex' : 'none';
|
||
if (headerBtn && isTeacher) {
|
||
headerBtn.title = 'Закрыть учебник';
|
||
headerBtn.querySelector('span').textContent = 'Закрыть';
|
||
}
|
||
if (blocker) blocker.classList.toggle('active', !isTeacher && _tbMode === 'demo');
|
||
onTbModeChange(_tbMode);
|
||
}
|
||
|
||
function onTbClose() {
|
||
if (_annotateActive) _crApplyAnnotate(false);
|
||
_tbActive = null;
|
||
const panel = document.getElementById('cr-tb-panel');
|
||
const frame = document.getElementById('cr-tb-frame');
|
||
const headerBtn = document.getElementById('cr-tb-btn');
|
||
const modeTog = document.getElementById('cr-tb-mode-toggle');
|
||
const blocker = document.getElementById('cr-tb-blocker');
|
||
panel.classList.remove('open');
|
||
if (modeTog) modeTog.style.display = 'none';
|
||
if (blocker) blocker.classList.remove('active');
|
||
setTimeout(() => { if (frame) frame.src = 'about:blank'; }, 300);
|
||
if (headerBtn) {
|
||
headerBtn.title = 'Открыть учебник';
|
||
const sp = headerBtn.querySelector('span'); if (sp) sp.textContent = 'Учебник';
|
||
}
|
||
}
|
||
|
||
function onTbNav(data) {
|
||
if (!_tbActive) return;
|
||
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
|
||
// In demo mode студенты следуют за учителем. В free — игнорируем nav-события.
|
||
if (isTeacher) return;
|
||
if (_tbMode !== 'demo') return;
|
||
const frame = document.getElementById('cr-tb-frame');
|
||
if (!frame) return;
|
||
// Если slug сменился — перезагружаем iframe, иначе шлём postMessage
|
||
if (data.slug && data.slug !== _tbActive) {
|
||
_tbActive = data.slug;
|
||
document.getElementById('cr-tb-bar-title').textContent = data.title || data.slug;
|
||
let src = `/textbook/${encodeURIComponent(data.slug)}?embed=1`;
|
||
if (data.hash) src += '#' + data.hash;
|
||
frame.src = src;
|
||
} else {
|
||
frame.contentWindow?.postMessage(
|
||
{ type: 'ls_tb_apply', hash: data.hash, scrollY: data.scrollY }, '*'
|
||
);
|
||
}
|
||
}
|
||
|
||
function onTbModeChange(mode) {
|
||
_tbMode = mode;
|
||
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
|
||
const blocker = document.getElementById('cr-tb-blocker');
|
||
const frame = document.getElementById('cr-tb-frame');
|
||
if (blocker) blocker.classList.toggle('active', !isTeacher && mode === 'demo');
|
||
document.getElementById('cr-tb-mode-demo')?.classList.toggle('active', mode === 'demo');
|
||
document.getElementById('cr-tb-mode-free')?.classList.toggle('active', mode === 'free');
|
||
// Сообщаем iframe — блокировать клики у студента в demo
|
||
if (frame && !isTeacher) {
|
||
frame.contentWindow?.postMessage({ type: 'ls_tb_lock', locked: mode === 'demo' }, '*');
|
||
}
|
||
}
|
||
|
||
// Teacher: relay textbook nav (hash/scroll) from iframe → backend → SSE
|
||
window.addEventListener('message', e => {
|
||
if (!_tbActive || !_sessionId) return;
|
||
const isTeacher = _me?.role === 'teacher' || _me?.role === 'admin';
|
||
if (!isTeacher) return;
|
||
const d = e.data;
|
||
if (!d || typeof d !== 'object') return;
|
||
if (d.type === 'ls_tb_nav') {
|
||
// navigation: hash или ссылка на другой учебник — отправляем сразу
|
||
const slug = d.slug || _tbActive;
|
||
LS.post(`/api/classroom/${_sessionId}/textbook/nav`, {
|
||
slug, hash: d.hash || null
|
||
}).catch(() => {});
|
||
// если открыли другой учебник — учитель сам переключает iframe (студенты получат через SSE)
|
||
if (slug !== _tbActive) {
|
||
const frame = document.getElementById('cr-tb-frame');
|
||
if (frame) {
|
||
let src = `/textbook/${encodeURIComponent(slug)}?embed=1`;
|
||
if (d.hash) src += '#' + d.hash;
|
||
frame.src = src;
|
||
_tbActive = slug;
|
||
}
|
||
}
|
||
} else if (d.type === 'ls_tb_scroll') {
|
||
clearTimeout(_tbNavThrottle);
|
||
_tbNavThrottle = setTimeout(() => {
|
||
LS.post(`/api/classroom/${_sessionId}/textbook/nav`, {
|
||
slug: _tbActive, scrollY: d.scrollY
|
||
}).catch(() => {});
|
||
}, 350);
|
||
}
|
||
});
|
||
|
||
function crToggleDrawPermission(uid) {
|
||
if (!_sessionId) return;
|
||
const numUid = Number(uid);
|
||
const hasPermit = _permittedStudents.has(numUid);
|
||
if (hasPermit) {
|
||
_permittedStudents.delete(numUid);
|
||
_crWsSend({ type: 'revoke_draw', sessionId: _sessionId, targetUserId: numUid });
|
||
} else {
|
||
_permittedStudents.add(numUid);
|
||
_crWsSend({ type: 'allow_draw', sessionId: _sessionId, targetUserId: numUid });
|
||
}
|
||
updateParticipantsList();
|
||
}
|
||
|
||
function crMutePeer(uid) {
|
||
if (!_sessionId) return;
|
||
_crWsSend({ type: 'mute_peer', sessionId: _sessionId, targetUserId: Number(uid) });
|
||
if (_participants[uid]) { _participants[uid].micMuted = true; updateParticipantsList(); }
|
||
}
|
||
|
||
/* ── notifications ── */
|
||
function toggleNotifDrop() {
|
||
const drop = document.getElementById('notif-drop');
|
||
if (!drop.classList.contains('open')) {
|
||
const r = document.getElementById('notif-btn').getBoundingClientRect();
|
||
drop.style.left = (r.right + 8) + 'px';
|
||
drop.style.top = r.top + 'px';
|
||
}
|
||
drop.classList.toggle('open');
|
||
if (drop.classList.contains('open')) LS.loadNotifs();
|
||
}
|
||
document.addEventListener('click', e => {
|
||
const drop = document.getElementById('notif-drop');
|
||
if (drop.classList.contains('open') &&
|
||
!drop.contains(e.target) &&
|
||
!document.getElementById('notif-btn').contains(e.target))
|
||
drop.classList.remove('open');
|
||
});
|
||
|
||
/* ── sidebar ── */
|
||
function toggleSidebar() {
|
||
const layout = document.querySelector('.app-layout');
|
||
layout.classList.toggle('sb-collapsed');
|
||
localStorage.setItem('ls_sb_collapsed', layout.classList.contains('sb-collapsed') ? '1' : '0');
|
||
if (window.lucide) lucide.createIcons();
|
||
}
|
||
|
||
function lsSearchOpen() {
|
||
if (typeof LS !== 'undefined' && LS.searchOpen) LS.searchOpen();
|
||
}
|
||
|
||
/* ── Quiz integration ── */
|
||
|
||
// Suppress api.js full-screen quiz overlay on classroom page
|
||
window._lsLiveOverriddenByClassroom = true;
|
||
|
||
let _quizLiveId = null;
|
||
let _quizQuestions = [];
|
||
let _quizPage = 0;
|
||
let _quizTotal = 0;
|
||
let _quizSearchTimer = null;
|
||
const _Q_LIMIT = 20;
|
||
|
||
function _crMathHtml(text) {
|
||
if (!text) return '';
|
||
const kat = window.katex;
|
||
if (!kat) { const d = document.createElement('span'); d.textContent = text; return d.innerHTML; }
|
||
let out = '', i = 0;
|
||
while (i < text.length) {
|
||
const ii = text.indexOf('\\(', i), bi = text.indexOf('\\[', i);
|
||
let next = -1, close = '', disp = false;
|
||
if (ii >= 0 && (bi < 0 || ii <= bi)) { next = ii; close = '\\)'; disp = false; }
|
||
else if (bi >= 0) { next = bi; close = '\\]'; disp = true; }
|
||
const plain = document.createElement('span');
|
||
if (next < 0) { plain.textContent = text.slice(i); out += plain.innerHTML; break; }
|
||
plain.textContent = text.slice(i, next); out += plain.innerHTML;
|
||
const ci = text.indexOf(close, next + 2);
|
||
if (ci < 0) { const p2 = document.createElement('span'); p2.textContent = text.slice(next); out += p2.innerHTML; break; }
|
||
try { out += kat.renderToString(text.slice(next + 2, ci), { displayMode: disp, throwOnError: false }); }
|
||
catch(e) { const p2 = document.createElement('span'); p2.textContent = text.slice(next, ci + close.length); out += p2.innerHTML; }
|
||
i = ci + close.length;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function crQuizOnSessionActive(isTeacher) {
|
||
if (isTeacher) {
|
||
const tabQuiz = document.getElementById('tab-quiz');
|
||
if (tabQuiz) tabQuiz.style.display = '';
|
||
document.getElementById('cr-tabs-inner')?.classList.add('tabs-4');
|
||
document.getElementById('cr-quiz-no-session').style.display = 'none';
|
||
document.getElementById('cr-quiz-session').style.display = 'flex';
|
||
// Warn and disable start if no class (personal session)
|
||
const hasClass = !!_session?.class_id;
|
||
const noClassEl = document.getElementById('cr-quiz-no-class');
|
||
if (noClassEl) noClassEl.style.display = hasClass ? 'none' : 'block';
|
||
const startBtn = document.getElementById('cr-quiz-start-btn');
|
||
if (startBtn) startBtn.disabled = !hasClass;
|
||
crQuizInit();
|
||
}
|
||
crStudentWidgetInit();
|
||
}
|
||
|
||
async function crQuizInit() {
|
||
await crQuizLoadTopics();
|
||
crQuizLoadQuestions(true);
|
||
// Check if a quiz is already running for this class
|
||
if (!_session?.class_id) return;
|
||
try {
|
||
const data = await LS.get(`/api/live/class/${_session.class_id}/active`);
|
||
if (data?.active && data.live) {
|
||
_quizLiveId = data.live.id;
|
||
document.getElementById('cr-quiz-start-area').style.display = 'none';
|
||
document.getElementById('cr-quiz-status-bar').style.display = 'flex';
|
||
document.getElementById('cr-quiz-status-text').textContent = 'Квиз активен';
|
||
if (data.question) crQuizShowActiveCard(data.question);
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
async function crQuizLoadTopics() {
|
||
// Derive topics from the first batch of questions (no dedicated topics endpoint)
|
||
try {
|
||
const data = await LS.get(`/api/questions?limit=200`);
|
||
const sel = document.getElementById('cr-quiz-topic-sel');
|
||
if (!sel) return;
|
||
const seen = new Map();
|
||
(data.rows || []).forEach(q => {
|
||
if (q.topic_id && q.topic && !seen.has(q.topic_id)) {
|
||
seen.set(q.topic_id, q.topic);
|
||
}
|
||
});
|
||
seen.forEach((name, id) => {
|
||
const o = document.createElement('option');
|
||
o.value = id; o.textContent = name;
|
||
sel.appendChild(o);
|
||
});
|
||
} catch {}
|
||
}
|
||
|
||
async function crQuizLoadQuestions(reset = true) {
|
||
if (reset) { _quizPage = 1; _quizQuestions = []; }
|
||
const search = document.getElementById('cr-quiz-search')?.value || '';
|
||
const topicId = document.getElementById('cr-quiz-topic-sel')?.value || '';
|
||
const diff = document.getElementById('cr-quiz-diff-sel')?.value || '';
|
||
const params = new URLSearchParams({ limit: _Q_LIMIT, page: _quizPage });
|
||
if (search) params.set('q', search);
|
||
if (topicId) params.set('topic_id', topicId);
|
||
if (diff) params.set('difficulty', diff);
|
||
try {
|
||
const data = await LS.get(`/api/questions?${params}`);
|
||
const qs = data.rows || [];
|
||
_quizTotal = data.total || qs.length;
|
||
_quizQuestions = reset ? qs : [..._quizQuestions, ...qs];
|
||
crQuizRenderList(reset ? qs : _quizQuestions);
|
||
const countEl = document.getElementById('cr-quiz-count');
|
||
if (countEl) { countEl.textContent = `${_quizTotal} вопросов`; countEl.style.display = _quizTotal > 0 ? '' : 'none'; }
|
||
const moreBtn = document.getElementById('cr-quiz-load-more');
|
||
if (moreBtn) moreBtn.style.display = _quizQuestions.length < _quizTotal ? '' : 'none';
|
||
} catch {}
|
||
}
|
||
|
||
function crQuizRenderList(questions) {
|
||
const scroll = document.getElementById('cr-quiz-q-scroll');
|
||
if (!scroll) return;
|
||
if (!questions.length) {
|
||
scroll.innerHTML = '<div style="padding:16px 12px;color:rgba(255,255,255,0.3);font-size:0.72rem;text-align:center">Вопросы не найдены</div>';
|
||
return;
|
||
}
|
||
scroll.innerHTML = questions.map(q => {
|
||
const diffColor = q.difficulty === 1 ? '#A8E063' : q.difficulty === 3 ? '#FF6B6B' : '#FFE066';
|
||
const diffLabel = q.difficulty === 1 ? 'Лёгкий' : q.difficulty === 3 ? 'Сложный' : 'Средний';
|
||
const safeText = (q.text || '').replace(/"/g, '"');
|
||
return `<div class="cr-quiz-q-item">
|
||
<div class="cr-quiz-q-body">
|
||
<div class="cr-quiz-q-text" data-text="${safeText}"></div>
|
||
<div class="cr-quiz-q-meta">
|
||
<span style="color:${diffColor}">${diffLabel}</span>
|
||
${q.topic ? `<span>· ${q.topic}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
<button class="cr-quiz-launch-btn" onclick="crQuizLaunch(${q.id})" title="Запустить вопрос" ${_session?.class_id ? '' : 'disabled'}>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||
</button>
|
||
</div>`;
|
||
}).join('');
|
||
scroll.querySelectorAll('[data-text]').forEach(el => { el.innerHTML = _crMathHtml(el.dataset.text); });
|
||
}
|
||
|
||
function crQuizLoadMore() { _quizPage++; crQuizLoadQuestions(false); }
|
||
|
||
function crQuizOnSearch(v) {
|
||
clearTimeout(_quizSearchTimer);
|
||
_quizSearchTimer = setTimeout(() => crQuizLoadQuestions(true), 350);
|
||
}
|
||
|
||
function crQuizOnTopicFilter() { crQuizLoadQuestions(true); }
|
||
function crQuizOnDiffFilter() { crQuizLoadQuestions(true); }
|
||
|
||
async function crQuizStart() {
|
||
if (!_session?.class_id) {
|
||
LS.toast('Квиз доступен только для классных сессий', 'warn');
|
||
return;
|
||
}
|
||
try {
|
||
const data = await LS.post('/api/live', { class_id: _session.class_id });
|
||
_quizLiveId = data.id;
|
||
document.getElementById('cr-quiz-start-area').style.display = 'none';
|
||
document.getElementById('cr-quiz-status-bar').style.display = 'flex';
|
||
document.getElementById('cr-quiz-status-text').textContent = 'Квиз запущен';
|
||
} catch(e) {
|
||
LS.toast(e.message || 'Ошибка запуска квиза', 'error');
|
||
}
|
||
}
|
||
|
||
async function crQuizEnd() {
|
||
if (!_quizLiveId) return;
|
||
try { await LS.del(`/api/live/${_quizLiveId}`); } catch {}
|
||
_quizLiveId = null;
|
||
document.getElementById('cr-quiz-start-area').style.display = 'block';
|
||
document.getElementById('cr-quiz-status-bar').style.display = 'none';
|
||
document.getElementById('cr-quiz-active-card').style.display = 'none';
|
||
document.getElementById('cr-quiz-result-stats').style.display = 'none';
|
||
}
|
||
|
||
async function crQuizLaunch(questionId) {
|
||
if (!_quizLiveId) await crQuizStart();
|
||
if (!_quizLiveId) return;
|
||
try {
|
||
const resp = await LS.put(`/api/live/${_quizLiveId}/question`, { question_id: questionId });
|
||
crQuizShowActiveCard(resp.question || { text: '...' });
|
||
document.getElementById('cr-quiz-result-stats').style.display = 'none';
|
||
} catch(e) { console.error('crQuizLaunch', e); }
|
||
}
|
||
|
||
function crQuizShowActiveCard(q) {
|
||
const card = document.getElementById('cr-quiz-active-card');
|
||
const textEl = document.getElementById('cr-quiz-active-text');
|
||
if (!card || !textEl) return;
|
||
card.style.display = 'block';
|
||
textEl.innerHTML = _crMathHtml(q.text || '');
|
||
document.getElementById('cr-quiz-ans-count').textContent = '0';
|
||
document.getElementById('cr-quiz-status-text').textContent = 'Вопрос активен';
|
||
document.getElementById('cr-quiz-result-stats').style.display = 'none';
|
||
}
|
||
|
||
function crQuizUpdateCounter(count) {
|
||
const el = document.getElementById('cr-quiz-ans-count');
|
||
if (el) el.textContent = count;
|
||
}
|
||
|
||
async function crQuizShowResults() {
|
||
if (!_quizLiveId) return;
|
||
try { const data = await LS.get(`/api/live/${_quizLiveId}/results`); crQuizRenderResults(data); } catch {}
|
||
}
|
||
|
||
function crQuizRenderResults(data) {
|
||
const container = document.getElementById('cr-quiz-result-stats');
|
||
if (!container) return;
|
||
const opts = data.options || [];
|
||
const stats = data.stats || {};
|
||
const total = stats.total || 0;
|
||
const correct = stats.correct || 0;
|
||
const maxCnt = Math.max(...opts.map(o => o.chosen_count || 0), 1);
|
||
const pct = total > 0 ? Math.round(correct / total * 100) : 0;
|
||
container.innerHTML = `
|
||
<div style="display:flex;gap:6px;padding:6px 10px 2px">
|
||
<div class="cr-quiz-result-stat"><span class="cr-quiz-result-stat-val" style="color:#A8E063">${pct}%</span><span class="cr-quiz-result-stat-lbl">Правильно</span></div>
|
||
<div class="cr-quiz-result-stat"><span class="cr-quiz-result-stat-val">${total}</span><span class="cr-quiz-result-stat-lbl">Ответов</span></div>
|
||
</div>
|
||
<div class="cr-quiz-result-bars">
|
||
${opts.map((o, i) => {
|
||
const cnt = o.chosen_count || 0;
|
||
const fill = Math.round(cnt / maxCnt * 100);
|
||
return `<div class="cr-quiz-result-row">
|
||
<span class="cr-quiz-result-key ${o.is_correct ? 'correct' : ''}">${String.fromCharCode(65+i)}</span>
|
||
<div class="cr-quiz-result-track"><div class="cr-quiz-result-fill ${o.is_correct ? 'correct-fill' : ''}" style="width:${fill}%"></div></div>
|
||
<span class="cr-quiz-result-count">${cnt}</span>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>`;
|
||
container.style.display = 'block';
|
||
}
|
||
|
||
/* ── Student quiz widget ── */
|
||
|
||
let _sqAnswered = false;
|
||
let _sqLiveId = null;
|
||
let _sqCollapsed = false;
|
||
|
||
async function crStudentWidgetInit() {
|
||
const isTeacher = _me && (_me.role === 'teacher' || _me.role === 'admin');
|
||
if (isTeacher || document.getElementById('cr-sq-widget')) return;
|
||
const w = document.createElement('div');
|
||
w.id = 'cr-sq-widget';
|
||
w.style.display = 'none';
|
||
w.innerHTML = `
|
||
<div class="cr-sq-head">
|
||
<span class="cr-sq-badge"><span class="cr-sq-badge-dot"></span>Квиз</span>
|
||
<button class="cr-sq-collapse" onclick="crSqToggle()" title="Свернуть/развернуть">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="cr-sq-body" id="cr-sq-body">
|
||
<div class="cr-sq-q" id="cr-sq-q"></div>
|
||
<div class="cr-sq-opts" id="cr-sq-opts"></div>
|
||
<div class="cr-sq-res" id="cr-sq-res" style="display:none"></div>
|
||
</div>`;
|
||
document.body.appendChild(w);
|
||
|
||
// Recover active question state on page reload
|
||
if (_session?.class_id) {
|
||
LS.get(`/api/live/class/${_session.class_id}/active`).then(data => {
|
||
if (!data?.active || !data.live || !data.question) return;
|
||
_sqLiveId = data.live.id;
|
||
// Merge options into question object
|
||
const q = { ...data.question, options: data.options || [] };
|
||
crStudentOnQuestion(q, data.live.id);
|
||
if (data.myAnswer) {
|
||
_sqAnswered = true;
|
||
document.querySelectorAll('.cr-sq-opt').forEach(b => b.disabled = true);
|
||
// Mark the previously selected option
|
||
const selBtn = document.querySelector(`.cr-sq-opt[onclick="crStudentAnswer(${data.myAnswer.option_id})"]`);
|
||
if (selBtn) selBtn.classList.add('selected');
|
||
}
|
||
}).catch(() => {});
|
||
}
|
||
}
|
||
|
||
function crSqToggle() {
|
||
_sqCollapsed = !_sqCollapsed;
|
||
const body = document.getElementById('cr-sq-body');
|
||
if (body) body.style.display = _sqCollapsed ? 'none' : '';
|
||
const icon = document.querySelector('#cr-sq-widget .cr-sq-collapse svg');
|
||
if (icon) icon.style.transform = _sqCollapsed ? 'rotate(180deg)' : '';
|
||
}
|
||
|
||
function crStudentOnQuestion(q, liveId) {
|
||
_sqAnswered = false;
|
||
_sqLiveId = liveId || q.liveId || null;
|
||
crStudentWidgetInit(); // ensure widget exists
|
||
const w = document.getElementById('cr-sq-widget');
|
||
if (!w) return;
|
||
w.style.display = 'flex';
|
||
if (_sqCollapsed) crSqToggle();
|
||
const qEl = document.getElementById('cr-sq-q');
|
||
if (qEl) qEl.innerHTML = _crMathHtml(q.text || '');
|
||
const optsEl = document.getElementById('cr-sq-opts');
|
||
if (optsEl) {
|
||
optsEl.innerHTML = (q.options || []).map((o, i) => {
|
||
const safeText = (o.text || '').replace(/"/g, '"');
|
||
return `<button class="cr-sq-opt" onclick="crStudentAnswer(${o.id})">
|
||
<span class="cr-sq-key">${String.fromCharCode(65+i)}</span>
|
||
<span class="cr-sq-opt-text" data-text="${safeText}"></span>
|
||
</button>`;
|
||
}).join('');
|
||
optsEl.querySelectorAll('[data-text]').forEach(el => { el.innerHTML = _crMathHtml(el.dataset.text); });
|
||
}
|
||
const resEl = document.getElementById('cr-sq-res');
|
||
if (resEl) resEl.style.display = 'none';
|
||
}
|
||
|
||
async function crStudentAnswer(optionId) {
|
||
if (_sqAnswered || !_sqLiveId) return;
|
||
_sqAnswered = true;
|
||
document.querySelectorAll('.cr-sq-opt').forEach(b => b.disabled = true);
|
||
const btn = document.querySelector(`.cr-sq-opt[onclick="crStudentAnswer(${optionId})"]`);
|
||
if (btn) btn.classList.add('selected');
|
||
try { await LS.post(`/api/live/${_sqLiveId}/answer`, { option_id: optionId }); } catch {}
|
||
}
|
||
|
||
function crStudentOnResults(data) {
|
||
const optsEl = document.getElementById('cr-sq-opts');
|
||
const resEl = document.getElementById('cr-sq-res');
|
||
if (!optsEl || !resEl) return;
|
||
const opts = data.options || [];
|
||
const btns = optsEl.querySelectorAll('.cr-sq-opt');
|
||
btns.forEach((b, i) => {
|
||
if (!opts[i]) return;
|
||
if (opts[i].is_correct) b.classList.add('cr-sq-opt-correct');
|
||
else if (b.classList.contains('selected')) b.classList.add('cr-sq-opt-wrong');
|
||
});
|
||
const stats = data.stats || {};
|
||
const total = stats.total || 0;
|
||
const corr = stats.correct || 0;
|
||
const pct = total > 0 ? Math.round(corr / total * 100) : 0;
|
||
resEl.innerHTML = `<div style="font-size:0.68rem;color:rgba(255,255,255,0.45);padding:4px 0 2px">${corr} из ${total} правильно (${pct}%)</div>`;
|
||
resEl.style.display = '';
|
||
}
|
||
|
||
function crStudentOnEnded() {
|
||
setTimeout(() => {
|
||
const w = document.getElementById('cr-sq-widget');
|
||
if (w) w.style.display = 'none';
|
||
}, 4000);
|
||
}
|
||
|
||
/* ── stop polling + leave on navigate away ── */
|
||
window.addEventListener('pagehide', () => {
|
||
stopPolling();
|
||
if (_timerHandle) { clearInterval(_timerHandle); _timerHandle = null; }
|
||
if (_rtc) { _rtc.destroy(); _rtc = null; }
|
||
if (_sessionId) {
|
||
const url = `/api/classroom/${_sessionId}/leave`;
|
||
const token = localStorage.getItem('ls_token');
|
||
// keepalive: true ensures the request completes even if the page is unloading
|
||
fetch(url, {
|
||
method: 'POST', keepalive: true,
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||
body: '{}',
|
||
}).catch(() => {});
|
||
}
|
||
});
|
||
|
||
/* ── run ── */
|
||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||
else init();
|
||
</script>
|
||
<script src="/js/mobile.js"></script>
|
||
|
||
<!-- ── Formula visual editor modal ──────────────────────────────────── -->
|
||
<div id="wb-formula-modal" style="display:none; position:fixed; inset:0; z-index:190;">
|
||
<div class="wbfm-overlay" onclick="wbFmClose()"></div>
|
||
<div class="wbfm-card">
|
||
|
||
<!-- header -->
|
||
<div class="wbfm-hdr">
|
||
<span class="wbfm-title">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9B5DE5" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h7l1 9 2-6 2 6 1-9h7"/><path d="M5 21h14"/></svg>
|
||
Вставить формулу
|
||
</span>
|
||
<button class="wbfm-close" onclick="wbFmClose()" title="Закрыть (Esc)">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- live preview (big) -->
|
||
<div class="wbfm-preview" id="wbfm-preview">
|
||
<span style="color:rgba(255,255,255,0.28);font-size:0.85rem">Нажмите любую кнопку — здесь появится формула</span>
|
||
<span class="wbfm-prev-hint">Ctrl+Enter — вставить</span>
|
||
</div>
|
||
|
||
<!-- category tabs -->
|
||
<div class="wbfm-tabs" id="wbfm-tabs">
|
||
<button class="wbfm-tab active" onclick="wbFmTab(this,'basic')">Основные</button>
|
||
<button class="wbfm-tab" onclick="wbFmTab(this,'frac')">Дроби · √</button>
|
||
<button class="wbfm-tab" onclick="wbFmTab(this,'powers')">Степени</button>
|
||
<button class="wbfm-tab" onclick="wbFmTab(this,'sums')">Суммы · ∫</button>
|
||
<button class="wbfm-tab" onclick="wbFmTab(this,'greek')">α β γ …</button>
|
||
<button class="wbfm-tab" onclick="wbFmTab(this,'trig')">sin · cos</button>
|
||
<button class="wbfm-tab" onclick="wbFmTab(this,'geo')">Геометрия</button>
|
||
<button class="wbfm-tab" onclick="wbFmTab(this,'matrix')">Матрицы</button>
|
||
</div>
|
||
|
||
<!-- visual button grid -->
|
||
<div class="wbfm-grid-wrap">
|
||
<div class="wbfm-grid" id="wbfm-grid"></div>
|
||
</div>
|
||
|
||
<!-- LaTeX code toggle (hidden by default) -->
|
||
<div class="wbfm-latex-toggle">
|
||
<button class="wbfm-lt-btn" onclick="wbFmToggleLatex()" id="wbfm-lt-btn">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||
LaTeX-код (для опытных)
|
||
<span class="wbfm-lt-arrow" id="wbfm-lt-arrow">▾</span>
|
||
</button>
|
||
<span class="wbfm-tab-hint" id="wbfm-tab-hint"></span>
|
||
</div>
|
||
<div class="wbfm-latex-area" id="wbfm-latex-area" style="display:none">
|
||
<textarea id="wbfm-input" class="wbfm-input" rows="2"
|
||
oninput="wbFmUpdatePreview()"
|
||
onkeydown="wbFmKeydown(event)"
|
||
placeholder="\frac{a}{b} \sqrt{x} \int_0^\infty e^{-x}\,dx"></textarea>
|
||
</div>
|
||
|
||
<!-- bottom bar: size + actions -->
|
||
<div class="wbfm-bottom">
|
||
<div class="wbfm-size-row">
|
||
Размер:
|
||
<button class="wbfm-sz" onclick="wbFmSz(this,24)">S</button>
|
||
<button class="wbfm-sz active" onclick="wbFmSz(this,32)">M</button>
|
||
<button class="wbfm-sz" onclick="wbFmSz(this,48)">L</button>
|
||
<button class="wbfm-sz" onclick="wbFmSz(this,72)">XL</button>
|
||
</div>
|
||
<div class="wbfm-actions">
|
||
<button class="wbfm-cancel" onclick="wbFmClose()">Отмена</button>
|
||
<button class="wbfm-insert" id="wbfm-insert-btn" onclick="wbFmInsert()" disabled>Вставить на доску</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Templates modal -->
|
||
<div class="cr-tpl-modal-overlay" id="cr-tpl-modal">
|
||
<div class="cr-tpl-modal">
|
||
<div class="cr-tpl-hdr">
|
||
<span class="cr-tpl-title">Шаблоны уроков</span>
|
||
<button class="cr-tpl-close" onclick="crHideTemplates()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="cr-tpl-list" id="cr-tpl-list">
|
||
<div class="cr-tpl-empty">Нет сохранённых шаблонов</div>
|
||
</div>
|
||
<div class="cr-tpl-save-area">
|
||
<input class="cr-tpl-name-input" id="cr-tpl-name" placeholder="Название шаблона…" maxlength="80">
|
||
<button class="cr-tpl-save-btn" onclick="crSaveTemplate()">Сохранить текущий урок</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Coordinate system edit modal -->
|
||
<div id="wb-coord-modal" style="display:none; position:fixed; inset:0; z-index:190; align-items:center; justify-content:center; background:rgba(0,0,0,0.6);">
|
||
<div style="background:#1a1028; border:1px solid rgba(155,93,229,0.4); border-radius:12px; padding:20px; width:380px; max-width:96vw;">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;">
|
||
<span style="font-size:14px;font-weight:700;color:#e8e0f7;">Система координат</span>
|
||
<button onclick="closeCoordModal()" style="background:none;border:none;cursor:pointer;color:#9B5DE5;font-size:18px;padding:0 4px;">✕</button>
|
||
</div>
|
||
<!-- Axis range -->
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px;">
|
||
<label style="font-size:11px;color:rgba(255,230,180,0.5);">X min
|
||
<input id="coord-xmin" type="number" value="-10" oninput="applyCoordSettings()" style="width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(155,93,229,0.3);border-radius:4px;color:#e8e0f7;padding:5px 8px;font-size:12px;">
|
||
</label>
|
||
<label style="font-size:11px;color:rgba(255,230,180,0.5);">X max
|
||
<input id="coord-xmax" type="number" value="10" oninput="applyCoordSettings()" style="width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(155,93,229,0.3);border-radius:4px;color:#e8e0f7;padding:5px 8px;font-size:12px;">
|
||
</label>
|
||
<label style="font-size:11px;color:rgba(255,230,180,0.5);">Y min
|
||
<input id="coord-ymin" type="number" value="-10" oninput="applyCoordSettings()" style="width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(155,93,229,0.3);border-radius:4px;color:#e8e0f7;padding:5px 8px;font-size:12px;">
|
||
</label>
|
||
<label style="font-size:11px;color:rgba(255,230,180,0.5);">Y max
|
||
<input id="coord-ymax" type="number" value="10" oninput="applyCoordSettings()" style="width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(155,93,229,0.3);border-radius:4px;color:#e8e0f7;padding:5px 8px;font-size:12px;">
|
||
</label>
|
||
</div>
|
||
<label style="font-size:11px;color:rgba(255,230,180,0.5);">Шаг сетки
|
||
<input id="coord-step" type="number" value="1" min="0.1" step="0.1" oninput="applyCoordSettings()" style="width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(155,93,229,0.3);border-radius:4px;color:#e8e0f7;padding:5px 8px;font-size:12px;">
|
||
</label>
|
||
<!-- Functions -->
|
||
<div style="margin-top:14px;">
|
||
<div style="font-size:11px;color:rgba(255,230,180,0.5);margin-bottom:8px;">Функции (y = ...)</div>
|
||
<div id="coord-fn-list"></div>
|
||
<button onclick="coordAddFn()" style="width:100%;padding:6px;background:rgba(155,93,229,0.12);border:1px solid rgba(155,93,229,0.3);border-radius:6px;color:#9B5DE5;cursor:pointer;font-size:12px;margin-top:4px;">+ Добавить функцию</button>
|
||
</div>
|
||
<div style="margin-top:16px;display:flex;justify-content:flex-end;">
|
||
<button onclick="closeCoordModal();if(_wb&&_wb._onStrokeUpdated&&_coordEditStroke)_wb._onStrokeUpdated(_coordEditStroke);" style="padding:7px 18px;background:linear-gradient(135deg,#9B5DE5,#4361EE);border:none;border-radius:7px;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">Готово</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Number Line edit modal ───────────────────────────────────────────── -->
|
||
<div id="wb-numline-modal" style="display:none; position:fixed; inset:0; z-index:190; align-items:center; justify-content:center; background:rgba(0,0,0,0.6);">
|
||
<div style="background:#1a1028; border:1px solid rgba(6,214,224,0.35); border-radius:12px; padding:20px; width:380px; max-width:96vw;">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;">
|
||
<span style="font-size:14px;font-weight:700;color:#e8e0f7;">Числовая ось</span>
|
||
<button onclick="closeNumLineModal()" style="background:none;border:none;cursor:pointer;color:#06D6E0;font-size:18px;padding:0 4px;">✕</button>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px;">
|
||
<label style="font-size:11px;color:rgba(255,230,180,0.5);">Мин
|
||
<input id="nl-min" type="number" value="-10" oninput="applyNumLineSettings()" style="width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(6,214,224,0.3);border-radius:4px;color:#e8e0f7;padding:5px 8px;font-size:12px;">
|
||
</label>
|
||
<label style="font-size:11px;color:rgba(255,230,180,0.5);">Макс
|
||
<input id="nl-max" type="number" value="10" oninput="applyNumLineSettings()" style="width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(6,214,224,0.3);border-radius:4px;color:#e8e0f7;padding:5px 8px;font-size:12px;">
|
||
</label>
|
||
<label style="font-size:11px;color:rgba(255,230,180,0.5);">Шаг
|
||
<input id="nl-step" type="number" value="1" min="0.1" step="0.1" oninput="applyNumLineSettings()" style="width:100%;margin-top:3px;background:rgba(255,255,255,0.07);border:1px solid rgba(6,214,224,0.3);border-radius:4px;color:#e8e0f7;padding:5px 8px;font-size:12px;">
|
||
</label>
|
||
</div>
|
||
<!-- Points -->
|
||
<div style="margin-bottom:12px;">
|
||
<div style="font-size:11px;color:rgba(255,230,180,0.5);margin-bottom:6px;">Точки</div>
|
||
<div id="nl-points-list"></div>
|
||
<button onclick="nlAddPoint()" style="width:100%;padding:5px;background:rgba(6,214,224,0.1);border:1px solid rgba(6,214,224,0.25);border-radius:5px;color:#06D6E0;cursor:pointer;font-size:11px;margin-top:4px;">+ Добавить точку</button>
|
||
</div>
|
||
<!-- Intervals -->
|
||
<div style="margin-bottom:14px;">
|
||
<div style="font-size:11px;color:rgba(255,230,180,0.5);margin-bottom:6px;">Интервалы (закрашенные области)</div>
|
||
<div id="nl-intervals-list"></div>
|
||
<button onclick="nlAddInterval()" style="width:100%;padding:5px;background:rgba(155,93,229,0.1);border:1px solid rgba(155,93,229,0.25);border-radius:5px;color:#9B5DE5;cursor:pointer;font-size:11px;margin-top:4px;">+ Добавить интервал</button>
|
||
</div>
|
||
<div style="display:flex;justify-content:flex-end;">
|
||
<button onclick="closeNumLineModal();if(_wb&&_wb._onStrokeUpdated&&_nlEditStroke)_wb._onStrokeUpdated(_nlEditStroke);" style="padding:7px 18px;background:linear-gradient(135deg,#06D6E0,#4361EE);border:none;border-radius:7px;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">Готово</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Universal Confirm Dialog ────────────────────────────────────────── -->
|
||
<div id="cr-dlg" style="display:none" class="cr-dlg-overlay" role="dialog" aria-modal="true">
|
||
<div class="cr-dlg">
|
||
<div class="cr-dlg-icon danger" id="cr-dlg-icon"></div>
|
||
<h3 class="cr-dlg-title" id="cr-dlg-title"></h3>
|
||
<p class="cr-dlg-msg" id="cr-dlg-msg"></p>
|
||
<div class="cr-dlg-actions">
|
||
<button class="cr-dlg-cancel" id="cr-dlg-cancel">Отмена</button>
|
||
<button class="cr-dlg-ok danger" id="cr-dlg-ok"></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Page context menu -->
|
||
<div class="wb-page-menu" id="wb-page-menu">
|
||
<button onclick="wbHidePageMenu();wbStartRenaming(_wbMenuPage)">Переименовать</button>
|
||
<button onclick="wbPageDuplicate(_wbMenuPage)">Дублировать</button>
|
||
<hr>
|
||
<button onclick="wbPageClear(_wbMenuPage)">Очистить страницу</button>
|
||
<button class="danger" onclick="wbPageDelete(_wbMenuPage)">Удалить страницу</button>
|
||
</div>
|
||
|
||
<!-- ── Settings panel ───────────────────────────────────────────────────── -->
|
||
<div class="cr-settings-overlay" id="cr-settings-overlay" onclick="if(event.target===this)closeSettings()">
|
||
<div class="cr-settings-panel">
|
||
<div class="cr-sp-head">
|
||
<div class="cr-sp-title">Настройки урока</div>
|
||
<button class="cr-sp-close" onclick="closeSettings()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="cr-sp-tabs">
|
||
<button class="cr-sp-tab active" data-tab="appearance" onclick="settingsTab('appearance')">Внешний вид</button>
|
||
<button class="cr-sp-tab" data-tab="audio" onclick="settingsTab('audio')">Аудио</button>
|
||
<button class="cr-sp-tab" data-tab="board" onclick="settingsTab('board')">Доска</button>
|
||
</div>
|
||
<div class="cr-sp-body">
|
||
|
||
<!-- Внешний вид -->
|
||
<div class="cr-sp-pane active" id="crs-appearance">
|
||
<div class="cr-sp-section">
|
||
<div class="cr-sp-section-label">Чат</div>
|
||
<div class="cr-sp-row">
|
||
<div class="cr-sp-row-lbl">Размер шрифта</div>
|
||
<div class="cr-seg" id="crs-chat-size">
|
||
<button data-val="small" onclick="crSetPref('chatFontSize','small')">S</button>
|
||
<button data-val="medium" onclick="crSetPref('chatFontSize','medium')">M</button>
|
||
<button data-val="large" onclick="crSetPref('chatFontSize','large')">L</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Аудио -->
|
||
<div class="cr-sp-pane" id="crs-audio">
|
||
<div class="cr-sp-section">
|
||
<div class="cr-sp-section-label">Микрофон</div>
|
||
<div class="cr-sp-row">
|
||
<div>
|
||
<div class="cr-sp-row-lbl">Войти без микрофона</div>
|
||
<div class="cr-sp-row-sub">Микрофон будет выключен при входе в урок</div>
|
||
</div>
|
||
<label class="cr-toggle">
|
||
<input type="checkbox" id="crs-muted-join" onchange="crSetPref('mutedOnJoin',this.checked)">
|
||
<span class="cr-toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
<div class="cr-sp-row">
|
||
<div class="cr-sp-row-lbl">Чувствительность</div>
|
||
<div class="cr-seg" id="crs-vad">
|
||
<button data-val="low" onclick="crSetPref('vadSensitivity','low')">Низкая</button>
|
||
<button data-val="medium" onclick="crSetPref('vadSensitivity','medium')">Средняя</button>
|
||
<button data-val="high" onclick="crSetPref('vadSensitivity','high')">Высокая</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="cr-sp-section">
|
||
<div class="cr-sp-section-label">Звуки</div>
|
||
<div class="cr-sp-row">
|
||
<div class="cr-sp-row-lbl">Звуки включены</div>
|
||
<label class="cr-toggle">
|
||
<input type="checkbox" id="crs-sfx-enabled" onchange="crSfxSetEnabled(this.checked)">
|
||
<span class="cr-toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
<div class="cr-sp-row" style="gap:10px">
|
||
<div class="cr-sp-row-lbl">Громкость</div>
|
||
<input type="range" id="crs-sfx-volume" min="0" max="100" value="75"
|
||
style="flex:1;accent-color:var(--violet)"
|
||
oninput="crSfxSetVolume(this.value)">
|
||
</div>
|
||
<div class="cr-sp-row">
|
||
<div class="cr-sp-row-lbl">Звук при новом сообщении</div>
|
||
<label class="cr-toggle">
|
||
<input type="checkbox" id="crs-chat-sound" onchange="crSetPref('chatSound',this.checked)">
|
||
<span class="cr-toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
<div class="cr-sp-row">
|
||
<div class="cr-sp-row-lbl">Звук при поднятой руке</div>
|
||
<label class="cr-toggle">
|
||
<input type="checkbox" id="crs-hand-sound" onchange="crSetPref('handSound',this.checked)">
|
||
<span class="cr-toggle-track"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="cr-sp-section">
|
||
<div class="cr-sp-section-label">Тест микрофона</div>
|
||
<div class="cr-mic-bar"><div class="cr-mic-fill" id="crs-mic-fill"></div></div>
|
||
<div class="cr-sp-note" id="crs-mic-status"></div>
|
||
<button class="cr-sp-btn" id="crs-mic-btn" onclick="crMicTestStart()">Начать тест</button>
|
||
</div>
|
||
<div class="cr-sp-section" id="crs-push-section">
|
||
<div class="cr-sp-section-label">Уведомления</div>
|
||
<div class="cr-sp-row">
|
||
<div>
|
||
<div class="cr-sp-row-lbl">Push-уведомление «Урок начался»</div>
|
||
<div class="cr-sp-row-sub">Браузер уведомит когда учитель начнёт урок</div>
|
||
</div>
|
||
</div>
|
||
<button class="cr-sp-btn cyan" id="crs-push-btn" onclick="crRequestPush()">Включить уведомления</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Доска -->
|
||
<div class="cr-sp-pane" id="crs-board">
|
||
<div class="cr-sp-section">
|
||
<div class="cr-sp-section-label">Инструмент при входе</div>
|
||
<div class="cr-seg" id="crs-def-tool">
|
||
<button data-val="pencil" onclick="crSetPref('defaultTool','pencil')">Карандаш</button>
|
||
<button data-val="select" onclick="crSetPref('defaultTool','select')">Выделение</button>
|
||
</div>
|
||
</div>
|
||
<div class="cr-sp-section">
|
||
<div class="cr-sp-section-label">Ведущая рука</div>
|
||
<div class="cr-sp-note">Панель чата и участников переедет на левую сторону экрана</div>
|
||
<div class="cr-seg" id="crs-hand">
|
||
<button data-val="right" onclick="crSetPref('leftHand',false)">Правша (панель справа)</button>
|
||
<button data-val="left" onclick="crSetPref('leftHand',true)">Левша (панель слева)</button>
|
||
</div>
|
||
</div>
|
||
<div class="cr-sp-section">
|
||
<div class="cr-sp-section-label">Стилус / палец — давление</div>
|
||
<div class="cr-sp-note">Толщина линии зависит от силы нажатия стилуса на планшет</div>
|
||
<div class="cr-seg" id="crs-stylus">
|
||
<button data-val="0" onclick="crSetPref('stylusMultiplier',0)">Выкл</button>
|
||
<button data-val="0.5" onclick="crSetPref('stylusMultiplier',0.5)">Слабая</button>
|
||
<button data-val="1" onclick="crSetPref('stylusMultiplier',1)">Средняя</button>
|
||
<button data-val="2" onclick="crSetPref('stylusMultiplier',2)">Сильная</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /.cr-sp-body -->
|
||
</div><!-- /.cr-settings-panel -->
|
||
</div><!-- /.cr-settings-overlay -->
|
||
|
||
<!-- ── Textbook picker modal (re-uses .cr-sim-picker-* styles) ─────────── -->
|
||
<div class="cr-sim-picker-overlay" id="cr-tb-picker-overlay" onclick="if(event.target===this)crCloseTbPicker()">
|
||
<div class="cr-sim-picker-modal">
|
||
<div class="cr-sim-picker-head">
|
||
<h3>Выбрать учебник</h3>
|
||
<button class="cr-sim-picker-close" onclick="crCloseTbPicker()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="cr-sim-picker-cats" id="cr-tb-picker-cats">
|
||
<button class="cr-sim-cat-btn active" onclick="crFilterTbs('all', this)">Все</button>
|
||
</div>
|
||
<div class="cr-sim-picker-body">
|
||
<div class="cr-sim-picker-grid" id="cr-tb-picker-grid">
|
||
<div style="grid-column:1/-1;color:rgba(255,255,255,0.5);padding:24px;text-align:center;font-size:.85rem">Загрузка…</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Simulation picker modal ─────────────────────────────────────────── -->
|
||
<div class="cr-sim-picker-overlay" id="cr-sim-picker-overlay" onclick="if(event.target===this)crCloseSimPicker()">
|
||
<div class="cr-sim-picker-modal">
|
||
<div class="cr-sim-picker-head">
|
||
<h3>Выбрать симуляцию</h3>
|
||
<button class="cr-sim-picker-close" onclick="crCloseSimPicker()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="cr-sim-picker-cats">
|
||
<button class="cr-sim-cat-btn active" onclick="crFilterSims('all', this)">Все</button>
|
||
<button class="cr-sim-cat-btn" onclick="crFilterSims('math', this)">Математика</button>
|
||
<button class="cr-sim-cat-btn" onclick="crFilterSims('phys', this)">Физика</button>
|
||
<button class="cr-sim-cat-btn" onclick="crFilterSims('chem', this)">Химия</button>
|
||
<button class="cr-sim-cat-btn" onclick="crFilterSims('bio', this)">Биология</button>
|
||
<button class="cr-sim-cat-btn" onclick="crFilterSims('game', this)">Игры</button>
|
||
</div>
|
||
<div class="cr-sim-picker-body">
|
||
<div class="cr-sim-picker-grid" id="cr-sim-picker-grid"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</body>
|
||
</html>
|