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

Панель инструментов: пресеты по контекстам и понятные подписи



Пресеты (было два, и хост не передавал их ни разу):
  lesson  — урок, полный набор без произвольного размера шрифта
  text    — описания, инфо, глоссарий, база знаний
  comment — комментарий проверяющего
  answer  — ответ учащегося, с audio

Заголовки вынесены из выпадающего списка на верхний уровень: на боевой базе
жирный абзац вместо заголовка встречается в 289 уроках, настоящий h2 — в 31.

Нативный browser title заменён на подсказку с названием и пояснением
«что я получу». Кнопки получили role/tabIndex/aria-label и видимый фокус.

Палитра цветов сведена с 12 произвольных оттенков к 6 семантическим
из темы платформы.

Удалена кнопка pdfToText: её onClick дублировал кнопку pdf, в пресетах
она не значилась и автору не показывалась.

Новый проп labels — словарь подписей от хоста: своей локализации у пакета нет.
Co-Authored-By: default avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent bb440fff
...@@ -188,7 +188,10 @@ const QEditor = ({ ...@@ -188,7 +188,10 @@ const QEditor = ({
toolsOptions = {type: 'all'}, toolsOptions = {type: 'all'},
onInfo, onInfo,
// Режим оформления контента, задаётся хостом: 'atma-content--lesson' | 'atma-content--compact' | '' // Режим оформления контента, задаётся хостом: 'atma-content--lesson' | 'atma-content--compact' | ''
contentClass = '' contentClass = '',
// Словарь подписей от хоста: { [ключ кнопки]: { title, hint } }.
// Нужен для перевода — своей локализации у пакета нет. Пусто — встроенные русские.
labels = {}
}) => { }) => {
global.uploadUrl = uploadOptions.url global.uploadUrl = uploadOptions.url
...@@ -358,36 +361,27 @@ const QEditor = ({ ...@@ -358,36 +361,27 @@ const QEditor = ({
setInnerModalType(type) setInnerModalType(type)
setModalIsOpen(true) setModalIsOpen(true)
} }
// Палитра сведена к шести семантическим значениям (решение владельца, §12 ТЗ,
// вопрос 3). Было по двенадцать произвольных оттенков на каждый список —
// с ними «заголовок выглядит одинаково во всех уроках» недостижимо.
// Значения взяты из темы платформы (_variables.scss / _content.scss),
// а не подобраны заново.
const colors = { const colors = {
color: [ color: [
'none', 'none', // обычный текст
'#8a8a8a', '#6B7280', // приглушённый
'#afafaf', '#1790FF', // акцент
'#44d724', '#09C358', // успех
'#0bd9b2', '#F6993F', // предупреждение
'#4fb7ff', '#FF5F56' // ошибка
'#226aff',
'#b153e5',
'#f54f8e',
'#f34c37',
'#ee7027',
'#d27303',
'#ffd102'
], ],
highlight: [ highlight: [
'none', 'none',
'#9B9B9B', '#E3E7EC', // нейтральное выделение
'#CCCCCC', '#CDE8FF', // акцент
'#9ee191', '#C9F2D8', // успех
'#43e7bf', '#FFE9B8', // предупреждение
'#4fb7ff', '#FFD9D6' // ошибка
'#6d9ef5',
'#cd92e8',
'#f597bc',
'#fa9084',
'#ef9558',
'#dea75b',
'#ffe672'
] ]
} }
const fontSizes = ['12px', '14px', '16px', '18px', '20px', '24px', '28px', '32px'] const fontSizes = ['12px', '14px', '16px', '18px', '20px', '24px', '28px', '32px']
...@@ -515,10 +509,9 @@ const QEditor = ({ ...@@ -515,10 +509,9 @@ const QEditor = ({
title: 'Эмодзи', title: 'Эмодзи',
render: ({ key }) => <EmojiPicker key={key} editor={editor} /> render: ({ key }) => <EmojiPicker key={key} editor={editor} />
}, },
pdfToText: { // pdfToText удалён: его onClick вызывал modalOpener('pdf', …) — тот же модал
title: 'Загрузить pdf', // с тем же аргументом, что и кнопка pdf, то есть буквальный дубль.
onClick: () => modalOpener('pdf', 'Загрузить pdf') // В пресетах он не значился, автору не показывался никогда.
},
interactiveImage: { interactiveImage: {
title: 'Интерактивное изображение', title: 'Интерактивное изображение',
onClick: () => { onClick: () => {
...@@ -1932,7 +1925,7 @@ const QEditor = ({ ...@@ -1932,7 +1925,7 @@ const QEditor = ({
return ( return (
<div className='atma-editor-wrap' style={style}> <div className='atma-editor-wrap' style={style}>
<div className='atma-editor'> <div className='atma-editor'>
<ToolBar editor={editor} {...{toolsOptions}} {...{toolsLib}} onInfo={onInfo} /> <ToolBar editor={editor} {...{toolsOptions}} {...{toolsLib}} onInfo={onInfo} labels={labels} />
<BubbleMenu <BubbleMenu
typpyOptions={{followCursor: true}} typpyOptions={{followCursor: true}}
editor={editor} editor={editor}
......
import React, { Fragment, useState } from 'react'; import React, { Fragment, useState } from 'react';
import { Tooltip } from 'antd';
// ─────────────────────────────────────────────────────────────────────────────
// Наборы кнопок по контекстам.
//
// Раньше пресета было два — 'all' и 'text', — и хост не передавал toolsOptions
// ни разу (проверено: 0 совпадений в AtmaGuru). То есть комментарий наставника,
// определение термина и ответ учащегося редактировались панелью с записью
// экрана, вебкамерой и экспортом в PDF.
//
// 'all' оставлен как синоним 'lesson' — на него по умолчанию падают вызовы,
// где контекст ещё не проставлен, и терять кнопки там нельзя.
//
// См. docs/Редактор уроков и рендер контента — ТЗ.md, Р6.
// ─────────────────────────────────────────────────────────────────────────────
// Заголовки вынесены на ВЕРХНИЙ уровень, а не спрятаны в выпадающий список.
// Причина в замере (§1.9 ТЗ): на боевой базе жирный абзац вместо заголовка
// встречается в 289 уроках, а настоящий h2 — в 31. Соотношение почти 10:1,
// потому что жирный — это одна видимая кнопка, а заголовок — догадаться открыть «Aa».
const HEADINGS = { type: 'g', items: ['paragraph', 'h2', 'h3', 'h4'] }
const TABLE_GROUP = {
type: 's',
toggle: false,
items: [
'insertTable', '|',
'addRowBefore', 'addRowAfter', 'deleteRow', '|',
'addColumnBefore', 'addColumnAfter', 'deleteColumn', '|',
'toggleHeaderCell', '|',
'mergeOrSplit', 'deleteTable', '|',
'toggleTableBorders'
]
}
const LESSON = [
{ type: 'g', items: ['undo', 'redo'] },
{ type: 'g', items: ['|'] },
const toolsInit = { HEADINGS,
'all': [ { type: 'g', items: ['|'] },
{ type: 'g', items: ['undo', 'redo'] },
{ type: 'g', items: ['|'] },
// Aa▼ — стиль текста { type: 's', toggle: false, items: ['bulletList', 'orderedList', 'insertToggleBlock'] },
{ type: 's', toggle: true, items: ['paragraph', 'h2', 'h3', 'h4'] }, { type: 's', toggle: true, items: ['alignLeft', 'alignCenter', 'alignRight'] },
TABLE_GROUP,
// Lists▼ — списки { type: 'g', items: ['emoji'] },
{ type: 's', toggle: false, items: ['bulletList', 'orderedList', 'insertToggleBlock'] }, { type: 'g', items: ['clearMarks'] },
{ type: 's', toggle: false, items: ['blockquote', 'hr', 'codeBlock'] },
// ≡▼ — выравнивание { type: 'g', items: ['|'] },
{ type: 's', toggle: true, items: ['alignLeft', 'alignCenter', 'alignRight'] },
// strike
// { type: 'g', items: ['strike'] },
// Table▼
{
type: 's',
toggle: false,
items: [
'insertTable', '|',
'addRowBefore', 'addRowAfter', 'deleteRow', '|',
'addColumnBefore', 'addColumnAfter', 'deleteColumn', '|',
'toggleHeaderCell', '|',
'mergeOrSplit', 'deleteTable', '|',
'toggleTableBorders'
]
},
{ type: 'g', items: ['emoji'] }, { type: 's', toggle: false, items: ['image', 'interactiveImage'] },
{ type: 'g', items: ['clearMarks'] }, { type: 's', toggle: false, items: ['video', 'iframe'] },
{ type: 's', toggle: false, items: ['blockquote', 'hr', 'codeBlock'] }, { type: 'g', items: ['audio'] },
{ type: 'g', items: ['voicemessage'] },
{ type: 'g', items: ['webcamera'] },
{ type: 'g', items: ['screencust'] },
// ─── разделитель ─── { type: 'g', items: ['|'] },
{ type: 'g', items: ['|'] },
// Image▼ — изображение / интерактивное { type: 's', toggle: false, items: ['iframe_pptx', 'iframe_pdf', 'pdf'] },
{ type: 's', toggle: false, items: ['image', 'interactiveImage'] }, { type: 's', toggle: false, items: ['link', 'buttonLink'] },
{ type: 'g', items: ['file'] },
{ type: 'g', items: ['iframe_custom'] },
{ type: 'g', items: ['export_pdf'] },
]
// Video▼ — загрузить / по ссылке const toolsInit = {
{ type: 's', toggle: false, items: ['video', 'iframe'] }, // Урок: полный набор. Размера шрифта здесь нет намеренно — см. toolsLib.
'lesson': LESSON,
'all': LESSON,
{ type: 'g', items: ['audio'] }, // Описание модуля, инфо, профессии, маркет, база знаний в админке
{ type: 'g', items: ['voicemessage'] }, 'text': [
{ type: 'g', items: ['webcamera'] }, { type: 'g', items: ['undo', 'redo'] },
{ type: 'g', items: ['screencust'] }, HEADINGS,
{ type: 'g', items: ['bulletList', 'orderedList', 'blockquote', 'hr'] },
{ type: 's', toggle: true, items: ['alignLeft', 'alignCenter', 'alignRight'] },
{ type: 's', toggle: false, items: ['link', 'image'] },
TABLE_GROUP,
{ type: 'g', items: ['clearMarks'] },
],
// ─── разделитель ─── // Комментарий наставника и администратора при проверке работ
{ type: 'g', items: ['|'] }, 'comment': [
{ type: 'g', items: ['undo', 'redo'] },
{ type: 'g', items: ['bulletList', 'orderedList'] },
{ type: 'g', items: ['link', 'image', 'file'] },
{ type: 'g', items: ['emoji'] },
{ type: 'g', items: ['clearMarks'] },
],
// Docs▼ — pptx / pdf / pdf->text // Ответ учащегося: эссе, его же база знаний.
{ type: 's', toggle: false, items: ['iframe_pptx', 'iframe_pdf', 'pdf'] }, // audio здесь обязателен — отдельное требование владельца: сотрудник должен
// иметь возможность приложить к ответу голосовую запись.
'answer': [
{ type: 'g', items: ['undo', 'redo'] },
{ type: 'g', items: ['bulletList', 'orderedList'] },
{ type: 'g', items: ['link', 'image', 'file'] },
{ type: 'g', items: ['audio'] },
{ type: 'g', items: ['emoji'] },
{ type: 'g', items: ['clearMarks'] },
],
}
// Link▼ — ссылка / кнопка-ссылка
{ type: 's', toggle: false, items: ['link', 'buttonLink'] },
{ type: 'g', items: ['file'] }, // ─────────────────────────────────────────────────────────────────────────────
{ type: 'g', items: ['iframe_custom'] }, // Что кнопка делает — словами автора, а не разработчика.
{ type: 'g', items: ['export_pdf'] }, //
], // Повод (§1.9 ТЗ): на боевой базе `audio`, `toggle-block`, `interactive-image`
'text': [ // и `buttonLink` использованы НОЛЬ раз, хотя все реализованы и работают.
{ // Причина видна в подписях: «Вставить iframe» — слово, которого нет в словаре
type: 'g', // методиста; четыре кнопки со словом «pdf», различить их невозможно.
items: [ //
'undo', // title — как называется по-человечески, hint — одна строка «что я получу».
'redo' // Хост может переопределить любую запись пропом labels (см. QEditor).
] //
}, // Заполнено только там, где нужно объяснение. Для «жирный» и «курсив»
{ // описание не нужно — их узнают по иконке.
type: 's', // ─────────────────────────────────────────────────────────────────────────────
toggle: true, const TOOL_INFO = {
items: [ // ── Медиа ───────────────────────────────────────────────────────────────
'paragraph', image: {
'h2', title: 'Изображение',
'h3', hint: 'Картинка из файла. Размер и положение настраиваются после вставки'
'h4' },
] interactiveImage: {
}, title: 'Картинка с подсказками',
{ hint: 'Схема или фото, на которых можно расставить точки — по клику раскрывается пояснение'
type: 'g', },
items: [ video: {
'bulletList', title: 'Видео файлом',
'orderedList', hint: 'Загрузить видеофайл. Для роликов с YouTube и VK есть отдельная кнопка'
'blockquote', },
'codeBlock', iframe: {
'hr', title: 'Видео по ссылке',
] hint: 'Ролик с YouTube, VK, Rutube или Дзена — вставляется по адресу страницы'
}, },
{ audio: {
type: 's', title: 'Аудио',
toggle: true, hint: 'Звуковой файл с проигрывателем прямо в уроке'
items: [ },
'alignLeft', voicemessage: {
'alignCenter', title: 'Голосовое сообщение',
'alignRight', hint: 'Записать голос с микрофона, не выходя из редактора'
] },
}, webcamera: {
{ title: 'Запись с камеры',
type: 'g', hint: 'Записать короткое видео с веб-камеры прямо здесь'
items: [ },
'clearMarks', screencust: {
'hardBreak', title: 'Запись экрана',
] hint: 'Показать порядок действий: записывается экран со звуком'
}, },
]
// ── Документы ───────────────────────────────────────────────────────────
// Четыре кнопки со словом «pdf» — главная путаница в старой панели.
iframe_pptx: {
title: 'Презентация PowerPoint',
hint: 'Файл .pptx листается прямо в уроке, скачивать не нужно'
},
iframe_pdf: {
title: 'PDF для просмотра',
hint: 'Документ листается в уроке как есть, вёрстка сохраняется'
},
pdf: {
title: 'Перенести текст из PDF',
hint: 'Файл разбирается в текст и ложится в урок — дальше его можно править'
},
export_pdf: {
title: 'Скачать урок в PDF',
hint: 'Действие над готовым уроком, а не вставка: сохраняет его файлом'
},
file: {
title: 'Файл на скачивание',
hint: 'Вложение: учащийся увидит название, размер и кнопку скачать'
},
// ── Ссылки и вставки ────────────────────────────────────────────────────
link: { title: 'Ссылка', hint: 'Превратить выделенный текст в ссылку' },
buttonLink: {
title: 'Кнопка-ссылка',
hint: 'Заметная кнопка вместо обычной ссылки — для главного действия урока'
},
iframe_custom: {
title: 'Встроить страницу по коду',
hint: 'Для сервисов, которые дают готовый код вставки: опрос, карта, календарь'
},
// ── Структура ───────────────────────────────────────────────────────────
paragraph: { title: 'Обычный текст', hint: 'Убрать заголовок, вернуть обычный абзац' },
h2: { title: 'Заголовок раздела', hint: 'Крупный заголовок — делит урок на части' },
h3: { title: 'Подзаголовок', hint: 'Заголовок внутри раздела' },
h4: { title: 'Мелкий заголовок', hint: 'Третий уровень, для коротких врезок' },
insertToggleBlock: {
title: 'Скрытый блок',
hint: 'Текст под заголовком, который раскрывается по клику: подробности, ответ, пример'
},
blockquote: { title: 'Цитата', hint: 'Выделенный блок с полосой слева' },
codeBlock: { title: 'Блок кода', hint: 'Моноширинный текст: команда, код, конфигурация' },
hr: { title: 'Разделитель', hint: 'Горизонтальная линия между смысловыми частями' },
clearMarks: { title: 'Убрать оформление', hint: 'Снять с выделенного текста цвет, жирность и прочее' },
insertTable: { title: 'Таблица', hint: 'Вставить таблицу; строки и столбцы настраиваются после' },
undo: { title: 'Отменить' },
redo: { title: 'Вернуть' },
emoji: { title: 'Эмодзи', hint: 'Вставить символ из набора' },
} }
const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo }) => { const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) => {
if (!editor) { if (!editor) {
return null return null
} }
/**
* Название и пояснение для кнопки.
*
* Порядок: словарь от хоста → встроенная таблица TOOL_INFO → title из toolsLib.
* Словарь нужен для перевода: в пакете локализации нет вообще, все подписи
* зашиты по-русски. Пока их было полсотни коротких, это терпелось; с описаниями
* объём текста вырос втрое, и русский зашился бы намертво.
* Хост (у него есть formatjs и словари) передаёт переведённые строки пропом.
* Нет пропа — работают встроенные русские, как раньше.
*/
const infoFor = (type, item) => {
const custom = labels[type] || {}
const built = TOOL_INFO[type] || {}
return {
title: custom.title || built.title || (item && item.title) || '',
hint: custom.hint !== undefined ? custom.hint : built.hint,
}
}
const [toolbarItems, setToolbarItems ] = useState((toolsOptions && toolsOptions.type ? toolsInit[toolsOptions.type] : toolsInit['all'])) const [toolbarItems, setToolbarItems ] = useState((toolsOptions && toolsOptions.type ? toolsInit[toolsOptions.type] : toolsInit['all']))
...@@ -224,40 +343,71 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo }) => { ...@@ -224,40 +343,71 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo }) => {
> >
<Fragment> <Fragment>
<div className={`qicon q${type}`} /> <div className={`qicon q${type}`} />
<span>{item.title}</span> <span>{infoFor(type, item).title}</span>
</Fragment> </Fragment>
</div> </div>
) )
} else { } else {
if (type === 'undo' || type === 'redo') { if (type === 'undo' || type === 'redo') {
let disabled = type === 'undo' ? !editor.can().undo() : !editor.can().redo() let disabled = type === 'undo' ? !editor.can().undo() : !editor.can().redo()
const undoInfo = infoFor(type, item);
return ( return (
<div <Tooltip
key={idx} key={idx}
placement="bottom"
mouseEnterDelay={0.3}
mouseLeaveDelay={0}
overlayClassName="atma-editor-tip"
title={<span className="atma-editor-tip-title">{undoInfo.title}</span>}
>
<div
onClick={(e) => {
item.onClick();
e.preventDefault();
e.stopPropagation()
return false;
}}
className={`qicon q${type}` + (disabled ? ' disabled' : '')}
role="button"
tabIndex={disabled ? -1 : 0}
aria-label={undoInfo.title}
aria-disabled={disabled}
/>
</Tooltip>
)
}
const info = infoFor(type, item);
return (
<Tooltip
key={idx}
placement="bottom"
// 300 мс: не мешает быстрому клику, но не заставляет ждать,
// как нативный title (там задержка около секунды и её не настроить)
mouseEnterDelay={0.3}
mouseLeaveDelay={0}
overlayClassName="atma-editor-tip"
title={
<span className="atma-editor-tip-body">
<b className="atma-editor-tip-title">{info.title}</b>
{info.hint && <span className="atma-editor-tip-hint">{info.hint}</span>}
</span>
}
>
<div
onClick={(e) => { onClick={(e) => {
console.log(e.target);
item.onClick(); item.onClick();
e.preventDefault(); e.preventDefault();
e.stopPropagation() e.stopPropagation()
return false; return false;
}} }}
className={`qicon q${type}` + (disabled ? ' disabled' : '')} className={`qicon q${type}` + (isActive(type) ? ' active' : '')}
title={item.title} role="button"
tabIndex={0}
aria-label={info.title}
/> />
) </Tooltip>
}
return (
<div
key={idx}
onClick={(e) => {
item.onClick();
e.preventDefault();
e.stopPropagation()
return false;
}}
className={`qicon q${type}` + (isActive(type) ? ' active' : '')}
title={item.title}
/>
) )
} }
......
...@@ -1811,3 +1811,44 @@ body{ ...@@ -1811,3 +1811,44 @@ body{
max-height: min(52vh, 320px); max-height: min(52vh, 320px);
} }
} }
// ─────────────────────────────────────────────────────────────────────────────
// Подсказка у кнопки панели.
//
// Заменяет нативный browser title: у того задержка около секунды, серый
// системный вид и невозможность показать вторую строку. Именно он давал
// ощущение «панель из 2003 года».
// ─────────────────────────────────────────────────────────────────────────────
.atma-editor-tip {
max-width: 280px;
.ant-tooltip-inner {
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
line-height: 1.45;
}
}
.atma-editor-tip-body {
display: block;
}
.atma-editor-tip-title {
display: block;
font-weight: 600;
margin-bottom: 2px;
}
.atma-editor-tip-hint {
display: block;
opacity: 0.8;
}
// Кнопка панели должна быть доступна с клавиатуры: теперь у неё role="button"
// и tabIndex, значит нужен видимый фокус.
.qicon:focus-visible {
outline: 2px solid #1790FF;
outline-offset: 1px;
border-radius: 4px;
}
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