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

update

parent a24ab44a
{ {
"name": "react-ag-qeditor", "name": "react-ag-qeditor",
"version": "1.1.82", "version": "1.1.83",
"description": "WYSIWYG html editor", "description": "WYSIWYG html editor",
"author": "atma", "author": "atma",
"license": "MIT", "license": "MIT",
......
...@@ -22,7 +22,7 @@ import TextStyle from '@tiptap/extension-text-style' ...@@ -22,7 +22,7 @@ import TextStyle from '@tiptap/extension-text-style'
import Superscript from '@tiptap/extension-superscript' import Superscript from '@tiptap/extension-superscript'
import Subscript from '@tiptap/extension-subscript' import Subscript from '@tiptap/extension-subscript'
import ToolBar from './components/ToolBar' import ToolBar, { Tip, toolInfo } from './components/ToolBar'
import EditorModal from './components/EditorModal' import EditorModal from './components/EditorModal'
import Uploader from './components/Uploader' import Uploader from './components/Uploader'
import EmojiPicker from './components/EmojiPicker' import EmojiPicker from './components/EmojiPicker'
...@@ -2120,10 +2120,14 @@ const QEditor = ({ ...@@ -2120,10 +2120,14 @@ const QEditor = ({
} else if (type === 'glossary') { } else if (type === 'glossary') {
if (window.location.pathname.includes('admin/maps')) { if (window.location.pathname.includes('admin/maps')) {
return ( return (
<Tip key={'bubbleItems-glossary'}
placement={'top'}
info={toolInfo('glossary', null, labels)}>
<div <div
key={'bubbleItems-glossary'}
className={'qicon'} className={'qicon'}
title={'Добавить в глоссарий'} role={'button'}
tabIndex={0}
aria-label={toolInfo('glossary', null, labels).title}
onClick={()=>{ onClick={()=>{
if (!editor.state.selection.empty) { if (!editor.state.selection.empty) {
const selectedText = editor.state.doc.textBetween( const selectedText = editor.state.doc.textBetween(
...@@ -2142,18 +2146,27 @@ const QEditor = ({ ...@@ -2142,18 +2146,27 @@ const QEditor = ({
> >
<Icon type='glossary' /> <Icon type='glossary' />
</div> </div>
</Tip>
) )
} }
} else { } else {
// Подсказка та же, что на панели: одна таблица
// подписей и одна обёртка. Раньше здесь стоял
// нативный `title` браузера — серый и с задержкой.
const info = toolInfo(type, toolsLib[type], labels);
return ( return (
<Tip key={'bubbleItems' + i} placement={'top'} info={info}>
<div <div
key={'bubbleItems' + i}
className={'qicon' + (editor.isActive(type) ? ' active' : '')} className={'qicon' + (editor.isActive(type) ? ' active' : '')}
title={toolsLib[type] ? toolsLib[type].title : ''} role={'button'}
tabIndex={0}
aria-label={info.title}
onClick={toolsLib[type].onClick} onClick={toolsLib[type].onClick}
> >
<Icon type={type} /> <Icon type={type} />
</div> </div>
</Tip>
) )
} }
})} })}
......
...@@ -240,9 +240,19 @@ const EmojiPicker = ({ editor }) => { ...@@ -240,9 +240,19 @@ const EmojiPicker = ({ editor }) => {
ref={rootRef} ref={rootRef}
className={'atma-editor-toolbar-emoji' + (isOpen ? ' open' : '')} className={'atma-editor-toolbar-emoji' + (isOpen ? ' open' : '')}
> >
{/*
Подсказка та же, что у остальных кнопок панели. Здесь дольше
всего оставался нативный `title` браузера: кнопка приходит
из своего компонента и обёртку панели минует — из-за этого
эмодзи была единственной кнопкой без общей подсказки.
*/}
<Tip info={{ title: 'Эмодзи', hint: 'Вставить символ из набора' }}>
<div <div
className={'qicon' + (isOpen ? ' active' : '')} className={'qicon' + (isOpen ? ' active' : '')}
title='Эмодзи' role='button'
tabIndex={0}
aria-label='Эмодзи'
aria-expanded={isOpen}
onMouseDown={(event) => { onMouseDown={(event) => {
event.preventDefault() event.preventDefault()
}} }}
...@@ -254,6 +264,7 @@ const EmojiPicker = ({ editor }) => { ...@@ -254,6 +264,7 @@ const EmojiPicker = ({ editor }) => {
> >
<Icon type='emoji' /> <Icon type='emoji' />
</div> </div>
</Tip>
{isOpen && ( {isOpen && (
<div <div
className='atma-editor-emoji-popover' className='atma-editor-emoji-popover'
......
...@@ -26,6 +26,7 @@ const HEADINGS = { type: 'g', items: ['paragraph', 'h2', 'h3', 'h4'] } ...@@ -26,6 +26,7 @@ const HEADINGS = { type: 'g', items: ['paragraph', 'h2', 'h3', 'h4'] }
const TABLE_GROUP = { const TABLE_GROUP = {
type: 's', type: 's',
toggle: false, toggle: false,
tip: 'table',
items: [ items: [
'insertTable', '|', 'insertTable', '|',
'addRowBefore', 'addRowAfter', 'deleteRow', '|', 'addRowBefore', 'addRowAfter', 'deleteRow', '|',
...@@ -81,10 +82,10 @@ const LESSON = [ ...@@ -81,10 +82,10 @@ const LESSON = [
{ type: 'g', items: ['|'] }, { type: 'g', items: ['|'] },
{ type: 'g', items: ['bulletList', 'orderedList'] }, { type: 'g', items: ['bulletList', 'orderedList'] },
{ type: 's', toggle: true, items: ['alignLeft', 'alignCenter', 'alignRight'] }, { type: 's', toggle: true, tip: 'align', items: ['alignLeft', 'alignCenter', 'alignRight'] },
// Особые блоки одним списком: у всех пяти общая роль — выделить кусок // Особые блоки одним списком: у всех пяти общая роль — выделить кусок
// материала из общего потока. // материала из общего потока.
{ type: 's', toggle: false, items: ['blockquote', 'callout', 'showAnswer', 'hr', 'codeBlock'] }, { type: 's', toggle: false, tip: 'blocks', items: ['blockquote', 'callout', 'showAnswer', 'hr', 'codeBlock'] },
TABLE_GROUP, TABLE_GROUP,
{ type: 'g', items: ['emoji'] }, { type: 'g', items: ['emoji'] },
...@@ -102,7 +103,7 @@ const LESSON = [ ...@@ -102,7 +103,7 @@ const LESSON = [
// //
// Иконки у записей близкие и в ряд читались как одно и то же; в списке // Иконки у записей близкие и в ряд читались как одно и то же; в списке
// у каждой подпись словами, и различить их можно с первого взгляда. // у каждой подпись словами, и различить их можно с первого взгляда.
{ type: 's', toggle: false, title: 'video', { type: 's', toggle: false, title: 'video', tip: 'media',
items: ['video', 'iframe', 'iframe_custom', 'voicemessage', 'webcamera', 'screencust'] }, items: ['video', 'iframe', 'iframe_custom', 'voicemessage', 'webcamera', 'screencust'] },
// ── «Оживить модуль» ───────────────────────────────────────────────────── // ── «Оживить модуль» ─────────────────────────────────────────────────────
...@@ -122,7 +123,7 @@ const LESSON = [ ...@@ -122,7 +123,7 @@ const LESSON = [
// четыре разные вещи со словом «pdf» в подписи. Рядом, с человеческими // четыре разные вещи со словом «pdf» в подписи. Рядом, с человеческими
// названиями, они наконец различимы. «Файл на скачивание» сюда же: // названиями, они наконец различимы. «Файл на скачивание» сюда же:
// вложение — такой же документ, а не сосед ссылки. // вложение — такой же документ, а не сосед ссылки.
{ type: 's', toggle: false, items: ['iframe_pptx', 'iframe_pdf', 'pdf', 'export_pdf', 'file'] }, { type: 's', toggle: false, tip: 'docs', items: ['iframe_pptx', 'iframe_pdf', 'pdf', 'export_pdf', 'file'] },
{ type: 'g', items: ['link'] }, { type: 'g', items: ['link'] },
] ]
...@@ -139,7 +140,7 @@ const LESSON = [ ...@@ -139,7 +140,7 @@ const LESSON = [
*/ */
const LESSON_NARROW = [ const LESSON_NARROW = [
{ type: 'g', items: ['undo', 'redo'] }, { type: 'g', items: ['undo', 'redo'] },
{ type: 's', toggle: true, items: ['paragraph', 'h2', 'h3', 'h4'] }, { type: 's', toggle: true, tip: 'headings', items: ['paragraph', 'h2', 'h3', 'h4'] },
{ type: 'g', items: ['bulletList', 'orderedList'] }, { type: 'g', items: ['bulletList', 'orderedList'] },
{ type: 's', toggle: true, items: ['alignLeft', 'alignCenter', 'alignRight'] }, { type: 's', toggle: true, items: ['alignLeft', 'alignCenter', 'alignRight'] },
{ type: 'g', items: ['link'] }, { type: 'g', items: ['link'] },
...@@ -317,9 +318,85 @@ const TOOL_INFO = { ...@@ -317,9 +318,85 @@ const TOOL_INFO = {
}, },
insertMore: { title: 'Вставить', hint: 'Всё, что кладёт в текст новый блок: картинка, видео, задание, файл' }, insertMore: { title: 'Вставить', hint: 'Всё, что кладёт в текст новый блок: картинка, видео, задание, файл' },
more: { title: 'Ещё', hint: 'Редкое и служебное: цитата, код, записи, документы' }, more: { title: 'Ещё', hint: 'Редкое и служебное: цитата, код, записи, документы' },
// ── Подписи самих выпадающих списков ────────────────────────────────────
//
// У кнопки, открывающей список, подсказки не было вовсе: наводишь —
// и ничего, хотя у каждого пункта внутри она есть. Кнопок таких шесть,
// и это ровно те места, где автор не понимает, что за ними прячется.
align: { title: 'Выравнивание', hint: 'По левому краю, по центру, по правому' },
headings: { title: 'Уровень текста', hint: 'Обычный текст или заголовок: H2, H3, H4' },
blocks: { title: 'Особые блоки', hint: 'Врезка, скрытый разбор, цитата, разделитель, блок кода' },
media: { title: 'Видео и встраивание', hint: 'Видеофайл, ролик по ссылке, вставка по коду, запись голоса, камеры и экрана' },
docs: { title: 'Документы', hint: 'Презентация, PDF, вложение на скачивание, выгрузка модуля в PDF' },
table: { title: 'Таблица', hint: 'Вставить таблицу и настроить строки, столбцы и границы' },
undo: { title: 'Отменить' }, undo: { title: 'Отменить' },
redo: { title: 'Вернуть' }, redo: { title: 'Вернуть' },
emoji: { title: 'Эмодзи', hint: 'Вставить символ из набора' }, emoji: { title: 'Эмодзи', hint: 'Вставить символ из набора' },
help: { title: 'Справка по редактору', hint: 'Что умеет редактор и как этим пользоваться' },
// ── Всплывающее меню над выделением ─────────────────────────────────────
//
// Там подсказок не было вовсе: работал нативный `title` браузера — серый,
// с секундной задержкой и одной строкой. Именно он и создавал ощущение
// «панель из 2003 года», из-за которого всю эту работу начали.
//
// Жирному и курсиву пояснение не нужно, их узнают по иконке, — но название
// всё равно должно всплывать в общем виде, а не системным всплывающим
// окном браузера.
bold: { title: 'Жирный' },
italic: { title: 'Курсив' },
underline: { title: 'Подчёркнутый' },
strike: { title: 'Зачёркнутый' },
superscript: { title: 'Надстрочный символ', hint: 'Степень или сноска: м², 1-й' },
subscript: { title: 'Подстрочный символ', hint: 'Индекс в формуле: H₂O' },
colorText: { title: 'Цвет текста', hint: 'Шесть смысловых цветов вместо произвольной палитры' },
highlight: { title: 'Выделение цветом', hint: 'Подсветка фоном, как маркером' },
fontSize: { title: 'Размер шрифта' },
glossary: { title: 'Добавить в глоссарий', hint: 'Занести выделенное слово в словарь компании' },
}
/**
* Название и пояснение кнопки — одна таблица на панель и на всплывающее меню.
*
* Порядок: словарь от хоста → встроенная таблица → title из toolsLib.
*/
export const toolInfo = (type, item, labels = {}) => {
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,
}
}
/**
* Обёртка-подсказка. Вынесена, чтобы панель и всплывающее меню показывали
* подсказку ОДИНАКОВО: разъедься они, получилось бы две разные подсказки
* в одном редакторе.
*/
export const Tip = ({ info, placement = 'bottom', children }) => {
if (!info.title) {
return children
}
return (
<Tooltip
placement={placement}
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>
}
>
{children}
</Tooltip>
)
} }
/** Ширина панели, ниже которой берётся узкий набор */ /** Ширина панели, ниже которой берётся узкий набор */
...@@ -376,15 +453,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) = ...@@ -376,15 +453,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
* Хост (у него есть formatjs и словари) передаёт переведённые строки пропом. * Хост (у него есть formatjs и словари) передаёт переведённые строки пропом.
* Нет пропа — работают встроенные русские, как раньше. * Нет пропа — работают встроенные русские, как раньше.
*/ */
const infoFor = (type, item) => { const infoFor = (type, item) => toolInfo(type, item, labels)
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 presetName = (toolsOptions && toolsOptions.type) ? toolsOptions.type : 'all' const presetName = (toolsOptions && toolsOptions.type) ? toolsOptions.type : 'all'
...@@ -648,13 +717,40 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) = ...@@ -648,13 +717,40 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
}}> }}>
<Fragment> <Fragment>
{ {
<div className={'qicon' + (activeType !== false && section.toggle !== false ? ' active' : '')}> // Подсказка у кнопки, открывающей список.
<Icon type={ // Ключ берётся из `tip` секции; нет его —
section.title // из первого пункта, чтобы кнопка не осталась
// совсем без подписи.
(() => {
const tipKey = section.tip || section.title || section.items[0];
const tip = infoFor(tipKey, toolsLib[tipKey]);
const iconType = section.title
? section.title ? section.title
: (activeType === false || section.toggle === false ? section.items[0] : activeType) : (activeType === false || section.toggle === false ? section.items[0] : activeType);
} />
return (
<Tooltip
placement="bottom"
mouseEnterDelay={0.3}
mouseLeaveDelay={0}
overlayClassName="atma-editor-tip"
title={
<span className="atma-editor-tip-body">
<b className="atma-editor-tip-title">{tip.title}</b>
{tip.hint && <span className="atma-editor-tip-hint">{tip.hint}</span>}
</span>
}
>
<div className={'qicon' + (activeType !== false && section.toggle !== false ? ' active' : '')}
role="button"
tabIndex={0}
aria-label={tip.title}
aria-haspopup="true">
<Icon type={iconType} />
</div> </div>
</Tooltip>
);
})()
} }
<div <div
className={'atma-editor-toolbar-s-opts'} className={'atma-editor-toolbar-s-opts'}
...@@ -684,10 +780,11 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) = ...@@ -684,10 +780,11 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
<div className="atma-editor-toolbar" ref={rootRef}> <div className="atma-editor-toolbar" ref={rootRef}>
{ getItems() } { getItems() }
{onInfo && ( {onInfo && (
<Tip info={infoFor('help', { title: 'Справка по редактору' })}>
<button <button
type="button" type="button"
className="atma-editor-toolbar-info" className="atma-editor-toolbar-info"
title="Справка по редактору" aria-label="Справка по редактору"
onClick={onInfo} onClick={onInfo}
> >
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
...@@ -696,6 +793,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) = ...@@ -696,6 +793,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
<circle cx="9" cy="5.5" r="0.75" fill="currentColor"/> <circle cx="9" cy="5.5" r="0.75" fill="currentColor"/>
</svg> </svg>
</button> </button>
</Tip>
)} )}
</div> </div>
) )
......
...@@ -113,6 +113,34 @@ const ICONS = { ...@@ -113,6 +113,34 @@ const ICONS = {
export default ICONS export default ICONS
/**
* Уровни текста подписываются буквами, а не рисуются значком.
*
* ── Почему ───────────────────────────────────────────────────────────────────
*
* Рисунком «обычный текст» и три заголовка были стопкой горизонтальных полос
* разной толщины. Ровно так же выглядят и три кнопки выравнивания — тоже
* стопки полос. В ряду семь кнопок, и различить, где заголовок, а где
* выравнивание, невозможно: полосы и там, и там.
*
* «H2» читается без обучения и без наведения. Это не отступление от языка
* набора: буквы в нём уже есть — B, I, U, S нарисованы так же.
*
* ── Почему уровни настоящие ──────────────────────────────────────────────────
*
* Подписи H2/H3/H4 — это те уровни, которые действительно попадут в разметку.
* H1 в контенте нет намеренно: единственный H1 на странице — название модуля,
* и второй сломал бы структуру для поиска и озвучивания с экрана. Написать
* «H1» на кнопке, которая делает H2, значило бы соврать в интерфейсе.
*/
const TEXT_ICONS = {
paragraph: 'T',
h1: 'H1',
h2: 'H2',
h3: 'H3',
h4: 'H4',
}
/** /**
* Иконка панели. * Иконка панели.
* *
...@@ -123,6 +151,15 @@ export default ICONS ...@@ -123,6 +151,15 @@ export default ICONS
* Рисунок 20×20 внутри кнопки 32×32 — полезная площадь из макета. * Рисунок 20×20 внутри кнопки 32×32 — полезная площадь из макета.
*/ */
export const Icon = ({ type, size = 20 }) => { export const Icon = ({ type, size = 20 }) => {
// Буквенная подпись — обычным текстом, а не текстом внутри svg: так она
// наследует цвет кнопки во всех шести состояниях тем же правилом
// на `color`, что и остальные иконки, и не зависит от шрифтов в svg.
if (TEXT_ICONS[type]) {
return (
<span className="qicon-text" aria-hidden="true">{TEXT_ICONS[type]}</span>
)
}
const body = ICONS[type] const body = ICONS[type]
if (!body) { if (!body) {
return null return null
......
...@@ -376,10 +376,23 @@ body{ ...@@ -376,10 +376,23 @@ body{
height: 8px; height: 8px;
} }
// ── Список открывается ПОД кнопкой ───────────────────────────
//
// Здесь не было `top`, и абсолютно позиционированный список
// вставал в своё «статическое» место — то есть по вертикальному
// центру кнопки, потому что группа выровнена `align-items: center`.
// У короткого списка сдвиг незаметен, а список таблицы из
// одиннадцати пунктов уезжал НАД панелью и накрывал текст модуля.
//
// `max-height` со прокруткой — на случай длинного списка внизу
// экрана: лучше прокрутить, чем показать половину за краем окна.
&-opts{ &-opts{
position: absolute; position: absolute;
clip: rect(0,0,0,0); top: calc(100% + 6px);
display: none;
width: 260px; width: 260px;
max-height: min(60vh, 420px);
overflow-y: auto;
border: 1px solid #E3E7EC; border: 1px solid #E3E7EC;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 12px 32px 0 rgba(15, 20, 25, 0.12); box-shadow: 0 12px 32px 0 rgba(15, 20, 25, 0.12);
...@@ -452,18 +465,24 @@ body{ ...@@ -452,18 +465,24 @@ body{
} }
} }
// В списке уровень показан САМИМ размером подписи — это
// нагляднее любой пометки. Поэтому и значок, и буквенная
// подпись здесь лишние: «H2» рядом с крупным «Заголовок
// раздела» повторяет то, что уже видно.
&[data-type="h2"], &[data-type="h3"], &[data-type="h4"]{ &[data-type="h2"], &[data-type="h3"], &[data-type="h4"]{
font-weight: 500; font-weight: 500;
padding: 8px; padding: 8px;
.qicon{ .qicon,
.qicon-text{
display: none; display: none;
} }
} }
&[data-type="paragraph"]{ &[data-type="paragraph"]{
padding: 8px; padding: 8px;
.qicon{ .qicon,
.qicon-text{
display: none; display: none;
} }
} }
...@@ -498,8 +517,11 @@ body{ ...@@ -498,8 +517,11 @@ body{
} }
&.show{ &.show{
// Было `clip: rect(0,0,0,0)` / `clip: auto`. Свойство устарело
// и, главное, не уживается с прокруткой внутри списка:
// обрезка считается от исходной коробки, а не от прокрученной.
#{$this}-opts{ #{$this}-opts{
clip: auto; display: block;
} }
&:after{ &:after{
...@@ -539,6 +561,19 @@ body{ ...@@ -539,6 +561,19 @@ body{
pointer-events: none; pointer-events: none;
} }
// Буквенная подпись уровня текста: T, H2, H3, H4.
// Цвет НЕ задаётся — наследуется от кнопки, как и у svg-иконок,
// поэтому все шесть состояний работают тем же правилом.
.qicon-text{
display: block;
pointer-events: none;
font-weight: 700;
font-size: 13px;
line-height: 1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
&:hover{ &:hover{
cursor: pointer; cursor: pointer;
background-color: #EDF1F6; background-color: #EDF1F6;
...@@ -970,13 +1005,21 @@ body{ ...@@ -970,13 +1005,21 @@ body{
margin-bottom: 10px; margin-bottom: 10px;
} }
// Выделение файла рисуется рамкой СНАРУЖИ плитки (`box-shadow` без
// inset), а список прокручиваемый. `overflow-y: auto` по стандарту
// делает `auto` и по второй оси — значит рамка у крайних плиток
// обрезалась: сверху, снизу и по бокам. На снимке автора это видно
// как «выделение подрезано».
//
// Отступ в 3 px даёт рамке место со всех сторон. Справа больше:
// там ещё полоса прокрутки.
&-list{ &-list{
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 10px; gap: 10px;
max-height: min(280px, calc(90dvh - 440px)); max-height: min(280px, calc(90dvh - 440px));
overflow-y: auto; overflow-y: auto;
padding-right: 4px; padding: 3px 7px 3px 3px;
&.is-video{ &.is-video{
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
......
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