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

update

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