Commit 9895625e authored by Яков's avatar Яков
Browse files

update

parent 4934c3af
......@@ -40,6 +40,10 @@ import InteractiveImage from './extensions/InteractiveImage'
import FontSize from './extensions/FontSize'
import BlockId from './extensions/BlockId'
import ActivityRef from './extensions/ActivityRef'
import Callout from './extensions/Callout'
import ShowAnswer from './extensions/ShowAnswer'
import SelfCheck from './extensions/SelfCheck'
import Glossary from './extensions/Glossary'
import { Icon } from './icons'
import ButtonLinkExtension from './extensions/ButtonLink'
// import Image from '@tiptap/extension-image'
......@@ -202,6 +206,10 @@ const QEditor = ({
// Открыть конструктор задания из карточки в редакторе
onOpenActivity = null,
activityLabels = null,
// Выбор термина глоссария. Возвращает промис с идентификатором либо null.
// Список терминов — у хоста: глоссарий это сущность платформы, и он свой
// у каждой компании.
onPickTerm = null,
/** Интерактивное изображение как сущность платформы: три колбэка */
onCreateInteractiveImage = null,
onLoadInteractiveImage = null,
......@@ -531,7 +539,7 @@ const QEditor = ({
// с тем же аргументом, что и кнопка pdf, то есть буквальный дубль.
// В пресетах он не значился, автору не показывался никогда.
activityRef: {
title: 'Задание в уроке',
title: 'Задание в модуле',
onClick: async () => {
if (!onPickActivity) {
message.info('Выбор задания недоступен в этом окне редактора')
......@@ -550,6 +558,21 @@ const QEditor = ({
modalOpener('interactiveImage', 'Интерактивное изображение')
},
},
callout: {
title: 'Врезка',
// toggleWrap, а не wrapIn: повторное нажатие снимает врезку.
// Иначе единственный способ передумать — удалить абзац и набрать
// заново, и авторы именно так и делали с цитатой.
onClick: () => editor.chain().focus().toggleCallout('info').run()
},
showAnswer: {
title: 'Скрытый разбор',
onClick: () => editor.chain().focus().setShowAnswer('Показать ответ').run()
},
selfCheck: {
title: 'Самопроверка',
onClick: () => editor.chain().focus().insertSelfCheck().run()
},
h2: {
title: 'Заголовок 2',
onClick: () => editor.chain().focus().toggleHeading({level: 2}).run()
......@@ -856,6 +879,10 @@ const QEditor = ({
FontSize,
BlockId,
ActivityRef.configure({ onOpenActivity, labels: activityLabels }),
Callout,
ShowAnswer,
SelfCheck,
Glossary.configure({ onPickTerm }),
ButtonLinkExtension,
Color.configure({
types: ['textStyle']
......
......@@ -58,7 +58,11 @@ const LESSON = [
{ type: 'g', items: ['bulletList', 'orderedList'] },
{ type: 's', toggle: true, items: ['alignLeft', 'alignCenter', 'alignRight'] },
{ type: 's', toggle: false, items: ['blockquote', 'hr', 'codeBlock'] },
// Особые блоки одним списком: у всех пяти общая роль — выделить кусок
// материала из общего потока. Врезка и скрытый разбор попали сюда, а не
// в «Оживить модуль», по замеру ширины: в один ряд панель укладывается
// до 1080 px, и две лишние видимые кнопки её ломали.
{ type: 's', toggle: false, items: ['blockquote', 'callout', 'showAnswer', 'hr', 'codeBlock'] },
TABLE_GROUP,
{ type: 'g', items: ['emoji'] },
......@@ -86,7 +90,7 @@ const LESSON = [
// и делает необязательными дорогие — разовую подсветку новинки
// и карточки-примеры в пустом уроке.
{ type: 'g', label: 'Оживить модуль',
items: ['audio', 'interactiveImage', 'insertToggleBlock', 'buttonLink'] },
items: ['audio', 'interactiveImage', 'selfCheck', 'insertToggleBlock', 'buttonLink'] },
{ type: 'g', items: ['|'] },
......@@ -120,11 +124,11 @@ const LESSON_NARROW = [
// («картинка»), и «Вставить» читалось бы как «вставить картинку».
{ type: 's', toggle: false, title: 'insertMore',
items: ['image', 'video', 'iframe', 'audio', 'interactiveImage',
'activityRef', 'insertToggleBlock', 'buttonLink', 'file', 'insertTable'] },
'activityRef', 'selfCheck', 'insertToggleBlock', 'buttonLink', 'file', 'insertTable'] },
// Ещё: редкое и служебное
{ type: 's', toggle: false, title: 'more',
items: ['blockquote', 'hr', 'codeBlock', 'emoji', 'clearMarks',
items: ['blockquote', 'callout', 'showAnswer', 'hr', 'codeBlock', 'emoji', 'clearMarks',
'voicemessage', 'webcamera', 'screencust',
'iframe_pptx', 'iframe_pdf', 'pdf', 'export_pdf', 'iframe_custom'] },
]
......@@ -265,6 +269,18 @@ const TOOL_INFO = {
title: 'Скрытый блок',
hint: 'Текст под заголовком, который раскрывается по клику: подробности, ответ, пример'
},
showAnswer: {
title: 'Скрытый разбор',
hint: 'Ответ, закрытый до клика: дать читателю подумать самому, а потом свериться'
},
callout: {
title: 'Врезка',
hint: 'Выделенная рамка «обратите внимание»: заметка, предупреждение, совет'
},
selfCheck: {
title: 'Самопроверка',
hint: 'Вопрос по ходу чтения с ответом сразу. Не создаёт попытку и не идёт в отчёты'
},
blockquote: { title: 'Цитата', hint: 'Выделенный блок с полосой слева' },
codeBlock: { title: 'Блок кода', hint: 'Моноширинный текст: команда, код, конфигурация' },
hr: { title: 'Разделитель', hint: 'Горизонтальная линия между смысловыми частями' },
......
import { Node, mergeAttributes } from '@tiptap/core'
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react'
import React from 'react'
/**
* Врезка — выделенный блок «обратите внимание».
*
* ── Почему отдельный узел, а не цитата с классом ─────────────────────────────
*
* Врезками авторы пользовались и раньше — руками, через цитату с подкрашенным
* фоном или таблицу в одну ячейку. Разметка при этом у каждого получалась своя,
* и на мобильном половина таких «врезок» разъезжалась.
*
* Отдельный узел даёт две вещи, которых у самодельных не было: одинаковый вид
* во всех уроках и понятный смысл для отрисовки — врезку видно как врезку,
* а не как цитату, у которой почему-то жёлтый фон.
*
* ── Почему стиль атрибутом, а не пятью узлами ────────────────────────────────
*
* callout(style=warning) и callout(style=info) отличаются только оформлением.
* Пять типов узлов означали бы пять веток в схеме, сборщике, разборщике HTML
* и в подсчёте прогресса — ради разного цвета рамки.
*
* ── Содержимое внутри обычное ────────────────────────────────────────────────
*
* Внутрь кладётся любой блок: абзацы, списки, картинка. Ограничивать нечем —
* врезка это рамка вокруг содержимого, а не особый вид текста.
*/
/** Стили и подписи. Значения совпадают с серверной схемой документа. */
const STYLES = [
{ value: 'info', label: 'Заметка' },
{ value: 'warning', label: 'Внимание' },
{ value: 'success', label: 'Хорошо' },
{ value: 'danger', label: 'Опасно' },
{ value: 'tip', label: 'Совет' },
]
const CalloutView = ({ node, updateAttributes, deleteNode }) => {
const style = node.attrs.style || 'info'
return (
<NodeViewWrapper as="div" className={`atma-callout atma-callout--${style}`} data-style={style}>
{/*
Панель редактирования помечена contentEditable={false}: иначе
курсор ставится в неё, и автор незаметно набирает текст
в служебные элементы вместо содержимого врезки.
*/}
<div className="atma-callout__bar" contentEditable={false}>
<select value={style}
onChange={(e) => updateAttributes({ style: e.target.value })}>
{STYLES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
<button type="button" onClick={() => deleteNode()} title="Убрать врезку">×</button>
</div>
<NodeViewContent className="atma-callout__body" />
</NodeViewWrapper>
)
}
const Callout = Node.create({
name: 'callout',
group: 'block',
content: 'block+',
defining: true,
addAttributes() {
return {
style: {
default: 'info',
parseHTML: (el) => el.getAttribute('data-style') || 'info',
renderHTML: (attrs) => ({ 'data-style': attrs.style || 'info' }),
},
blockId: { default: null },
}
},
parseHTML() {
return [{ tag: 'div[data-style].atma-callout' }, { tag: 'div.atma-callout' }]
},
renderHTML({ HTMLAttributes, node }) {
const style = node.attrs.style || 'info'
return [
'div',
mergeAttributes(HTMLAttributes, {
class: `atma-callout atma-callout--${style}`,
'data-style': style,
}),
0,
]
},
addNodeView() {
return ReactNodeViewRenderer(CalloutView)
},
addCommands() {
return {
setCallout:
(style = 'info') =>
({ commands }) =>
commands.wrapIn(this.name, { style }),
toggleCallout:
(style = 'info') =>
({ commands }) =>
commands.toggleWrap(this.name, { style }),
}
},
})
export default Callout
export { STYLES as CALLOUT_STYLES }
import { Mark, mergeAttributes } from '@tiptap/core'
/**
* Термин глоссария.
*
* ── Почему марка, а не узел ──────────────────────────────────────────────────
*
* Термин — это кусок предложения, а не блок. Узлом он ломал бы всё, что
* работает с текстом: перенос строки, поиск, выделение мышью через границу
* термина. Марка ложится поверх текста, как жирность.
*
* ── Что в марке хранится, а чего нет ─────────────────────────────────────────
*
* ТОЛЬКО идентификатор. Ни слова, ни определения, ни ссылки.
*
* Определения живут в глоссарии компании и там же правятся. Скопируй
* определение в документ — и правка в глоссарии не доедет до сотни уроков,
* где термин уже употреблён; получится две редакции одного термина, причём
* устаревшая будет выглядеть убедительнее — она прямо в тексте.
*
* Само слово — обычный текст под маркой, его автор пишет как хочет:
* «регламент», «регламента», «регламентом» — термин один.
*
* ── Почему в HTML не уезжает onclick ─────────────────────────────────────────
*
* Раньше сервер вставлял в термин инлайновый обработчик — ровно то, что
* санитайзер обязан вырезать. Теперь в разметке только data-glossary-id,
* а открытие окна делает один делегированный слушатель на странице.
*/
const Glossary = Mark.create({
name: 'glossary',
inclusive: false, // набранное сразу за термином им уже не становится
addOptions() {
return {
/** Хост открывает выбор термина: пакет про глоссарий платформы не знает */
onPickTerm: null,
}
},
addAttributes() {
return {
glossaryId: {
default: null,
parseHTML: (el) => {
const v = parseInt(el.getAttribute('data-glossary-id') || '', 10)
return Number.isFinite(v) ? v : null
},
renderHTML: (attrs) =>
(attrs.glossaryId ? { 'data-glossary-id': String(attrs.glossaryId) } : {}),
},
}
},
parseHTML() {
return [{ tag: 'span[data-glossary-id]' }]
},
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes, { class: 'glossary' }), 0]
},
addCommands() {
return {
setGlossary:
(glossaryId) =>
({ commands }) =>
commands.setMark(this.name, { glossaryId }),
unsetGlossary:
() =>
({ commands }) =>
commands.unsetMark(this.name),
}
},
})
export default Glossary
import { Node, mergeAttributes } from '@tiptap/core'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import React from 'react'
/**
* Самопроверка — вопрос по ходу чтения.
*
* ── Почему не задание ────────────────────────────────────────────────────────
*
* Задание создаёт попытку, результат, баллы и иногда очередь на проверку
* человеком. Всё это уместно на выходе из модуля и совершенно неуместно
* посреди абзаца: автор хочет спросить «поняли?», а получает строчку
* в отчётности и работу ментору.
*
* Поэтому самопроверка ничего этого не создаёт. Ответ попадает в поблочный
* прогресс (payload), обратная связь показывается сразу, «неверно» ничем
* не грозит.
*
* ── Почему варианты живут в документе ────────────────────────────────────────
*
* У задания вопросы отдаёт сервер: там есть что скрывать до попытки.
* Здесь скрывать нечего — правильность видна через секунду после ответа.
*
* Оговорка: в собранный HTML варианты НЕ уезжают, сервер оставляет там один
* вопрос. Иначе правильный ответ читался бы в исходном коде страницы до того,
* как человек его выбрал. Живой блок собирает отрисовка из документа.
*
* ── Почему узел неделимый ────────────────────────────────────────────────────
*
* Вопрос и варианты — это структура, а не поток текста. Будь они содержимым,
* автор мог бы поставить курсор между вариантами и набрать абзац, и такой
* документ пришлось бы чинить при разборе.
*/
const emptyOption = () => ({ text: '', correct: false })
const SelfCheckView = ({ node, updateAttributes, deleteNode }) => {
const a = node.attrs
const options = Array.isArray(a.options) && a.options.length ? a.options : [emptyOption()]
const patch = (i, part) => {
const next = options.map((o, idx) => (idx === i ? { ...o, ...part } : o))
updateAttributes({ options: next })
}
const pickCorrect = (i) => {
// При одиночном выборе отметка правильного снимает её с остальных:
// иначе автор оставляет два «правильных», и вопрос без множественного
// выбора становится непроходимым — второй верный ответ выбрать нечем.
const next = options.map((o, idx) =>
a.multiple
? (idx === i ? { ...o, correct: !o.correct } : o)
: { ...o, correct: idx === i }
)
updateAttributes({ options: next })
}
return (
<NodeViewWrapper as="div" className="atma-self-check atma-self-check--edit" contentEditable={false}>
<div className="atma-self-check__head">
<span className="atma-self-check__label">Самопроверка</span>
<label>
<input type="checkbox"
checked={!!a.multiple}
onChange={(e) => updateAttributes({ multiple: e.target.checked })} />
несколько ответов
</label>
<label>
<input type="checkbox"
checked={!!a.retry}
onChange={(e) => updateAttributes({ retry: e.target.checked })} />
можно ответить снова
</label>
<label>
<input type="checkbox"
checked={!!a.required}
onChange={(e) => updateAttributes({ required: e.target.checked })} />
обязательный
</label>
<button type="button" onClick={() => deleteNode()} title="Убрать самопроверку">×</button>
</div>
<input type="text"
className="atma-self-check__question-input"
value={a.question || ''}
placeholder="Вопрос"
onChange={(e) => updateAttributes({ question: e.target.value })} />
<div className="atma-self-check__options">
{options.map((option, i) => (
<div key={i} className="atma-self-check__option-row">
<input type={a.multiple ? 'checkbox' : 'radio'}
checked={!!option.correct}
onChange={() => pickCorrect(i)}
title="Правильный ответ" />
<input type="text"
value={option.text || ''}
placeholder={`Вариант ${i + 1}`}
onChange={(e) => patch(i, { text: e.target.value })} />
{options.length > 1 && (
<button type="button"
onClick={() => updateAttributes({ options: options.filter((_, idx) => idx !== i) })}
title="Убрать вариант">×</button>
)}
</div>
))}
<button type="button"
className="atma-self-check__add"
onClick={() => updateAttributes({ options: [...options, emptyOption()] })}>
Добавить вариант
</button>
</div>
<div className="atma-self-check__feedbacks">
<input type="text"
value={a.feedbackRight || ''}
placeholder="Ответ верный — что написать"
onChange={(e) => updateAttributes({ feedbackRight: e.target.value })} />
<input type="text"
value={a.feedbackWrong || ''}
placeholder="Ответ неверный — что написать"
onChange={(e) => updateAttributes({ feedbackWrong: e.target.value })} />
</div>
</NodeViewWrapper>
)
}
const SelfCheck = Node.create({
name: 'selfCheck',
group: 'block',
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
question: { default: '' },
options: { default: [] },
multiple: { default: false },
feedbackRight: { default: null },
feedbackWrong: { default: null },
retry: { default: true },
required: { default: false },
blockId: { default: null },
}
},
parseHTML() {
return [{ tag: 'div[data-self-check]' }]
},
renderHTML({ HTMLAttributes, node }) {
const a = node.attrs
// Варианты и правильные ответы в HTML не уезжают — только вопрос.
// Восстанавливать их из HTML не нужно и нечем: источник документа —
// JSON, а HTML это его отпечаток для показа.
return [
'div',
mergeAttributes(
{
'data-self-check': 'true',
class: 'atma-self-check',
},
a.required ? { 'data-required': 'true' } : {},
a.blockId ? { 'data-block-id': a.blockId } : {}
),
a.question || 'Вопрос',
]
},
addNodeView() {
return ReactNodeViewRenderer(SelfCheckView)
},
addCommands() {
return {
insertSelfCheck:
(attrs = {}) =>
({ commands }) =>
commands.insertContent({
type: this.name,
attrs: { options: [emptyOption(), emptyOption()], ...attrs },
}),
}
},
})
export default SelfCheck
import { Node, mergeAttributes } from '@tiptap/core'
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react'
import React from 'react'
/**
* Скрытый разбор — «подумайте сами, потом откройте».
*
* ── Чем отличается от сворачиваемого блока ───────────────────────────────────
*
* Технически это тот же спойлер, и в собранном HTML он использует ту же
* разметку, что toggleBlock, — переписывать отрисовку и стили ради него
* незачем.
*
* Разница в смысле, и она стоит отдельного узла: сворачиваемый блок прячет
* второстепенное, чтобы не мешало; скрытый разбор прячет ГЛАВНОЕ, и прячет
* намеренно — до того, как читатель попробует сам. Автору в панели нужен
* ровно этот выбор, а не «вставьте спойлер и назовите его правильно».
*
* ── Почему подпись отдельным атрибутом ───────────────────────────────────────
*
* Подпись кнопки — не содержимое разбора. Держи её первым абзацем внутри,
* и она попала бы в текст урока, в поиск и в подсчёт прочитанного.
*/
const ShowAnswerView = ({ node, updateAttributes, deleteNode }) => (
<NodeViewWrapper as="div" className="atma-show-answer">
<div className="atma-show-answer__bar" contentEditable={false}>
<input type="text"
value={node.attrs.label || ''}
placeholder="Показать ответ"
onChange={(e) => updateAttributes({ label: e.target.value })} />
<button type="button" onClick={() => deleteNode()} title="Убрать разбор">×</button>
</div>
{/*
У автора разбор всегда раскрыт: прятать содержимое от того, кто его
и пишет, — прямой способ забыть, что там внутри. Свернётся он
у читателя, при отрисовке.
*/}
<NodeViewContent className="atma-show-answer__body" />
</NodeViewWrapper>
)
const ShowAnswer = Node.create({
name: 'showAnswer',
group: 'block',
content: 'block+',
defining: true,
addAttributes() {
return {
label: {
default: null,
parseHTML: (el) => el.getAttribute('data-label') || null,
renderHTML: (attrs) => (attrs.label ? { 'data-label': attrs.label } : {}),
},
blockId: { default: null },
}
},
parseHTML() {
return [{ tag: 'div.atma-show-answer' }]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { class: 'atma-show-answer' }), 0]
},
addNodeView() {
return ReactNodeViewRenderer(ShowAnswerView)
},
addCommands() {
return {
setShowAnswer:
(label = null) =>
({ commands }) =>
commands.wrapIn(this.name, { label }),
}
},
})
export default ShowAnswer
......@@ -321,6 +321,48 @@ const ResizableVideoView = ({ editor, node, updateAttributes, getPos, selected }
</Fragment>
)}
</div>
{/*
Досмотр настраивается только у выделенного видео и подписан
словами. Держать это в общей панели нельзя: настройка относится
к конкретному ролику, а не к материалу целиком.
*/}
{selected && (
<div className="atma-video-watch" contentEditable={false}>
<label>
<input
type="checkbox"
checked={node.attrs.threshold != null}
onChange={(e) =>
updateAttributes({ threshold: e.target.checked ? 80 : null })
}
/>
досмотреть до
</label>
<input
type="number"
min={1}
max={100}
disabled={node.attrs.threshold == null}
value={node.attrs.threshold == null ? 80 : node.attrs.threshold}
onChange={(e) => {
const v = parseInt(e.target.value, 10)
updateAttributes({
threshold: Number.isFinite(v) ? Math.min(100, Math.max(1, v)) : 80,
})
}}
/>
<span>%</span>
<label>
<input
type="checkbox"
checked={!!node.attrs.required}
onChange={(e) => updateAttributes({ required: e.target.checked })}
/>
обязательное
</label>
</div>
)}
</NodeViewWrapper>
)
}
......@@ -367,6 +409,36 @@ const Video = Node.create({
parseHTML: (el) => el.getAttribute('data-node-id'),
renderHTML: (attrs) => ({ 'data-node-id': attrs['data-node-id'] }),
},
/**
* Порог обязательного досмотра, проценты.
*
* Считается не по положению ползунка, а по сумме фактически
* проигранных отрезков: перемотал в конец — досмотра нет.
* Само измерение живёт на стороне показа, здесь только настройка.
*
* null — порога нет, видео идёт как обычный блок «увидел на экране».
* Ставить его по умолчанию нельзя: в существующих уроках 500+ видео,
* и включённый порог задним числом закрыл бы модули у тех, кто их
* давно прошёл.
*/
threshold: {
default: null,
parseHTML: (el) => {
const v = parseInt(el.getAttribute('data-threshold') || '', 10)
return Number.isFinite(v) ? Math.min(100, Math.max(1, v)) : null
},
renderHTML: (attrs) =>
(attrs.threshold ? { 'data-threshold': String(attrs.threshold) } : {}),
},
/** Обязательный блок: без него модуль не считается освоенным */
required: {
default: false,
parseHTML: (el) => el.getAttribute('data-required') === 'true',
renderHTML: (attrs) => (attrs.required ? { 'data-required': 'true' } : {}),
},
}
},
......
import React from 'react'
/**
* Набор иконок панели: 56 штук, один визуальный язык.
* Набор иконок панели: 61 штука, один визуальный язык.
*
* ── Почему тут разметка, а не картинки ───────────────────────────────────────
*
......@@ -90,6 +90,25 @@ const ICONS = {
// не выбиваться из набора.
insertMore: '<path d="M5 3.5h14a1.5 1.5 0 0 1 1.5 1.5v14a1.5 1.5 0 0 1 -1.5 1.5h-14a1.5 1.5 0 0 1 -1.5 -1.5v-14a1.5 1.5 0 0 1 1.5 -1.5z"></path><path d="M12 8.5v7"></path><path d="M8.5 12h7"></path>',
more: '<path d="M5.2 12a1.3 1.3 0 1 0 2.6 0a1.3 1.3 0 1 0 -2.6 0z"></path><path d="M10.7 12a1.3 1.3 0 1 0 2.6 0a1.3 1.3 0 1 0 -2.6 0z"></path><path d="M16.2 12a1.3 1.3 0 1 0 2.6 0a1.3 1.3 0 1 0 -2.6 0z"></path>',
// ── Блоки второго этапа ──────────────────────────────────────────────────
//
// Тоже дорисованы: в поставке дизайнера их нет, узлы появились позже макета.
// Язык тот же — 24×24, штрих 1.5, рисунок в поле 20×20.
//
// Врезка: рамка с толстой полосой слева и восклицательным знаком —
// «обратите внимание». От цитаты отличается рамкой: у цитаты только полоса.
callout: '<path d="M4 5.2h15a1.8 1.8 0 0 1 1.8 1.8v10a1.8 1.8 0 0 1 -1.8 1.8h-15z"></path><path d="M4 5.2v13.6" stroke-width="3"></path><path d="M12.4 8.6v4.2"></path><path d="M12.4 15.5v.1"></path>',
// Самопроверка: два варианта ответа, у верхнего галочка — «спросить
// по ходу чтения». От задания (activityRef) отличается тем, что нет рамки
// карточки: самопроверка живёт в тексте, а не отдельным блоком отчётности.
selfCheck: '<path d="M4.4 7.6a1.9 1.9 0 1 0 3.8 0a1.9 1.9 0 1 0 -3.8 0z"></path><path d="M5.3 7.6l.7.7 1.3-1.4"></path><path d="M11 7.6h8.6"></path><path d="M4.4 16.4a1.9 1.9 0 1 0 3.8 0a1.9 1.9 0 1 0 -3.8 0z"></path><path d="M11 16.4h6.2"></path>',
// Скрытый разбор: глаз со стрелкой вниз — «откроется по клику».
// От скрытого блока отличается глазом: там список, который свернули,
// здесь ответ, который намеренно прячут до попытки.
showAnswer: '<path d="M3.4 10.4c2.4-3.4 5.3-5.1 8.6-5.1s6.2 1.7 8.6 5.1c-2.4 3.4-5.3 5.1-8.6 5.1s-6.2-1.7-8.6-5.1z"></path><path d="M10.1 10.4a1.9 1.9 0 1 0 3.8 0a1.9 1.9 0 1 0 -3.8 0z"></path><path d="M12 18v2.6"></path><path d="M10.2 18.9 12 20.7l1.8-1.8"></path>',
}
export default ICONS
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment