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
42cd3cf9
Commit
42cd3cf9
authored
Aug 29, 2026
by
Яков
Browse files
update
parent
cf69071b
Changes
6
Hide whitespace changes
Inline
Side-by-side
package.json
View file @
42cd3cf9
{
{
"name"
:
"react-ag-qeditor"
,
"name"
:
"react-ag-qeditor"
,
"version"
:
"1.1.8
7
"
,
"version"
:
"1.1.8
9
"
,
"description"
:
"WYSIWYG html editor"
,
"description"
:
"WYSIWYG html editor"
,
"author"
:
"atma"
,
"author"
:
"atma"
,
"license"
:
"
MIT
"
,
"license"
:
"
MIT
"
,
...
...
src/QEditor.jsx
View file @
42cd3cf9
...
@@ -1797,21 +1797,29 @@ const QEditor = ({
...
@@ -1797,21 +1797,29 @@ const QEditor = ({
})
})
.
command
(({
tr
,
state
})
=>
{
.
command
(({
tr
,
state
})
=>
{
// После setImage курсор — NodeSelection на картинке.
// После setImage курсор — NodeSelection на картинке.
// На десктопе: TextSelection сразу после картинки (inline).
// Уводим его в пустой абзац ПОД картинкой, при
// На мобильном: курсор в следующий параграф — иначе он
// необходимости создавая его.
// растягивается на всю высоту картинки.
//
// Раньше на десктопе курсор ставился «сразу после
// картинки» — а она занимает всю ширину строки, и это
// верхний ПРАВЫЙ угол: тонкая каретка сбоку от картинки
// выглядит как сбой, печатать «под картинкой» нельзя,
// текст прыгает на следующую строку сам (замечание
// владельца 29.08.2026). На мобильном курсор туда же
// уводили и раньше — теперь поведение одно на всех.
const
{
$to
}
=
state
.
selection
const
{
$to
}
=
state
.
selection
if
(
isMobile
)
{
const
paragraph
=
state
.
schema
.
nodes
.
paragraph
const
afterPara
=
$to
.
after
(
Math
.
min
(
$to
.
depth
,
1
))
try
{
const
target
=
afterPara
<
tr
.
doc
.
nodeSize
-
1
const
after
=
$to
.
after
(
Math
.
min
(
$to
.
depth
,
1
))
?
afterPara
+
1
const
следом
=
tr
.
doc
.
nodeAt
(
after
)
:
afterPara
const
пустойАбзац
=
следом
try
{
&&
следом
.
type
===
paragraph
tr
.
setSelection
(
TextSelection
.
create
(
tr
.
doc
,
target
))
&&
следом
.
content
.
size
===
0
}
catch
{
if
(
!
пустойАбзац
)
{
tr
.
setSelection
(
TextSelection
.
create
(
tr
.
doc
,
$to
.
pos
))
tr
.
insert
(
after
,
paragraph
.
create
(
))
}
}
}
else
{
tr
.
setSelection
(
TextSelection
.
create
(
tr
.
doc
,
after
+
1
))
}
catch
{
tr
.
setSelection
(
TextSelection
.
create
(
tr
.
doc
,
$to
.
pos
))
tr
.
setSelection
(
TextSelection
.
create
(
tr
.
doc
,
$to
.
pos
))
}
}
return
true
return
true
...
...
src/components/Tip.js
View file @
42cd3cf9
import
React
from
'
react
'
import
React
,
{
useState
}
from
'
react
'
import
{
Tooltip
}
from
'
antd
'
import
{
Tooltip
}
from
'
antd
'
/**
/**
...
@@ -21,18 +21,66 @@ import { Tooltip } from 'antd'
...
@@ -21,18 +21,66 @@ import { Tooltip } from 'antd'
* Именно она давала ощущение «панель из 2003 года», с которого началась вся
* Именно она давала ощущение «панель из 2003 года», с которого началась вся
* работа над редактором. Здесь — название и строка «что я получу».
* работа над редактором. Здесь — название и строка «что я получу».
*/
*/
/**
* Насколько опустить подсказку, чтобы она не легла на саму панель.
*
* Когда кнопки не помещаются в один ряд (узкое окно, панель 70–80 px вместо 46),
* подсказка кнопки ВЕРХНЕГО ряда всплывала под этой кнопкой — то есть поверх
* нижнего ряда: иконки закрыты, а сама подсказка читается вперемешку с ними.
* Поэтому опускаем её до нижнего края панели: расстояние считаем от кнопки,
* над которой курсор, до низа панели.
*
* Кнопку ищем через :hover, а не по ref: подсказка оборачивает и обычные
* кнопки, и чужие компоненты (выпадающие списки, эмодзи), и не каждый из них
* пробрасывает ref наружу. Вне панели (меню над выделением) поиск ничего
* не находит, и поведение остаётся прежним.
*/
const
смещениеПодПанель
=
()
=>
{
if
(
typeof
document
===
'
undefined
'
)
{
return
null
}
const
панель
=
document
.
querySelector
(
'
.atma-editor-toolbar
'
)
if
(
!
панель
)
{
return
null
}
// Без spread по NodeList: сборка пакета превращает [...nodeList] в
// [].concat(nodeList), а это кладёт в массив сам список, а не его элементы —
// обработчик падал с TypeError, и подсказка оставалась на месте.
const
подКурсором
=
панель
.
querySelectorAll
(
'
:hover
'
)
const
кнопка
=
подКурсором
.
length
?
подКурсором
[
подКурсором
.
length
-
1
]
:
null
if
(
!
кнопка
)
{
return
null
}
const
сдвиг
=
Math
.
round
(
панель
.
getBoundingClientRect
().
bottom
-
кнопка
.
getBoundingClientRect
().
bottom
)
return
сдвиг
>
1
?
[
0
,
сдвиг
]
:
null
}
const
Tip
=
({
info
,
placement
=
'
bottom
'
,
children
})
=>
{
const
Tip
=
({
info
,
placement
=
'
bottom
'
,
children
})
=>
{
const
[
offset
,
setOffset
]
=
useState
(
null
)
// Нет названия — нет и подсказки: пустое чёрное окошко хуже, чем ничего
// Нет названия — нет и подсказки: пустое чёрное окошко хуже, чем ничего
if
(
!
info
||
!
info
.
title
)
{
if
(
!
info
||
!
info
.
title
)
{
return
children
return
children
}
}
const
onOpenChange
=
(
open
)
=>
{
if
(
!
open
)
{
setOffset
(
null
)
return
}
setOffset
(
placement
===
'
bottom
'
?
смещениеПодПанель
()
:
null
)
}
return
(
return
(
<
Tooltip
<
Tooltip
placement
=
{
placement
}
placement
=
{
placement
}
mouseEnterDelay
=
{
0.3
}
mouseEnterDelay
=
{
0.3
}
mouseLeaveDelay
=
{
0
}
mouseLeaveDelay
=
{
0
}
overlayClassName
=
"
atma-editor-tip
"
overlayClassName
=
"
atma-editor-tip
"
onOpenChange
=
{
onOpenChange
}
align
=
{
offset
?
{
offset
}
:
undefined
}
title
=
{
title
=
{
<
span
className
=
"
atma-editor-tip-body
"
>
<
span
className
=
"
atma-editor-tip-body
"
>
<
b
className
=
"
atma-editor-tip-title
"
>
{
info
.
title
}
<
/b
>
<
b
className
=
"
atma-editor-tip-title
"
>
{
info
.
title
}
<
/b
>
...
...
src/components/ToolBar.js
View file @
42cd3cf9
import
React
,
{
Fragment
,
useEffect
,
useRef
,
useState
}
from
'
react
'
;
import
React
,
{
Fragment
,
useEffect
,
useRef
,
useState
}
from
'
react
'
;
import
{
Tooltip
}
from
'
antd
'
;
import
{
Icon
}
from
'
../icons
'
;
import
{
Icon
}
from
'
../icons
'
;
import
Tip
from
'
./Tip
'
;
import
Tip
from
'
./Tip
'
;
...
@@ -559,14 +558,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
...
@@ -559,14 +558,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
const
undoInfo
=
infoFor
(
type
,
item
);
const
undoInfo
=
infoFor
(
type
,
item
);
return
(
return
(
<
Tooltip
<
Tip
key
=
{
idx
}
info
=
{
undoInfo
}
>
key
=
{
idx
}
placement
=
"
bottom
"
mouseEnterDelay
=
{
0.3
}
mouseLeaveDelay
=
{
0
}
overlayClassName
=
"
atma-editor-tip
"
title
=
{
<
span
className
=
"
atma-editor-tip-title
"
>
{
undoInfo
.
title
}
<
/span>
}
>
<
div
<
div
onClick
=
{(
e
)
=>
{
onClick
=
{(
e
)
=>
{
item
.
onClick
();
item
.
onClick
();
...
@@ -582,27 +574,13 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
...
@@ -582,27 +574,13 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
>
>
<
Icon
type
=
{
type
}
/
>
<
Icon
type
=
{
type
}
/
>
<
/div
>
<
/div
>
<
/T
oolt
ip
>
<
/Tip
>
)
)
}
}
const
info
=
infoFor
(
type
,
item
);
const
info
=
infoFor
(
type
,
item
);
return
(
return
(
<
Tooltip
<
Tip
key
=
{
idx
}
info
=
{
info
}
>
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
<
div
onClick
=
{(
e
)
=>
{
onClick
=
{(
e
)
=>
{
item
.
onClick
();
item
.
onClick
();
...
@@ -618,7 +596,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
...
@@ -618,7 +596,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
>
>
<
Icon
type
=
{
type
}
/
>
<
Icon
type
=
{
type
}
/
>
<
/div
>
<
/div
>
<
/T
oolt
ip
>
<
/Tip
>
)
)
}
}
...
@@ -703,18 +681,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
...
@@ -703,18 +681,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
:
(
activeType
===
false
||
section
.
toggle
===
false
?
section
.
items
[
0
]
:
activeType
);
:
(
activeType
===
false
||
section
.
toggle
===
false
?
section
.
items
[
0
]
:
activeType
);
return
(
return
(
<
Tooltip
<
Tip
info
=
{
tip
}
>
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
'
:
''
)}
<
div
className
=
{
'
qicon
'
+
(
activeType
!==
false
&&
section
.
toggle
!==
false
?
'
active
'
:
''
)}
role
=
"
button
"
role
=
"
button
"
tabIndex
=
{
0
}
tabIndex
=
{
0
}
...
@@ -722,7 +689,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
...
@@ -722,7 +689,7 @@ const ToolBar = ({ editor, toolsLib = [], toolsOptions, onInfo, labels = {} }) =
aria
-
haspopup
=
"
true
"
>
aria
-
haspopup
=
"
true
"
>
<
Icon
type
=
{
iconType
}
/
>
<
Icon
type
=
{
iconType
}
/
>
<
/div
>
<
/div
>
<
/T
oolt
ip
>
<
/Tip
>
);
);
})()
})()
}
}
...
...
src/extensions/Image.jsx
View file @
42cd3cf9
...
@@ -29,41 +29,12 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
...
@@ -29,41 +29,12 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
const
isNoWrap
=
!
node
.
attrs
.
wrap
&&
(
node
.
attrs
.
align
===
'
left
'
||
node
.
attrs
.
align
===
'
right
'
);
const
isNoWrap
=
!
node
.
attrs
.
wrap
&&
(
node
.
attrs
.
align
===
'
left
'
||
node
.
attrs
.
align
===
'
right
'
);
// На десктопе вставляем \u200B после картинки, чтобы курсор можно было
// Раньше сразу после картинки вставлялся \u200B — «чтобы курсор можно было
// поставить inline сразу после неё. На мобильном это не нужно — там курсор
// поставить inline сразу после неё». Убрано 29.08.2026: картинка занимает
// всё равно встаёт на всю высоту картинки, выглядит некорректно.
// всю ширину строки, и позиция «сразу после» — это верхний ПРАВЫЙ угол,
useEffect
(()
=>
{
// где тонкая каретка выглядит как сбой («курсор почему-то справа», замечание
if
(
isMobile
)
return
// владельца). Курсор после вставки теперь уводится в абзац под картинкой
if
(
!
editor
||
!
getPos
||
editor
.
isDestroyed
)
return
// (QEditor), а невидимый символ только мусорил в сохранённом HTML.
let
pos
try
{
pos
=
getPos
()
}
catch
{
return
}
if
(
typeof
pos
!==
'
number
'
)
return
const
{
doc
}
=
editor
.
state
const
node
=
doc
.
nodeAt
(
pos
)
if
(
!
node
||
node
.
type
.
name
!==
'
image
'
)
return
const
next
=
doc
.
nodeAt
(
pos
+
node
.
nodeSize
)
if
(
next
?.
isText
&&
next
.
text
===
'
\
u200B
'
)
return
requestAnimationFrame
(()
=>
{
if
(
editor
.
isDestroyed
)
return
try
{
const
p
=
getPos
()
const
n
=
editor
.
state
.
doc
.
nodeAt
(
p
)
if
(
!
n
||
n
.
type
.
name
!==
'
image
'
)
return
editor
.
commands
.
insertContentAt
(
p
+
n
.
nodeSize
,
'
\
u200B
'
)
}
catch
{}
})
},
[])
// Получаем текущую ширину редактора и доступное пространство
// Получаем текущую ширину редактора и доступное пространство
const
getEditorDimensions
=
()
=>
{
const
getEditorDimensions
=
()
=>
{
...
...
src/extensions/TrailingNode.js
View file @
42cd3cf9
...
@@ -26,6 +26,28 @@ import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'
...
@@ -26,6 +26,28 @@ import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'
* Абзацы после заголовков и текста НЕ добавляем: там курсор ставится и так,
* Абзацы после заголовков и текста НЕ добавляем: там курсор ставится и так,
* а лишняя пустая строка уезжала бы в сохранённый HTML на каждом сохранении.
* а лишняя пустая строка уезжала бы в сохранённый HTML на каждом сохранении.
*/
*/
/**
* Есть ли в узле место, куда встанет курсор.
*
* Абзац — текстовый узел, но абзац, в котором лежит ОДНА картинка и больше
* ничего, для автора такой же «блок», как видео или таблица: картинка занимает
* всю ширину строки, и печатать в этом абзаце можно только сбоку от неё.
* Поэтому документ, заканчивающийся таким абзацем, тоже требует пустой строки
* следом. (Обычная картинка — инлайновый узел внутри <p>, в отличие
* от интерактивной, которая сама по себе блок.)
*/
const
местоДляКурсора
=
(
узел
)
=>
{
if
(
!
узел
.
isTextblock
)
{
return
false
}
if
(
узел
.
childCount
===
0
)
{
return
true
}
// \u200B — невидимый символ, который прежние версии редактора ставили
// после картинки; текстом он не является
return
узел
.
textContent
.
replace
(
/
\u
200B/g
,
''
).
trim
()
!==
''
}
export
const
TrailingNode
=
Extension
.
create
({
export
const
TrailingNode
=
Extension
.
create
({
name
:
'
trailingNode
'
,
name
:
'
trailingNode
'
,
...
@@ -41,7 +63,7 @@ export const TrailingNode = Extension.create({
...
@@ -41,7 +63,7 @@ export const TrailingNode = Extension.create({
const
{
doc
,
schema
,
tr
}
=
newState
const
{
doc
,
schema
,
tr
}
=
newState
const
last
=
doc
.
lastChild
const
last
=
doc
.
lastChild
if
(
!
last
||
last
.
isTextblock
||
!
schema
.
nodes
.
paragraph
)
{
if
(
!
last
||
местоДляКурсора
(
last
)
||
!
schema
.
nodes
.
paragraph
)
{
return
null
return
null
}
}
...
@@ -51,7 +73,7 @@ export const TrailingNode = Extension.create({
...
@@ -51,7 +73,7 @@ export const TrailingNode = Extension.create({
props
:
{
props
:
{
handleClick
:
(
view
,
pos
,
event
)
=>
{
handleClick
:
(
view
,
pos
,
event
)
=>
{
const
last
=
view
.
state
.
doc
.
lastChild
const
last
=
view
.
state
.
doc
.
lastChild
if
(
!
last
||
last
.
isTextblock
)
{
if
(
!
last
||
местоДляКурсора
(
last
)
)
{
return
false
return
false
}
}
...
...
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