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 = ({
toolsOptions = {type: 'all'},
onInfo,
// Режим оформления контента, задаётся хостом: 'atma-content--lesson' | 'atma-content--compact' | ''
contentClass = ''
contentClass = '',
// Словарь подписей от хоста: { [ключ кнопки]: { title, hint } }.
// Нужен для перевода — своей локализации у пакета нет. Пусто — встроенные русские.
labels = {}
}) => {
global.uploadUrl = uploadOptions.url
......@@ -358,36 +361,27 @@ const QEditor = ({
setInnerModalType(type)
setModalIsOpen(true)
}
// Палитра сведена к шести семантическим значениям (решение владельца, §12 ТЗ,
// вопрос 3). Было по двенадцать произвольных оттенков на каждый список —
// с ними «заголовок выглядит одинаково во всех уроках» недостижимо.
// Значения взяты из темы платформы (_variables.scss / _content.scss),
// а не подобраны заново.
const colors = {
color: [
'none',
'#8a8a8a',
'#afafaf',
'#44d724',
'#0bd9b2',
'#4fb7ff',
'#226aff',
'#b153e5',
'#f54f8e',
'#f34c37',
'#ee7027',
'#d27303',
'#ffd102'
'none', // обычный текст
'#6B7280', // приглушённый
'#1790FF', // акцент
'#09C358', // успех
'#F6993F', // предупреждение
'#FF5F56' // ошибка
],
highlight: [
'none',
'#9B9B9B',
'#CCCCCC',
'#9ee191',
'#43e7bf',
'#4fb7ff',
'#6d9ef5',
'#cd92e8',
'#f597bc',
'#fa9084',
'#ef9558',
'#dea75b',
'#ffe672'
'#E3E7EC', // нейтральное выделение
'#CDE8FF', // акцент
'#C9F2D8', // успех
'#FFE9B8', // предупреждение
'#FFD9D6' // ошибка
]
}
const fontSizes = ['12px', '14px', '16px', '18px', '20px', '24px', '28px', '32px']
......@@ -515,10 +509,9 @@ const QEditor = ({
title: 'Эмодзи',
render: ({ key }) => <EmojiPicker key={key} editor={editor} />
},
pdfToText: {
title: 'Загрузить pdf',
onClick: () => modalOpener('pdf', 'Загрузить pdf')
},
// pdfToText удалён: его onClick вызывал modalOpener('pdf', …) — тот же модал
// с тем же аргументом, что и кнопка pdf, то есть буквальный дубль.
// В пресетах он не значился, автору не показывался никогда.
interactiveImage: {
title: 'Интерактивное изображение',
onClick: () => {
......@@ -1932,7 +1925,7 @@ const QEditor = ({
return (
<div className='atma-editor-wrap' style={style}>
<div className='atma-editor'>
<ToolBar editor={editor} {...{toolsOptions}} {...{toolsLib}} onInfo={onInfo} />
<ToolBar editor={editor} {...{toolsOptions}} {...{toolsLib}} onInfo={onInfo} labels={labels} />
<BubbleMenu
typpyOptions={{followCursor: true}}
editor={editor}
......
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 = {
'all': [
{ type: 'g', items: ['undo', 'redo'] },
{ type: 'g', items: ['|'] },
HEADINGS,
{ type: 'g', items: ['|'] },
// Aa▼ — стиль текста
{ type: 's', toggle: true, items: ['paragraph', 'h2', 'h3', 'h4'] },
{ type: 's', toggle: false, items: ['bulletList', 'orderedList', 'insertToggleBlock'] },
{ type: 's', toggle: true, items: ['alignLeft', 'alignCenter', 'alignRight'] },
TABLE_GROUP,
// Lists▼ — списки
{ type: 's', toggle: false, items: ['bulletList', 'orderedList', 'insertToggleBlock'] },
{ type: 'g', items: ['emoji'] },
{ type: 'g', items: ['clearMarks'] },
{ type: 's', toggle: false, items: ['blockquote', 'hr', 'codeBlock'] },
// ≡▼ — выравнивание
{ 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: ['|'] },
{ type: 'g', items: ['emoji'] },
{ type: 'g', items: ['clearMarks'] },
{ type: 's', toggle: false, items: ['blockquote', 'hr', 'codeBlock'] },
{ type: 's', toggle: false, items: ['image', 'interactiveImage'] },
{ type: 's', toggle: false, items: ['video', 'iframe'] },
{ 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: ['image', 'interactiveImage'] },
{ type: 's', toggle: false, items: ['iframe_pptx', 'iframe_pdf', 'pdf'] },
{ type: 's', toggle: false, items: ['link', 'buttonLink'] },
{ type: 'g', items: ['file'] },
{ type: 'g', items: ['iframe_custom'] },
{ type: 'g', items: ['export_pdf'] },
]
// Video▼ — загрузить / по ссылке
{ type: 's', toggle: false, items: ['video', 'iframe'] },
const toolsInit = {
// Урок: полный набор. Размера шрифта здесь нет намеренно — см. toolsLib.
'lesson': LESSON,
'all': LESSON,
{ type: 'g', items: ['audio'] },
{ type: 'g', items: ['voicemessage'] },
{ type: 'g', items: ['webcamera'] },
{ type: 'g', items: ['screencust'] },
// Описание модуля, инфо, профессии, маркет, база знаний в админке
'text': [
{ type: 'g', items: ['undo', 'redo'] },
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'] },
],
'text': [
{
type: 'g',
items: [
'undo',
'redo'
]
},
{
type: 's',
toggle: true,
items: [
'paragraph',
'h2',
'h3',
'h4'
]
},
{
type: 'g',
items: [
'bulletList',
'orderedList',
'blockquote',
'codeBlock',
'hr',
]
},
{
type: 's',
toggle: true,
items: [
'alignLeft',
'alignCenter',
'alignRight',
]
},
{
type: 'g',
items: [
'clearMarks',
'hardBreak',
]
},
]
// ─────────────────────────────────────────────────────────────────────────────
// Что кнопка делает — словами автора, а не разработчика.
//
// Повод (§1.9 ТЗ): на боевой базе `audio`, `toggle-block`, `interactive-image`
// и `buttonLink` использованы НОЛЬ раз, хотя все реализованы и работают.
// Причина видна в подписях: «Вставить iframe» — слово, которого нет в словаре
// методиста; четыре кнопки со словом «pdf», различить их невозможно.
//
// title — как называется по-человечески, hint — одна строка «что я получу».
// Хост может переопределить любую запись пропом labels (см. QEditor).
//
// Заполнено только там, где нужно объяснение. Для «жирный» и «курсив»
// описание не нужно — их узнают по иконке.
// ─────────────────────────────────────────────────────────────────────────────
const TOOL_INFO = {
// ── Медиа ───────────────────────────────────────────────────────────────
image: {
title: 'Изображение',
hint: 'Картинка из файла. Размер и положение настраиваются после вставки'
},
interactiveImage: {
title: 'Картинка с подсказками',
hint: 'Схема или фото, на которых можно расставить точки — по клику раскрывается пояснение'
},
video: {
title: 'Видео файлом',
hint: 'Загрузить видеофайл. Для роликов с YouTube и VK есть отдельная кнопка'
},
iframe: {
title: 'Видео по ссылке',
hint: 'Ролик с YouTube, VK, Rutube или Дзена — вставляется по адресу страницы'
},
audio: {
title: 'Аудио',
hint: 'Звуковой файл с проигрывателем прямо в уроке'
},
voicemessage: {
title: 'Голосовое сообщение',
hint: 'Записать голос с микрофона, не выходя из редактора'
},
webcamera: {
title: 'Запись с камеры',
hint: 'Записать короткое видео с веб-камеры прямо здесь'
},
screencust: {
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) {
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']))
......@@ -224,40 +343,71 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo }) => {
>
<Fragment>
<div className={`qicon q${type}`} />
<span>{item.title}</span>
<span>{infoFor(type, item).title}</span>
</Fragment>
</div>
)
} else {
if (type === 'undo' || type === 'redo') {
let disabled = type === 'undo' ? !editor.can().undo() : !editor.can().redo()
const undoInfo = infoFor(type, item);
return (
<div
<Tooltip
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) => {
console.log(e.target);
item.onClick();
e.preventDefault();
e.stopPropagation()
return false;
}}
className={`qicon q${type}` + (disabled ? ' disabled' : '')}
title={item.title}
className={`qicon q${type}` + (isActive(type) ? ' active' : '')}
role="button"
tabIndex={0}
aria-label={info.title}
/>
)
}
return (
<div
key={idx}
onClick={(e) => {
item.onClick();
e.preventDefault();
e.stopPropagation()
return false;
}}
className={`qicon q${type}` + (isActive(type) ? ' active' : '')}
title={item.title}
/>
</Tooltip>
)
}
......
......@@ -1811,3 +1811,44 @@ body{
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