Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Menu
Open sidebar
lib
react-ag-qeditor
Commits
c8ea4443
Commit
c8ea4443
authored
Sep 01, 2026
by
Яков
Browse files
update
parent
50e7c146
Changes
9
Show whitespace changes
Inline
Side-by-side
package.json
View file @
c8ea4443
{
{
"name"
:
"react-ag-qeditor"
,
"name"
:
"react-ag-qeditor"
,
"version"
:
"1.1.9
0
"
,
"version"
:
"1.1.9
2
"
,
"description"
:
"WYSIWYG html editor"
,
"description"
:
"WYSIWYG html editor"
,
"author"
:
"atma"
,
"author"
:
"atma"
,
"license"
:
"
MIT
"
,
"license"
:
"
MIT
"
,
...
...
src/QEditor.jsx
View file @
c8ea4443
...
@@ -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
)
=>
{
...
...
src/components/ToolBar.js
View file @
c8ea4443
...
@@ -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
:
'
Врезка
'
,
...
...
src/components/Uploader.js
View file @
c8ea4443
...
@@ -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
])
{
...
...
src/extensions/BlockId.js
View file @
c8ea4443
...
@@ -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
({
...
...
src/extensions/ButtonLink.js
View file @
c8ea4443
...
@@ -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
'
)
||
''
,
...
...
src/extensions/Image.jsx
View file @
c8ea4443
...
@@ -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
()
}
...
...
src/extensions/Video.js
View file @
c8ea4443
...
@@ -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
])
...
...
src/index.scss
View file @
c8ea4443
...
@@ -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); }
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment