28db2de74f
План улучшения симуляций — plans/simulations-improvement/README.md. - LabFX: reduced-motion/эконом-режим (prefers-reduced-motion + тумблер localStorage labfx-economy). Тряска отключается, частицы ×0.25 — доступность и экономия на слабых устройствах сразу для всех ~50 симуляций. Кнопка-тумблер в lab.html рядом со звуком. - lesson-editor: блок «Симуляция» — выпадающий список из /api/lab/sims (сгруппирован по предметам) вместо сырого ввода simId; неизвестный id не теряется, помечается «(не найдена)». Закрывает хрупкую вставку в урок. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3534 lines
172 KiB
HTML
3534 lines
172 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Редактор урока — LearnSpace</title>
|
||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||
<link rel="stylesheet" href="/css/ls.css" />
|
||
<!-- KaTeX preview -->
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
|
||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
||
onload="window._katexLoaded=true;window._katexCb&&window._katexCb()"></script>
|
||
<!-- Highlight.js -->
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/atom-one-dark.min.css" />
|
||
<script defer src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"
|
||
onload="window._hljsLoaded=true"></script>
|
||
<!-- Mermaid diagrams -->
|
||
<script defer src="https://cdn.jsdelivr.net/npm/mermaid@10.9.0/dist/mermaid.min.js"
|
||
onload="mermaid.initialize({startOnLoad:false,theme:'neutral'});window._mermaidLoaded=true"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||
<style>
|
||
html, body { height: 100%; }
|
||
body { background: #f4f5f8; }
|
||
|
||
/* ── topbar ── */
|
||
.editor-topbar {
|
||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||
background: #fff; border-bottom: 1px solid rgba(15,23,42,0.09);
|
||
height: 54px; display: flex; align-items: center; padding: 0 16px;
|
||
gap: 10px;
|
||
}
|
||
.etb-back {
|
||
display: flex; align-items: center; gap: 6px; text-decoration: none;
|
||
font-size: 0.8rem; font-weight: 700; color: var(--text-3); transition: color 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
.etb-back:hover { color: var(--violet); }
|
||
.etb-title {
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.85rem; font-weight: 800;
|
||
color: #0F172A; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
max-width: 220px; flex-shrink: 1;
|
||
}
|
||
.etb-section-wrap {
|
||
display: flex; align-items: center; gap: 6px; flex-shrink: 0;
|
||
}
|
||
.etb-select {
|
||
padding: 5px 8px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 8px;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.78rem; color: #3D4F6B;
|
||
background: #f8f9fc; outline: none; cursor: pointer;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.etb-select:focus { border-color: var(--violet); }
|
||
.etb-spacer { flex: 1; }
|
||
.etb-word-count {
|
||
font-size: 0.74rem; color: var(--text-3); font-weight: 600; white-space: nowrap;
|
||
background: rgba(15,23,42,0.04); border-radius: 8px; padding: 4px 9px;
|
||
flex-shrink: 0;
|
||
}
|
||
.etb-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
|
||
.etb-status {
|
||
font-size: 0.76rem; color: var(--text-3); font-weight: 600;
|
||
transition: color 0.3s; white-space: nowrap;
|
||
}
|
||
.etb-status.saved { color: #059652; }
|
||
.etb-icon-btn {
|
||
width: 30px; height: 30px; border: 1.5px solid rgba(15,23,42,0.1); border-radius: 8px;
|
||
background: #f8f9fc; color: var(--text-3); cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center;
|
||
transition: all 0.12s; flex-shrink: 0;
|
||
}
|
||
.etb-icon-btn:hover:not(:disabled) { background: rgba(155,93,229,0.07); color: var(--violet); border-color: rgba(155,93,229,0.25); }
|
||
.etb-icon-btn:disabled { opacity: 0.35; cursor: default; }
|
||
.btn-save {
|
||
padding: 8px 18px; border: none; border-radius: 999px;
|
||
background: var(--violet); color: #fff;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.84rem; font-weight: 700;
|
||
cursor: pointer; box-shadow: 0 2px 8px rgba(155,93,229,0.3);
|
||
display: flex; align-items: center; gap: 7px; transition: all 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
.btn-save:hover { background: #8a47d8; }
|
||
.btn-save:disabled { opacity: 0.45; cursor: default; }
|
||
.btn-preview {
|
||
padding: 8px 14px; border: 1.5px solid rgba(155,93,229,0.25); border-radius: 999px;
|
||
background: rgba(155,93,229,0.06); color: var(--violet);
|
||
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
|
||
cursor: pointer; display: flex; align-items: center; gap: 6px; transition: all 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
.btn-preview:hover { background: rgba(155,93,229,0.12); }
|
||
.btn-pub {
|
||
padding: 8px 14px; border: 1.5px solid rgba(6,214,160,0.3); border-radius: 999px;
|
||
background: rgba(6,214,160,0.07); color: #047857;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
|
||
cursor: pointer; transition: all 0.15s; flex-shrink: 0;
|
||
}
|
||
.btn-pub:hover { background: rgba(6,214,160,0.14); border-color: #06D6A0; }
|
||
.btn-pub.published { background: rgba(6,214,160,0.12); border-color: #06D6A0; color: #047857; }
|
||
.btn-pub-all {
|
||
padding: 8px 14px; border: 1.5px solid rgba(99,102,241,0.25); border-radius: 999px;
|
||
background: rgba(99,102,241,0.07); color: #4338CA;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
|
||
cursor: pointer; transition: all 0.15s; flex-shrink: 0;
|
||
}
|
||
.btn-pub-all:hover { background: rgba(99,102,241,0.14); border-color: #6366F1; }
|
||
|
||
/* ── layout ── */
|
||
.editor-wrap {
|
||
margin-top: 54px;
|
||
display: flex; min-height: calc(100vh - 54px);
|
||
}
|
||
|
||
/* ── block palette (left) ── */
|
||
.block-palette {
|
||
width: 200px; flex-shrink: 0;
|
||
background: #fff; border-right: 1px solid rgba(15,23,42,0.07);
|
||
padding: 20px 12px; position: sticky; top: 54px; height: calc(100vh - 54px);
|
||
overflow-y: auto;
|
||
}
|
||
.palette-section { margin-bottom: 20px; }
|
||
.palette-search {
|
||
width: 100%; box-sizing: border-box;
|
||
border: 1.5px solid rgba(15,23,42,0.1); border-radius: 10px;
|
||
padding: 7px 10px; font-family: 'Manrope', sans-serif; font-size: 0.82rem;
|
||
color: #1E293B; background: #f8f9fc; outline: none; margin-bottom: 14px;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.palette-search:focus { border-color: var(--violet); background: #fff; }
|
||
.palette-label {
|
||
font-size: 0.66rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
|
||
color: var(--text-3); margin-bottom: 8px; padding: 0 4px;
|
||
}
|
||
.palette-btn {
|
||
width: 100%; padding: 8px 10px; border-radius: 10px;
|
||
border: 1.5px solid transparent; background: transparent;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 600; color: #3D4F6B;
|
||
cursor: pointer; display: flex; align-items: center; gap: 8px;
|
||
transition: all 0.12s; text-align: left; margin-bottom: 4px;
|
||
}
|
||
.palette-btn:hover { background: rgba(155,93,229,0.06); color: var(--violet); border-color: rgba(155,93,229,0.15); }
|
||
.palette-btn-icon { width: 22px; height: 22px; border-radius: 7px; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; flex-shrink: 0; }
|
||
.pbi-heading { background: rgba(15,23,42,0.08); }
|
||
.pbi-text { background: rgba(15,23,42,0.06); }
|
||
.pbi-formula { background: rgba(155,93,229,0.12); color: var(--violet); }
|
||
.pbi-image { background: rgba(6,214,160,0.1); color: #047857; }
|
||
.pbi-quiz { background: rgba(241,91,181,0.1); color: #be185d; }
|
||
.pbi-code { background: rgba(15,23,42,0.08); }
|
||
.pbi-div { background: rgba(15,23,42,0.04); }
|
||
.pbi-callout { background: rgba(251,191,36,0.15); color: #92400e; }
|
||
.pbi-video { background: rgba(239,68,68,0.1); color: #b91c1c; }
|
||
.pbi-table { background: rgba(59,130,246,0.1); color: #1d4ed8; }
|
||
.pbi-flash { background: rgba(16,185,129,0.1); color: #065f46; }
|
||
.pbi-sim { background: rgba(139,92,246,0.12); color: #5b21b6; }
|
||
|
||
/* ── editor canvas (center) ── */
|
||
.editor-canvas {
|
||
flex: 1; min-width: 0; padding: 32px 28px 80px;
|
||
max-width: 800px; margin: 0 auto;
|
||
}
|
||
|
||
/* ── lesson title editor ── */
|
||
.lesson-title-input {
|
||
width: 100%; border: none; outline: none;
|
||
font-family: 'Unbounded', sans-serif; font-size: 1.5rem; font-weight: 800;
|
||
color: #0F172A; background: transparent; resize: none;
|
||
line-height: 1.3; padding: 0; margin-bottom: 28px;
|
||
}
|
||
.lesson-title-input::placeholder { color: rgba(15,23,42,0.25); }
|
||
|
||
/* ── block card ── */
|
||
.block-card {
|
||
background: #fff; border: 1.5px solid rgba(15,23,42,0.07);
|
||
border-radius: 16px; margin-bottom: 12px;
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
position: relative;
|
||
}
|
||
.block-card:hover { border-color: rgba(155,93,229,0.2); box-shadow: 0 2px 10px rgba(15,23,42,0.06); }
|
||
.block-card.dragging { opacity: 0.4; }
|
||
.block-card.drag-over { border-color: var(--violet); box-shadow: 0 0 0 2px rgba(155,93,229,0.2); }
|
||
|
||
.block-header {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 10px 14px 0; color: var(--text-3);
|
||
}
|
||
.block-drag-handle {
|
||
cursor: grab; color: #CBD5E1; padding: 2px; flex-shrink: 0;
|
||
touch-action: none;
|
||
}
|
||
.block-drag-handle:active { cursor: grabbing; }
|
||
.block-type-label {
|
||
font-size: 0.66rem; font-weight: 700; text-transform: uppercase;
|
||
letter-spacing: 0.07em; color: var(--text-3); flex: 1;
|
||
}
|
||
.block-actions { display: flex; gap: 4px; }
|
||
.block-action-btn {
|
||
width: 24px; height: 24px; border: none; background: none;
|
||
color: #CBD5E1; cursor: pointer; border-radius: 6px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
transition: all 0.12s;
|
||
}
|
||
.block-action-btn:hover { background: rgba(15,23,42,0.06); color: #6B7A8E; }
|
||
.block-action-btn.danger:hover { background: rgba(239,71,111,0.08); color: #EF476F; }
|
||
.block-action-btn.dup:hover { background: rgba(155,93,229,0.08); color: var(--violet); }
|
||
|
||
.block-body { padding: 8px 14px 14px; }
|
||
.block-card.collapsed .block-body { display: none; }
|
||
.block-card.collapsed .block-header { padding-bottom: 10px; }
|
||
.block-collapse-btn { transition: transform 0.15s; }
|
||
.block-card.collapsed .block-collapse-btn { transform: rotate(-90deg); }
|
||
|
||
/* ── block inputs ── */
|
||
.block-textarea {
|
||
width: 100%; border: none; outline: none; resize: none;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.95rem; color: #1E293B;
|
||
background: transparent; line-height: 1.7; padding: 0;
|
||
min-height: 60px; field-sizing: content;
|
||
}
|
||
.block-textarea::placeholder { color: rgba(15,23,42,0.25); }
|
||
.block-input {
|
||
width: 100%; border: 1.5px solid rgba(15,23,42,0.1); border-radius: 10px;
|
||
padding: 9px 12px; font-family: 'Manrope', sans-serif; font-size: 0.88rem;
|
||
color: #1E293B; background: #f8f9fc; outline: none; transition: border-color 0.15s;
|
||
box-sizing: border-box;
|
||
}
|
||
.block-input:focus { border-color: var(--violet); background: #fff; }
|
||
.block-row { display: flex; gap: 10px; align-items: flex-start; }
|
||
.block-row .block-input { flex: 1; }
|
||
.block-row-label {
|
||
font-size: 0.74rem; font-weight: 700; color: #6B7A8E; margin-bottom: 5px;
|
||
}
|
||
.block-field { margin-bottom: 10px; }
|
||
.block-field:last-child { margin-bottom: 0; }
|
||
|
||
/* ── formula preview ── */
|
||
.formula-preview {
|
||
margin-top: 8px; padding: 10px 14px;
|
||
background: rgba(155,93,229,0.04); border-radius: 10px;
|
||
border: 1px solid rgba(155,93,229,0.1);
|
||
font-size: 1rem; overflow-x: auto;
|
||
min-height: 38px;
|
||
}
|
||
|
||
/* ── formula symbol picker ── */
|
||
.sym-picker { display: flex; flex-wrap: wrap; gap: 3px; margin-bottom: 7px; }
|
||
.sym-btn { padding: 3px 6px; border-radius: 5px; border: 1px solid rgba(15,23,42,0.1);
|
||
background: #fff; cursor: pointer; font-size: 0.8rem; font-family: 'KaTeX_Math', 'Latin Modern Math', serif;
|
||
color: #3D4F6B; transition: .12s; line-height: 1.4; }
|
||
.sym-btn:hover { background: rgba(155,93,229,0.12); border-color: rgba(155,93,229,0.3); color: #9B5DE5; }
|
||
.sym-cat-row { display: flex; gap: 4px; margin-bottom: 5px; flex-wrap: wrap; }
|
||
.sym-cat-btn { padding: 2px 8px; border-radius: 5px; border: 1px solid rgba(15,23,42,0.1);
|
||
background: none; cursor: pointer; font-size: .72rem; font-weight: 600;
|
||
color: var(--text-3); transition: .12s; }
|
||
.sym-cat-btn.active { background: rgba(155,93,229,0.1); border-color: rgba(155,93,229,0.3); color: #9B5DE5; }
|
||
|
||
/* ── heading level selector ── */
|
||
.heading-level-select {
|
||
width: 70px; padding: 6px 8px; border: 1.5px solid rgba(15,23,42,0.1); border-radius: 8px;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.82rem; color: #3D4F6B;
|
||
background: #f8f9fc; outline: none; cursor: pointer; flex-shrink: 0;
|
||
}
|
||
|
||
/* ── quiz builder ── */
|
||
.quiz-option-row {
|
||
display: flex; align-items: center; gap: 8px; margin-bottom: 7px;
|
||
}
|
||
.quiz-opt-radio { flex-shrink: 0; cursor: pointer; accent-color: var(--violet); width: 16px; height: 16px; }
|
||
.quiz-opt-input { flex: 1; }
|
||
.quiz-opt-del { width: 24px; height: 24px; flex-shrink: 0; }
|
||
.btn-add-opt {
|
||
padding: 5px 12px; border: 1.5px dashed rgba(155,93,229,0.25); border-radius: 8px;
|
||
background: none; color: var(--violet); font-family: 'Manrope', sans-serif;
|
||
font-size: 0.78rem; font-weight: 700; cursor: pointer; transition: all 0.12s;
|
||
}
|
||
.btn-add-opt:hover { background: rgba(155,93,229,0.06); border-color: var(--violet); }
|
||
.quiz-mode-toggle { display:flex; align-items:center; gap:7px; margin-bottom:8px; font-size:0.82rem; color:#6B7A8E; cursor:pointer; user-select:none; }
|
||
.quiz-mode-toggle input { accent-color:var(--violet); width:14px; height:14px; cursor:pointer; }
|
||
.quiz-explanation { margin-top:10px; }
|
||
.quiz-explanation .block-row-label { margin-bottom:4px; }
|
||
|
||
/* ── empty canvas hint ── */
|
||
.canvas-hint {
|
||
text-align: center; padding: 60px 28px;
|
||
border: 2px dashed rgba(155,93,229,0.15); border-radius: 20px;
|
||
color: var(--text-3); font-size: 0.88rem; line-height: 1.8;
|
||
}
|
||
.canvas-hint-title { font-family: 'Unbounded', sans-serif; font-size: 0.95rem; font-weight: 800; color: #0F172A; margin-bottom: 8px; }
|
||
|
||
/* ── callout block ── */
|
||
.callout-style-btns { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
|
||
.callout-style-btn {
|
||
padding: 4px 10px; border-radius: 7px; border: 1.5px solid transparent;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700;
|
||
cursor: pointer; background: #f8f9fc; color: #6B7A8E; transition: all 0.12s;
|
||
}
|
||
.callout-style-btn.active { border-color: currentColor; }
|
||
.callout-style-btn.cs-info { color: #1d4ed8; }
|
||
.callout-style-btn.cs-warning { color: #92400e; }
|
||
.callout-style-btn.cs-success { color: #065f46; }
|
||
.callout-style-btn.cs-error { color: #991b1b; }
|
||
.callout-style-btn.cs-info.active { background: rgba(59,130,246,0.08); }
|
||
.callout-style-btn.cs-warning.active { background: rgba(251,191,36,0.12); }
|
||
.callout-style-btn.cs-success.active { background: rgba(16,185,129,0.08); }
|
||
.callout-style-btn.cs-error.active { background: rgba(239,68,68,0.08); }
|
||
|
||
/* ── video embed ── */
|
||
.video-preview {
|
||
margin-top: 10px; border-radius: 12px; overflow: hidden;
|
||
aspect-ratio: 16/9; background: #0F172A;
|
||
}
|
||
.video-preview iframe { width: 100%; height: 100%; border: none; }
|
||
|
||
/* ── table editor ── */
|
||
.table-editor { overflow-x: auto; margin-bottom: 8px; }
|
||
.table-editor table { border-collapse: collapse; width: 100%; min-width: 280px; }
|
||
.table-editor td, .table-editor th {
|
||
border: 1.5px solid rgba(15,23,42,0.12); padding: 6px 10px;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.88rem; color: #1E293B;
|
||
min-width: 80px; outline: none;
|
||
}
|
||
.table-editor th {
|
||
background: rgba(155,93,229,0.06); font-weight: 700;
|
||
}
|
||
.table-editor td:focus, .table-editor th:focus {
|
||
outline: 2px solid rgba(155,93,229,0.4); outline-offset: -2px;
|
||
background: rgba(155,93,229,0.03);
|
||
}
|
||
.table-btns { display: flex; gap: 6px; flex-wrap: wrap; }
|
||
.table-btn {
|
||
padding: 4px 10px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 7px;
|
||
background: #f8f9fc; font-family: 'Manrope', sans-serif; font-size: 0.76rem;
|
||
font-weight: 700; color: #6B7A8E; cursor: pointer; transition: all 0.12s;
|
||
}
|
||
.table-btn:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,0.05); }
|
||
|
||
/* ── flashcard editor ── */
|
||
.flashcard-editor {
|
||
display: flex; gap: 12px;
|
||
}
|
||
.flashcard-side { flex: 1; }
|
||
.flashcard-side-label {
|
||
font-size: 0.74rem; font-weight: 700; color: #6B7A8E; margin-bottom: 5px;
|
||
display: flex; align-items: center; gap: 4px;
|
||
}
|
||
|
||
/* ── matching editor ── */
|
||
.matching-pair-row {
|
||
display: flex; align-items: center; gap: 8px; margin-bottom: 7px;
|
||
}
|
||
.matching-pair-row .block-input { flex: 1; }
|
||
|
||
/* ── ordering editor ── */
|
||
.ordering-item-row {
|
||
display: flex; align-items: center; gap: 8px; margin-bottom: 7px;
|
||
}
|
||
.ordering-item-row .block-input { flex: 1; }
|
||
|
||
/* ── fill-blank preview ── */
|
||
.fill-blank-preview {
|
||
padding: 10px 14px; background: rgba(155,93,229,0.04);
|
||
border: 1px solid rgba(155,93,229,0.1); border-radius: 10px;
|
||
font-size: 0.92rem; line-height: 1.8; color: #1E293B;
|
||
}
|
||
|
||
/* ── rich text editor ── */
|
||
.rich-toolbar {
|
||
display: flex; gap: 3px; padding: 4px 6px;
|
||
background: #f4f5f8; border-radius: 8px 8px 0 0;
|
||
border: 1.5px solid rgba(15,23,42,0.1); border-bottom: none;
|
||
opacity: 0; pointer-events: none; transition: opacity 0.15s;
|
||
}
|
||
.rich-toolbar.visible { opacity: 1; pointer-events: all; }
|
||
.rich-toolbar-btn {
|
||
width: 26px; height: 26px; border: none; border-radius: 6px;
|
||
background: none; cursor: pointer; font-family: 'Manrope', sans-serif;
|
||
font-size: 0.82rem; font-weight: 700; color: #6B7A8E;
|
||
display: flex; align-items: center; justify-content: center;
|
||
transition: all 0.12s;
|
||
}
|
||
.rich-toolbar-btn:hover { background: rgba(155,93,229,0.1); color: var(--violet); }
|
||
.rich-editor {
|
||
min-height: 80px; outline: none;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.95rem; color: #1E293B;
|
||
line-height: 1.7; padding: 10px 12px;
|
||
border: 1.5px solid rgba(15,23,42,0.1); border-radius: 0 0 10px 10px;
|
||
background: #fff; transition: border-color 0.15s;
|
||
}
|
||
.rich-editor:focus { border-color: var(--violet); }
|
||
.rich-editor:empty::before { content: attr(data-placeholder); color: rgba(15,23,42,0.25); }
|
||
.rich-editor code { background: rgba(155,93,229,0.08); padding: 1px 5px; border-radius: 4px; font-family: monospace; font-size: 0.88em; }
|
||
|
||
/* ── sim block ── */
|
||
.sim-preview {
|
||
margin-top: 8px; padding: 14px; border-radius: 10px;
|
||
background: rgba(139,92,246,0.06); border: 1.5px solid rgba(139,92,246,0.15);
|
||
font-family: 'Manrope', sans-serif; font-size: 0.88rem; color: #5b21b6;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}
|
||
|
||
/* ── accordion editor ── */
|
||
.accordion-preview {
|
||
margin-top: 8px; border: 1.5px solid rgba(99,102,241,0.15); border-radius: 12px; overflow: hidden;
|
||
}
|
||
.accordion-preview-header {
|
||
padding: 12px 16px; background: rgba(99,102,241,0.05);
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
font-weight: 700; font-size: 0.88rem; color: #4338CA; cursor: pointer;
|
||
}
|
||
.accordion-preview-body {
|
||
padding: 12px 16px; font-size: 0.88rem; color: #1E293B; line-height: 1.6;
|
||
border-top: 1px solid rgba(99,102,241,0.1);
|
||
}
|
||
|
||
/* ── timeline editor ── */
|
||
.timeline-item-row {
|
||
display: flex; gap: 8px; margin-bottom: 10px; align-items: flex-start;
|
||
}
|
||
.timeline-dot {
|
||
width: 12px; height: 12px; border-radius: 50%; background: #0EA5E9;
|
||
flex-shrink: 0; margin-top: 10px;
|
||
}
|
||
.timeline-fields { flex: 1; display: flex; flex-direction: column; gap: 6px; }
|
||
|
||
/* ── diagram editor ── */
|
||
.diagram-preview {
|
||
margin-top: 10px; padding: 16px; border-radius: 12px;
|
||
background: #fff; border: 1.5px solid rgba(168,85,247,0.15);
|
||
text-align: center; overflow-x: auto;
|
||
}
|
||
|
||
/* ── geogebra editor ── */
|
||
.geogebra-preview {
|
||
margin-top: 10px; border-radius: 12px; overflow: hidden;
|
||
aspect-ratio: 16/10; background: #f8f9fc;
|
||
border: 1.5px solid rgba(34,197,94,0.15);
|
||
}
|
||
.geogebra-preview iframe { width: 100%; height: 100%; border: none; }
|
||
|
||
/* ── audio editor ── */
|
||
.audio-preview {
|
||
margin-top: 10px;
|
||
}
|
||
.audio-preview audio { width: 100%; border-radius: 10px; }
|
||
|
||
/* ── palette drag ── */
|
||
.palette-btn.dragging { opacity: 0.5; }
|
||
.block-drop-zone {
|
||
height: 4px; margin: -2px 0; border-radius: 4px;
|
||
transition: height 0.15s, background 0.15s, margin 0.15s;
|
||
}
|
||
.block-drop-zone.active {
|
||
height: 40px; margin: 6px 0;
|
||
background: rgba(155,93,229,0.08); border: 2px dashed rgba(155,93,229,0.3);
|
||
border-radius: 12px;
|
||
}
|
||
|
||
/* ── image upload ── */
|
||
.img-upload-row { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
|
||
.img-upload-btn {
|
||
padding: 7px 14px; border: 1.5px solid rgba(6,214,160,0.3); border-radius: 10px;
|
||
background: rgba(6,214,160,0.06); color: #047857;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 700;
|
||
cursor: pointer; transition: all 0.15s; display: flex; align-items: center; gap: 6px;
|
||
}
|
||
.img-upload-btn:hover { background: rgba(6,214,160,0.14); border-color: #06D6A0; }
|
||
.img-upload-progress {
|
||
font-size: 0.78rem; color: var(--text-3); font-weight: 600;
|
||
}
|
||
|
||
/* ── code preview ── */
|
||
.code-preview {
|
||
margin-top: 8px; border-radius: 10px; overflow: hidden;
|
||
}
|
||
.code-preview pre {
|
||
margin: 0; padding: 14px 16px; font-size: 0.84rem; line-height: 1.6;
|
||
border-radius: 10px; overflow-x: auto;
|
||
}
|
||
.code-preview code { font-family: 'Fira Code', 'JetBrains Mono', monospace; }
|
||
|
||
/* ── inline preview ── */
|
||
.editor-canvas.preview-mode .block-card {
|
||
border-color: transparent; box-shadow: none; background: transparent;
|
||
}
|
||
.editor-canvas.preview-mode .block-header { display: none; }
|
||
.editor-canvas.preview-mode .block-body { padding: 4px 0; }
|
||
.editor-canvas.preview-mode .block-drop-zone { display: none; }
|
||
.preview-block { margin-bottom: 16px; }
|
||
.preview-block h2 {
|
||
font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800;
|
||
color: #0F172A; margin: 24px 0 12px; border-bottom: 2px solid rgba(155,93,229,0.15);
|
||
padding-bottom: 8px;
|
||
}
|
||
.preview-block h3 {
|
||
font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800;
|
||
color: #0F172A; margin: 20px 0 10px;
|
||
}
|
||
.preview-block p { font-size: 0.95rem; line-height: 1.8; color: #1E293B; margin: 0 0 12px; }
|
||
.preview-block .pv-formula {
|
||
padding: 14px 18px; background: rgba(155,93,229,0.04);
|
||
border-left: 3px solid var(--violet); border-radius: 0 10px 10px 0;
|
||
margin: 12px 0; font-size: 1.05rem;
|
||
}
|
||
.preview-block .pv-callout {
|
||
padding: 14px 18px; border-radius: 12px; margin: 12px 0;
|
||
font-size: 0.9rem; line-height: 1.7;
|
||
}
|
||
.pv-callout.pv-info { background: rgba(59,130,246,0.06); border-left: 3px solid #3B82F6; }
|
||
.pv-callout.pv-warning { background: rgba(251,191,36,0.08); border-left: 3px solid #F59E0B; }
|
||
.pv-callout.pv-success { background: rgba(16,185,129,0.06); border-left: 3px solid #10B981; }
|
||
.pv-callout.pv-error { background: rgba(239,68,68,0.06); border-left: 3px solid #EF4444; }
|
||
.preview-block .pv-img-wrap { display: flex; margin: 12px 0; }
|
||
.preview-block .pv-img-wrap.align-left { justify-content: flex-start; }
|
||
.preview-block .pv-img-wrap.align-center { justify-content: center; }
|
||
.preview-block .pv-img-wrap.align-right { justify-content: flex-end; }
|
||
.preview-block .pv-img-wrap.align-full { display: block; }
|
||
.preview-block .pv-image { border-radius: 12px; max-width: 100%; }
|
||
.preview-block .pv-img-wrap.align-left .pv-image,
|
||
.preview-block .pv-img-wrap.align-right .pv-image { max-width: 55%; }
|
||
.preview-block .pv-img-wrap.align-full .pv-image { width: 100%; }
|
||
.preview-block .pv-image-caption {
|
||
text-align: center; font-size: 0.82rem; color: var(--text-3); margin-top: 6px;
|
||
}
|
||
.align-btns { display: flex; gap: 4px; margin-bottom: 10px; }
|
||
.align-btn {
|
||
padding: 5px 10px; border-radius: 8px; border: 1.5px solid rgba(15,23,42,0.1);
|
||
background: #f8f9fc; cursor: pointer; transition: all 0.12s; line-height: 1;
|
||
display: flex; align-items: center; gap: 4px; font-size: 0.78rem; font-weight: 600;
|
||
color: #6B7A8E;
|
||
}
|
||
.align-btn.active, .align-btn:hover { border-color: var(--violet); background: rgba(155,93,229,0.07); color: var(--violet); }
|
||
.preview-block .pv-divider { height: 1.5px; background: rgba(15,23,42,0.08); margin: 20px 0; }
|
||
.preview-block .pv-code pre {
|
||
background: #282c34; color: #abb2bf; padding: 16px 18px;
|
||
border-radius: 12px; font-size: 0.84rem; overflow-x: auto;
|
||
}
|
||
.preview-block table {
|
||
width: 100%; border-collapse: collapse; margin: 12px 0;
|
||
}
|
||
.preview-block th, .preview-block td {
|
||
border: 1.5px solid rgba(15,23,42,0.1); padding: 8px 12px;
|
||
font-size: 0.88rem; text-align: left;
|
||
}
|
||
.preview-block th { background: rgba(155,93,229,0.05); font-weight: 700; }
|
||
.preview-block .pv-quiz {
|
||
background: rgba(241,91,181,0.04); border: 1.5px solid rgba(241,91,181,0.12);
|
||
border-radius: 14px; padding: 16px 18px; margin: 12px 0;
|
||
}
|
||
.preview-block .pv-quiz-q { font-weight: 700; margin-bottom: 10px; }
|
||
.preview-block .pv-quiz-opt {
|
||
padding: 7px 12px; margin: 4px 0; border-radius: 8px;
|
||
background: rgba(15,23,42,0.03); font-size: 0.88rem;
|
||
}
|
||
.preview-block .pv-quiz-opt.correct {
|
||
background: rgba(6,214,160,0.1); border: 1px solid rgba(6,214,160,0.3);
|
||
}
|
||
/* ── flashcard flip ── */
|
||
.pv-flip-wrap {
|
||
perspective: 900px; cursor: pointer; margin: 12px 0; user-select: none;
|
||
touch-action: manipulation; -webkit-tap-highlight-color: transparent;
|
||
}
|
||
.pv-fc-card {
|
||
position: relative; width: 100%; min-height: 150px;
|
||
transform-style: preserve-3d; transition: transform 0.5s cubic-bezier(.4,.2,.2,1);
|
||
}
|
||
.pv-fc-card.flipped { transform: rotateY(180deg); }
|
||
.pv-fc-front, .pv-fc-back {
|
||
position: absolute; inset: 0; padding: 22px 20px; min-height: 150px;
|
||
backface-visibility: hidden; -webkit-backface-visibility: hidden;
|
||
border-radius: 14px; display: flex; flex-direction: column;
|
||
justify-content: center; align-items: center; text-align: center;
|
||
box-sizing: border-box;
|
||
}
|
||
.pv-fc-front {
|
||
background: linear-gradient(135deg,rgba(155,93,229,0.07),rgba(155,93,229,0.03));
|
||
border: 1.5px solid rgba(155,93,229,0.2);
|
||
}
|
||
.pv-fc-back {
|
||
background: linear-gradient(135deg,rgba(6,214,160,0.07),rgba(6,214,160,0.03));
|
||
border: 1.5px solid rgba(6,214,160,0.2);
|
||
transform: rotateY(180deg);
|
||
}
|
||
.pv-fc-label {
|
||
font-size: 0.65rem; font-weight: 800; color: var(--text-3); text-transform: uppercase;
|
||
letter-spacing: 0.08em; margin-bottom: 10px;
|
||
}
|
||
.pv-fc-text { font-size: 1rem; font-weight: 700; color: #0F172A; line-height: 1.55; }
|
||
.pv-fc-hint { font-size: 0.7rem; color: #CBD5E1; margin-top: 14px; }
|
||
.btn-preview-mode {
|
||
padding: 8px 14px; border: 1.5px solid rgba(6,214,224,0.3); border-radius: 999px;
|
||
background: rgba(6,214,224,0.06); color: #0891B2;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
|
||
cursor: pointer; display: flex; align-items: center; gap: 6px; transition: all 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
.btn-preview-mode:hover { background: rgba(6,214,224,0.14); }
|
||
.btn-preview-mode.active { background: rgba(6,214,224,0.18); border-color: #06D6E0; color: #0E7490; }
|
||
/* ── MOBILE ADAPTATION ── */
|
||
@media (max-width: 768px) {
|
||
/* Topbar: hide non-essential items */
|
||
.block-palette { display: none; }
|
||
.etb-section-wrap { display: none !important; }
|
||
.etb-word-count { display: none; }
|
||
.etb-status { display: none; }
|
||
.btn-pub-all { display: none; }
|
||
.btn-pub { display: none; }
|
||
.btn-preview { display: none; }
|
||
#btn-undo, #btn-redo { display: none; }
|
||
.etb-icon-btn[onclick*="SaveTpl"],
|
||
.etb-icon-btn[onclick*="LoadTpl"] { display: none; }
|
||
.etb-title { max-width: 130px; font-size: 0.75rem; }
|
||
/* Icon-only buttons in topbar (font-size:0 hides text, icons use px dimensions) */
|
||
.btn-preview-mode { padding: 0 10px; min-height: 34px; font-size: 0; gap: 0; }
|
||
.btn-save { padding: 0 12px; min-height: 34px; font-size: 0; gap: 0; }
|
||
|
||
/* Canvas: compact on mobile */
|
||
.editor-canvas { padding: 16px 10px 80px; }
|
||
.lesson-title-input { font-size: 1.1rem; margin-bottom: 18px; }
|
||
|
||
/* Block header: hide drag handle + dup to free up space */
|
||
.block-drag-handle { display: none; }
|
||
.block-action-btn.dup { display: none; }
|
||
.block-type-select { max-width: 90px; font-size: 0.62rem; }
|
||
/* All side-by-side flex rows <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> wrap on mobile */
|
||
.block-row { flex-wrap: wrap; }
|
||
/* Flashcard: stack two sides vertically */
|
||
.flashcard-editor { flex-direction: column; }
|
||
/* Columns editor: force single column */
|
||
.columns-editor-grid { grid-template-columns: 1fr !important; }
|
||
/* Style/align button groups: wrap when they overflow */
|
||
.align-btns { flex-wrap: wrap; }
|
||
/* Callout style buttons already have flex-wrap, just ensure consistent sizing */
|
||
.callout-style-btn { flex: 1; text-align: center; }
|
||
/* Image alt/caption pair: each takes full width */
|
||
.block-row .block-field { min-width: 0; }
|
||
/* Formula symbol picker: slightly smaller */
|
||
.sym-btn { padding: 2px 5px; font-size: 0.75rem; }
|
||
/* Table: ensure cells don't get too tiny */
|
||
.table-editor td, .table-editor th { min-width: 60px; font-size: 0.82rem; }
|
||
|
||
/* Outline panel: full-width dropdown below topbar instead of sticky sidebar */
|
||
.outline-panel {
|
||
position: fixed; top: 54px; left: 0; right: 0;
|
||
width: 100%; height: auto; max-height: 50vh;
|
||
border-left: none; border-bottom: 1px solid rgba(15,23,42,0.1);
|
||
z-index: 90; box-shadow: 0 4px 20px rgba(15,23,42,0.12);
|
||
}
|
||
|
||
/* Modals: full width */
|
||
.tpl-modal { padding: 8px; }
|
||
.modal { max-width: calc(100vw - 16px) !important; padding: 18px !important; }
|
||
|
||
/* Preview tables: horizontal scroll, text still wraps in cells */
|
||
.preview-block table { display: block; overflow-x: auto; }
|
||
.preview-block th, .preview-block td { padding: 5px 8px; font-size: 0.82rem; word-break: break-word; }
|
||
|
||
/* Quiz buttons: larger tap target */
|
||
.pv-quiz-opt-btn { padding: 11px 14px; min-height: 44px; }
|
||
.pv-quiz-check-btn { padding: 11px 20px; min-height: 44px; }
|
||
|
||
/* Rich toolbar: larger touch targets */
|
||
.rich-toolbar-btn { width: 34px; height: 34px; }
|
||
.mini-toolbar .rich-toolbar-btn { width: 32px; height: 32px; }
|
||
|
||
/* Image align: left/right <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg> full width on mobile */
|
||
.pv-img-wrap.align-left .pv-image,
|
||
.pv-img-wrap.align-right .pv-image { max-width: 100%; }
|
||
|
||
/* Flashcard: use min-height based on content, not fixed absolute */
|
||
.pv-fc-card { min-height: 120px; }
|
||
.pv-fc-front, .pv-fc-back { min-height: 120px; padding: 16px 14px; }
|
||
.pv-fc-text { font-size: 0.9rem; }
|
||
|
||
/* Timeline date input: no max-width constraint on mobile */
|
||
.timeline-item-row input[style*="max-width:200px"] { max-width: 100% !important; }
|
||
}
|
||
|
||
/* Mobile FAB for adding blocks */
|
||
.mob-add-fab {
|
||
display: none;
|
||
}
|
||
@media (max-width: 768px) {
|
||
.mob-add-fab {
|
||
display: flex; position: fixed; bottom: 22px; right: 18px; z-index: 95;
|
||
width: 52px; height: 52px; border-radius: 50%;
|
||
background: var(--violet); color: #fff; border: none;
|
||
font-size: 1.5rem; line-height: 1; cursor: pointer;
|
||
align-items: center; justify-content: center;
|
||
box-shadow: 0 4px 16px rgba(155,93,229,0.45);
|
||
transition: transform 0.15s;
|
||
}
|
||
.mob-add-fab:active { transform: scale(0.92); }
|
||
|
||
/* Mobile block palette sheet */
|
||
.mob-palette-sheet {
|
||
position: fixed; bottom: 0; left: 0; right: 0; z-index: 94;
|
||
background: #fff; border-radius: 20px 20px 0 0;
|
||
box-shadow: 0 -4px 30px rgba(15,23,42,0.14);
|
||
padding: 8px 12px 28px; max-height: 55vh; overflow-y: auto;
|
||
transform: translateY(100%); transition: transform 0.25s cubic-bezier(.4,.2,.2,1);
|
||
}
|
||
.mob-palette-sheet.open { transform: translateY(0); }
|
||
.mob-palette-sheet-handle {
|
||
width: 40px; height: 4px; border-radius: 2px;
|
||
background: rgba(15,23,42,0.12); margin: 6px auto 12px;
|
||
}
|
||
.mob-palette-sheet .palette-section { margin-bottom: 14px; }
|
||
.mob-palette-sheet .palette-btn {
|
||
display: inline-flex; width: auto; padding: 7px 12px; margin: 3px;
|
||
}
|
||
.mob-backdrop {
|
||
display: none; position: fixed; inset: 0; z-index: 93;
|
||
background: rgba(0,0,0,0.25);
|
||
}
|
||
.mob-backdrop.show { display: block; }
|
||
}
|
||
|
||
/* ── C1: quote ── */
|
||
.preview-block .pv-quote {
|
||
border-left: 4px solid var(--violet); padding: 14px 20px; margin: 12px 0;
|
||
background: rgba(155,93,229,0.04); border-radius: 0 12px 12px 0;
|
||
}
|
||
.preview-block .pv-quote-text { font-size: 1.05rem; font-style: italic; color: #1E293B; line-height: 1.7; }
|
||
.preview-block .pv-quote-author { font-size: 0.82rem; font-weight: 700; color: var(--text-3); margin-top: 8px; }
|
||
|
||
/* ── C2: checklist ── */
|
||
.checklist-item-row { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; }
|
||
.checklist-item-row input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--violet); flex-shrink: 0; cursor: pointer; }
|
||
.preview-block .pv-checklist { padding: 4px 0; }
|
||
.preview-block .pv-checklist-item {
|
||
display: flex; align-items: center; gap: 10px; padding: 7px 4px;
|
||
font-size: 0.9rem; border-bottom: 1px solid rgba(15,23,42,0.05); cursor: pointer;
|
||
}
|
||
.preview-block .pv-checklist-item:last-child { border-bottom: none; }
|
||
.preview-block .pv-checklist-item.checked { color: var(--text-3); text-decoration: line-through; }
|
||
.preview-block .pv-checklist-cb { width: 17px; height: 17px; accent-color: var(--violet); cursor: pointer; flex-shrink: 0; }
|
||
|
||
/* ── C3: button/CTA ── */
|
||
.preview-block .pv-btn-cta-wrap { margin: 14px 0; }
|
||
.preview-block .pv-btn-cta-wrap.align-left { text-align: left; }
|
||
.preview-block .pv-btn-cta-wrap.align-center { text-align: center; }
|
||
.preview-block .pv-btn-cta-wrap.align-right { text-align: right; }
|
||
.preview-block .pv-btn-cta {
|
||
display: inline-block; padding: 11px 28px; border-radius: 10px;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.9rem; font-weight: 700;
|
||
text-decoration: none; transition: all 0.15s;
|
||
}
|
||
.pv-btn-cta.style-primary { background: var(--violet); color: #fff; box-shadow: 0 2px 8px rgba(155,93,229,0.3); }
|
||
.pv-btn-cta.style-secondary { background: rgba(155,93,229,0.1); color: var(--violet); border: 1.5px solid rgba(155,93,229,0.25); }
|
||
.pv-btn-cta.style-outline { background: transparent; color: var(--violet); border: 2px solid var(--violet); }
|
||
|
||
/* ── C5: outline panel ── */
|
||
.outline-panel {
|
||
width: 210px; flex-shrink: 0;
|
||
background: #fff; border-left: 1px solid rgba(15,23,42,0.07);
|
||
padding: 16px 10px; position: sticky; top: 54px; height: calc(100vh - 54px);
|
||
overflow-y: auto; display: none;
|
||
}
|
||
.outline-panel.open { display: block; }
|
||
.outline-panel-title {
|
||
font-size: 0.66rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
|
||
color: var(--text-3); margin-bottom: 10px; padding: 0 4px;
|
||
}
|
||
.outline-item {
|
||
display: block; width: 100%; text-align: left; border: none; background: none;
|
||
padding: 6px 8px; border-radius: 8px; cursor: pointer; font-size: 0.8rem;
|
||
color: #3D4F6B; font-family: 'Manrope', sans-serif; font-weight: 600;
|
||
transition: all 0.12s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
margin-bottom: 2px;
|
||
}
|
||
.outline-item:hover { background: rgba(155,93,229,0.06); color: var(--violet); }
|
||
.outline-item.h3 { padding-left: 20px; font-size: 0.74rem; font-weight: 500; color: #6B7A8E; }
|
||
.outline-empty { font-size: 0.78rem; color: #CBD5E1; padding: 6px; }
|
||
|
||
/* ── E3: interactive quiz in preview ── */
|
||
.pv-quiz-opt-btn {
|
||
display: block; width: 100%; text-align: left;
|
||
padding: 8px 12px; margin: 4px 0; border-radius: 8px;
|
||
background: rgba(15,23,42,0.03); border: 1.5px solid transparent;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.88rem; cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
.pv-quiz-opt-btn:hover:not(:disabled) { background: rgba(155,93,229,0.06); border-color: rgba(155,93,229,0.2); }
|
||
.pv-quiz-opt-btn.selected { background: rgba(155,93,229,0.08); border-color: rgba(155,93,229,0.3); color: var(--violet); }
|
||
.pv-quiz-opt-btn.right { background: rgba(6,214,160,0.1); border-color: rgba(6,214,160,0.4); color: #047857; }
|
||
.pv-quiz-opt-btn.wrong { background: rgba(239,68,68,0.08); border-color: rgba(239,68,68,0.3); color: #DC2626; }
|
||
.pv-quiz-opt-btn:disabled { cursor: default; }
|
||
.pv-quiz-check-btn {
|
||
margin-top: 8px; padding: 7px 16px; border-radius: 8px; border: none;
|
||
background: var(--violet); color: #fff;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700; cursor: pointer;
|
||
}
|
||
.pv-quiz-check-btn:disabled { opacity: 0.45; cursor: default; }
|
||
.pv-quiz-expl { margin-top: 8px; font-size: 0.82rem; color: #6B7A8E; display: none; }
|
||
.pv-quiz-expl.show { display: block; }
|
||
|
||
/* ── E1: mini rich editor ── */
|
||
.mini-rich-wrap { position: relative; }
|
||
.mini-toolbar {
|
||
display: flex; gap: 3px; margin-bottom: 5px; opacity: 0;
|
||
transition: opacity 0.15s; pointer-events: none;
|
||
}
|
||
.mini-rich-wrap:focus-within .mini-toolbar { opacity: 1; pointer-events: auto; }
|
||
.mini-rich-editor {
|
||
min-height: 60px; width: 100%; outline: none; line-height: 1.7;
|
||
font-family: 'Manrope', sans-serif; font-size: 0.92rem; color: #1E293B;
|
||
padding: 0; background: transparent;
|
||
}
|
||
.mini-rich-editor:empty:before {
|
||
content: attr(data-placeholder); color: rgba(15,23,42,0.25); pointer-events: none;
|
||
}
|
||
|
||
/* ── E5: block type select ── */
|
||
.block-type-select {
|
||
font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em;
|
||
color: var(--text-3); background: none; border: none; cursor: pointer;
|
||
font-family: 'Manrope', sans-serif; padding: 2px 4px; border-radius: 4px;
|
||
flex: 1; max-width: 110px;
|
||
}
|
||
.block-type-select:focus { outline: 1px solid rgba(155,93,229,0.3); color: var(--violet); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Topbar -->
|
||
<div class="editor-topbar">
|
||
<a href="#" id="etb-back" class="etb-back">
|
||
<i data-lucide="arrow-left" style="width:15px;height:15px"></i> Назад
|
||
</a>
|
||
<div class="etb-title" id="etb-title">Загрузка…</div>
|
||
|
||
<div class="etb-section-wrap" id="etb-section-wrap" style="display:none">
|
||
<label style="font-size:0.74rem;color:var(--text-3);font-weight:600">Раздел:</label>
|
||
<select id="section-select" class="etb-select" onchange="assignSection(this.value)">
|
||
<option value="">— без раздела —</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="etb-spacer"></div>
|
||
<span id="word-count-display" class="etb-word-count"></span>
|
||
|
||
<div class="etb-actions">
|
||
<button class="etb-icon-btn" id="btn-undo" onclick="undo()" title="Отменить (Ctrl+Z)" disabled>
|
||
<i data-lucide="undo-2" style="width:15px;height:15px"></i>
|
||
</button>
|
||
<button class="etb-icon-btn" id="btn-redo" onclick="redo()" title="Повторить (Ctrl+Y)" disabled>
|
||
<i data-lucide="redo-2" style="width:15px;height:15px"></i>
|
||
</button>
|
||
<button class="etb-icon-btn" id="btn-outline" onclick="toggleOutline()" title="Структура урока">
|
||
<i data-lucide="list" style="width:15px;height:15px"></i>
|
||
</button>
|
||
<button class="etb-icon-btn" onclick="openSaveTplModal()" title="Сохранить как шаблон">
|
||
<i data-lucide="bookmark-plus" style="width:15px;height:15px"></i>
|
||
</button>
|
||
<button class="etb-icon-btn" onclick="openLoadTplModal()" title="Загрузить из шаблона">
|
||
<i data-lucide="folder-open" style="width:15px;height:15px"></i>
|
||
</button>
|
||
<span class="etb-status" id="etb-status"></span>
|
||
<button class="btn-pub-all" id="btn-pub-all" onclick="publishAllLessons()" title="Опубликовать все уроки курса сразу">Опубликовать всё</button>
|
||
<button class="btn-pub" id="btn-pub" onclick="togglePublish()">Опубликовать</button>
|
||
<button class="btn-preview-mode" id="btn-preview-mode" onclick="togglePreviewMode()">
|
||
<i data-lucide="monitor" style="width:14px;height:14px"></i> Превью
|
||
</button>
|
||
<button class="btn-preview" onclick="goPreview()">
|
||
<i data-lucide="external-link" style="width:14px;height:14px"></i> Открыть
|
||
</button>
|
||
<button class="etb-icon-btn" onclick="exportPDF()" title="Экспорт в PDF" style="border-color:rgba(155,93,229,0.25);color:var(--violet)">
|
||
<i data-lucide="file-down" style="width:15px;height:15px"></i>
|
||
</button>
|
||
<button class="btn-save" id="btn-save" onclick="saveLesson()">
|
||
<i data-lucide="save" style="width:14px;height:14px"></i> Сохранить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="editor-wrap">
|
||
<!-- Block palette -->
|
||
<div class="block-palette">
|
||
<input type="text" class="palette-search" id="palette-search"
|
||
placeholder="Поиск блоков..."
|
||
oninput="filterPalette(this.value)" />
|
||
<div class="palette-section">
|
||
<div class="palette-label">Текст</div>
|
||
<button class="palette-btn" onclick="addBlock('heading')">
|
||
<div class="palette-btn-icon pbi-heading">H</div> Заголовок
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('text')">
|
||
<div class="palette-btn-icon pbi-text">¶</div> Параграф
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('callout')">
|
||
<div class="palette-btn-icon pbi-callout"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M9 18h6m-5 2h4M12 2a7 7 0 00-4 12.7V17h8v-2.3A7 7 0 0012 2z"/></svg></div> Выноска
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('quote')">
|
||
<div class="palette-btn-icon" style="background:rgba(155,93,229,0.1);color:var(--violet);font-size:1rem;font-weight:900">"</div> Цитата
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('checklist')">
|
||
<div class="palette-btn-icon" style="background:rgba(6,214,160,0.1);color:#06D6A0"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg></div> Чеклист
|
||
</button>
|
||
</div>
|
||
<div class="palette-section">
|
||
<div class="palette-label">Медиа</div>
|
||
<button class="palette-btn" onclick="addBlock('image')">
|
||
<div class="palette-btn-icon pbi-image"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg></div> Изображение
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('svg-draw')">
|
||
<div class="palette-btn-icon" style="background:rgba(139,92,246,0.1);color:#8b5cf6"><svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg></div> Рисунок
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('video')">
|
||
<div class="palette-btn-icon pbi-video"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><rect x="2" y="5" width="14" height="14" rx="2"/><path d="M22 7l-6 4 6 4V7z"/></svg></div> Видео
|
||
</button>
|
||
</div>
|
||
<div class="palette-section">
|
||
<div class="palette-label">Контент</div>
|
||
<button class="palette-btn" onclick="addBlock('formula')">
|
||
<div class="palette-btn-icon pbi-formula">∑</div> Формула
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('code')">
|
||
<div class="palette-btn-icon pbi-code"></></div> Код
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('table')">
|
||
<div class="palette-btn-icon pbi-table">⊞</div> Таблица
|
||
</button>
|
||
</div>
|
||
<div class="palette-section">
|
||
<div class="palette-label">Интерактив</div>
|
||
<button class="palette-btn" onclick="addBlock('quiz')">
|
||
<div class="palette-btn-icon pbi-quiz">?</div> Вопрос
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('flashcard')">
|
||
<div class="palette-btn-icon pbi-flash"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><rect x="2" y="3" width="20" height="18" rx="2"/><path d="M8 3v18"/></svg></div> Карточка
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('sim')">
|
||
<div class="palette-btn-icon pbi-sim"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><circle cx="12" cy="12" r="2"/><ellipse cx="12" cy="12" rx="10" ry="4"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(60 12 12)"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(120 12 12)"/></svg></div> Симуляция
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('matching')">
|
||
<div class="palette-btn-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M16 3h5v5M4 20L21 3M21 16v5h-5M15 15l6 6M4 4l5 5"/></svg></div> Сопоставление
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('fill-blank')">
|
||
<div class="palette-btn-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"/><path d="M14 2v6h6M16 13H8m8 4H8m2-8H8"/></svg></div> Пропуски
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('ordering')">
|
||
<div class="palette-btn-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M12 5v14m7-7l-7 7-7-7"/></svg></div> Порядок
|
||
</button>
|
||
</div>
|
||
<div class="palette-section">
|
||
<div class="palette-label">Расширенные</div>
|
||
<button class="palette-btn" onclick="addBlock('accordion')">
|
||
<div class="palette-btn-icon" style="background:rgba(99,102,241,0.1);color:#6366F1"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg></div> Аккордеон
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('timeline')">
|
||
<div class="palette-btn-icon" style="background:rgba(14,165,233,0.1);color:#0EA5E9"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></div> Хронология
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('diagram')">
|
||
<div class="palette-btn-icon" style="background:rgba(168,85,247,0.1);color:#A855F7"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="8" y="14" width="7" height="7"/><path d="M6.5 10v4M17.5 10v2.5H15m-3.5 1.5V10"/></svg></div> Диаграмма
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('geogebra')">
|
||
<div class="palette-btn-icon" style="background:rgba(34,197,94,0.1);color:#22C55E"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg></div> GeoGebra
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('audio')">
|
||
<div class="palette-btn-icon" style="background:rgba(249,115,22,0.1);color:#F97316"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></div> Аудио
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('columns')">
|
||
<div class="palette-btn-icon" style="background:rgba(59,130,246,0.1);color:#3B82F6"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="18" rx="1"/><rect x="14" y="3" width="7" height="18" rx="1"/></svg></div> Колонки
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('alert')">
|
||
<div class="palette-btn-icon" style="background:rgba(239,71,111,0.1);color:#EF476F"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg></div> Баннер
|
||
</button>
|
||
</div>
|
||
<div class="palette-section">
|
||
<div class="palette-label">Прочее</div>
|
||
<button class="palette-btn" onclick="addBlock('button')">
|
||
<div class="palette-btn-icon" style="background:rgba(155,93,229,0.1);color:var(--violet)"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="10" rx="5"/></svg></div> Кнопка
|
||
</button>
|
||
<button class="palette-btn" onclick="addBlock('divider')">
|
||
<div class="palette-btn-icon pbi-div">—</div> Разделитель
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Editor canvas -->
|
||
<div class="editor-canvas">
|
||
<!-- Lesson title -->
|
||
<textarea class="lesson-title-input" id="lesson-title"
|
||
placeholder="Название урока…" rows="1"
|
||
oninput="autoResize(this);markDirty()"></textarea>
|
||
|
||
<!-- Blocks -->
|
||
<div id="blocks-container"></div>
|
||
</div>
|
||
|
||
<!-- Outline panel (C5) -->
|
||
<div class="outline-panel" id="outline-panel">
|
||
<div class="outline-panel-title">Структура</div>
|
||
<div id="outline-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mobile: FAB + palette sheet -->
|
||
<button class="mob-add-fab" id="mob-add-fab" onclick="toggleMobPalette()" title="Добавить блок">+</button>
|
||
<div class="mob-backdrop" id="mob-backdrop" onclick="closeMobPalette()"></div>
|
||
<div class="mob-palette-sheet" id="mob-palette-sheet">
|
||
<div class="mob-palette-sheet-handle"></div>
|
||
<div id="mob-palette-inner"></div>
|
||
</div>
|
||
|
||
<script src="/js/api.js"></script>
|
||
<script src="/js/imggen.js"></script>
|
||
<script src="/js/sidebar.js"></script>
|
||
<script src="/js/svg-sanitize.js"></script>
|
||
<script src="/js/svg-draw.js"></script>
|
||
<script>
|
||
if (!LS.requireAuth()) throw new Error();
|
||
const user = LS.getUser();
|
||
if (!['admin','teacher'].includes(user?.role)) {
|
||
location.href = '/theory';
|
||
}
|
||
lucide.createIcons();
|
||
|
||
/* ── state ── */
|
||
const lessonId = new URLSearchParams(location.search).get('id');
|
||
if (!lessonId) location.href = '/theory';
|
||
|
||
let lesson = null;
|
||
let blocks = []; // [{id, type, orderIndex, data}] — live state
|
||
let _nextId = 1; // local temp ids for new blocks
|
||
let _dirty = false;
|
||
let _saving = false;
|
||
|
||
/* ── undo/redo ── */
|
||
let _history = [];
|
||
let _historyIdx = -1;
|
||
const HISTORY_MAX = 50;
|
||
|
||
function pushHistory() {
|
||
const snap = JSON.stringify(blocks);
|
||
// drop redoable future
|
||
if (_historyIdx < _history.length - 1) {
|
||
_history = _history.slice(0, _historyIdx + 1);
|
||
}
|
||
_history.push(snap);
|
||
if (_history.length > HISTORY_MAX) _history.shift();
|
||
_historyIdx = _history.length - 1;
|
||
updateUndoRedoBtns();
|
||
}
|
||
|
||
function updateUndoRedoBtns() {
|
||
document.getElementById('btn-undo').disabled = (_historyIdx <= 0);
|
||
document.getElementById('btn-redo').disabled = (_historyIdx >= _history.length - 1);
|
||
}
|
||
|
||
function undo() {
|
||
if (_historyIdx <= 0) return;
|
||
_historyIdx--;
|
||
blocks = JSON.parse(_history[_historyIdx]);
|
||
renderBlocks();
|
||
markDirty();
|
||
updateUndoRedoBtns();
|
||
}
|
||
|
||
function redo() {
|
||
if (_historyIdx >= _history.length - 1) return;
|
||
_historyIdx++;
|
||
blocks = JSON.parse(_history[_historyIdx]);
|
||
renderBlocks();
|
||
markDirty();
|
||
updateUndoRedoBtns();
|
||
}
|
||
|
||
function markDirty() {
|
||
_dirty = true;
|
||
setStatus('');
|
||
updateWordCount();
|
||
if (document.getElementById('outline-panel')?.classList.contains('open')) updateOutline();
|
||
}
|
||
function setStatus(msg, ok) {
|
||
const el = document.getElementById('etb-status');
|
||
el.textContent = msg;
|
||
el.className = 'etb-status' + (ok ? ' saved' : '');
|
||
}
|
||
|
||
/* ── auto-save on blur / keyboard ── */
|
||
let _autoSaveTimer = null;
|
||
function scheduleAutoSave() {
|
||
clearTimeout(_autoSaveTimer);
|
||
_autoSaveTimer = setTimeout(() => { if (_dirty) saveLesson(true); }, 2000);
|
||
}
|
||
|
||
/* ── load ── */
|
||
async function loadLesson() {
|
||
try {
|
||
lesson = await LS.api('/api/lessons/' + lessonId);
|
||
} catch {
|
||
document.getElementById('etb-title').textContent = 'Урок не найден';
|
||
return;
|
||
}
|
||
document.title = 'Редактор: ' + lesson.title + ' — LearnSpace';
|
||
document.getElementById('etb-title').textContent = lesson.title;
|
||
document.getElementById('lesson-title').value = lesson.title;
|
||
autoResize(document.getElementById('lesson-title'));
|
||
|
||
const back = document.getElementById('etb-back');
|
||
back.href = '/course?id=' + lesson.courseId;
|
||
|
||
const pubBtn = document.getElementById('btn-pub');
|
||
pubBtn.textContent = lesson.isPublished ? 'Снять с публикации' : 'Опубликовать';
|
||
if (lesson.isPublished) pubBtn.classList.add('published');
|
||
|
||
blocks = (lesson.blocks || []).map(b => ({ ...b, _id: b.id || ('n' + _nextId++) }));
|
||
|
||
// push initial history snapshot
|
||
pushHistory();
|
||
|
||
renderBlocks();
|
||
|
||
// load sections if courseId is known
|
||
if (lesson.courseId) {
|
||
loadSections(lesson.courseId, lesson.sectionId);
|
||
}
|
||
}
|
||
|
||
/* ── sections ── */
|
||
async function loadSections(courseId, currentSectionId) {
|
||
try {
|
||
const sections = await LS.api('/api/courses/' + courseId + '/sections');
|
||
if (!sections || !sections.length) return;
|
||
const wrap = document.getElementById('etb-section-wrap');
|
||
const sel = document.getElementById('section-select');
|
||
sections.forEach(s => {
|
||
const opt = document.createElement('option');
|
||
opt.value = s.id;
|
||
opt.textContent = s.title || s.name || ('Раздел ' + s.id);
|
||
if (currentSectionId && String(s.id) === String(currentSectionId)) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
});
|
||
wrap.style.display = 'flex';
|
||
} catch { /* sections endpoint may not exist yet */ }
|
||
}
|
||
|
||
async function assignSection(sectionId) {
|
||
try {
|
||
await LS.api('/api/lessons/' + lessonId, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ sectionId: sectionId || null }),
|
||
});
|
||
if (lesson) lesson.sectionId = sectionId || null;
|
||
setStatus('Раздел обновлён', true);
|
||
setTimeout(() => setStatus(''), 2000);
|
||
} catch (e) { LS.toast(e.message || 'Ошибка обновления раздела', 'error'); }
|
||
}
|
||
|
||
/* ── KaTeX helper ── */
|
||
function renderKatexIn(el) {
|
||
if (!el) return;
|
||
const doRender = () => {
|
||
try {
|
||
renderMathInElement(el, {
|
||
delimiters: [
|
||
{ left:'$$', right:'$$', display:true },
|
||
{ left:'$', right:'$', display:false },
|
||
], throwOnError: false,
|
||
});
|
||
} catch {}
|
||
};
|
||
if (window._katexLoaded && window.renderMathInElement) doRender();
|
||
else { window._katexCb = doRender; }
|
||
}
|
||
|
||
/* ── block defaults ── */
|
||
const BLOCK_DEFAULTS = {
|
||
heading: { level: 2, text: '' },
|
||
text: { html: '' },
|
||
formula: { tex: '', label: '' },
|
||
image: { url: '', alt: '', caption: '' },
|
||
'svg-draw': { svg: '', caption: '' },
|
||
code: { code: '', lang: 'js' },
|
||
quiz: { question: '', options: ['', ''], correctIndex: 0 },
|
||
divider: {},
|
||
callout: { style: 'info', text: '' },
|
||
video: { url: '', caption: '' },
|
||
table: { rows: [['', ''], ['', '']], headers: true },
|
||
flashcard: { front: '', back: '' },
|
||
sim: { simId: '', caption: '' },
|
||
matching: { question: '', pairs: [{left:'', right:''}] },
|
||
'fill-blank': { text: 'Столица Франции — {Париж}.', blanks: [] },
|
||
ordering: { question: '', items: ['Шаг 1','Шаг 2','Шаг 3'] },
|
||
accordion: { title: 'Подробнее…', content: '' },
|
||
timeline: { items: [{ date: '', title: '', text: '' }] },
|
||
diagram: { code: 'graph TD\n A[Начало] --> B{Условие}\n B -->|Да| C[Действие]\n B -->|Нет| D[Конец]', caption: '' },
|
||
geogebra: { materialId: '', caption: '' },
|
||
audio: { url: '', caption: '' },
|
||
columns: { cols: [{ content: '' }, { content: '' }] },
|
||
alert: { style: 'exam', text: '' },
|
||
quote: { text: '', author: '' },
|
||
checklist: { items: [{ text: '', checked: false }] },
|
||
button: { label: 'Нажмите здесь', url: '', style: 'primary', align: 'center' },
|
||
};
|
||
const BLOCK_LABELS = {
|
||
heading: 'Заголовок', text: 'Параграф', formula: 'Формула',
|
||
image: 'Изображение', 'svg-draw': 'Рисунок', code: 'Код', quiz: 'Вопрос', divider: 'Разделитель',
|
||
callout: 'Выноска', video: 'Видео', table: 'Таблица',
|
||
flashcard: 'Карточка', sim: 'Симуляция',
|
||
matching: 'Сопоставление', 'fill-blank': 'Пропуски', ordering: 'Порядок',
|
||
accordion: 'Аккордеон', timeline: 'Хронология', diagram: 'Диаграмма',
|
||
geogebra: 'GeoGebra', audio: 'Аудио',
|
||
columns: 'Колонки', alert: 'Баннер',
|
||
quote: 'Цитата', checklist: 'Чеклист', button: 'Кнопка',
|
||
};
|
||
|
||
function addBlock(type, data, afterId) {
|
||
pushHistory();
|
||
const id = 'n' + _nextId++;
|
||
// deep-clone defaults
|
||
const defaults = JSON.parse(JSON.stringify(BLOCK_DEFAULTS[type] || {}));
|
||
const newBlock = {
|
||
_id: id, type,
|
||
orderIndex: blocks.length,
|
||
data: Object.assign(defaults, data || {}),
|
||
};
|
||
if (afterId) {
|
||
const idx = blocks.findIndex(b => b._id === afterId);
|
||
blocks.splice(idx + 1, 0, newBlock);
|
||
} else {
|
||
blocks.push(newBlock);
|
||
}
|
||
renderBlocks();
|
||
markDirty();
|
||
// focus the new block's first input
|
||
setTimeout(() => {
|
||
const card = document.querySelector(`.block-card[data-bid="${id}"]`);
|
||
if (card) {
|
||
const inp = card.querySelector('textarea,input[type=text],[contenteditable]');
|
||
if (inp) inp.focus();
|
||
}
|
||
}, 40);
|
||
}
|
||
|
||
function removeBlock(id) {
|
||
pushHistory();
|
||
blocks = blocks.filter(b => b._id !== id);
|
||
renderBlocks();
|
||
markDirty();
|
||
}
|
||
|
||
function moveBlock(id, dir) {
|
||
pushHistory();
|
||
const idx = blocks.findIndex(b => b._id === id);
|
||
if (idx < 0) return;
|
||
const swapIdx = idx + dir;
|
||
if (swapIdx < 0 || swapIdx >= blocks.length) return;
|
||
[blocks[idx], blocks[swapIdx]] = [blocks[swapIdx], blocks[idx]];
|
||
renderBlocks();
|
||
markDirty();
|
||
}
|
||
|
||
function duplicateBlock(id) {
|
||
pushHistory();
|
||
const idx = blocks.findIndex(b => b._id === id);
|
||
if (idx < 0) return;
|
||
const orig = blocks[idx];
|
||
const copy = {
|
||
_id: 'n' + _nextId++,
|
||
type: orig.type,
|
||
orderIndex: orig.orderIndex,
|
||
data: JSON.parse(JSON.stringify(orig.data)),
|
||
};
|
||
blocks.splice(idx + 1, 0, copy);
|
||
renderBlocks();
|
||
markDirty();
|
||
}
|
||
|
||
/* ── render all blocks ── */
|
||
/* ── SVG-draw widgets: mount/re-mount after each render ───────────── */
|
||
const _svgDrawInst = {};
|
||
function mountSvgDrawEditors() {
|
||
if (!window.SvgDraw) return;
|
||
// tear down instances whose host left the DOM (e.g. after a full re-render)
|
||
Object.keys(_svgDrawInst).forEach(function (bid) {
|
||
const inst = _svgDrawInst[bid];
|
||
if (!inst || !inst.el || !inst.el.isConnected) {
|
||
try { inst && inst.destroy(); } catch (e) {}
|
||
delete _svgDrawInst[bid];
|
||
}
|
||
});
|
||
document.querySelectorAll('.svgdraw-host').forEach(function (host) {
|
||
if (host._svgdMounted) return;
|
||
host._svgdMounted = true;
|
||
const bid = host.dataset.bid;
|
||
const b = blocks.find(function (x) { return x._id === bid; });
|
||
_svgDrawInst[bid] = SvgDraw.mount(host, {
|
||
svg: (b && b.data && b.data.svg) || '',
|
||
width: 800, height: 500,
|
||
onChange: function (svg) {
|
||
updateBlockData(bid, 'svg', svg);
|
||
markDirty();
|
||
if (typeof scheduleAutoSave === 'function') scheduleAutoSave();
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderBlocks() {
|
||
updateWordCount();
|
||
if (document.getElementById('outline-panel')?.classList.contains('open')) updateOutline();
|
||
const container = document.getElementById('blocks-container');
|
||
if (!blocks.length) {
|
||
container.innerHTML = `<div class="canvas-hint">
|
||
<div class="canvas-hint-title">Добавьте первый блок</div>
|
||
Выберите тип блока из палитры слева или нажмите кнопку ниже.<br>
|
||
Каждый блок можно перетащить, переместить или удалить.
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = blocks.map((b, i) => renderBlockCard(b, i)).join('');
|
||
mountSvgDrawEditors();
|
||
|
||
// wire events
|
||
container.querySelectorAll('.block-card').forEach(card => {
|
||
const bid = card.dataset.bid;
|
||
// drag and drop
|
||
card.setAttribute('draggable', 'true');
|
||
card.addEventListener('dragstart', e => {
|
||
// не перехватывать холст SVG-рисовалки — там пользователь рисует
|
||
if (e.target.closest && e.target.closest('.svgdraw-host')) { e.preventDefault(); return; }
|
||
e.dataTransfer.setData('text/plain', bid);
|
||
card.classList.add('dragging');
|
||
});
|
||
card.addEventListener('dragend', () => card.classList.remove('dragging'));
|
||
card.addEventListener('dragover', e => { e.preventDefault(); card.classList.add('drag-over'); });
|
||
card.addEventListener('dragleave', () => card.classList.remove('drag-over'));
|
||
card.addEventListener('drop', e => {
|
||
e.preventDefault(); card.classList.remove('drag-over');
|
||
const fromId = e.dataTransfer.getData('text/plain');
|
||
if (fromId === bid) return;
|
||
pushHistory();
|
||
const fromIdx = blocks.findIndex(b => b._id === fromId);
|
||
const toIdx = blocks.findIndex(b => b._id === bid);
|
||
const [moved] = blocks.splice(fromIdx, 1);
|
||
blocks.splice(toIdx, 0, moved);
|
||
renderBlocks(); markDirty();
|
||
});
|
||
});
|
||
|
||
// wire rich-text editors (contenteditable)
|
||
container.querySelectorAll('.rich-editor').forEach(el => {
|
||
const bid = el.dataset.bid;
|
||
const toolbar = el.previousElementSibling;
|
||
el.addEventListener('focus', () => { if (toolbar) toolbar.classList.add('visible'); });
|
||
el.addEventListener('blur', () => {
|
||
setTimeout(() => {
|
||
if (!toolbar.contains(document.activeElement)) toolbar.classList.remove('visible');
|
||
}, 150);
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (b) { b.data.html = el.innerHTML; markDirty(); scheduleAutoSave(); }
|
||
});
|
||
el.addEventListener('input', () => {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (b) { b.data.html = el.innerHTML; markDirty(); scheduleAutoSave(); }
|
||
});
|
||
});
|
||
|
||
// wire mini rich editors
|
||
container.querySelectorAll('.mini-rich-editor').forEach(el => {
|
||
const ibid = el.dataset.bid;
|
||
const key = el.dataset.key;
|
||
const syncMini = () => {
|
||
const b = blocks.find(x => x._id === ibid);
|
||
if (!b) return;
|
||
if (key.startsWith('col:')) {
|
||
const ci = parseInt(key.slice(4), 10);
|
||
if (Array.isArray(b.data.cols)) b.data.cols[ci].content = el.innerHTML;
|
||
} else {
|
||
b.data[key] = el.innerHTML;
|
||
}
|
||
};
|
||
el.addEventListener('input', () => { syncMini(); markDirty(); scheduleAutoSave(); });
|
||
el.addEventListener('blur', () => { syncMini(); markDirty(); scheduleAutoSave(); });
|
||
});
|
||
|
||
// wire table editors
|
||
container.querySelectorAll('.table-editor table').forEach(table => {
|
||
const bid = table.dataset.bid;
|
||
table.querySelectorAll('td,th').forEach(cell => {
|
||
cell.addEventListener('input', () => syncTableData(bid, table));
|
||
cell.addEventListener('blur', () => { syncTableData(bid, table); markDirty(); scheduleAutoSave(); });
|
||
});
|
||
});
|
||
|
||
lucide.createIcons();
|
||
|
||
// Highlight code blocks
|
||
if (window.hljs) {
|
||
container.querySelectorAll('.code-preview pre code').forEach(el => hljs.highlightElement(el));
|
||
}
|
||
|
||
// Mermaid diagrams
|
||
if (window.mermaid) {
|
||
try { mermaid.run({ nodes: container.querySelectorAll('.mermaid') }); } catch {}
|
||
}
|
||
}
|
||
|
||
/* ── formula symbol picker ── */
|
||
const SYM_CATS = {
|
||
'Греческие': [
|
||
['\\alpha','α'],['\\beta','β'],['\\gamma','γ'],['\\delta','δ'],['\\epsilon','ε'],
|
||
['\\zeta','ζ'],['\\eta','η'],['\\theta','θ'],['\\iota','ι'],['\\kappa','κ'],
|
||
['\\lambda','λ'],['\\mu','μ'],['\\nu','ν'],['\\xi','ξ'],['\\pi','π'],
|
||
['\\rho','ρ'],['\\sigma','σ'],['\\tau','τ'],['\\phi','φ'],['\\chi','χ'],
|
||
['\\psi','ψ'],['\\omega','ω'],
|
||
['\\Gamma','Γ'],['\\Delta','Δ'],['\\Theta','Θ'],['\\Lambda','Λ'],['\\Xi','Ξ'],
|
||
['\\Pi','Π'],['\\Sigma','Σ'],['\\Phi','Φ'],['\\Psi','Ψ'],['\\Omega','Ω'],
|
||
],
|
||
'Операции': [
|
||
['\\frac{a}{b}','a/b'],['\\sqrt{x}','√'],['\\sqrt[n]{x}','ⁿ√'],
|
||
['\\sum','∑'],['\\prod','∏'],['\\int','∫'],['\\iint','∬'],['\\oint','∮'],
|
||
['\\lim','lim'],['\\infty','∞'],['\\partial','∂'],['\\nabla','∇'],
|
||
['\\pm','±'],['\\mp','∓'],['\\times','×'],['\\div','÷'],['\\cdot','·'],
|
||
],
|
||
'Степени': [
|
||
['^{2}','x²'],['^{3}','x³'],['_{n}','xₙ'],['_{i}','xᵢ'],
|
||
['e^{x}','eˣ'],['10^{n}','10ⁿ'],
|
||
],
|
||
'Отношения': [
|
||
['\\leq','≤'],['\\geq','≥'],['\\neq','≠'],['\\approx','≈'],['\\equiv','≡'],
|
||
['\\sim','∼'],['\\propto','∝'],['\\ll','≪'],['\\gg','≫'],
|
||
],
|
||
'Стрелки': [
|
||
['\\to','<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>'],['\\leftarrow','<svg class="ic" viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>'],['\\Rightarrow','<svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>'],['\\Leftrightarrow','<svg class="ic" viewBox="0 0 24 24"><line x1="3" y1="12" x2="21" y2="12"/><polyline points="8 17 3 12 8 7"/><polyline points="16 7 21 12 16 17"/></svg>'],
|
||
['\\uparrow','<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>'],['\\downarrow','<svg class="ic" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>'],
|
||
],
|
||
'Скобки': [
|
||
['\\left( \\right)','(…)'],['\\left[ \\right]','[…]'],['\\left\\{ \\right\\}','{…}'],
|
||
['\\left| \\right|','|…|'],['\\left\\| \\right\\|','‖…‖'],
|
||
],
|
||
'Физика': [
|
||
['\\vec{F}','F⃗'],['\\hat{x}','x̂'],['\\hbar','ℏ'],['\\Delta t','Δt'],
|
||
['\\mathbf{E}','E'],['\\mathbf{B}','B'],
|
||
],
|
||
};
|
||
let _symCurrentCat = 'Греческие';
|
||
|
||
function _buildSymPicker(bid) {
|
||
const cats = Object.keys(SYM_CATS);
|
||
const catBtns = cats.map(c =>
|
||
`<button class="sym-cat-btn${c===_symCurrentCat?' active':''}"
|
||
onclick="_symSetCat('${bid}','${c}',this)">${c}</button>`
|
||
).join('');
|
||
const syms = (SYM_CATS[_symCurrentCat]||[]).map(([latex, display]) =>
|
||
`<button class="sym-btn" title="${latex}" onclick="insertSym('${bid}','${latex.replace(/'/g,"\\'")}')">` +
|
||
`${display}</button>`
|
||
).join('');
|
||
return `<div class="sym-cat-row">${catBtns}</div><div class="sym-picker">${syms}</div>`;
|
||
}
|
||
|
||
function _symSetCat(bid, cat, btn) {
|
||
_symCurrentCat = cat;
|
||
const picker = document.getElementById('sym-picker-' + bid);
|
||
if (!picker) return;
|
||
picker.querySelectorAll('.sym-cat-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
picker.querySelector('.sym-picker').innerHTML =
|
||
(SYM_CATS[cat]||[]).map(([latex, display]) =>
|
||
`<button class="sym-btn" title="${latex}" onclick="insertSym('${bid}','${latex.replace(/'/g,"\\'")}')">` +
|
||
`${display}</button>`
|
||
).join('');
|
||
}
|
||
|
||
function toggleSymPicker(bid) {
|
||
const el = document.getElementById('sym-picker-' + bid);
|
||
if (!el) return;
|
||
if (el.style.display === 'none') {
|
||
el.innerHTML = _buildSymPicker(bid);
|
||
el.style.display = 'block';
|
||
} else {
|
||
el.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function insertSym(bid, latex) {
|
||
const ta = document.getElementById('ftex-' + bid);
|
||
if (!ta) return;
|
||
const start = ta.selectionStart, end = ta.selectionEnd;
|
||
const val = ta.value;
|
||
ta.value = val.slice(0, start) + latex + val.slice(end);
|
||
ta.selectionStart = ta.selectionEnd = start + latex.length;
|
||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||
ta.focus();
|
||
}
|
||
|
||
/* ── render single block card ── */
|
||
function renderBlockCard(b, i) {
|
||
const isCollapsed = !!b._collapsed;
|
||
const typeOptions = Object.keys(BLOCK_LABELS).map(t =>
|
||
`<option value="${t}" ${t === b.type ? 'selected' : ''}>${BLOCK_LABELS[t]}</option>`
|
||
).join('');
|
||
return `<div class="block-card${isCollapsed ? ' collapsed' : ''}" data-bid="${b._id}">
|
||
<div class="block-header">
|
||
<span class="block-drag-handle" title="Перетащить"><i data-lucide="grip-vertical" style="width:14px;height:14px"></i></span>
|
||
<select class="block-type-select" title="Изменить тип блока"
|
||
onchange="changeBlockType('${b._id}',this.value)">${typeOptions}</select>
|
||
<div class="block-actions">
|
||
<button class="block-action-btn block-collapse-btn" title="${isCollapsed ? 'Развернуть' : 'Свернуть'}" onclick="toggleBlockCollapse('${b._id}')">
|
||
<i data-lucide="chevron-down" style="width:13px;height:13px"></i>
|
||
</button>
|
||
<button class="block-action-btn" title="Вверх" onclick="moveBlock('${b._id}',-1)">
|
||
<i data-lucide="chevron-up" style="width:13px;height:13px"></i>
|
||
</button>
|
||
<button class="block-action-btn" title="Вниз" onclick="moveBlock('${b._id}',1)">
|
||
<i data-lucide="chevron-down" style="width:13px;height:13px"></i>
|
||
</button>
|
||
<button class="block-action-btn dup" title="Дублировать" onclick="duplicateBlock('${b._id}')">
|
||
<i data-lucide="copy" style="width:13px;height:13px"></i>
|
||
</button>
|
||
<button class="block-action-btn danger" title="Удалить" onclick="removeBlock('${b._id}')">
|
||
<i data-lucide="trash-2" style="width:13px;height:13px"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="block-body">${renderBlockEditor(b)}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderBlockEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
switch (b.type) {
|
||
|
||
case 'heading':
|
||
return `<div class="block-row">
|
||
<select class="heading-level-select" onchange="updateBlockData('${bid}','level',parseInt(this.value));markDirty()">
|
||
<option value="2" ${d.level!==3?'selected':''}>H2</option>
|
||
<option value="3" ${d.level===3?'selected':''}>H3</option>
|
||
</select>
|
||
<input class="block-input" type="text" value="${escAttr(d.text||'')}"
|
||
placeholder="Текст заголовка…"
|
||
oninput="updateBlockData('${bid}','text',this.value);markDirty();scheduleAutoSave()" />
|
||
</div>`;
|
||
|
||
case 'text': {
|
||
// backward compat: if only data.text exists (old format), use as plaintext
|
||
const htmlContent = d.html != null ? d.html : esc(d.text || '');
|
||
return `<div>
|
||
<div class="rich-toolbar" id="rt-${bid}">
|
||
<button class="rich-toolbar-btn" title="Жирный" onmousedown="event.preventDefault();document.execCommand('bold')"><b>B</b></button>
|
||
<button class="rich-toolbar-btn" title="Курсив" onmousedown="event.preventDefault();document.execCommand('italic')"><i>I</i></button>
|
||
<button class="rich-toolbar-btn" title="Код" onmousedown="event.preventDefault();wrapCode('${bid}')"><code style="font-size:0.78rem"></></code></button>
|
||
<button class="rich-toolbar-btn" title="Ссылка" onmousedown="event.preventDefault();insertLink()"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71m4.54 5.07a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg></button>
|
||
</div>
|
||
<div class="rich-editor" contenteditable="true" data-bid="${bid}"
|
||
data-placeholder="Введите текст параграфа…">${htmlContent}</div>
|
||
</div>`;
|
||
}
|
||
|
||
case 'formula':
|
||
return `<div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Метка (необязательно)</div>
|
||
<input class="block-input" type="text" placeholder="Закон Кулона" value="${escAttr(d.label||'')}"
|
||
oninput="updateBlockData('${bid}','label',this.value);markDirty()" />
|
||
</div>
|
||
<div class="block-field">
|
||
<div class="block-row-label" style="display:flex;align-items:center;justify-content:space-between">
|
||
<span>LaTeX-формула</span>
|
||
<button style="background:none;border:none;cursor:pointer;font-size:.72rem;color:#9B5DE5;padding:0"
|
||
onclick="toggleSymPicker('${bid}')">𝛴 символы</button>
|
||
</div>
|
||
<div id="sym-picker-${bid}" style="display:none;margin-bottom:6px">
|
||
${_buildSymPicker(bid)}
|
||
</div>
|
||
<textarea class="block-textarea" rows="2" id="ftex-${bid}"
|
||
placeholder="F = k \\frac{q_1 q_2}{r^2}"
|
||
oninput="autoResize(this);updateBlockData('${bid}','tex',this.value);updateFormulaPreview('${bid}');markDirty();scheduleAutoSave()"
|
||
>${esc(d.tex||'')}</textarea>
|
||
</div>
|
||
<div class="formula-preview" id="fp-${bid}">$$${esc(d.tex||'')}$$</div>
|
||
</div>`;
|
||
|
||
case 'image': {
|
||
const imgAlign = d.align || 'center';
|
||
const alignOpts = [
|
||
{ val: 'left', icon: 'align-left', label: 'Лево' },
|
||
{ val: 'center', icon: 'align-center', label: 'Центр' },
|
||
{ val: 'right', icon: 'align-right', label: 'Право' },
|
||
{ val: 'full', icon: 'maximize-2', label: 'Во всю' },
|
||
];
|
||
return `<div>
|
||
<div class="align-btns">
|
||
${alignOpts.map(a => `<button class="align-btn${imgAlign===a.val?' active':''}"
|
||
onclick="updateBlockData('${bid}','align','${a.val}');markDirty();rerenderBlock('${bid}')"
|
||
title="${a.label}">
|
||
<i data-lucide="${a.icon}" style="width:13px;height:13px"></i>${a.label}
|
||
</button>`).join('')}
|
||
</div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">URL изображения</div>
|
||
<input class="block-input" type="text" placeholder="https://…" value="${escAttr(d.url||'')}"
|
||
oninput="updateBlockData('${bid}','url',this.value);markDirty()" />
|
||
</div>
|
||
<div class="img-upload-row">
|
||
<input type="file" id="img-file-${bid}" accept="image/*" style="display:none"
|
||
onchange="uploadImage('${bid}')" />
|
||
<button class="img-upload-btn" onclick="document.getElementById('img-file-${bid}').click()">
|
||
<i data-lucide="upload" style="width:14px;height:14px"></i> Загрузить файл
|
||
</button>
|
||
<button class="img-upload-btn" onclick="genBlockImage('${bid}')" title="Сгенерировать изображение с ИИ">
|
||
<i data-lucide="sparkles" style="width:14px;height:14px"></i> Сгенерировать
|
||
</button>
|
||
<span class="img-upload-progress" id="img-status-${bid}"></span>
|
||
</div>
|
||
<div class="block-row">
|
||
<div class="block-field" style="flex:1">
|
||
<div class="block-row-label">Alt-текст</div>
|
||
<input class="block-input" type="text" placeholder="Описание изображения" value="${escAttr(d.alt||'')}"
|
||
oninput="updateBlockData('${bid}','alt',this.value);markDirty()" />
|
||
</div>
|
||
<div class="block-field" style="flex:1">
|
||
<div class="block-row-label">Подпись</div>
|
||
<input class="block-input" type="text" placeholder="Рис. 1" value="${escAttr(d.caption||'')}"
|
||
oninput="updateBlockData('${bid}','caption',this.value);markDirty()" />
|
||
</div>
|
||
</div>
|
||
${d.url ? `<img src="${escAttr(d.url)}" style="max-height:120px;border-radius:8px;margin-top:8px;object-fit:cover" alt="" />` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
case 'svg-draw':
|
||
return `<div>
|
||
<div class="svgdraw-host" data-bid="${bid}"></div>
|
||
<div class="block-field" style="margin-top:8px">
|
||
<div class="block-row-label">Подпись</div>
|
||
<input class="block-input" type="text" placeholder="Рис. 1" value="${escAttr(d.caption||'')}"
|
||
oninput="updateBlockData('${bid}','caption',this.value);markDirty()" />
|
||
</div>
|
||
</div>`;
|
||
|
||
case 'code':
|
||
return `<div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Язык</div>
|
||
<select class="block-input" style="max-width:160px"
|
||
onchange="updateBlockData('${bid}','lang',this.value);updateCodePreview('${bid}');markDirty()">
|
||
${['','js','python','java','cpp','c','csharp','html','css','sql','json','bash','typescript','php','ruby','go','rust','kotlin','swift'].map(l =>
|
||
`<option value="${l}" ${(d.lang||'')=== l?'selected':''}>${l||'— авто —'}</option>`
|
||
).join('')}
|
||
</select>
|
||
</div>
|
||
<textarea class="block-textarea" rows="4" style="font-family:'Fira Code','JetBrains Mono',monospace;font-size:0.84rem"
|
||
placeholder="// код здесь…"
|
||
oninput="autoResize(this);updateBlockData('${bid}','code',this.value);updateCodePreview('${bid}');markDirty();scheduleAutoSave()"
|
||
>${esc(d.code||'')}</textarea>
|
||
<div class="code-preview" id="code-preview-${bid}">
|
||
<pre><code class="${d.lang?'language-'+escAttr(d.lang):''}">${esc(d.code||'')}</code></pre>
|
||
</div>
|
||
</div>`;
|
||
|
||
case 'quiz':
|
||
return renderQuizEditor(b);
|
||
|
||
case 'divider':
|
||
return `<div style="height:1.5px;background:rgba(15,23,42,0.08);border-radius:99px;margin:4px 0"></div>`;
|
||
|
||
case 'callout':
|
||
return renderCalloutEditor(b);
|
||
|
||
case 'video':
|
||
return renderVideoEditor(b);
|
||
|
||
case 'table':
|
||
return renderTableEditor(b);
|
||
|
||
case 'flashcard':
|
||
return renderFlashcardEditor(b);
|
||
|
||
case 'sim':
|
||
return renderSimEditor(b);
|
||
|
||
case 'matching':
|
||
return renderMatchingEditor(b);
|
||
|
||
case 'fill-blank':
|
||
return renderFillBlankEditor(b);
|
||
|
||
case 'ordering':
|
||
return renderOrderingEditor(b);
|
||
|
||
case 'accordion':
|
||
return renderAccordionEditor(b);
|
||
|
||
case 'timeline':
|
||
return renderTimelineEditor(b);
|
||
|
||
case 'diagram':
|
||
return renderDiagramEditor(b);
|
||
|
||
case 'geogebra':
|
||
return renderGeoGebraEditor(b);
|
||
|
||
case 'audio':
|
||
return renderAudioEditor(b);
|
||
|
||
case 'columns':
|
||
return renderColumnsEditor(b);
|
||
|
||
case 'alert':
|
||
return renderAlertEditor(b);
|
||
|
||
case 'quote':
|
||
return renderQuoteEditor(b);
|
||
|
||
case 'checklist':
|
||
return renderChecklistEditor(b);
|
||
|
||
case 'button':
|
||
return renderButtonEditor(b);
|
||
|
||
default: return '<span style="color:var(--text-3);font-size:0.82rem">Неизвестный тип блока</span>';
|
||
}
|
||
}
|
||
|
||
/* ── quiz ── */
|
||
function renderQuizEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
const opts = Array.isArray(d.options) ? d.options : ['', ''];
|
||
const isMulti = !!d.multi;
|
||
const correctSet = isMulti
|
||
? new Set(Array.isArray(d.correctIndices) ? d.correctIndices : [d.correctIndex ?? 0])
|
||
: null;
|
||
return `<div id="quiz-body-${bid}">
|
||
<div class="block-field">
|
||
<div class="block-row-label">Вопрос</div>
|
||
<input class="block-input" type="text" placeholder="Что является…" value="${escAttr(d.question||'')}"
|
||
oninput="updateBlockData('${bid}','question',this.value);markDirty()" />
|
||
</div>
|
||
<label class="quiz-mode-toggle">
|
||
<input type="checkbox" ${isMulti ? 'checked' : ''}
|
||
onchange="toggleQuizMode('${bid}',this.checked)">
|
||
Несколько верных ответов
|
||
</label>
|
||
<div class="block-row-label">Варианты ответа (${isMulti ? '<svg class="ic" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="9 11 12 14 22 4"/></svg>=верные' : '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor" stroke="none"/></svg>=верный'})</div>
|
||
<div id="quiz-opts-${bid}">
|
||
${opts.map((o, i) => renderQuizOptRow(bid, i, o, isMulti ? correctSet.has(i) : i === (d.correctIndex ?? 0), isMulti)).join('')}
|
||
</div>
|
||
<button class="btn-add-opt" onclick="addQuizOption('${bid}')">+ добавить вариант</button>
|
||
<div class="quiz-explanation">
|
||
<div class="block-row-label">Объяснение (необязательно)</div>
|
||
<textarea class="block-textarea" rows="2"
|
||
placeholder="Правильный ответ, потому что…"
|
||
oninput="autoResize(this);updateBlockData('${bid}','explanation',this.value);markDirty();scheduleAutoSave()"
|
||
>${esc(d.explanation||'')}</textarea>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderQuizOptRow(bid, i, val, isCorrect, isMulti) {
|
||
if (isMulti) {
|
||
return `<div class="quiz-option-row" id="qrow-${bid}-${i}">
|
||
<input type="checkbox" class="quiz-opt-radio"
|
||
${isCorrect ? 'checked' : ''}
|
||
onchange="toggleQuizCorrect('${bid}',${i},this.checked)" />
|
||
<input class="block-input quiz-opt-input" type="text" placeholder="Вариант ${i+1}" value="${escAttr(val)}"
|
||
oninput="updateQuizOption('${bid}',${i},this.value)" />
|
||
<button class="block-action-btn danger quiz-opt-del" onclick="removeQuizOption('${bid}',${i})">
|
||
<i data-lucide="x" style="width:12px;height:12px"></i>
|
||
</button>
|
||
</div>`;
|
||
}
|
||
return `<div class="quiz-option-row" id="qrow-${bid}-${i}">
|
||
<input type="radio" class="quiz-opt-radio" name="correct-${bid}"
|
||
${isCorrect ? 'checked' : ''}
|
||
onchange="updateBlockData('${bid}','correctIndex',${i});markDirty()" />
|
||
<input class="block-input quiz-opt-input" type="text" placeholder="Вариант ${i+1}" value="${escAttr(val)}"
|
||
oninput="updateQuizOption('${bid}',${i},this.value)" />
|
||
<button class="block-action-btn danger quiz-opt-del" onclick="removeQuizOption('${bid}',${i})">
|
||
<i data-lucide="x" style="width:12px;height:12px"></i>
|
||
</button>
|
||
</div>`;
|
||
}
|
||
|
||
function addQuizOption(bid) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
if (!Array.isArray(b.data.options)) b.data.options = [];
|
||
b.data.options.push('');
|
||
const qb = document.getElementById('quiz-body-' + bid);
|
||
if (qb) qb.outerHTML = renderQuizEditor(b);
|
||
else renderBlocks();
|
||
lucide.createIcons();
|
||
markDirty();
|
||
}
|
||
|
||
function removeQuizOption(bid, i) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b || !Array.isArray(b.data.options) || b.data.options.length <= 2) return;
|
||
b.data.options.splice(i, 1);
|
||
if (b.data.multi) {
|
||
b.data.correctIndices = (b.data.correctIndices || [])
|
||
.filter(ci => ci !== i).map(ci => ci > i ? ci - 1 : ci);
|
||
if (!b.data.correctIndices.length) b.data.correctIndices = [0];
|
||
} else {
|
||
if (b.data.correctIndex >= b.data.options.length)
|
||
b.data.correctIndex = b.data.options.length - 1;
|
||
}
|
||
const qb = document.getElementById('quiz-body-' + bid);
|
||
if (qb) qb.outerHTML = renderQuizEditor(b);
|
||
else renderBlocks();
|
||
lucide.createIcons();
|
||
markDirty();
|
||
}
|
||
|
||
function updateQuizOption(bid, i, val) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b || !Array.isArray(b.data.options)) return;
|
||
b.data.options[i] = val;
|
||
markDirty();
|
||
scheduleAutoSave();
|
||
}
|
||
|
||
function toggleQuizMode(bid, isMulti) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
b.data.multi = isMulti;
|
||
if (isMulti) {
|
||
b.data.correctIndices = [b.data.correctIndex ?? 0];
|
||
} else {
|
||
b.data.correctIndex = (b.data.correctIndices || [])[0] ?? 0;
|
||
delete b.data.correctIndices;
|
||
}
|
||
const qb = document.getElementById('quiz-body-' + bid);
|
||
if (qb) qb.outerHTML = renderQuizEditor(b);
|
||
else renderBlocks();
|
||
lucide.createIcons();
|
||
markDirty();
|
||
}
|
||
|
||
function toggleQuizCorrect(bid, i, checked) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
if (!Array.isArray(b.data.correctIndices)) b.data.correctIndices = [];
|
||
if (checked) {
|
||
if (!b.data.correctIndices.includes(i)) b.data.correctIndices.push(i);
|
||
} else {
|
||
b.data.correctIndices = b.data.correctIndices.filter(ci => ci !== i);
|
||
if (!b.data.correctIndices.length) b.data.correctIndices = [i]; // keep at least one
|
||
}
|
||
markDirty();
|
||
scheduleAutoSave();
|
||
}
|
||
|
||
/* ── callout ── */
|
||
const CALLOUT_META = {
|
||
info: { emoji: LS.icon('lightbulb',16), label: 'Info', cls: 'cs-info' },
|
||
warning: { emoji: LS.icon('warning',16), label: 'Warning', cls: 'cs-warning' },
|
||
success: { emoji: LS.icon('check-circle',16), label: 'Success', cls: 'cs-success' },
|
||
error: { emoji: LS.icon('x-close',16), label: 'Error', cls: 'cs-error' },
|
||
};
|
||
|
||
function renderCalloutEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
const style = d.style || 'info';
|
||
const btns = Object.entries(CALLOUT_META).map(([key, m]) =>
|
||
`<button class="callout-style-btn ${m.cls}${key === style ? ' active' : ''}"
|
||
onclick="setCalloutStyle('${bid}','${key}')">${m.emoji} ${m.label}</button>`
|
||
).join('');
|
||
return `<div>
|
||
<div class="callout-style-btns">${btns}</div>
|
||
<input class="block-input" type="text"
|
||
placeholder="Заголовок (необязательно)…"
|
||
value="${esc(d.title||'')}"
|
||
oninput="updateBlockData('${bid}','title',this.value);markDirty();scheduleAutoSave()">
|
||
${renderMiniRich(bid, 'text', d.text||'', 'Текст выноски…')}
|
||
</div>`;
|
||
}
|
||
|
||
function setCalloutStyle(bid, style) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
b.data.style = style;
|
||
// re-render just this block body
|
||
const card = document.querySelector(`.block-card[data-bid="${bid}"]`);
|
||
if (card) {
|
||
card.querySelector('.block-body').innerHTML = renderCalloutEditor(b);
|
||
}
|
||
markDirty();
|
||
}
|
||
|
||
/* ── video ── */
|
||
function renderVideoEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
const embedUrl = getEmbedUrl(d.url || '');
|
||
const isFile = d.url && !d.url.startsWith('http') || (d.url && d.url.startsWith('blob:'));
|
||
const showPlayer = d.url && !getEmbedUrl(d.url) && (d.url.startsWith('/') || d.url.startsWith('blob:'));
|
||
return `<div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">URL видео (YouTube, Rutube или прямая ссылка)</div>
|
||
<input class="block-input" id="vurl-${bid}" type="text" placeholder="https://youtube.com/watch?v=… или rutube.ru/video/…" value="${escAttr(d.url||'')}"
|
||
oninput="updateBlockData('${bid}','url',this.value);updateVideoPreview('${bid}',this.value,null);markDirty()" />
|
||
</div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Или загрузить видеофайл (MP4, WebM, OGG)</div>
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<input type="file" id="vfile-${bid}" accept="video/mp4,video/webm,video/ogg,.mp4,.webm,.ogg"
|
||
style="display:none" onchange="uploadVideoFile('${bid}',this)" />
|
||
<button class="btn-add-item" onclick="document.getElementById('vfile-${bid}').click()">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="display:inline-block;vertical-align:middle;margin-right:4px"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>Загрузить файл
|
||
</button>
|
||
<span id="vfile-name-${bid}" style="font-size:0.78rem;color:var(--text-3)"></span>
|
||
</div>
|
||
</div>
|
||
<div class="block-row">
|
||
<div class="block-field" style="flex:1">
|
||
<div class="block-row-label">Подпись</div>
|
||
<input class="block-input" type="text" placeholder="Название видео" value="${escAttr(d.caption||'')}"
|
||
oninput="updateBlockData('${bid}','caption',this.value);markDirty()" />
|
||
</div>
|
||
<div class="block-field" style="width:130px;flex-shrink:0">
|
||
<div class="block-row-label">Старт (сек)</div>
|
||
<input class="block-input" type="number" min="0" placeholder="0" value="${escAttr(d.startSec!=null?String(d.startSec):'')}"
|
||
oninput="updateBlockData('${bid}','startSec',this.value?parseInt(this.value):null);updateVideoPreview('${bid}',document.getElementById('vurl-${bid}').value,this.value?parseInt(this.value):null);markDirty()" />
|
||
</div>
|
||
</div>
|
||
<div id="video-preview-${bid}">
|
||
${embedUrl ? `<div class="video-preview"><iframe src="${escAttr(embedUrl)}" allowfullscreen></iframe></div>` : (d.url && !embedUrl ? `<video controls src="${escAttr(d.url)}" style="width:100%;border-radius:10px;margin-top:8px"></video>` : '')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function uploadVideoFile(bid, input) {
|
||
const file = input.files[0];
|
||
if (!file) return;
|
||
const nameEl = document.getElementById('vfile-name-' + bid);
|
||
if (nameEl) nameEl.textContent = file.name;
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
const btn = input.previousElementSibling;
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Загрузка…'; }
|
||
fetch('/api/files/upload', { method: 'POST', body: formData })
|
||
.then(r => r.json())
|
||
.then(res => {
|
||
if (res.url) {
|
||
updateBlockData(bid, 'url', res.url);
|
||
const urlInput = document.getElementById('vurl-' + bid);
|
||
if (urlInput) urlInput.value = res.url;
|
||
const preview = document.getElementById('video-preview-' + bid);
|
||
if (preview) preview.innerHTML = `<video controls src="${res.url}" style="width:100%;border-radius:10px;margin-top:8px"></video>`;
|
||
markDirty();
|
||
LS.toast('Видео загружено', 'success');
|
||
} else {
|
||
LS.toast(res.error || 'Ошибка загрузки', 'error');
|
||
}
|
||
})
|
||
.catch(() => LS.toast('Ошибка загрузки видео', 'error'))
|
||
.finally(() => { if (btn) { btn.disabled = false; btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="display:inline-block;vertical-align:middle;margin-right:4px"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>Загрузить файл'; } });
|
||
}
|
||
|
||
function getEmbedUrl(url) {
|
||
if (!url) return '';
|
||
// already embed
|
||
if (url.includes('youtube.com/embed/') || url.includes('player.vimeo.com') ||
|
||
url.includes('rutube.ru/play/embed/')) return url;
|
||
// youtube watch (with optional timestamp t= / start=)
|
||
const m = url.match(/[?&]v=([^&]+)/);
|
||
if (m) {
|
||
const ts = url.match(/[?&](?:t|start)=([^&]+)/);
|
||
const base = 'https://www.youtube.com/embed/' + m[1];
|
||
return ts ? base + '?start=' + parseInt(ts[1]) : base;
|
||
}
|
||
// youtu.be short
|
||
const m2 = url.match(/youtu\.be\/([^?]+)/);
|
||
if (m2) {
|
||
const ts = url.match(/[?&]t=([^&]+)/);
|
||
const base = 'https://www.youtube.com/embed/' + m2[1];
|
||
return ts ? base + '?start=' + parseInt(ts[1]) : base;
|
||
}
|
||
// rutube.ru/video/ID/
|
||
const mr = url.match(/rutube\.ru\/video\/([a-f0-9]+)/i);
|
||
if (mr) {
|
||
const ts = url.match(/[?&]t=([^&]+)/);
|
||
const base = 'https://rutube.ru/play/embed/' + mr[1];
|
||
return ts ? base + '?t=' + parseInt(ts[1]) : base;
|
||
}
|
||
return url;
|
||
}
|
||
|
||
function updateVideoPreview(bid, url, startSec) {
|
||
const el = document.getElementById('video-preview-' + bid);
|
||
if (!el) return;
|
||
let embedUrl = getEmbedUrl(url);
|
||
// apply startSec override if provided and URL has no t= already
|
||
if (embedUrl && startSec != null && startSec > 0) {
|
||
const sep = embedUrl.includes('?') ? '&' : '?';
|
||
const param = embedUrl.includes('rutube') ? 't' : 'start';
|
||
if (!embedUrl.includes('start=') && !embedUrl.match(/[?&]t=/))
|
||
embedUrl += sep + param + '=' + startSec;
|
||
}
|
||
el.innerHTML = embedUrl
|
||
? `<div class="video-preview"><iframe src="${escAttr(embedUrl)}" allowfullscreen></iframe></div>`
|
||
: '';
|
||
}
|
||
|
||
/* ── table ── */
|
||
function renderTableEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
const rows = Array.isArray(d.rows) && d.rows.length ? d.rows : [['',''],['','']];
|
||
const hasHeaders = d.headers !== false;
|
||
|
||
let tableHtml = `<table data-bid="${bid}">`;
|
||
rows.forEach((row, ri) => {
|
||
tableHtml += '<tr>';
|
||
row.forEach((cell, ci) => {
|
||
const tag = (ri === 0 && hasHeaders) ? 'th' : 'td';
|
||
tableHtml += `<${tag} contenteditable="true" data-row="${ri}" data-col="${ci}">${esc(String(cell||''))}</${tag}>`;
|
||
});
|
||
tableHtml += '</tr>';
|
||
});
|
||
tableHtml += '</table>';
|
||
|
||
return `<div>
|
||
<div class="table-editor">${tableHtml}</div>
|
||
<div class="table-btns">
|
||
<label style="display:flex;align-items:center;gap:5px;font-size:0.78rem;font-weight:700;color:#6B7A8E;cursor:pointer">
|
||
<input type="checkbox" ${hasHeaders?'checked':''} onchange="updateBlockData('${bid}','headers',this.checked);rerenderBlock('${bid}');markDirty()" />
|
||
Заголовки
|
||
</label>
|
||
<button class="table-btn" onclick="tableAddRow('${bid}')">+ строка</button>
|
||
<button class="table-btn" onclick="tableRemoveRow('${bid}')">− строка</button>
|
||
<button class="table-btn" onclick="tableAddCol('${bid}')">+ столбец</button>
|
||
<button class="table-btn" onclick="tableRemoveCol('${bid}')">− столбец</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function syncTableData(bid, table) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
const rows = [];
|
||
table.querySelectorAll('tr').forEach(tr => {
|
||
const row = [];
|
||
tr.querySelectorAll('td,th').forEach(cell => row.push(cell.textContent));
|
||
rows.push(row);
|
||
});
|
||
b.data.rows = rows;
|
||
}
|
||
|
||
function tableAddRow(bid) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
const cols = b.data.rows[0] ? b.data.rows[0].length : 2;
|
||
b.data.rows.push(Array(cols).fill(''));
|
||
rerenderBlock(bid); markDirty();
|
||
}
|
||
|
||
function tableRemoveRow(bid) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b || b.data.rows.length <= 1) return;
|
||
b.data.rows.pop();
|
||
rerenderBlock(bid); markDirty();
|
||
}
|
||
|
||
function tableAddCol(bid) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
b.data.rows = b.data.rows.map(r => [...r, '']);
|
||
rerenderBlock(bid); markDirty();
|
||
}
|
||
|
||
function tableRemoveCol(bid) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
if (b.data.rows[0] && b.data.rows[0].length <= 1) return;
|
||
b.data.rows = b.data.rows.map(r => r.slice(0, -1));
|
||
rerenderBlock(bid); markDirty();
|
||
}
|
||
|
||
/* ── flashcard ── */
|
||
function renderFlashcardEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
return `<div class="flashcard-editor">
|
||
<div class="flashcard-side">
|
||
<div class="flashcard-side-label">Лицевая сторона</div>
|
||
${renderMiniRich(bid, 'front', d.front||'', 'Вопрос / понятие…')}
|
||
</div>
|
||
<div class="flashcard-side">
|
||
<div class="flashcard-side-label"><svg class="ic" viewBox="0 0 24 24"><polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg> Обратная сторона</div>
|
||
${renderMiniRich(bid, 'back', d.back||'', 'Ответ / определение…')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* ── sim ── */
|
||
// Каталог симуляций (кэш) — чтобы выбирать из списка, а не вписывать id руками.
|
||
let _simCatalog = null, _simCatalogLoading = false;
|
||
const SIM_SUBJ_RU = { phys:'Физика', chem:'Химия', bio:'Биология', math:'Математика', game:'Игры' };
|
||
function loadSimCatalog(cb) {
|
||
if (_simCatalog) { cb(_simCatalog); return; }
|
||
if (_simCatalogLoading) { setTimeout(() => loadSimCatalog(cb), 200); return; }
|
||
_simCatalogLoading = true;
|
||
LS.api('/api/lab/sims')
|
||
.then(r => { _simCatalog = (r && r.sims) || []; _simCatalogLoading = false; cb(_simCatalog); })
|
||
.catch(() => { _simCatalog = []; _simCatalogLoading = false; cb(_simCatalog); });
|
||
}
|
||
function simOptionsHtml(selected) {
|
||
const list = _simCatalog || [];
|
||
const bySubj = {};
|
||
for (const s of list) (bySubj[s.subject] = bySubj[s.subject] || []).push(s);
|
||
let html = '<option value="">— выберите симуляцию —</option>';
|
||
const known = new Set(list.map(s => s.id));
|
||
if (selected && !known.has(selected)) {
|
||
html += `<option value="${escAttr(selected)}" selected>${escAttr(selected)} (не найдена)</option>`;
|
||
}
|
||
for (const subj of Object.keys(bySubj)) {
|
||
html += `<optgroup label="${escAttr(SIM_SUBJ_RU[subj] || subj)}">`;
|
||
for (const s of bySubj[subj]) {
|
||
html += `<option value="${escAttr(s.id)}"${s.id === selected ? ' selected' : ''}>${escAttr(s.title || s.id)}</option>`;
|
||
}
|
||
html += '</optgroup>';
|
||
}
|
||
return html;
|
||
}
|
||
function renderSimEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
// подгрузить каталог и заполнить select уже после вставки в DOM
|
||
loadSimCatalog(() => { const sel = document.getElementById('sim-select-' + bid); if (sel) sel.innerHTML = simOptionsHtml(d.simId || ''); });
|
||
const initial = _simCatalog
|
||
? simOptionsHtml(d.simId || '')
|
||
: `<option value="${escAttr(d.simId || '')}" selected>${d.simId ? escAttr(d.simId) : 'Загрузка…'}</option>`;
|
||
return `<div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Симуляция</div>
|
||
<select class="block-input" id="sim-select-${bid}"
|
||
onchange="updateBlockData('${bid}','simId',this.value);updateSimPreview('${bid}',this.value);markDirty()">${initial}</select>
|
||
</div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Подпись</div>
|
||
<input class="block-input" type="text" placeholder="Описание симуляции" value="${escAttr(d.caption||'')}"
|
||
oninput="updateBlockData('${bid}','caption',this.value);markDirty()" />
|
||
</div>
|
||
<div id="sim-preview-${bid}">
|
||
${d.simId ? `<div class="sim-preview">${LS.icon('atom',14)} Симуляция: <strong>${escAttr(d.simId)}</strong></div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function updateSimPreview(bid, simId) {
|
||
const el = document.getElementById('sim-preview-' + bid);
|
||
if (!el) return;
|
||
let label = simId;
|
||
if (_simCatalog) { const s = _simCatalog.find(x => x.id === simId); if (s) label = s.title || simId; }
|
||
el.innerHTML = simId
|
||
? `<div class="sim-preview">${LS.icon('atom',14)} Симуляция: <strong>${escAttr(label)}</strong></div>`
|
||
: '';
|
||
}
|
||
|
||
/* ── matching editor ── */
|
||
function renderMatchingEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
const pairs = Array.isArray(d.pairs) ? d.pairs : [{left:'', right:''}];
|
||
return `<div id="matching-body-${bid}">
|
||
<div class="block-field">
|
||
<div class="block-row-label">Вопрос</div>
|
||
<input class="block-input" type="text" placeholder="Сопоставьте элементы…" value="${escAttr(d.question||'')}"
|
||
oninput="updateBlockData('${bid}','question',this.value);markDirty()" />
|
||
</div>
|
||
<div class="block-row-label" style="margin-top:6px">Пары (левая часть <svg class="ic" viewBox="0 0 24 24"><line x1="3" y1="12" x2="21" y2="12"/><polyline points="8 17 3 12 8 7"/><polyline points="16 7 21 12 16 17"/></svg> правая часть)</div>
|
||
<div id="matching-pairs-${bid}">
|
||
${pairs.map((p, i) => `<div class="matching-pair-row">
|
||
<input class="block-input" type="text" placeholder="Левая ${i+1}" value="${escAttr(p.left||'')}"
|
||
oninput="updateMatchingPair('${bid}',${i},'left',this.value)" />
|
||
<span style="color:var(--text-3);font-weight:700;flex-shrink:0"><svg class="ic" viewBox="0 0 24 24"><line x1="3" y1="12" x2="21" y2="12"/><polyline points="8 17 3 12 8 7"/><polyline points="16 7 21 12 16 17"/></svg></span>
|
||
<input class="block-input" type="text" placeholder="Правая ${i+1}" value="${escAttr(p.right||'')}"
|
||
oninput="updateMatchingPair('${bid}',${i},'right',this.value)" />
|
||
<button class="block-action-btn danger" onclick="removeMatchingPair('${bid}',${i})" title="Удалить пару">
|
||
<i data-lucide="x" style="width:12px;height:12px"></i>
|
||
</button>
|
||
</div>`).join('')}
|
||
</div>
|
||
<button class="btn-add-opt" onclick="addMatchingPair('${bid}')">+ добавить пару</button>
|
||
</div>`;
|
||
}
|
||
|
||
function updateMatchingPair(bid, i, side, val) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b || !Array.isArray(b.data.pairs)) return;
|
||
b.data.pairs[i][side] = val;
|
||
markDirty(); scheduleAutoSave();
|
||
}
|
||
|
||
function addMatchingPair(bid) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
if (!Array.isArray(b.data.pairs)) b.data.pairs = [];
|
||
b.data.pairs.push({left:'', right:''});
|
||
rerenderBlock(bid);
|
||
markDirty();
|
||
}
|
||
|
||
function removeMatchingPair(bid, i) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b || !Array.isArray(b.data.pairs) || b.data.pairs.length <= 1) return;
|
||
b.data.pairs.splice(i, 1);
|
||
rerenderBlock(bid);
|
||
markDirty();
|
||
}
|
||
|
||
/* ── fill-blank editor ── */
|
||
function renderFillBlankEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
const preview = (d.text||'').replace(/\{([^}]+)\}/g, '<span style="border-bottom:2px solid var(--violet);padding:0 12px;color:var(--violet);font-weight:700">______</span>');
|
||
return `<div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Текст с пропусками</div>
|
||
<div style="font-size:0.74rem;color:var(--text-3);margin-bottom:6px">Оберните ответы в фигурные скобки: <code style="background:rgba(155,93,229,0.1);padding:1px 5px;border-radius:4px;font-size:0.74rem">{ответ}</code></div>
|
||
<button class="btn-add-opt" style="margin-bottom:6px"
|
||
onmousedown="event.preventDefault()"
|
||
onclick="wrapFillBlank('${bid}')">
|
||
<i data-lucide="braces" style="width:12px;height:12px;vertical-align:middle"></i>
|
||
{ } Сделать пропуском
|
||
</button>
|
||
<textarea class="block-textarea" rows="3" id="fb-text-${bid}"
|
||
placeholder="Столица Франции — {Париж}."
|
||
oninput="autoResize(this);updateBlockData('${bid}','text',this.value);updateFillBlankPreview('${bid}');markDirty();scheduleAutoSave()"
|
||
>${esc(d.text||'')}</textarea>
|
||
</div>
|
||
<div class="block-field" style="margin-top:8px">
|
||
<div class="block-row-label">Предпросмотр</div>
|
||
<div class="fill-blank-preview" id="fbp-${bid}">${preview}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function wrapFillBlank(bid) {
|
||
const ta = document.getElementById('fb-text-' + bid);
|
||
if (!ta) return;
|
||
const start = ta.selectionStart, end = ta.selectionEnd;
|
||
if (start === end) { LS.toast('Выделите слово или фразу, чтобы сделать пропуском', 'info'); return; }
|
||
const val = ta.value;
|
||
ta.value = val.slice(0, start) + '{' + val.slice(start, end) + '}' + val.slice(end);
|
||
ta.selectionStart = start;
|
||
ta.selectionEnd = end + 2;
|
||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||
ta.focus();
|
||
}
|
||
|
||
function updateFillBlankPreview(bid) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
const el = document.getElementById('fbp-' + bid);
|
||
if (!el || !b) return;
|
||
el.innerHTML = (b.data.text||'').replace(/\{([^}]+)\}/g, '<span style="border-bottom:2px solid var(--violet);padding:0 12px;color:var(--violet);font-weight:700">______</span>');
|
||
}
|
||
|
||
/* ── ordering editor ── */
|
||
function renderOrderingEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
const items = Array.isArray(d.items) ? d.items : ['Шаг 1','Шаг 2','Шаг 3'];
|
||
return `<div id="ordering-body-${bid}">
|
||
<div class="block-field">
|
||
<div class="block-row-label">Вопрос</div>
|
||
<input class="block-input" type="text" placeholder="Расставьте в правильном порядке…" value="${escAttr(d.question||'')}"
|
||
oninput="updateBlockData('${bid}','question',this.value);markDirty()" />
|
||
</div>
|
||
<div class="block-row-label" style="margin-top:6px">Элементы (текущий порядок = правильный)</div>
|
||
<div class="ordering-hint" style="font-size:0.74rem;color:var(--text-3);margin-bottom:6px">Элементы будут перемешаны для ученика.</div>
|
||
<div id="ordering-items-${bid}">
|
||
${items.map((item, i) => `<div class="ordering-item-row">
|
||
<span style="color:var(--text-3);font-weight:700;font-size:0.8rem;width:22px;text-align:center;flex-shrink:0">${i+1}</span>
|
||
<input class="block-input" type="text" placeholder="Элемент ${i+1}" value="${escAttr(item||'')}"
|
||
oninput="updateOrderingItem('${bid}',${i},this.value)" />
|
||
<button class="block-action-btn danger" onclick="removeOrderingItem('${bid}',${i})" title="Удалить">
|
||
<i data-lucide="x" style="width:12px;height:12px"></i>
|
||
</button>
|
||
</div>`).join('')}
|
||
</div>
|
||
<button class="btn-add-opt" onclick="addOrderingItem('${bid}')">+ добавить элемент</button>
|
||
</div>`;
|
||
}
|
||
|
||
function updateOrderingItem(bid, i, val) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b || !Array.isArray(b.data.items)) return;
|
||
b.data.items[i] = val;
|
||
markDirty(); scheduleAutoSave();
|
||
}
|
||
|
||
function addOrderingItem(bid) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
if (!Array.isArray(b.data.items)) b.data.items = [];
|
||
b.data.items.push('');
|
||
rerenderBlock(bid);
|
||
markDirty();
|
||
}
|
||
|
||
function removeOrderingItem(bid, i) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b || !Array.isArray(b.data.items) || b.data.items.length <= 2) return;
|
||
b.data.items.splice(i, 1);
|
||
rerenderBlock(bid);
|
||
markDirty();
|
||
}
|
||
|
||
/* ── accordion editor ── */
|
||
function renderAccordionEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
return `<div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Заголовок (видимый)</div>
|
||
<input class="block-input" type="text" placeholder="Нажмите, чтобы раскрыть…" value="${escAttr(d.title||'')}"
|
||
oninput="updateBlockData('${bid}','title',this.value);markDirty();scheduleAutoSave()" />
|
||
</div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Скрытое содержимое</div>
|
||
${renderMiniRich(bid, 'content', d.content||'', 'Текст, который появится при раскрытии…')}
|
||
</div>
|
||
<div class="accordion-preview">
|
||
<div class="accordion-preview-header">${esc(d.title||'Подробнее…')} <svg class="ic" viewBox="0 0 24 24"><polyline points="2 9 12 21 22 9"/></svg></div>
|
||
<div class="accordion-preview-body">${d.content||'…'}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* ── timeline editor ── */
|
||
function renderTimelineEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
const items = Array.isArray(d.items) ? d.items : [{ date:'', title:'', text:'' }];
|
||
return `<div id="timeline-body-${bid}">
|
||
<div id="timeline-items-${bid}">
|
||
${items.map((it, i) => `<div class="timeline-item-row">
|
||
<div class="timeline-dot"></div>
|
||
<div class="timeline-fields">
|
||
<input class="block-input" type="text" placeholder="Дата / период" value="${escAttr(it.date||'')}"
|
||
style="max-width:200px;font-weight:700"
|
||
oninput="updateTimelineItem('${bid}',${i},'date',this.value)" />
|
||
<input class="block-input" type="text" placeholder="Событие" value="${escAttr(it.title||'')}"
|
||
oninput="updateTimelineItem('${bid}',${i},'title',this.value)" />
|
||
<input class="block-input" type="text" placeholder="Описание (необязательно)" value="${escAttr(it.text||'')}"
|
||
style="font-size:0.82rem;color:#6B7A8E"
|
||
oninput="updateTimelineItem('${bid}',${i},'text',this.value)" />
|
||
</div>
|
||
<button class="block-action-btn danger" onclick="removeTimelineItem('${bid}',${i})" title="Удалить">
|
||
<i data-lucide="x" style="width:12px;height:12px"></i>
|
||
</button>
|
||
</div>`).join('')}
|
||
</div>
|
||
<button class="btn-add-opt" onclick="addTimelineItem('${bid}')">+ добавить событие</button>
|
||
</div>`;
|
||
}
|
||
|
||
function updateTimelineItem(bid, i, key, val) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b || !Array.isArray(b.data.items)) return;
|
||
b.data.items[i][key] = val;
|
||
markDirty(); scheduleAutoSave();
|
||
}
|
||
function addTimelineItem(bid) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
if (!Array.isArray(b.data.items)) b.data.items = [];
|
||
b.data.items.push({ date:'', title:'', text:'' });
|
||
rerenderBlock(bid); markDirty();
|
||
}
|
||
function removeTimelineItem(bid, i) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b || !Array.isArray(b.data.items) || b.data.items.length <= 1) return;
|
||
b.data.items.splice(i, 1);
|
||
rerenderBlock(bid); markDirty();
|
||
}
|
||
|
||
/* ── diagram editor (Mermaid) ── */
|
||
function renderDiagramEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
return `<div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Mermaid-код диаграммы</div>
|
||
<div style="font-size:0.74rem;color:var(--text-3);margin-bottom:6px">
|
||
Синтаксис: <a href="https://mermaid.js.org/syntax/flowchart.html" target="_blank" style="color:var(--violet)">mermaid.js.org</a>
|
||
</div>
|
||
<textarea class="block-textarea" rows="6" style="font-family:monospace;font-size:0.84rem"
|
||
placeholder="graph TD\n A --> B"
|
||
oninput="autoResize(this);updateBlockData('${bid}','code',this.value);updateDiagramPreview('${bid}');markDirty();scheduleAutoSave()"
|
||
>${esc(d.code||'')}</textarea>
|
||
</div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Подпись</div>
|
||
<input class="block-input" type="text" placeholder="Блок-схема алгоритма" value="${escAttr(d.caption||'')}"
|
||
oninput="updateBlockData('${bid}','caption',this.value);markDirty()" />
|
||
</div>
|
||
<div class="diagram-preview" id="diagram-preview-${bid}">
|
||
<div class="mermaid">${esc(d.code||'')}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function updateDiagramPreview(bid) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
const el = document.getElementById('diagram-preview-' + bid);
|
||
if (!el || !b) return;
|
||
el.innerHTML = '<div class="mermaid">' + esc(b.data.code || '') + '</div>';
|
||
if (window.mermaid) {
|
||
try { mermaid.run({ nodes: el.querySelectorAll('.mermaid') }); } catch {}
|
||
}
|
||
}
|
||
|
||
/* ── geogebra editor ── */
|
||
function renderGeoGebraEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
return `<div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">ID материала GeoGebra</div>
|
||
<div style="font-size:0.74rem;color:var(--text-3);margin-bottom:6px">
|
||
Введите ID из URL: geogebra.org/m/<b>abc123</b>
|
||
</div>
|
||
<input class="block-input" type="text" placeholder="abc123" value="${escAttr(d.materialId||'')}"
|
||
oninput="updateBlockData('${bid}','materialId',this.value);updateGeoGebraPreview('${bid}');markDirty()" />
|
||
</div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Подпись</div>
|
||
<input class="block-input" type="text" placeholder="Интерактивная модель" value="${escAttr(d.caption||'')}"
|
||
oninput="updateBlockData('${bid}','caption',this.value);markDirty()" />
|
||
</div>
|
||
<div id="geogebra-preview-${bid}">
|
||
${d.materialId ? `<div class="geogebra-preview"><iframe src="https://www.geogebra.org/material/iframe/id/${escAttr(d.materialId)}/width/800/height/500/border/888888/sfsb/true/smb/false/stb/false/stbh/false/ai/false/asb/false/sri/false/rc/false/ld/false/sdz/false/ctl/false" allowfullscreen></iframe></div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function updateGeoGebraPreview(bid) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
const el = document.getElementById('geogebra-preview-' + bid);
|
||
if (!el || !b) return;
|
||
const mid = b.data.materialId || '';
|
||
el.innerHTML = mid
|
||
? `<div class="geogebra-preview"><iframe src="https://www.geogebra.org/material/iframe/id/${escAttr(mid)}/width/800/height/500/border/888888/sfsb/true/smb/false/stb/false/stbh/false/ai/false/asb/false/sri/false/rc/false/ld/false/sdz/false/ctl/false" allowfullscreen></iframe></div>`
|
||
: '';
|
||
}
|
||
|
||
/* ── audio editor ── */
|
||
function renderAudioEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
return `<div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">URL аудиофайла</div>
|
||
<input class="block-input" type="text" placeholder="https://… (.mp3, .ogg, .wav)" value="${escAttr(d.url||'')}"
|
||
oninput="updateBlockData('${bid}','url',this.value);updateAudioPreview('${bid}');markDirty()" />
|
||
</div>
|
||
<div class="img-upload-row">
|
||
<input type="file" id="audio-file-${bid}" accept="audio/*" style="display:none"
|
||
onchange="uploadAudio('${bid}')" />
|
||
<button class="img-upload-btn" onclick="document.getElementById('audio-file-${bid}').click()">
|
||
<i data-lucide="upload" style="width:14px;height:14px"></i> Загрузить аудио
|
||
</button>
|
||
<span class="img-upload-progress" id="audio-status-${bid}"></span>
|
||
</div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Подпись</div>
|
||
<input class="block-input" type="text" placeholder="Название аудио" value="${escAttr(d.caption||'')}"
|
||
oninput="updateBlockData('${bid}','caption',this.value);markDirty()" />
|
||
</div>
|
||
<div class="audio-preview" id="audio-preview-${bid}">
|
||
${d.url ? `<audio controls src="${escAttr(d.url)}"></audio>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function updateAudioPreview(bid) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
const el = document.getElementById('audio-preview-' + bid);
|
||
if (!el || !b) return;
|
||
el.innerHTML = b.data.url ? `<audio controls src="${escAttr(b.data.url)}"></audio>` : '';
|
||
}
|
||
|
||
async function uploadAudio(bid) {
|
||
const input = document.getElementById('audio-file-' + bid);
|
||
if (!input || !input.files.length) return;
|
||
const file = input.files[0];
|
||
if (!file.type.startsWith('audio/')) { LS.toast('Выберите аудиофайл', 'error'); return; }
|
||
if (file.size > 50 * 1024 * 1024) { LS.toast('Максимум 50 МБ', 'error'); return; }
|
||
|
||
const statusEl = document.getElementById('audio-status-' + bid);
|
||
if (statusEl) statusEl.textContent = 'Загрузка…';
|
||
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
try {
|
||
const res = await LS.uploadFile(fd);
|
||
const url = LS.downloadFileUrl(res.id);
|
||
updateBlockData(bid, 'url', url);
|
||
rerenderBlock(bid);
|
||
markDirty();
|
||
} catch (e) {
|
||
if (statusEl) statusEl.textContent = 'Ошибка: ' + (e.message || '');
|
||
}
|
||
}
|
||
|
||
/* ── columns editor ── */
|
||
function renderColumnsEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
const cols = Array.isArray(d.cols) ? d.cols : [{ content: '' }, { content: '' }];
|
||
return `<div>
|
||
<div class="block-field" style="display:flex;gap:8px;align-items:center;margin-bottom:10px">
|
||
<div class="block-row-label" style="margin-bottom:0">Колонок:</div>
|
||
<select class="block-input" style="width:auto" onchange="updateColumnCount('${bid}',+this.value);markDirty()">
|
||
<option value="2" ${cols.length===2?'selected':''}>2</option>
|
||
<option value="3" ${cols.length===3?'selected':''}>3</option>
|
||
</select>
|
||
</div>
|
||
<div class="columns-editor-grid" style="display:grid;grid-template-columns:repeat(${cols.length},1fr);gap:10px">
|
||
${cols.map((c, i) => `<div class="columns-editor-col">
|
||
<div class="block-row-label" style="font-size:0.7rem">Колонка ${i+1}</div>
|
||
${renderMiniRich(bid, 'col:' + i, c.content||'', 'Текст, формулы, HTML…')}
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function updateColumnCount(bid, count) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
const cols = Array.isArray(b.data.cols) ? b.data.cols : [];
|
||
while (cols.length < count) cols.push({ content: '' });
|
||
while (cols.length > count) cols.pop();
|
||
b.data.cols = cols;
|
||
rerenderBlock(bid);
|
||
}
|
||
|
||
function updateColumnContent(bid, idx, val) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
if (!Array.isArray(b.data.cols)) b.data.cols = [{ content: '' }, { content: '' }];
|
||
if (b.data.cols[idx]) b.data.cols[idx].content = val;
|
||
}
|
||
|
||
/* ── alert/banner editor ── */
|
||
function renderAlertEditor(b) {
|
||
const d = b.data;
|
||
const bid = b._id;
|
||
const styles = [
|
||
{ val: 'exam', label: 'К экзамену', icon: '<svg class="ic" viewBox="0 0 24 24"><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.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>' },
|
||
{ val: 'homework', label: 'Домашнее задание', icon: '<svg class="ic" viewBox="0 0 24 24"><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" ry="1"/></svg>' },
|
||
{ val: 'important', label: 'Важно', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>️' },
|
||
{ val: 'tip', label: 'Совет', icon: '<svg class="ic" viewBox="0 0 24 24"><line x1="9" y1="18" x2="15" y2="18"/><line x1="10" y1="22" x2="14" y2="22"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>' },
|
||
{ val: 'celebrate', label: 'Поздравление', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z"/></svg>' },
|
||
];
|
||
return `<div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Стиль</div>
|
||
<select class="block-input" onchange="updateBlockData('${bid}','style',this.value);rerenderBlock('${bid}');markDirty()">
|
||
${styles.map(s => `<option value="${s.val}" ${d.style===s.val?'selected':''}>${s.icon} ${s.label}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Текст баннера</div>
|
||
<textarea class="block-input" rows="2" placeholder="Текст сообщения…" style="resize:vertical"
|
||
oninput="updateBlockData('${bid}','text',this.value);markDirty()">${esc(d.text||'')}</textarea>
|
||
</div>
|
||
<div class="alert-editor-preview" style="margin-top:10px">${renderAlertPreviewHTML(d)}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderAlertPreviewHTML(d) {
|
||
const map = {
|
||
exam: { bg: 'linear-gradient(135deg,#9B5DE5 0%,#6366F1 100%)', icon: '<svg class="ic" viewBox="0 0 24 24"><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.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>', label: 'К экзамену' },
|
||
homework: { bg: 'linear-gradient(135deg,#06B6D4 0%,#0EA5E9 100%)', icon: '<svg class="ic" viewBox="0 0 24 24"><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" ry="1"/></svg>', label: 'Домашнее задание' },
|
||
important: { bg: 'linear-gradient(135deg,#EF476F 0%,#F97316 100%)', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>️', label: 'Важно' },
|
||
tip: { bg: 'linear-gradient(135deg,#06D6A0 0%,#22C55E 100%)', icon: '<svg class="ic" viewBox="0 0 24 24"><line x1="9" y1="18" x2="15" y2="18"/><line x1="10" y1="22" x2="14" y2="22"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>', label: 'Совет' },
|
||
celebrate: { bg: 'linear-gradient(135deg,#FFD166 0%,#F59E0B 100%)', icon: '<svg class="ic" viewBox="0 0 24 24"><path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z"/></svg>', label: 'Поздравление' },
|
||
};
|
||
const s = map[d.style] || map.important;
|
||
return `<div style="background:${s.bg};border-radius:14px;padding:16px 20px;color:#fff;font-weight:700;font-size:0.88rem;display:flex;align-items:center;gap:10px">
|
||
<span style="font-size:1.3rem">${s.icon}</span>
|
||
<div><div style="font-size:0.68rem;text-transform:uppercase;letter-spacing:0.06em;opacity:0.8;margin-bottom:2px">${s.label}</div><div>${esc(d.text||'Текст баннера…')}</div></div>
|
||
</div>`;
|
||
}
|
||
|
||
/* ── rich text helpers ── */
|
||
function wrapCode(bid) {
|
||
const sel = window.getSelection();
|
||
if (!sel || sel.isCollapsed) return;
|
||
const range = sel.getRangeAt(0);
|
||
const code = document.createElement('code');
|
||
range.surroundContents(code);
|
||
sel.removeAllRanges();
|
||
// update data
|
||
const el = document.querySelector(`.rich-editor[data-bid="${bid}"]`);
|
||
if (el) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (b) { b.data.html = el.innerHTML; markDirty(); }
|
||
}
|
||
}
|
||
|
||
/* ── C1: quote editor ── */
|
||
function renderQuoteEditor(b) {
|
||
const d = b.data, bid = b._id;
|
||
return `<div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Текст цитаты</div>
|
||
${renderMiniRich(bid, 'text', d.text||'', 'Текст цитаты…')}
|
||
</div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Автор (необязательно)</div>
|
||
<input class="block-input" type="text" placeholder="Имя автора…" value="${escAttr(d.author||'')}"
|
||
oninput="updateBlockData('${bid}','author',this.value);markDirty();scheduleAutoSave()" />
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* ── C2: checklist editor ── */
|
||
function renderChecklistEditor(b) {
|
||
const d = b.data, bid = b._id;
|
||
const items = Array.isArray(d.items) ? d.items : [];
|
||
const rows = items.map((it, i) => `
|
||
<div class="checklist-item-row" id="citem-${bid}-${i}">
|
||
<input type="checkbox" ${it.checked ? 'checked' : ''}
|
||
onchange="toggleChecklistItem('${bid}',${i},this.checked)" />
|
||
<input class="block-input" style="flex:1" type="text" placeholder="Пункт…" value="${escAttr(it.text||'')}"
|
||
oninput="updateChecklistItemText('${bid}',${i},this.value)" />
|
||
<button class="block-action-btn" onclick="removeChecklistItem('${bid}',${i})" title="Удалить">
|
||
<i data-lucide="x" style="width:13px;height:13px"></i>
|
||
</button>
|
||
</div>`).join('');
|
||
return `<div id="cl-body-${bid}">
|
||
${rows}
|
||
<button class="btn-add-item" onclick="addChecklistItem('${bid}')">
|
||
<i data-lucide="plus" style="width:13px;height:13px;display:inline-block;vertical-align:middle;margin-right:4px"></i>Добавить пункт
|
||
</button>
|
||
</div>`;
|
||
}
|
||
|
||
function addChecklistItem(bid) {
|
||
const b = blocks.find(x => x._id === bid); if (!b) return;
|
||
if (!Array.isArray(b.data.items)) b.data.items = [];
|
||
b.data.items.push({ text: '', checked: false });
|
||
rerenderBlock(bid); lucide.createIcons(); markDirty();
|
||
}
|
||
function removeChecklistItem(bid, i) {
|
||
const b = blocks.find(x => x._id === bid); if (!b) return;
|
||
b.data.items.splice(i, 1);
|
||
rerenderBlock(bid); lucide.createIcons(); markDirty();
|
||
}
|
||
function updateChecklistItemText(bid, i, val) {
|
||
const b = blocks.find(x => x._id === bid); if (!b) return;
|
||
if (b.data.items[i]) b.data.items[i].text = val;
|
||
markDirty(); scheduleAutoSave();
|
||
}
|
||
function toggleChecklistItem(bid, i, checked) {
|
||
const b = blocks.find(x => x._id === bid); if (!b) return;
|
||
if (b.data.items[i]) b.data.items[i].checked = checked;
|
||
markDirty(); scheduleAutoSave();
|
||
}
|
||
|
||
/* ── C3: button/CTA editor ── */
|
||
function renderButtonEditor(b) {
|
||
const d = b.data, bid = b._id;
|
||
const styles = [
|
||
{ val:'primary', label:'Primary' },
|
||
{ val:'secondary', label:'Secondary' },
|
||
{ val:'outline', label:'Outline' },
|
||
];
|
||
const aligns = [
|
||
{ val:'left', icon:'align-left' },
|
||
{ val:'center', icon:'align-center' },
|
||
{ val:'right', icon:'align-right' },
|
||
];
|
||
const styleBtns = styles.map(s => `<button class="align-btn${d.style===s.val?' active':''}" onclick="updateBlockData('${bid}','style','${s.val}');markDirty();rerenderBlock('${bid}')">${s.label}</button>`).join('');
|
||
const alignBtns = aligns.map(a => `<button class="align-btn${(d.align||'center')===a.val?' active':''}" onclick="updateBlockData('${bid}','align','${a.val}');markDirty();rerenderBlock('${bid}')" title="${a.val}"><i data-lucide="${a.icon}" style="width:13px;height:13px"></i></button>`).join('');
|
||
return `<div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Текст кнопки</div>
|
||
<input class="block-input" type="text" placeholder="Нажмите здесь" value="${escAttr(d.label||'')}"
|
||
oninput="updateBlockData('${bid}','label',this.value);markDirty();scheduleAutoSave()" />
|
||
</div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">URL (необязательно)</div>
|
||
<input class="block-input" type="text" placeholder="https://…" value="${escAttr(d.url||'')}"
|
||
oninput="updateBlockData('${bid}','url',this.value);markDirty();scheduleAutoSave()" />
|
||
</div>
|
||
<div class="block-row" style="gap:16px">
|
||
<div class="block-field">
|
||
<div class="block-row-label">Стиль</div>
|
||
<div class="align-btns">${styleBtns}</div>
|
||
</div>
|
||
<div class="block-field">
|
||
<div class="block-row-label">Выравнивание</div>
|
||
<div class="align-btns">${alignBtns}</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* ── C5: outline panel ── */
|
||
function updateOutline() {
|
||
const list = document.getElementById('outline-list');
|
||
if (!list) return;
|
||
const headings = blocks.filter(b => b.type === 'heading');
|
||
if (!headings.length) {
|
||
list.innerHTML = '<div class="outline-empty">Нет заголовков</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = headings.map(b => {
|
||
const lvl = b.data.level || 2;
|
||
const txt = b.data.text || '(без текста)';
|
||
return `<button class="outline-item${lvl>=3?' h3':''}" onclick="scrollToBlock('${b._id}')" title="${escAttr(txt)}">${esc(txt)}</button>`;
|
||
}).join('');
|
||
}
|
||
|
||
function scrollToBlock(bid) {
|
||
const card = document.querySelector(`.block-card[data-bid="${bid}"]`);
|
||
if (card) {
|
||
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
// On mobile, close the outline panel after navigating
|
||
if (window.innerWidth <= 768) toggleOutline();
|
||
}
|
||
}
|
||
|
||
function toggleOutline() {
|
||
const panel = document.getElementById('outline-panel');
|
||
const btn = document.getElementById('btn-outline');
|
||
if (!panel) return;
|
||
const open = panel.classList.toggle('open');
|
||
if (btn) btn.style.color = open ? 'var(--violet)' : '';
|
||
if (open) updateOutline();
|
||
}
|
||
|
||
function insertLink() {
|
||
const url = prompt('Введите URL ссылки:');
|
||
if (!url) return;
|
||
document.execCommand('createLink', false, url);
|
||
// make links open in new tab
|
||
document.querySelectorAll('.rich-editor a, .mini-rich-editor a').forEach(a => a.target = '_blank');
|
||
}
|
||
|
||
/* ── collapse / expand block ── */
|
||
function toggleBlockCollapse(bid) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
b._collapsed = !b._collapsed;
|
||
const card = document.querySelector(`.block-card[data-bid="${bid}"]`);
|
||
if (!card) return;
|
||
card.classList.toggle('collapsed', b._collapsed);
|
||
const colBtn = card.querySelector('.block-collapse-btn');
|
||
if (colBtn) colBtn.title = b._collapsed ? 'Развернуть' : 'Свернуть';
|
||
}
|
||
|
||
/* ── rerender single block ── */
|
||
function rerenderBlock(bid) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
const card = document.querySelector(`.block-card[data-bid="${bid}"]`);
|
||
if (!card) { renderBlocks(); return; }
|
||
card.querySelector('.block-body').innerHTML = renderBlockEditor(b);
|
||
// re-wire table if needed
|
||
const table = card.querySelector('.table-editor table');
|
||
if (table) {
|
||
table.querySelectorAll('td,th').forEach(cell => {
|
||
cell.addEventListener('input', () => syncTableData(bid, table));
|
||
cell.addEventListener('blur', () => { syncTableData(bid, table); markDirty(); scheduleAutoSave(); });
|
||
});
|
||
}
|
||
// re-wire rich editor
|
||
const richEl = card.querySelector('.rich-editor');
|
||
if (richEl) {
|
||
const toolbar = richEl.previousElementSibling;
|
||
richEl.addEventListener('focus', () => { if (toolbar) toolbar.classList.add('visible'); });
|
||
richEl.addEventListener('blur', () => {
|
||
setTimeout(() => {
|
||
if (!toolbar || !toolbar.contains(document.activeElement)) toolbar && toolbar.classList.remove('visible');
|
||
}, 150);
|
||
const bk = blocks.find(x => x._id === bid);
|
||
if (bk) { bk.data.html = richEl.innerHTML; markDirty(); scheduleAutoSave(); }
|
||
});
|
||
richEl.addEventListener('input', () => {
|
||
const bk = blocks.find(x => x._id === bid);
|
||
if (bk) { bk.data.html = richEl.innerHTML; markDirty(); scheduleAutoSave(); }
|
||
});
|
||
}
|
||
// re-wire mini rich editors
|
||
card.querySelectorAll('.mini-rich-editor').forEach(el => {
|
||
const ibid = el.dataset.bid;
|
||
const key = el.dataset.key;
|
||
const syncMini = () => {
|
||
const bk = blocks.find(x => x._id === ibid);
|
||
if (!bk) return;
|
||
if (key.startsWith('col:')) {
|
||
const ci = parseInt(key.slice(4), 10);
|
||
if (Array.isArray(bk.data.cols)) bk.data.cols[ci].content = el.innerHTML;
|
||
} else {
|
||
bk.data[key] = el.innerHTML;
|
||
}
|
||
};
|
||
el.addEventListener('input', () => { syncMini(); markDirty(); scheduleAutoSave(); });
|
||
el.addEventListener('blur', () => { syncMini(); markDirty(); scheduleAutoSave(); });
|
||
});
|
||
lucide.createIcons();
|
||
}
|
||
|
||
/* ── update block data field ── */
|
||
function updateBlockData(bid, key, val) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
b.data[key] = val;
|
||
}
|
||
|
||
/* ── word count / read time ── */
|
||
function updateWordCount() {
|
||
const el = document.getElementById('word-count-display');
|
||
if (!el) return;
|
||
const stripTags = s => s.replace(/<[^>]*>/g, ' ');
|
||
const countWords = s => (s.match(/\S+/g) || []).length;
|
||
let total = 0;
|
||
const titleEl = document.getElementById('lesson-title');
|
||
if (titleEl) total += countWords(titleEl.value || '');
|
||
for (const b of blocks) {
|
||
const d = b.data;
|
||
switch (b.type) {
|
||
case 'heading': total += countWords(d.text || ''); break;
|
||
case 'text': total += countWords(stripTags(d.html || d.text || '')); break;
|
||
case 'callout': total += countWords((d.title || '') + ' ' + (d.text || '')); break;
|
||
case 'quiz': total += countWords((d.question || '') + ' ' + (d.options || []).join(' ') + ' ' + (d.explanation || '')); break;
|
||
case 'fill-blank':total += countWords(d.text || ''); break;
|
||
case 'accordion': (d.items || []).forEach(it => { total += countWords((it.title || '') + ' ' + (it.body || '')); }); break;
|
||
case 'timeline': (d.items || []).forEach(it => { total += countWords((it.date || '') + ' ' + (it.text || '')); }); break;
|
||
case 'table': (d.rows || []).forEach(row => row.forEach(c => { total += countWords(String(c || '')); })); break;
|
||
}
|
||
}
|
||
const mins = Math.max(1, Math.round(total / 200));
|
||
el.textContent = `${total} сл · ~${mins} мин`;
|
||
}
|
||
|
||
/* ── palette search ── */
|
||
function filterPalette(q) {
|
||
const qn = q.trim().toLowerCase();
|
||
document.querySelectorAll('.palette-section').forEach(sec => {
|
||
let anyVisible = false;
|
||
sec.querySelectorAll('.palette-btn').forEach(btn => {
|
||
const match = !qn || btn.textContent.trim().toLowerCase().includes(qn);
|
||
btn.style.display = match ? '' : 'none';
|
||
if (match) anyVisible = true;
|
||
});
|
||
sec.style.display = anyVisible ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
/* ── formula preview ── */
|
||
function updateFormulaPreview(bid) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
const el = document.getElementById('fp-' + bid);
|
||
if (!el || !b) return;
|
||
el.textContent = '$$' + (b.data.tex || '') + '$$';
|
||
renderKatexIn(el);
|
||
}
|
||
|
||
/* ── save ── */
|
||
async function saveLesson(auto) {
|
||
if (_saving) return;
|
||
_saving = true;
|
||
const btn = document.getElementById('btn-save');
|
||
btn.disabled = true;
|
||
if (!auto) setStatus('Сохраняю…');
|
||
|
||
// sync any active rich editors
|
||
document.querySelectorAll('.rich-editor').forEach(el => {
|
||
const bid = el.dataset.bid;
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (b) b.data.html = el.innerHTML;
|
||
});
|
||
document.querySelectorAll('.mini-rich-editor').forEach(el => {
|
||
const bid = el.dataset.bid;
|
||
const key = el.dataset.key;
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
if (key.startsWith('col:')) {
|
||
const ci = parseInt(key.slice(4), 10);
|
||
if (Array.isArray(b.data.cols)) b.data.cols[ci].content = el.innerHTML;
|
||
} else {
|
||
b.data[key] = el.innerHTML;
|
||
}
|
||
});
|
||
|
||
// update title
|
||
const titleEl = document.getElementById('lesson-title');
|
||
const newTitle = titleEl.value.trim() || lesson.title;
|
||
|
||
try {
|
||
// 1. update title if changed
|
||
if (newTitle !== lesson.title) {
|
||
await LS.api('/api/lessons/' + lessonId, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title: newTitle }),
|
||
});
|
||
lesson.title = newTitle;
|
||
document.getElementById('etb-title').textContent = newTitle;
|
||
}
|
||
|
||
// 2. save blocks
|
||
const payload = blocks.map((b, i) => ({
|
||
orderIndex: i,
|
||
type: b.type,
|
||
data: b.data,
|
||
}));
|
||
await LS.api('/api/lessons/' + lessonId + '/blocks', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ blocks: payload }),
|
||
});
|
||
|
||
_dirty = false;
|
||
setStatus('Сохранено', true);
|
||
} catch (e) {
|
||
setStatus('Ошибка сохранения');
|
||
if (!auto) LS.toast(e.message || 'Ошибка сохранения', 'error');
|
||
} finally {
|
||
_saving = false;
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
/* ── publish toggle ── */
|
||
async function togglePublish() {
|
||
try {
|
||
const newPub = !lesson.isPublished;
|
||
await LS.api('/api/lessons/' + lessonId, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ isPublished: newPub }),
|
||
});
|
||
lesson.isPublished = newPub;
|
||
const btn = document.getElementById('btn-pub');
|
||
btn.textContent = newPub ? 'Снять с публикации' : 'Опубликовать';
|
||
btn.classList.toggle('published', newPub);
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
/* ── publish all lessons in the course ── */
|
||
async function publishAllLessons() {
|
||
if (!lesson.courseId) return;
|
||
const btn = document.getElementById('btn-pub-all');
|
||
btn.disabled = true;
|
||
try {
|
||
const r = await LS.api('/api/courses/' + lesson.courseId + '/publish-all', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ publish: true }),
|
||
});
|
||
LS.toast(`Опубликовано уроков: ${r.lessonsUpdated}`, 'success');
|
||
// reflect current lesson as published
|
||
lesson.isPublished = true;
|
||
const pubBtn = document.getElementById('btn-pub');
|
||
pubBtn.textContent = 'Снять с публикации';
|
||
pubBtn.classList.add('published');
|
||
} catch (e) {
|
||
LS.toast(e.message || 'Ошибка публикации', 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
/* ── preview ── */
|
||
function goPreview() {
|
||
if (_dirty) {
|
||
saveLesson().then(() => window.open('/lesson?id=' + lessonId, '_blank'));
|
||
} else {
|
||
window.open('/lesson?id=' + lessonId, '_blank');
|
||
}
|
||
}
|
||
|
||
/* ── Export PDF ── */
|
||
function exportPDF() {
|
||
if (_dirty) {
|
||
saveLesson().then(() => window.open('/lesson?id=' + lessonId + '&print=1', '_blank'));
|
||
} else {
|
||
window.open('/lesson?id=' + lessonId + '&print=1', '_blank');
|
||
}
|
||
}
|
||
|
||
/* ── keyboard shortcuts ── */
|
||
document.addEventListener('keydown', e => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveLesson(); }
|
||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') { e.preventDefault(); undo(); }
|
||
if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redo(); }
|
||
});
|
||
|
||
/* ── beforeunload ── */
|
||
window.addEventListener('beforeunload', e => {
|
||
if (_dirty) { e.preventDefault(); e.returnValue = ''; }
|
||
});
|
||
|
||
/* ── helpers ── */
|
||
function autoResize(el) {
|
||
el.style.height = 'auto';
|
||
el.style.height = el.scrollHeight + 'px';
|
||
}
|
||
function escAttr(s) { return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); }
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
FEATURE: Drag from palette
|
||
══════════════════════════════════════════════════════════════════ */
|
||
(function initPaletteDrag() {
|
||
// Extract block type from onclick="addBlock('xxx')"
|
||
document.querySelectorAll('.palette-btn').forEach(btn => {
|
||
const m = btn.getAttribute('onclick')?.match(/addBlock\('([^']+)'\)/);
|
||
if (!m) return;
|
||
const type = m[1];
|
||
btn.setAttribute('draggable', 'true');
|
||
btn.addEventListener('dragstart', e => {
|
||
e.dataTransfer.setData('application/block-type', type);
|
||
e.dataTransfer.effectAllowed = 'copy';
|
||
btn.classList.add('dragging');
|
||
});
|
||
btn.addEventListener('dragend', () => btn.classList.remove('dragging'));
|
||
});
|
||
|
||
// Canvas accepts drops from palette
|
||
const canvas = document.querySelector('.editor-canvas');
|
||
canvas.addEventListener('dragover', e => {
|
||
if (e.dataTransfer.types.includes('application/block-type')) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'copy';
|
||
}
|
||
});
|
||
canvas.addEventListener('drop', e => {
|
||
const type = e.dataTransfer.getData('application/block-type');
|
||
if (!type) return;
|
||
e.preventDefault();
|
||
// Find the block closest to drop point
|
||
const cards = [...document.querySelectorAll('.block-card')];
|
||
let afterId = null;
|
||
for (const card of cards) {
|
||
const rect = card.getBoundingClientRect();
|
||
if (e.clientY > rect.top + rect.height / 2) afterId = card.dataset.bid;
|
||
}
|
||
addBlock(type, null, afterId);
|
||
});
|
||
})();
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
FEATURE: Image upload
|
||
══════════════════════════════════════════════════════════════════ */
|
||
async function uploadImage(bid) {
|
||
const input = document.getElementById('img-file-' + bid);
|
||
if (!input || !input.files.length) return;
|
||
const file = input.files[0];
|
||
if (!file.type.startsWith('image/')) { LS.toast('Выберите изображение', 'error'); return; }
|
||
if (file.size > 10 * 1024 * 1024) { LS.toast('Максимум 10 МБ', 'error'); return; }
|
||
|
||
const statusEl = document.getElementById('img-status-' + bid);
|
||
if (statusEl) statusEl.textContent = 'Загрузка…';
|
||
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
try {
|
||
const res = await LS.uploadFile(fd);
|
||
const url = LS.downloadFileUrl(res.id);
|
||
updateBlockData(bid, 'url', url);
|
||
rerenderBlock(bid);
|
||
markDirty();
|
||
} catch (e) {
|
||
if (statusEl) statusEl.textContent = 'Ошибка: ' + (e.message || '');
|
||
}
|
||
}
|
||
|
||
function genBlockImage(bid) {
|
||
if (!LS.imagePromptModal) { LS.toast('Модуль генерации не загружен'); return; }
|
||
LS.imagePromptModal({
|
||
title: 'Изображение для урока',
|
||
onUse: function (url) {
|
||
updateBlockData(bid, 'url', url);
|
||
rerenderBlock(bid);
|
||
markDirty();
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
FEATURE: Code syntax highlighting
|
||
══════════════════════════════════════════════════════════════════ */
|
||
function highlightCodeBlock(bid) {
|
||
if (!window._hljsLoaded || !window.hljs) return;
|
||
const el = document.getElementById('code-preview-' + bid);
|
||
if (!el) return;
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
const codeEl = el.querySelector('code');
|
||
if (!codeEl) return;
|
||
codeEl.textContent = b.data.code || '';
|
||
if (b.data.lang) codeEl.className = 'language-' + b.data.lang;
|
||
hljs.highlightElement(codeEl);
|
||
}
|
||
|
||
function updateCodePreview(bid) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b) return;
|
||
const el = document.getElementById('code-preview-' + bid);
|
||
if (!el) return;
|
||
const codeEl = el.querySelector('code');
|
||
if (!codeEl) return;
|
||
codeEl.textContent = b.data.code || '';
|
||
if (b.data.lang) codeEl.className = 'language-' + b.data.lang;
|
||
else codeEl.className = '';
|
||
if (window.hljs) hljs.highlightElement(codeEl);
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
FEATURE: Inline preview mode
|
||
══════════════════════════════════════════════════════════════════ */
|
||
let _previewMode = false;
|
||
|
||
function togglePreviewMode() {
|
||
_previewMode = !_previewMode;
|
||
const btn = document.getElementById('btn-preview-mode');
|
||
const canvas = document.querySelector('.editor-canvas');
|
||
const palette = document.querySelector('.block-palette');
|
||
btn.classList.toggle('active', _previewMode);
|
||
|
||
if (_previewMode) {
|
||
// Sync rich editors before preview
|
||
document.querySelectorAll('.rich-editor').forEach(el => {
|
||
const bid = el.dataset.bid;
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (b) b.data.html = el.innerHTML;
|
||
});
|
||
canvas.classList.add('preview-mode');
|
||
palette.style.display = 'none';
|
||
renderPreview();
|
||
} else {
|
||
canvas.classList.remove('preview-mode');
|
||
palette.style.display = '';
|
||
renderBlocks();
|
||
}
|
||
}
|
||
|
||
function renderPreview() {
|
||
const container = document.getElementById('blocks-container');
|
||
container.innerHTML = blocks.map(b => renderPreviewBlock(b)).join('');
|
||
// KaTeX (формула-блоки + формулы внутри ячеек таблиц)
|
||
container.querySelectorAll('.pv-formula, .pv-table').forEach(el => renderKatexIn(el));
|
||
// Highlight.js
|
||
if (window.hljs) {
|
||
container.querySelectorAll('pre code').forEach(el => hljs.highlightElement(el));
|
||
}
|
||
// Mermaid
|
||
if (window.mermaid) {
|
||
try { mermaid.run({ nodes: container.querySelectorAll('.mermaid') }); } catch {}
|
||
}
|
||
lucide.createIcons();
|
||
}
|
||
|
||
function renderPreviewBlock(b) {
|
||
const d = b.data;
|
||
switch (b.type) {
|
||
case 'heading':
|
||
return `<div class="preview-block"><h${d.level||2}>${esc(d.text||'')}</h${d.level||2}></div>`;
|
||
case 'text':
|
||
return `<div class="preview-block"><p>${d.html || esc(d.text||'')}</p></div>`;
|
||
case 'formula':
|
||
return `<div class="preview-block"><div class="pv-formula">${d.label ? '<div style="font-size:0.78rem;font-weight:700;color:var(--violet);margin-bottom:4px">'+esc(d.label)+'</div>' : ''}$$${esc(d.tex||'')}$$</div></div>`;
|
||
case 'image': {
|
||
const ia = d.align || 'center';
|
||
return `<div class="preview-block"><div class="pv-img-wrap align-${escAttr(ia)}">${d.url ? `<img class="pv-image" src="${escAttr(d.url)}" alt="${escAttr(d.alt||'')}" />` : ''}</div>${d.caption ? `<div class="pv-image-caption">${esc(d.caption)}</div>` : ''}</div>`;
|
||
}
|
||
case 'svg-draw': {
|
||
const safeSvg = (window.SvgSanitize ? SvgSanitize.clean(d.svg || '') : '').replace('<svg ', '<svg style="max-width:100%;height:auto;display:block;margin:0 auto" ');
|
||
return `<div class="preview-block"><div class="pv-svg">${safeSvg}</div>${d.caption ? `<div class="pv-image-caption">${esc(d.caption)}</div>` : ''}</div>`;
|
||
}
|
||
case 'divider':
|
||
return `<div class="preview-block"><div class="pv-divider"></div></div>`;
|
||
case 'code':
|
||
return `<div class="preview-block pv-code"><pre><code class="${d.lang ? 'language-'+escAttr(d.lang) : ''}">${esc(d.code||'')}</code></pre></div>`;
|
||
case 'callout':
|
||
return `<div class="preview-block"><div class="pv-callout pv-${d.style||'info'}">${d.title?`<strong>${esc(d.title)}</strong><br>`:''}${d.text||''}</div></div>`;
|
||
case 'video': {
|
||
const embed = getEmbedUrl(d.url||'');
|
||
return `<div class="preview-block">${embed ? `<div style="aspect-ratio:16/9;border-radius:12px;overflow:hidden"><iframe src="${escAttr(embed)}" style="width:100%;height:100%;border:none" allowfullscreen></iframe></div>` : ''}${d.caption ? `<div style="font-size:0.82rem;color:var(--text-3);margin-top:6px">${esc(d.caption)}</div>` : ''}</div>`;
|
||
}
|
||
case 'table': {
|
||
const rows = Array.isArray(d.rows) ? d.rows : [];
|
||
const hasH = d.headers !== false;
|
||
let html = '<div class="preview-block pv-table"><table>';
|
||
rows.forEach((r, ri) => {
|
||
html += '<tr>';
|
||
r.forEach(c => { const t = (ri===0&&hasH)?'th':'td'; html += `<${t}>${esc(String(c||''))}</${t}>`; });
|
||
html += '</tr>';
|
||
});
|
||
return html + '</table></div>';
|
||
}
|
||
case 'quiz': {
|
||
const opts = Array.isArray(d.options) ? d.options : [];
|
||
const ci = d.correctIndex ?? 0;
|
||
const correctJson = d.multi
|
||
? escAttr(JSON.stringify(Array.isArray(d.correctIndices) ? d.correctIndices : [ci]))
|
||
: '';
|
||
const btns = opts.map((o, i) => {
|
||
const txt = esc(typeof o === 'object' ? (o.text||'') : o);
|
||
if (d.multi) return `<button class="pv-quiz-opt-btn" onclick="pvToggle(this)">${txt}</button>`;
|
||
return `<button class="pv-quiz-opt-btn" onclick="pvAnswer(this,${i},${ci})">${txt}</button>`;
|
||
}).join('');
|
||
const checkBtn = d.multi ? `<button class="pv-quiz-check-btn" onclick="pvCheck(this)">Проверить</button>` : '';
|
||
return `<div class="preview-block"><div class="pv-quiz"><div class="pv-quiz-q">${esc(d.question||'')}</div><div class="pv-quiz-opts"${d.multi?` data-correct="${correctJson}" data-multi="1"`:''}>` +
|
||
btns + checkBtn + `</div>${d.explanation?`<div class="pv-quiz-expl">${esc(d.explanation)}</div>`:''}</div></div>`;
|
||
}
|
||
case 'flashcard':
|
||
return `<div class="preview-block"><div class="pv-flip-wrap" onclick="this.querySelector('.pv-fc-card').classList.toggle('flipped')"><div class="pv-fc-card"><div class="pv-fc-front"><div class="pv-fc-label">Вопрос</div><div class="pv-fc-text">${d.front||''}</div><div class="pv-fc-hint">Нажмите, чтобы увидеть ответ <svg class="ic" viewBox="0 0 24 24"><polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg></div></div><div class="pv-fc-back"><div class="pv-fc-label">Ответ</div><div class="pv-fc-text">${d.back||''}</div><div class="pv-fc-hint">Нажмите, чтобы вернуться <svg class="ic" viewBox="0 0 24 24"><polyline points="9 14 4 9 9 4"/><path d="M20 20v-7a4 4 0 0 0-4-4H4"/></svg></div></div></div></div></div>`;
|
||
case 'sim':
|
||
return `<div class="preview-block"><div class="sim-preview">${LS.icon('atom',14)} Симуляция: <strong>${esc(d.simId||'')}</strong></div></div>`;
|
||
case 'matching': {
|
||
const pairs = Array.isArray(d.pairs) ? d.pairs : [];
|
||
return `<div class="preview-block"><div class="pv-quiz"><div class="pv-quiz-q">${esc(d.question||'')}</div>${pairs.map(p => `<div class="pv-quiz-opt">${esc(p.left||'')} <svg class="ic" viewBox="0 0 24 24"><line x1="3" y1="12" x2="21" y2="12"/><polyline points="8 17 3 12 8 7"/><polyline points="16 7 21 12 16 17"/></svg> ${esc(p.right||'')}</div>`).join('')}</div></div>`;
|
||
}
|
||
case 'fill-blank':
|
||
return `<div class="preview-block"><div class="pv-quiz">${(d.text||'').replace(/\{([^}]+)\}/g, '<span style="border-bottom:2px solid var(--violet);padding:0 12px;color:var(--violet);font-weight:700">______</span>')}</div></div>`;
|
||
case 'ordering': {
|
||
const items = Array.isArray(d.items) ? d.items : [];
|
||
return `<div class="preview-block"><div class="pv-quiz"><div class="pv-quiz-q">${esc(d.question||'')}</div>${items.map((it,i) => `<div class="pv-quiz-opt">${i+1}. ${esc(it)}</div>`).join('')}</div></div>`;
|
||
}
|
||
case 'accordion':
|
||
return `<div class="preview-block"><div class="accordion-preview"><div class="accordion-preview-header" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==='none'?'':'none'">${esc(d.title||'Подробнее…')} <svg class="ic" viewBox="0 0 24 24"><polyline points="2 9 12 21 22 9"/></svg></div><div class="accordion-preview-body">${d.content||''}</div></div></div>`;
|
||
case 'timeline': {
|
||
const items = Array.isArray(d.items) ? d.items : [];
|
||
return `<div class="preview-block">${items.map(it => `<div style="display:flex;gap:12px;margin-bottom:14px"><div style="display:flex;flex-direction:column;align-items:center"><div style="width:12px;height:12px;border-radius:50%;background:#0EA5E9;flex-shrink:0"></div><div style="width:2px;flex:1;background:rgba(14,165,233,0.2)"></div></div><div><div style="font-size:0.78rem;font-weight:700;color:#0EA5E9">${esc(it.date||'')}</div><div style="font-weight:700;color:#0F172A">${esc(it.title||'')}</div>${it.text?`<div style="font-size:0.85rem;color:#6B7A8E;margin-top:2px">${esc(it.text)}</div>`:''}</div></div>`).join('')}</div>`;
|
||
}
|
||
case 'diagram':
|
||
return `<div class="preview-block"><div class="diagram-preview"><div class="mermaid">${esc(d.code||'')}</div></div>${d.caption?`<div style="text-align:center;font-size:0.82rem;color:var(--text-3);margin-top:6px">${esc(d.caption)}</div>`:''}</div>`;
|
||
case 'geogebra':
|
||
return `<div class="preview-block">${d.materialId?`<div class="geogebra-preview"><iframe src="https://www.geogebra.org/material/iframe/id/${escAttr(d.materialId)}/width/800/height/500/border/888888/sfsb/true/smb/false/stb/false/stbh/false/ai/false/asb/false/sri/false/rc/false/ld/false/sdz/false/ctl/false" allowfullscreen></iframe></div>`:'<div style="color:var(--text-3);text-align:center;padding:20px">Не указан ID материала GeoGebra</div>'}${d.caption?`<div style="text-align:center;font-size:0.82rem;color:var(--text-3);margin-top:6px">${esc(d.caption)}</div>`:''}</div>`;
|
||
case 'audio':
|
||
return `<div class="preview-block">${d.url?`<audio controls src="${escAttr(d.url)}" style="width:100%"></audio>`:''}${d.caption?`<div style="font-size:0.82rem;color:var(--text-3);margin-top:6px">${esc(d.caption)}</div>`:''}</div>`;
|
||
case 'columns': {
|
||
const cols = Array.isArray(d.cols) ? d.cols : [];
|
||
return `<div class="preview-block"><div style="display:grid;grid-template-columns:repeat(${cols.length},1fr);gap:16px">${cols.map(c => `<div style="font-size:0.88rem;line-height:1.7;color:#3D4F6B">${c.content||'<span style="color:#ccc">Пусто</span>'}</div>`).join('')}</div></div>`;
|
||
}
|
||
case 'alert':
|
||
return `<div class="preview-block">${renderAlertPreviewHTML(d)}</div>`;
|
||
case 'quote':
|
||
return `<div class="preview-block"><div class="pv-quote"><div class="pv-quote-text">${d.text||''}</div>${d.author?`<div class="pv-quote-author">— ${esc(d.author)}</div>`:''}</div></div>`;
|
||
case 'checklist': {
|
||
const items = Array.isArray(d.items) ? d.items : [];
|
||
const rows = items.map((it, i) => {
|
||
const checked = it.checked ? ' checked' : '';
|
||
const cls = it.checked ? ' class="pv-checklist-item checked"' : ' class="pv-checklist-item"';
|
||
return `<label${cls} style="cursor:pointer"><input type="checkbox" class="pv-checklist-cb"${checked} onchange="this.closest('label').classList.toggle('checked',this.checked)">${esc(it.text||'')}</label>`;
|
||
}).join('');
|
||
return `<div class="preview-block"><div class="pv-checklist">${rows}</div></div>`;
|
||
}
|
||
case 'button': {
|
||
const align = d.align || 'center';
|
||
const style = d.style || 'primary';
|
||
const label = esc(d.label || 'Кнопка');
|
||
const href = d.url ? `href="${escAttr(d.url)}" target="_blank" rel="noopener"` : 'href="#" onclick="return false"';
|
||
return `<div class="preview-block"><div class="pv-btn-cta-wrap align-${escAttr(align)}"><a ${href} class="pv-btn-cta style-${escAttr(style)}">${label}</a></div></div>`;
|
||
}
|
||
default: return '';
|
||
}
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
TEMPLATE: Save / Load lesson templates
|
||
══════════════════════════════════════════════════════════════════ */
|
||
|
||
// inject template modals
|
||
document.body.insertAdjacentHTML('beforeend', `
|
||
<!-- Save lesson as template modal -->
|
||
<div class="tpl-modal" id="save-tpl-modal" onclick="if(event.target===this)closeSaveTplModal()">
|
||
<div class="modal" style="max-width:420px">
|
||
<div class="modal-title">${LS.icon('bookmark-plus',18)} Сохранить как шаблон</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Название шаблона</label>
|
||
<input class="form-input" id="tpl-save-title" placeholder="Например: Урок-лекция" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Категория</label>
|
||
<select class="form-input" id="tpl-save-cat">
|
||
<option value="general">Общее</option>
|
||
<option value="lecture">Лекция</option>
|
||
<option value="practice">Практика</option>
|
||
<option value="lab">Лабораторная</option>
|
||
<option value="test">Контроль</option>
|
||
</select>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn-cancel" onclick="closeSaveTplModal()">Отмена</button>
|
||
<button class="btn-primary" id="btn-do-save-tpl" onclick="doSaveTpl()">Сохранить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Load lesson template modal -->
|
||
<div class="tpl-modal" id="load-tpl-modal" onclick="if(event.target===this)closeLoadTplModal()">
|
||
<div class="modal" style="max-width:640px;padding:28px">
|
||
<div class="modal-title">${LS.icon('folder-open',18)} Загрузить из шаблона</div>
|
||
<div id="tpl-list" style="max-height:420px;overflow-y:auto">
|
||
<div class="spinner"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`);
|
||
|
||
// inject styles
|
||
document.head.insertAdjacentHTML('beforeend', '<style>' +
|
||
'.tpl-modal{position:fixed;inset:0;background:rgba(15,23,42,0.4);backdrop-filter:blur(6px);z-index:200;display:none;align-items:center;justify-content:center;padding:20px;}' +
|
||
'.tpl-modal.open{display:flex;}' +
|
||
'.tpl-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;margin-top:12px;}' +
|
||
'.tpl-card{background:#f8f9fc;border:1.5px solid rgba(15,23,42,0.08);border-radius:14px;padding:14px 16px;cursor:pointer;transition:all .15s;}' +
|
||
'.tpl-card:hover{border-color:rgba(155,93,229,0.3);background:rgba(155,93,229,0.04);transform:translateY(-2px);box-shadow:0 4px 16px rgba(15,23,42,0.08);}' +
|
||
'.tpl-card-title{font-size:0.88rem;font-weight:700;color:#0F172A;margin-bottom:4px;display:flex;align-items:center;gap:6px;}' +
|
||
'.tpl-card-meta{font-size:0.72rem;color:var(--text-3);display:flex;align-items:center;gap:8px;}' +
|
||
'.tpl-card-cat{font-size:0.66rem;font-weight:700;padding:2px 8px;border-radius:99px;background:rgba(155,93,229,0.08);color:var(--violet);}' +
|
||
'.tpl-card-actions{display:flex;gap:4px;margin-top:8px;}' +
|
||
'.tpl-card-btn{padding:5px 12px;border:1.5px solid rgba(155,93,229,0.2);border-radius:99px;background:rgba(155,93,229,0.06);color:var(--violet);font-family:"Manrope",sans-serif;font-size:0.74rem;font-weight:700;cursor:pointer;transition:all .12s;}' +
|
||
'.tpl-card-btn:hover{background:var(--violet);color:#fff;}' +
|
||
'.tpl-card-btn.danger{border-color:rgba(241,91,181,0.2);background:rgba(241,91,181,0.06);color:#E0335E;}' +
|
||
'.tpl-card-btn.danger:hover{background:#E0335E;color:#fff;}' +
|
||
'.tpl-empty{text-align:center;padding:32px;color:var(--text-3);font-size:0.86rem;}' +
|
||
'</style>');
|
||
|
||
const CAT_LABELS = { general:'Общее', lecture:'Лекция', practice:'Практика', lab:'Лабораторная', test:'Контроль' };
|
||
|
||
function openSaveTplModal() {
|
||
document.getElementById('tpl-save-title').value = lesson?.title || '';
|
||
document.getElementById('save-tpl-modal').classList.add('open');
|
||
setTimeout(() => document.getElementById('tpl-save-title').focus(), 50);
|
||
}
|
||
function closeSaveTplModal() {
|
||
document.getElementById('save-tpl-modal').classList.remove('open');
|
||
}
|
||
|
||
async function doSaveTpl() {
|
||
const title = document.getElementById('tpl-save-title').value.trim();
|
||
if (!title) { document.getElementById('tpl-save-title').focus(); return; }
|
||
const btn = document.getElementById('btn-do-save-tpl');
|
||
btn.disabled = true;
|
||
try {
|
||
await LS.saveLessonTemplate({
|
||
title,
|
||
category: document.getElementById('tpl-save-cat').value,
|
||
lessonId: parseInt(lessonId),
|
||
});
|
||
closeSaveTplModal();
|
||
LS.toast('Шаблон сохранён', 'success');
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
finally { btn.disabled = false; }
|
||
}
|
||
|
||
function openLoadTplModal() {
|
||
document.getElementById('load-tpl-modal').classList.add('open');
|
||
loadTplList();
|
||
}
|
||
function closeLoadTplModal() {
|
||
document.getElementById('load-tpl-modal').classList.remove('open');
|
||
}
|
||
|
||
async function loadTplList() {
|
||
const wrap = document.getElementById('tpl-list');
|
||
wrap.innerHTML = '<div class="spinner"></div>';
|
||
try {
|
||
const list = await LS.getLessonTemplates();
|
||
if (!list.length) {
|
||
wrap.innerHTML = '<div class="tpl-empty">' + LS.icon('file-text', 32) + '<br>Нет доступных шаблонов</div>';
|
||
return;
|
||
}
|
||
wrap.innerHTML = '<div class="tpl-grid">' + list.map(t => {
|
||
const blockCount = Array.isArray(t.blocks) ? t.blocks.length : 0;
|
||
const canDelete = t.createdBy === user.id || user.role === 'admin';
|
||
return '<div class="tpl-card">' +
|
||
'<div class="tpl-card-title">' + LS.icon('file-text', 14) + ' ' + esc(t.title) + '</div>' +
|
||
'<div class="tpl-card-meta">' +
|
||
'<span class="tpl-card-cat">' + esc(CAT_LABELS[t.category] || t.category) + '</span>' +
|
||
'<span>' + blockCount + ' блоков</span>' +
|
||
'<span>' + esc(t.creatorName) + '</span>' +
|
||
'</div>' +
|
||
'<div class="tpl-card-actions">' +
|
||
'<button class="tpl-card-btn" onclick="useLessonTpl(' + t.id + ')">Вставить</button>' +
|
||
(canDelete ? '<button class="tpl-card-btn danger" onclick="delLessonTpl(' + t.id + ')">'+LS.icon('trash',12)+'</button>' : '') +
|
||
'</div>' +
|
||
'</div>';
|
||
}).join('') + '</div>';
|
||
} catch (e) {
|
||
wrap.innerHTML = '<div class="tpl-empty">Ошибка загрузки</div>';
|
||
}
|
||
}
|
||
|
||
async function useLessonTpl(tplId) {
|
||
if (!lesson) return;
|
||
try {
|
||
const res = await LS.createFromLessonTemplate(tplId, {
|
||
courseId: lesson.courseId,
|
||
sectionId: lesson.sectionId || null,
|
||
title: lesson.title + ' (из шаблона)',
|
||
});
|
||
closeLoadTplModal();
|
||
LS.toast('Урок создан из шаблона', 'success');
|
||
location.href = '/lesson-editor?id=' + res.id;
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
async function delLessonTpl(tplId) {
|
||
const ok = await LS.confirm('Удалить этот шаблон урока?', { title:'Удаление шаблона', confirmText:'Удалить', danger:true });
|
||
if (!ok) return;
|
||
try {
|
||
await LS.deleteLessonTemplate(tplId);
|
||
LS.toast('Шаблон удалён', 'success');
|
||
loadTplList();
|
||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
E1: Mini rich editor helper
|
||
══════════════════════════════════════════════════════════════════ */
|
||
const _MINI_RICH_ICON_LINK = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71m4.54 5.07a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>`;
|
||
|
||
function renderMiniRich(bid, key, value, placeholder) {
|
||
return `<div class="mini-rich-wrap">
|
||
<div class="mini-toolbar">
|
||
<button class="rich-toolbar-btn" title="Жирный" onmousedown="event.preventDefault();document.execCommand('bold')"><b>B</b></button>
|
||
<button class="rich-toolbar-btn" title="Курсив" onmousedown="event.preventDefault();document.execCommand('italic')"><i>I</i></button>
|
||
<button class="rich-toolbar-btn" title="Ссылка" onmousedown="event.preventDefault();insertLink()">${_MINI_RICH_ICON_LINK}</button>
|
||
</div>
|
||
<div class="mini-rich-editor" contenteditable="true"
|
||
data-bid="${bid}" data-key="${escAttr(key)}"
|
||
data-placeholder="${escAttr(placeholder)}">${value}</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
E3: Interactive quiz answer handlers
|
||
══════════════════════════════════════════════════════════════════ */
|
||
function pvAnswer(btn, idx, correctIdx) {
|
||
const opts = btn.closest('.pv-quiz-opts');
|
||
if (opts.dataset.answered) return;
|
||
opts.dataset.answered = '1';
|
||
[...opts.querySelectorAll('.pv-quiz-opt-btn')].forEach((b, i) => {
|
||
b.disabled = true;
|
||
if (i === correctIdx) b.classList.add('right');
|
||
});
|
||
if (idx !== correctIdx) btn.classList.add('wrong');
|
||
const expl = opts.parentElement.querySelector('.pv-quiz-expl');
|
||
if (expl) expl.classList.add('show');
|
||
}
|
||
|
||
function pvToggle(btn) {
|
||
if (btn.disabled) return;
|
||
btn.classList.toggle('selected');
|
||
}
|
||
|
||
function pvCheck(checkBtn) {
|
||
const opts = checkBtn.closest('.pv-quiz-opts');
|
||
if (opts.dataset.answered) return;
|
||
opts.dataset.answered = '1';
|
||
const correctSet = new Set(JSON.parse(opts.dataset.correct || '[]'));
|
||
[...opts.querySelectorAll('.pv-quiz-opt-btn')].forEach((b, i) => {
|
||
b.disabled = true;
|
||
const sel = b.classList.contains('selected');
|
||
const ok = correctSet.has(i);
|
||
if (ok) b.classList.add('right');
|
||
else if (sel) b.classList.add('wrong');
|
||
});
|
||
checkBtn.disabled = true;
|
||
const expl = opts.parentElement.querySelector('.pv-quiz-expl');
|
||
if (expl) expl.classList.add('show');
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
E5: Change block type
|
||
══════════════════════════════════════════════════════════════════ */
|
||
function changeBlockType(bid, newType) {
|
||
const b = blocks.find(x => x._id === bid);
|
||
if (!b || b.type === newType) return;
|
||
pushHistory();
|
||
const old = b.data;
|
||
// migrate common text fields
|
||
const defaults = JSON.parse(JSON.stringify(BLOCK_DEFAULTS[newType] || {}));
|
||
const src = old.html || old.text || old.question || old.content || old.front || '';
|
||
if ('html' in defaults) defaults.html = src;
|
||
if ('text' in defaults) defaults.text = src;
|
||
if ('question' in defaults) defaults.question = src;
|
||
if ('content' in defaults) defaults.content = old.content || src;
|
||
if ('title' in defaults) defaults.title = old.title || '';
|
||
if ('front' in defaults) defaults.front = old.front || src;
|
||
if ('back' in defaults) defaults.back = old.back || '';
|
||
b.type = newType;
|
||
b.data = defaults;
|
||
renderBlocks();
|
||
markDirty();
|
||
}
|
||
|
||
loadLesson();
|
||
|
||
/* ── Mobile palette sheet ── */
|
||
function toggleMobPalette() {
|
||
const sheet = document.getElementById('mob-palette-sheet');
|
||
const backdrop = document.getElementById('mob-backdrop');
|
||
if (sheet.classList.contains('open')) {
|
||
closeMobPalette();
|
||
} else {
|
||
const inner = document.getElementById('mob-palette-inner');
|
||
if (inner && !inner.children.length) {
|
||
const palette = document.querySelector('.block-palette');
|
||
if (palette) {
|
||
inner.innerHTML = palette.innerHTML;
|
||
const search = inner.querySelector('.palette-search');
|
||
if (search) search.remove();
|
||
// Wrap each onclick to also close the sheet
|
||
inner.querySelectorAll('.palette-btn').forEach(btn => {
|
||
const orig = btn.getAttribute('onclick') || '';
|
||
btn.setAttribute('onclick', orig + ';closeMobPalette()');
|
||
});
|
||
}
|
||
}
|
||
sheet.classList.add('open');
|
||
backdrop.classList.add('show');
|
||
}
|
||
}
|
||
|
||
function closeMobPalette() {
|
||
document.getElementById('mob-palette-sheet')?.classList.remove('open');
|
||
document.getElementById('mob-backdrop')?.classList.remove('show');
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|