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

update

parent 50e7c146
{ {
"name": "react-ag-qeditor", "name": "react-ag-qeditor",
"version": "1.1.90", "version": "1.1.92",
"description": "WYSIWYG html editor", "description": "WYSIWYG html editor",
"author": "atma", "author": "atma",
"license": "MIT", "license": "MIT",
......
...@@ -219,7 +219,23 @@ const QEditor = ({ ...@@ -219,7 +219,23 @@ const QEditor = ({
// Документ tiptap наружу. Отдельным пропом, а НЕ вторым аргументом onChange: // Документ tiptap наружу. Отдельным пропом, а НЕ вторым аргументом onChange:
// редактор используется в 21 месте, и все ждут строку с HTML. // редактор используется в 21 месте, и все ждут строку с HTML.
// Не передали — ничего не меняется. // Не передали — ничего не меняется.
onChangeDoc = null onChangeDoc = null,
/**
* Документ tiptap ВНУТРЬ — то, чем редактор открывается.
*
* До этого пропа редактор всегда стартовал с HTML (`value`), даже когда урок
* хранится документом. HTML же перед записью проходит санитайзер платформы,
* а тот оставляет закрытый список атрибутов. Всё, чего в списке нет, автор
* терял при первом же цикле «открыть — сохранить»: цвет и размер шрифта
* (инлайновый style вырезается), подпись скрытого разбора (data-label),
* оформление кнопки-ссылки (цвета и кегль). Правка выглядела применённой
* до перезагрузки страницы и молча пропадала после.
*
* Документ этих потерь не знает: он и есть то, что сохраняется.
* Поэтому если хост его дал — открываемся с него, а `value` остаётся
* для прежних мест (задания, комментарии, старые уроки без документа).
*/
document: initialDoc = null
}) => { }) => {
global.uploadUrl = uploadOptions.url global.uploadUrl = uploadOptions.url
...@@ -762,7 +778,13 @@ const QEditor = ({ ...@@ -762,7 +778,13 @@ const QEditor = ({
}, },
highlight: { highlight: {
title: 'Цвет фона', title: 'Цвет фона',
onClick: () => setColorsSelected('highlight') // focus() — как у цвета текста: без него нажатие уводит фокус
// из редактора, и выделение, к которому автор собирался применить
// заливку, теряется.
onClick: () => {
setColorsSelected('highlight')
editor.chain().focus()
}
}, },
voicemessage: { voicemessage: {
title: 'Записать голосовое сообщение', title: 'Записать голосовое сообщение',
...@@ -933,7 +955,12 @@ const QEditor = ({ ...@@ -933,7 +955,12 @@ const QEditor = ({
dragPreviewOpacity: 0.3 // Настройка прозрачности dragPreviewOpacity: 0.3 // Настройка прозрачности
}) })
], ],
content: value, // Документ старше HTML: см. проп `document`. Пустой документ
// (`content: []`) в расчёт не берём — он бы стёр урок, у которого
// тело лежит только в html.
content: (initialDoc && Array.isArray(initialDoc.content) && initialDoc.content.length)
? initialDoc
: value,
onUpdate: ({editor}) => { onUpdate: ({editor}) => {
onChange(editor.getHTML()) onChange(editor.getHTML())
...@@ -2129,13 +2156,26 @@ const QEditor = ({ ...@@ -2129,13 +2156,26 @@ const QEditor = ({
) )
}) })
: colors[colorsSelected].map((itemColor, i) => { : colors[colorsSelected].map((itemColor, i) => {
// Образец показывает БУКВУ, а не просто заливку:
// у цвета текста буква окрашена, у выделения —
// стоит на цветной подложке. Шесть пастельных
// плашек подряд читались как отключённые кнопки
// (жалоба «палитра задизейблена»), хотя работали.
const isText = colorsSelected === 'color'
const sample = itemColor === 'none'
? {}
: (isText ? {color: itemColor} : {background: itemColor})
return ( return (
<div <div
key={'colors' + colorsSelected + i} key={'colors' + colorsSelected + i}
className={ className={
'qcolors' + (itemColor === 'none' ? ' unset' : '') 'qcolors' + (itemColor === 'none' ? ' unset' : '')
} }
style={{background: itemColor}} title={itemColor === 'none'
? (isText ? 'Обычный цвет текста' : 'Убрать выделение')
: (isText ? 'Цвет текста' : 'Цвет выделения')}
style={sample}
onClick={() => { onClick={() => {
if (itemColor === 'none') { if (itemColor === 'none') {
colorsSelected === 'color' colorsSelected === 'color'
...@@ -2148,7 +2188,7 @@ const QEditor = ({ ...@@ -2148,7 +2188,7 @@ const QEditor = ({
} }
setColorsSelected(null) setColorsSelected(null)
}} }}
/> >{itemColor === 'none' ? '' : 'А'}</div>
) )
})) }))
: bubbleItems.map((type, i) => { : bubbleItems.map((type, i) => {
......
...@@ -169,7 +169,37 @@ const toolsInit = { ...@@ -169,7 +169,37 @@ const toolsInit = {
'lesson-narrow': LESSON_NARROW, 'lesson-narrow': LESSON_NARROW,
'all-narrow': LESSON_NARROW, 'all-narrow': LESSON_NARROW,
// Описание модуля, инфо, профессии, маркет, база знаний в админке /**
* Статья базы знаний в админке.
*
* Полный набор урока МИНУС две кнопки (решение владельца 01.09.2026:
* «расширить функционал редактора в базе знаний, всё возможно, кроме связи
* с упражнениями»):
*
* activityRef — та самая связь с упражнением. Задание принадлежит модулю,
* у статьи модуля нет, ссылаться не на что.
* selfCheck — ответ самопроверки уезжает в поблочный прогресс урока,
* а у статьи прохождения не бывает. В html от неё осталась
* бы плашка с вопросом и без вариантов — молча неработающий
* блок; лучше не показывать кнопку вовсе.
*
* Остальное работает: статья хранится тем же html, что и урок, и оживляют
* её те же скрипты платформы (сворачиваемый блок, картинка с подсказками,
* плеер).
*/
'knowledge': LESSON
.map((group) => (Array.isArray(group.items)
? { ...group, items: group.items.filter((i) => i !== 'activityRef' && i !== 'selfCheck') }
: group))
.filter((group) => !Array.isArray(group.items) || group.items.length > 0),
'knowledge-narrow': LESSON_NARROW
.map((group) => (Array.isArray(group.items)
? { ...group, items: group.items.filter((i) => i !== 'activityRef' && i !== 'selfCheck') }
: group))
.filter((group) => !Array.isArray(group.items) || group.items.length > 0),
// Описание модуля, инфо, профессии, маркет
'text': [ 'text': [
{ type: 'g', items: ['undo', 'redo'] }, { type: 'g', items: ['undo', 'redo'] },
HEADINGS, HEADINGS,
...@@ -194,6 +224,10 @@ const toolsInit = { ...@@ -194,6 +224,10 @@ const toolsInit = {
// иметь возможность приложить к ответу голосовую запись. // иметь возможность приложить к ответу голосовую запись.
'answer': [ 'answer': [
{ type: 'g', items: ['undo', 'redo'] }, { type: 'g', items: ['undo', 'redo'] },
// Заголовки вернули (просьба тестировщика 01.09.2026): развёрнутый ответ
// бывает на две страницы, и без них сотрудник не может разделить его
// на части — а ментор потом читает сплошной текст.
HEADINGS,
{ type: 'g', items: ['bulletList', 'orderedList'] }, { type: 'g', items: ['bulletList', 'orderedList'] },
{ type: 'g', items: ['link', 'image', 'file'] }, { type: 'g', items: ['link', 'image', 'file'] },
{ type: 'g', items: ['audio'] }, { type: 'g', items: ['audio'] },
...@@ -291,13 +325,20 @@ const TOOL_INFO = { ...@@ -291,13 +325,20 @@ const TOOL_INFO = {
h2: { title: 'Заголовок раздела', hint: 'Крупный заголовок — делит материал на части' }, h2: { title: 'Заголовок раздела', hint: 'Крупный заголовок — делит материал на части' },
h3: { title: 'Подзаголовок', hint: 'Заголовок внутри раздела' }, h3: { title: 'Подзаголовок', hint: 'Заголовок внутри раздела' },
h4: { title: 'Мелкий заголовок', hint: 'Третий уровень, для коротких врезок' }, h4: { title: 'Мелкий заголовок', hint: 'Третий уровень, для коротких врезок' },
// Название вернули прежнее — «Раскрывающийся список».
//
// Переименование в «Скрытый блок» выглядело безобидным, но рядом появился
// «Скрытый разбор», и два похожих названия слились в одно: тестировщик
// решила, что список ПРОПАЛ и его заменили разбором, и полурока собрала
// не тем блоком. Привычное имя стоит дороже точного.
insertToggleBlock: { insertToggleBlock: {
title: 'Скрытый блок', title: 'Раскрывающийся список',
hint: 'Текст под заголовком, который раскрывается по клику: подробности, ответ, пример' hint: 'Текст под заголовком, который раскрывается по клику: подробности, ответ, пример'
}, },
showAnswer: { showAnswer: {
title: 'Скрытый разбор', title: 'Скрытый разбор',
hint: 'Ответ, закрытый до клика: дать читателю подумать самому, а потом свериться' hint: 'То же, что раскрывающийся список, но по смыслу: ответ, закрытый до клика — '
+ 'дать читателю подумать самому, а потом свериться'
}, },
callout: { callout: {
title: 'Врезка', title: 'Врезка',
......
...@@ -96,6 +96,7 @@ export default class Uploader extends React.Component { ...@@ -96,6 +96,7 @@ export default class Uploader extends React.Component {
progress: 0, progress: 0,
isUpload: false, isUpload: false,
isSuccess: false, isSuccess: false,
uploadError: null,
html: '' html: ''
} }
...@@ -184,6 +185,9 @@ export default class Uploader extends React.Component { ...@@ -184,6 +185,9 @@ export default class Uploader extends React.Component {
{this.processingMessage} {this.processingMessage}
</div> </div>
)} )}
{this.state.uploadError && (
<div className='atma-editor-uploader-error'>{this.state.uploadError}</div>
)}
<div className={'atma-editor-uploader-uitems'}> <div className={'atma-editor-uploader-uitems'}>
{ {
Object.keys(this.state.files).map((uid, i) => ( Object.keys(this.state.files).map((uid, i) => (
...@@ -216,7 +220,8 @@ export default class Uploader extends React.Component { ...@@ -216,7 +220,8 @@ export default class Uploader extends React.Component {
disabled={disabledFileUpload} disabled={disabledFileUpload}
beforeUpload={(a) => { beforeUpload={(a) => {
this.files[a.uid] = {...a, percent: 0, uploaded: false}; this.files[a.uid] = {...a, percent: 0, uploaded: false};
this.setState({ isUpload: true, files: this.files }); // Прошлая ошибка к новой попытке отношения не имеет
this.setState({ isUpload: true, files: this.files, uploadError: null });
}} }}
onStart={() => { onStart={() => {
this.setState({disabledFileUpload: true, isUpload: true}); this.setState({disabledFileUpload: true, isUpload: true});
...@@ -270,6 +275,28 @@ export default class Uploader extends React.Component { ...@@ -270,6 +275,28 @@ export default class Uploader extends React.Component {
if (resp.state === 'success' && resp.html) { if (resp.state === 'success' && resp.html) {
this.setState({html: resp.html, uploaderSuccess: true}, () => this.onSuccess(undefined, resp.html)) this.setState({html: resp.html, uploaderSuccess: true}, () => this.onSuccess(undefined, resp.html))
} }
// Отказ сервера — тоже ответ, и его надо показать.
//
// Раньше обрабатывался только успех: при ошибке
// (а «перенести текст из PDF» падает, если на сервере
// нет распознавания) плашка «подождите…» висела
// навсегда, кнопка «Вставить» вставляла пустоту,
// и понять, что произошло, было нельзя.
if (resp.state !== 'success') {
this.setState({
isUpload: false,
disabledFileUpload: false,
uploadError: resp.message || 'Файл не удалось обработать',
})
}
}}
onError={(err) => {
this.setState({
isUpload: false,
disabledFileUpload: false,
uploadError: (err && err.message) || 'Файл не удалось загрузить',
})
}} }}
onProgress={(o, file) => { onProgress={(o, file) => {
if (this.files[file.uid]) { if (this.files[file.uid]) {
......
...@@ -23,10 +23,15 @@ import { Extension } from '@tiptap/core' ...@@ -23,10 +23,15 @@ import { Extension } from '@tiptap/core'
* Поймано сквозной проверкой: два сохранения подряд без единой правки давали * Поймано сквозной проверкой: два сохранения подряд без единой правки давали
* две ревизии вместо одной. * две ревизии вместо одной.
*/ */
// Список обязан совпадать с LessonDocumentSchema::BLOCK_NODES на сервере:
// сервер раздаёт идентификаторы этим же типам, и узел, которого нет здесь,
// терял бы свой data-block-id при каждом разборе HTML — вместе с прогрессом
// учащегося по этому блоку.
const BLOCK_TYPES = [ const BLOCK_TYPES = [
'paragraph', 'heading', 'bulletList', 'orderedList', 'blockquote', 'codeBlock', 'paragraph', 'heading', 'bulletList', 'orderedList', 'blockquote', 'codeBlock',
'horizontalRule', 'image', 'video', 'audio', 'iframe', 'interactiveImage', 'horizontalRule', 'image', 'video', 'audio', 'iframe', 'interactiveImage',
'toggleBlock', 'buttonLink', 'table', 'toggleBlock', 'buttonLink', 'table',
'callout', 'showAnswer', 'selfCheck', 'activityRef',
] ]
const BlockId = Extension.create({ const BlockId = Extension.create({
......
...@@ -36,6 +36,11 @@ const ButtonLink = Node.create({ ...@@ -36,6 +36,11 @@ const ButtonLink = Node.create({
return [ return [
{ {
tag: 'a[data-button-link="true"]', tag: 'a[data-button-link="true"]',
// Приоритет выше обычного (50): тот же элемент подходит
// и под марку ссылки, а выигрывает правило с большим весом.
// Пока веса были равны, кнопка при разборе HTML превращалась
// в подчёркнутую ссылку и теряла все настройки вида.
priority: 100,
getAttrs: (element) => ({ getAttrs: (element) => ({
text: element.textContent || 'Кнопка', text: element.textContent || 'Кнопка',
href: element.getAttribute('href') || '', href: element.getAttribute('href') || '',
......
...@@ -648,7 +648,15 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select ...@@ -648,7 +648,15 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
// Единая структура NodeViewWrapper > div > content — img всегда на одной глубине, // Единая структура NodeViewWrapper > div > content — img всегда на одной глубине,
// поэтому при смене выравнивания React не размонтирует img и не перезагружает его. // поэтому при смене выравнивания React не размонтирует img и не перезагружает его.
return ( return (
<NodeViewWrapper as="div" style={getOuterStyle()} contentEditable={false} data-image-wrapper> // data-align и data-wrap продублированы на обёртке нарочно: по ним
// оформление платформы отличает обтекаемую картинку от обычной.
// Абзац с одиночным медиа разрешено делать шире колонки текста
// (центрированная картинка идёт по --atma-bleed), но обтекаемая
// картинка обязана остаться В КОЛОНКЕ — иначе она уезжает к краю
// листа, левее текста, и текст обтекает её со ступенькой.
<NodeViewWrapper as="div" style={getOuterStyle()} contentEditable={false} data-image-wrapper
data-align={node.attrs.align || 'left'}
data-wrap={node.attrs.wrap ? 'true' : undefined}>
<div <div
ref={wrapperRef} ref={wrapperRef}
style={getInnerStyle()} style={getInnerStyle()}
......
...@@ -101,15 +101,46 @@ const ResizableVideoView = ({ editor, node, updateAttributes, getPos, selected } ...@@ -101,15 +101,46 @@ const ResizableVideoView = ({ editor, node, updateAttributes, getPos, selected }
const { width: editorWidth } = getEditorDimensions() const { width: editorWidth } = getEditorDimensions()
const initialWidth = Math.min(editorWidth, 720) const initialWidth = Math.min(editorWidth, 720)
const initialHeight = Math.round((initialWidth * 9) / 16) const el = videoRef.current
/**
* Высота — по НАСТОЯЩИМ пропорциям файла, а не по 16:9.
*
* Раньше здесь всегда стояло width * 9 / 16. Для ролика с YouTube это
* верно, а для записи с камеры (обычно 4:3) и для вертикального видео
* с телефона — нет: кадр вписывался в чужую рамку с широкими чёрными
* полями, и автор видел «видео не влезает в границы плеера».
*
* Пропорции известны только после метаданных, поэтому ждём событие.
* Запасные 16:9 остаются на случай, когда метаданные не приходят вовсе
* (файл ещё грузится, формат не поддержан) — без них высота была бы
* нулевой и узел схлопнулся бы.
*/
const apply = () => {
const ratio = el && el.videoWidth && el.videoHeight
? el.videoWidth / el.videoHeight
: 16 / 9
setTimeout(() => {
safeUpdateAttributes({ safeUpdateAttributes({
width: initialWidth, width: initialWidth,
height: initialHeight, height: Math.round(initialWidth / ratio),
align: node.attrs.align || 'left', align: node.attrs.align || 'left',
}) })
}, 0) }
if (el && el.readyState >= 1 && el.videoWidth) {
setTimeout(apply, 0)
} else if (el) {
el.addEventListener('loadedmetadata', apply, { once: true })
// Метаданных можно не дождаться — тогда ставим размер по запасным
// пропорциям, иначе узел останется без высоты насовсем.
setTimeout(() => {
el.removeEventListener('loadedmetadata', apply)
if (!node.attrs.height) apply()
}, 4000)
} else {
setTimeout(apply, 0)
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [node.attrs.width, node.attrs.height]) }, [node.attrs.width, node.attrs.height])
......
...@@ -123,9 +123,17 @@ body{ ...@@ -123,9 +123,17 @@ body{
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
// Окно записи 16:9, а камера отдаёт что угодно — чаще 4:3, на ноутбуках
// бывает и вертикальный кадр. При height:auto такое видео вылезало за
// контейнер, а overflow: hidden срезал ему низ ВМЕСТЕ с панелью
// проигрывания: «после записанного видео не видно нижнее меню».
//
// contain вписывает кадр целиком: по бокам появляются поля, зато видно
// и запись, и её органы управления.
&-video { &-video {
width: 100%; width: 100%;
height: auto; height: 100%;
object-fit: contain;
border-radius: 12px; border-radius: 12px;
} }
...@@ -631,15 +639,13 @@ body{ ...@@ -631,15 +639,13 @@ body{
padding: 12px 15px; padding: 12px 15px;
padding-bottom: 0; padding-bottom: 0;
// ── Поле набора урока выглядит листом с полями ─────────────────────── // ── Поле набора урока выглядит листом ────────────────────────────────
// //
// Текст ограничен мерой строки (`--atma-measure`, 608 px ≈ 70 знаков — // Лист — белый прямоугольник шириной ровно с колонку контента
// требование ТЗ владельца §6.2: «порядка 65–75 символов на desktop»), // (`--atma-bleed` из платформы) на светлой подложке. Ширину колонки
// а поле ввода шире. Пустые поля по бокам сами по себе читаются как сбой: // задаёт платформа, здесь она не зашита: с 01.09.2026 это 1000 px
// «почему в редакторе слева-справа такой отступ?» — вопрос владельца // и на текст, и на медиа (решение владельца), а до того колонка была
// 29.08.2026. Ширину не трогаем (у учащегося та же, на этом держится // уже поля — и пустые поля по бокам читались как сбой, отсюда лист.
// WYSIWYG), а показываем, что это ПОЛЯ СТРАНИЦЫ: белый лист шириной
// с колонку контента на светлой подложке.
// //
// Только урок: в узких карточках (комментарий наставника, глоссарий) // Только урок: в узких карточках (комментарий наставника, глоссарий)
// лист шире содержимого не бывает, и подложка там ни к чему. // лист шире содержимого не бывает, и подложка там ни к чему.
...@@ -654,6 +660,20 @@ body{ ...@@ -654,6 +660,20 @@ body{
background: #ffffff; background: #ffffff;
border-radius: 4px; border-radius: 4px;
box-shadow: 0 0 0 1px #E3E7EC; box-shadow: 0 0 0 1px #E3E7EC;
// Лист обязан СОДЕРЖАТЬ обтекаемую картинку по высоте.
//
// Картинка с обтеканием — это float, а float не увеличивает
// высоту родителя. Если такая картинка стоит последней,
// текста под ней нет, и лист заканчивался по последней СТРОКЕ:
// картинка наполовину висела ниже белого поля, поверх подложки
// («блок для ввода подтягивается вверх, изображение не попадает
// в блок» — п.1 разбора тестировщика).
//
// flow-root, а не clearfix-псевдоэлемент: у ProseMirror ::after
// занят служебной разметкой, а новый блочный потомок сбивает
// расчёт позиций каретки.
display: flow-root;
} }
} }
...@@ -897,6 +917,39 @@ body{ ...@@ -897,6 +917,39 @@ body{
color: #656D77; color: #656D77;
} }
// Отказ загрузки или разбора файла. Раньше показать его было негде,
// и «перенести текст из PDF» на сервере без распознавания выглядело
// как вечное «подождите…».
&-error{
margin-bottom: 16px;
padding: 8px 12px;
border: 1px solid #FFA39E;
border-radius: 6px;
background: #FFF1F0;
color: #A8071A;
font-size: 13px;
text-align: left;
}
&-processing{
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
font-size: 13px;
color: #6B7280;
text-align: left;
}
&-processing-spinner{
width: 14px;
height: 14px;
border: 2px solid #D9D9D9;
border-top-color: #1790FF;
border-radius: 50%;
animation: atma-uploader-spin 0.8s linear infinite;
}
&-uitems{ &-uitems{
display: block; display: block;
text-align: left; text-align: left;
...@@ -1449,14 +1502,37 @@ body{ ...@@ -1449,14 +1502,37 @@ body{
// ── Образец цвета ────────────────────────────────────────────────────────
//
// Внутри образца стоит буква «А» — и это не украшение.
//
// Цвета выделения по замыслу пастельные (текст поверх них обязан читаться),
// и шесть бледных плашек подряд выглядели как отключённые кнопки: «палитра
// задизейблена» — дословная жалоба тестировщика 01.09.2026, при том что
// палитра работала. Рядом палитра цвета ТЕКСТА из насыщенных цветов, и на
// её фоне разница читалась как поломка.
//
// Буква показывает, что образец делает: у цвета текста она окрашена сама,
// у выделения — стоит на цветной подложке. Обе палитры при этом выглядят
// одинаково живыми.
.qcolors{ .qcolors{
display: inline-block; display: inline-flex;
align-items: center;
justify-content: center;
width: 20px; width: 20px;
height: 20px; height: 20px;
opacity: 1; opacity: 1;
border: 1px solid rgba(15, 20, 25, 0.15);
border-radius: 4px;
font-size: 13px;
font-weight: 600;
line-height: 1;
color: #0F1419;
user-select: none;
&:hover{ &:hover{
cursor: pointer; cursor: pointer;
border-color: rgba(15, 20, 25, 0.45);
} }
&.unset{ &.unset{
...@@ -1889,3 +1965,7 @@ body{ ...@@ -1889,3 +1965,7 @@ body{
outline-offset: 2px; outline-offset: 2px;
border-radius: 8px; border-radius: 8px;
} }
@keyframes atma-uploader-spin {
to { transform: rotate(360deg); }
}
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