Files
Maxim Dolgolyov 28db2de74f feat(labs): Фаза0 — эконом-режим FX + выбор симуляции из списка в редакторе
План улучшения симуляций — 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>
2026-06-13 10:33:50 +03:00

3534 lines
172 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Редактор урока — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<!-- 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">&lt;/&gt;</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">&lt;/&gt;</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>
{&nbsp;} Сделать пропуском
</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,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
/* ══════════════════════════════════════════════════════════════════
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>