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
0a1a0a91
Commit
0a1a0a91
authored
Aug 29, 2026
by
Яков
Browse files
update
parent
bfd5fe15
Changes
5
Show whitespace changes
Inline
Side-by-side
package.json
View file @
0a1a0a91
{
{
"name"
:
"react-ag-qeditor"
,
"name"
:
"react-ag-qeditor"
,
"version"
:
"1.1.8
5
"
,
"version"
:
"1.1.8
6
"
,
"description"
:
"WYSIWYG html editor"
,
"description"
:
"WYSIWYG html editor"
,
"author"
:
"atma"
,
"author"
:
"atma"
,
"license"
:
"
MIT
"
,
"license"
:
"
MIT
"
,
...
...
src/QEditor.jsx
View file @
0a1a0a91
...
@@ -37,6 +37,7 @@ import Audio from './extensions/Audio'
...
@@ -37,6 +37,7 @@ import Audio from './extensions/Audio'
import
TableExtension
from
'
./extensions/TableExtension
'
import
TableExtension
from
'
./extensions/TableExtension
'
import
ToggleBlock
from
'
./extensions/ToggleBlock
'
import
ToggleBlock
from
'
./extensions/ToggleBlock
'
import
InteractiveImage
from
'
./extensions/InteractiveImage
'
import
InteractiveImage
from
'
./extensions/InteractiveImage
'
import
TrailingNode
from
'
./extensions/TrailingNode
'
import
FontSize
from
'
./extensions/FontSize
'
import
FontSize
from
'
./extensions/FontSize
'
import
BlockId
from
'
./extensions/BlockId
'
import
BlockId
from
'
./extensions/BlockId
'
import
ActivityRef
from
'
./extensions/ActivityRef
'
import
ActivityRef
from
'
./extensions/ActivityRef
'
...
@@ -909,6 +910,8 @@ const QEditor = ({
...
@@ -909,6 +910,8 @@ const QEditor = ({
}),
}),
WordNavigation
,
WordNavigation
,
ImageWrapEnter
,
ImageWrapEnter
,
// Пустой абзац в конце: иначе после последней картинки писать негде
TrailingNode
,
DragAndDrop
.
configure
({
DragAndDrop
.
configure
({
uploadUrl
:
uploadOptions
.
url
,
uploadUrl
:
uploadOptions
.
url
,
allowedFileTypes
:
[
allowedFileTypes
:
[
...
...
src/extensions/Image.jsx
View file @
0a1a0a91
...
@@ -7,6 +7,12 @@ import { isMobile } from 'react-device-detect';
...
@@ -7,6 +7,12 @@ import { isMobile } from 'react-device-detect';
const
{
TextArea
}
=
Input
;
const
{
TextArea
}
=
Input
;
const
{
Text
}
=
Typography
;
const
{
Text
}
=
Typography
;
// Слои накладок узла — все ниже липкой панели инструментов
// (--atma-toolbar-z-index, по умолчанию 10): панель и накладки лежат в одном
// контексте наложения, и при равном z-index побеждает то, что ниже в DOM.
// Так кнопка подписи заезжала на панель, стоило прокрутить страницу.
const
Z
=
{
action
:
6
,
resize
:
7
,
bar
:
8
,
remove
:
9
};
const
MIN_WIDTH
=
60
;
const
MIN_WIDTH
=
60
;
const
BORDER_COLOR
=
'
#0096fd
'
;
const
BORDER_COLOR
=
'
#0096fd
'
;
const
ALIGN_OPTIONS
=
[
'
left
'
,
'
center
'
,
'
right
'
];
const
ALIGN_OPTIONS
=
[
'
left
'
,
'
center
'
,
'
right
'
];
...
@@ -404,23 +410,24 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
...
@@ -404,23 +410,24 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
const
sharedMargin
=
{
marginTop
:
'
0.5rem
'
,
marginBottom
:
'
0.5rem
'
};
const
sharedMargin
=
{
marginTop
:
'
0.5rem
'
,
marginBottom
:
'
0.5rem
'
};
const
noSelect
=
{
userSelect
:
'
none
'
,
WebkitUserSelect
:
'
none
'
,
touchAction
:
'
manipulation
'
};
const
noSelect
=
{
userSelect
:
'
none
'
,
WebkitUserSelect
:
'
none
'
,
touchAction
:
'
manipulation
'
};
if
(
align
===
'
center
'
)
{
// Без обтекания картинка остаётся В ПОТОКЕ строки: inline-block на всю
// Используем float:left+width:100% чтобы не создавать block-in-inline внутри <p>
// ширину, БЕЗ float.
// (иначе параграф получает лишнюю высоту). textAlign:center центрирует внутренний inline-block.
//
return
{
// Раньше здесь стоял float:left+width:100% — «чтобы не создавать
...
sharedMargin
,
...
noSelect
,
lineHeight
:
0
,
// block-in-inline внутри <p> (иначе параграф получает лишнюю высоту)».
display
:
'
inline-block
'
,
float
:
'
left
'
,
clear
:
'
both
'
,
// Ценой была высота НУЛЕВАЯ: абзац с плавающей картинкой не содержит её
width
:
'
100%
'
,
textAlign
:
'
center
'
,
// по высоте, картинка вылезала за поле ввода, курсор вставал сбоку от неё
};
// на пустой строке, а клик по картинке ниже конца абзаца выделял узел —
}
// следующая же набранная буква стирала картинку (воспроизведено на стенде).
if
(
!
wrap
)
{
// Блока в инлайне тут нет и с inline-block: он инлайнового уровня.
// verticalAlign: top убирает провал под базовую линию, ради которого
// и брали float.
if
(
align
===
'
center
'
||
!
wrap
)
{
return
{
return
{
...
sharedMargin
,
...
noSelect
,
lineHeight
:
0
,
...
sharedMargin
,
...
noSelect
,
lineHeight
:
0
,
display
:
'
inline-block
'
,
display
:
'
inline-block
'
,
verticalAlign
:
'
top
'
,
float
:
'
left
'
,
clear
:
'
both
'
,
width
:
'
100%
'
,
width
:
'
100%
'
,
...(
align
===
'
right
'
?
{
textAlign
:
'
right
'
}
:
{
textAlign
:
'
left
'
}
),
textAlign
:
align
===
'
center
'
?
'
center
'
:
(
align
===
'
right
'
?
'
right
'
:
'
left
'
),
};
};
}
}
return
{
return
{
...
@@ -519,7 +526,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
...
@@ -519,7 +526,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
setTempFrontAlt
(
node
.
attrs
.
frontAlt
||
''
);
setTempFrontAlt
(
node
.
attrs
.
frontAlt
||
''
);
setAltModalVisible
(
true
);
setAltModalVisible
(
true
);
}
}
}
}
style
=
{
{
position
:
'
absolute
'
,
top
:
4
,
right
:
'
30px
'
,
zIndex
:
15
}
}
style
=
{
{
position
:
'
absolute
'
,
top
:
4
,
right
:
'
30px
'
,
zIndex
:
Z
.
action
}
}
>
>
<
FontSizeOutlined
/>
<
FontSizeOutlined
/>
</
Button
>
</
Button
>
...
@@ -538,7 +545,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
...
@@ -538,7 +545,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
}
}
}
}
}
}
style
=
{
{
style
=
{
{
position
:
'
absolute
'
,
top
:
4
,
right
:
4
,
zIndex
:
30
,
position
:
'
absolute
'
,
top
:
4
,
right
:
4
,
zIndex
:
Z
.
remove
,
backgroundColor
:
'
white
'
,
border
:
'
1px solid #d9d9d9
'
,
backgroundColor
:
'
white
'
,
border
:
'
1px solid #d9d9d9
'
,
borderRadius
:
'
50%
'
,
width
:
20
,
height
:
20
,
borderRadius
:
'
50%
'
,
width
:
20
,
height
:
20
,
fontSize
:
12
,
lineHeight
:
1
,
padding
:
'
0px 0px 2px 0px
'
,
cursor
:
'
pointer
'
fontSize
:
12
,
lineHeight
:
1
,
padding
:
'
0px 0px 2px 0px
'
,
cursor
:
'
pointer
'
...
@@ -562,7 +569,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
...
@@ -562,7 +569,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
transform
:
node
.
attrs
.
align
===
'
center
'
transform
:
node
.
attrs
.
align
===
'
center
'
?
`translateX(
${
dir
[
1
]
===
'
w
'
?
'
-100%
'
:
'
0%
'
}
)`
:
'
none
'
,
?
`translateX(
${
dir
[
1
]
===
'
w
'
?
'
-100%
'
:
'
0%
'
}
)`
:
'
none
'
,
cursor
:
`
${
dir
}
-resize`
,
cursor
:
`
${
dir
}
-resize`
,
zIndex
:
10
zIndex
:
Z
.
resize
}
}
}
}
/>
/>
))
}
))
}
...
@@ -571,7 +578,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
...
@@ -571,7 +578,7 @@ const ResizableImageTemplate = ({ node, updateAttributes, editor, getPos, select
transform
:
'
translateX(-50%)
'
,
transform
:
'
translateX(-50%)
'
,
backgroundColor
:
'
white
'
,
backgroundColor
:
'
white
'
,
boxShadow
:
'
0 2px 8px rgba(0,0,0,0.15)
'
,
boxShadow
:
'
0 2px 8px rgba(0,0,0,0.15)
'
,
borderRadius
:
4
,
padding
:
4
,
zIndex
:
20
,
borderRadius
:
4
,
padding
:
4
,
zIndex
:
Z
.
bar
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
2
,
whiteSpace
:
'
nowrap
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
2
,
whiteSpace
:
'
nowrap
'
,
}
}
>
}
}
>
{
ALIGN_OPTIONS
.
map
(
a
=>
(
{
ALIGN_OPTIONS
.
map
(
a
=>
(
...
...
src/extensions/InteractiveImage.js
View file @
0a1a0a91
...
@@ -9,6 +9,14 @@ const MIN_WIDTH = 60;
...
@@ -9,6 +9,14 @@ const MIN_WIDTH = 60;
const
BORDER_COLOR
=
'
#0096fd
'
;
const
BORDER_COLOR
=
'
#0096fd
'
;
const
ALIGN_OPTIONS
=
[
'
left
'
,
'
center
'
,
'
right
'
];
const
ALIGN_OPTIONS
=
[
'
left
'
,
'
center
'
,
'
right
'
];
// Слои накладок узла.
//
// Все ОБЯЗАНЫ быть ниже липкой панели инструментов (--atma-toolbar-z-index, по
// умолчанию 10): панель и накладки лежат в одном контексте наложения, и при
// равном z-index побеждает то, что ниже в DOM, — то есть накладка. Так кнопка
// «Редактировать» заезжала на панель, стоило прокрутить страницу.
const
Z
=
{
marker
:
5
,
action
:
6
,
resize
:
7
,
bar
:
8
,
remove
:
9
};
const
InteractiveImageView
=
({
node
,
updateAttributes
,
editor
,
getPos
,
selected
,
extension
})
=>
{
const
InteractiveImageView
=
({
node
,
updateAttributes
,
editor
,
getPos
,
selected
,
extension
})
=>
{
const
[
modalVisible
,
setModalVisible
]
=
useState
(
false
)
const
[
modalVisible
,
setModalVisible
]
=
useState
(
false
)
const
[
points
,
setPoints
]
=
useState
(
node
.
attrs
.
points
||
[])
const
[
points
,
setPoints
]
=
useState
(
node
.
attrs
.
points
||
[])
...
@@ -85,6 +93,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
...
@@ -85,6 +93,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
const
[
editingText
,
setEditingText
]
=
useState
(
''
)
const
[
editingText
,
setEditingText
]
=
useState
(
''
)
const
[
editingTitle
,
setEditingTitle
]
=
useState
(
''
)
const
[
editingTitle
,
setEditingTitle
]
=
useState
(
''
)
const
[
isResizing
,
setIsResizing
]
=
useState
(
false
)
const
[
isResizing
,
setIsResizing
]
=
useState
(
false
)
const
[
hovered
,
setHovered
]
=
useState
(
false
)
const
imgRef
=
useRef
(
null
)
const
imgRef
=
useRef
(
null
)
const
isInitialized
=
useRef
(
false
)
const
isInitialized
=
useRef
(
false
)
const
wrapperRef
=
useRef
(
null
)
const
wrapperRef
=
useRef
(
null
)
...
@@ -317,25 +326,28 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
...
@@ -317,25 +326,28 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
// ─── Стили (идентично Image.jsx) ─────────────────────────────────────────
// ─── Стили (идентично Image.jsx) ─────────────────────────────────────────
// Без обтекания это обычный блок, и float ему противопоказан.
//
// Раньше здесь стоял float:left+width:100% — копия из Image.jsx, где он
// вынужденный: обычная картинка живёт ИНЛАЙНОВОЙ нодой внутри <p>, и блок
// туда иначе не положить. Интерактивная картинка объявлена group: 'block',
// то есть и так лежит отдельным блоком, а float выбивал её из потока:
// следующий абзац оказывался не под картинкой, а СПРАВА от неё, курсор
// ставился сбоку, и «написать текст после картинки» становилось нерешаемой
// задачей (жалоба владельца 29.08.2026).
const
getOuterStyle
=
()
=>
{
const
getOuterStyle
=
()
=>
{
const
{
align
,
wrap
,
width
}
=
node
.
attrs
;
const
{
align
,
wrap
,
width
}
=
node
.
attrs
;
const
w
=
width
?
`
${
width
}
px`
:
'
auto
'
;
const
w
=
width
?
`
${
width
}
px`
:
'
auto
'
;
const
sharedMargin
=
{
marginTop
:
'
0.5rem
'
,
marginBottom
:
'
0.5rem
'
};
const
sharedMargin
=
{
marginTop
:
'
0.5rem
'
,
marginBottom
:
'
0.5rem
'
};
if
(
align
===
'
center
'
)
{
if
(
align
===
'
center
'
||
!
wrap
)
{
return
{
...
sharedMargin
,
lineHeight
:
0
,
display
:
'
inline-block
'
,
float
:
'
left
'
,
clear
:
'
both
'
,
width
:
'
100%
'
,
textAlign
:
'
center
'
,
};
}
if
(
!
wrap
)
{
return
{
return
{
...
sharedMargin
,
lineHeight
:
0
,
...
sharedMargin
,
lineHeight
:
0
,
display
:
'
inline-block
'
,
float
:
'
left
'
,
clear
:
'
both
'
,
width
:
'
100%
'
,
display
:
'
block
'
,
clear
:
'
both
'
,
width
:
'
100%
'
,
...(
align
===
'
right
'
?
{
textAlign
:
'
right
'
}
:
{
textAlign
:
'
left
'
}
),
textAlign
:
align
===
'
center
'
?
'
center
'
:
(
align
===
'
right
'
?
'
right
'
:
'
left
'
),
};
};
}
}
// Обтекание — единственный случай, когда float нужен: текст обязан идти сбоку
return
{
return
{
...
sharedMargin
,
lineHeight
:
0
,
...
sharedMargin
,
lineHeight
:
0
,
display
:
'
inline-block
'
,
display
:
'
inline-block
'
,
...
@@ -390,6 +402,9 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
...
@@ -390,6 +402,9 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
<
div
<
div
ref
=
{
wrapperRef
}
ref
=
{
wrapperRef
}
style
=
{
getInnerStyle
()}
style
=
{
getInnerStyle
()}
title
=
"
Двойной клик — редактировать точки
"
onMouseEnter
=
{()
=>
setHovered
(
true
)}
onMouseLeave
=
{()
=>
setHovered
(
false
)}
onClick
=
{(
e
)
=>
{
onClick
=
{(
e
)
=>
{
e
.
stopPropagation
();
e
.
stopPropagation
();
try
{
try
{
...
@@ -397,6 +412,9 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
...
@@ -397,6 +412,9 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
if
(
typeof
pos
===
'
number
'
)
editor
.
commands
.
setNodeSelection
(
pos
);
if
(
typeof
pos
===
'
number
'
)
editor
.
commands
.
setNodeSelection
(
pos
);
}
catch
{}
}
catch
{}
}}
}}
// «Нажимаешь на картинку — ничего не происходит»: одиночный клик
// только выделяет узел, а точки открывались единственной кнопкой
onDoubleClick
=
{(
e
)
=>
{
e
.
stopPropagation
();
setModalVisible
(
true
);
}}
>
>
<
img
<
img
ref
=
{
imgRef
}
ref
=
{
imgRef
}
...
@@ -418,15 +436,18 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
...
@@ -418,15 +436,18 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
}}
}}
/
>
/
>
{
/* Кнопка редактирования точек */
}
{
/* Кнопка редактирования точек — только под курсором или у выделенной
картинки: постоянно висящая кнопка закрывала кусок изображения */
}
{(
hovered
||
selected
||
isResizing
)
&&
(
<
Button
<
Button
size
=
"
default
"
size
=
"
default
"
type
=
"
primary
"
type
=
"
primary
"
onClick
=
{(
e
)
=>
{
e
.
stopPropagation
();
setModalVisible
(
true
);
}}
onClick
=
{(
e
)
=>
{
e
.
stopPropagation
();
setModalVisible
(
true
);
}}
style
=
{{
position
:
'
absolute
'
,
top
:
4
,
right
:
30
,
zIndex
:
10
}}
style
=
{{
position
:
'
absolute
'
,
top
:
4
,
right
:
30
,
zIndex
:
Z
.
action
}}
>
>
Редактировать
Редактировать
<
/Button
>
<
/Button
>
)}
{
/* Маркеры точек */
}
{
/* Маркеры точек */
}
{
points
.
map
((
point
,
idx
)
=>
(
{
points
.
map
((
point
,
idx
)
=>
(
...
@@ -442,7 +463,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
...
@@ -442,7 +463,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
borderRadius
:
'
50%
'
,
padding
:
0
,
borderRadius
:
'
50%
'
,
padding
:
0
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
,
transform
:
'
translate(-50%, -50%)
'
,
transform
:
'
translate(-50%, -50%)
'
,
zIndex
:
5
,
zIndex
:
Z
.
marker
,
backgroundColor
:
'
#1677ff
'
,
border
:
'
none
'
,
backgroundColor
:
'
#1677ff
'
,
border
:
'
none
'
,
pointerEvents
:
'
none
'
,
pointerEvents
:
'
none
'
,
}}
}}
...
@@ -466,7 +487,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
...
@@ -466,7 +487,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
}
}
}}
}}
style
=
{{
style
=
{{
position
:
'
absolute
'
,
top
:
4
,
right
:
4
,
zIndex
:
30
,
position
:
'
absolute
'
,
top
:
4
,
right
:
4
,
zIndex
:
Z
.
remove
,
backgroundColor
:
'
white
'
,
border
:
'
1px solid #d9d9d9
'
,
backgroundColor
:
'
white
'
,
border
:
'
1px solid #d9d9d9
'
,
borderRadius
:
'
50%
'
,
width
:
20
,
height
:
20
,
borderRadius
:
'
50%
'
,
width
:
20
,
height
:
20
,
fontSize
:
12
,
lineHeight
:
1
,
padding
:
'
0px 0px 2px 0px
'
,
cursor
:
'
pointer
'
fontSize
:
12
,
lineHeight
:
1
,
padding
:
'
0px 0px 2px 0px
'
,
cursor
:
'
pointer
'
...
@@ -491,7 +512,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
...
@@ -491,7 +512,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
transform
:
node
.
attrs
.
align
===
'
center
'
transform
:
node
.
attrs
.
align
===
'
center
'
?
`translateX(
${
dir
[
1
]
===
'
w
'
?
'
-100%
'
:
'
0%
'
}
)`
:
'
none
'
,
?
`translateX(
${
dir
[
1
]
===
'
w
'
?
'
-100%
'
:
'
0%
'
}
)`
:
'
none
'
,
cursor
:
`
${
dir
}
-resize`
,
cursor
:
`
${
dir
}
-resize`
,
zIndex
:
10
zIndex
:
Z
.
resize
}}
}}
/
>
/
>
))}
))}
...
@@ -502,7 +523,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
...
@@ -502,7 +523,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
transform
:
'
translateX(-50%)
'
,
transform
:
'
translateX(-50%)
'
,
backgroundColor
:
'
white
'
,
backgroundColor
:
'
white
'
,
boxShadow
:
'
0 2px 8px rgba(0,0,0,0.15)
'
,
boxShadow
:
'
0 2px 8px rgba(0,0,0,0.15)
'
,
borderRadius
:
4
,
padding
:
4
,
zIndex
:
20
,
borderRadius
:
4
,
padding
:
4
,
zIndex
:
Z
.
bar
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
2
,
whiteSpace
:
'
nowrap
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
2
,
whiteSpace
:
'
nowrap
'
,
}}
>
}}
>
{
ALIGN_OPTIONS
.
map
(
a
=>
(
{
ALIGN_OPTIONS
.
map
(
a
=>
(
...
@@ -606,6 +627,10 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
...
@@ -606,6 +627,10 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
<
Popconfirm
<
Popconfirm
icon
=
{
null
}
icon
=
{
null
}
open
=
{
true
}
open
=
{
true
}
// Всплывашка живёт РЯДОМ С МАРКЕРОМ, а не в портале body:
// из портала она не знала о прокрутке картинки внутри окна
// и отрывалась от точки, повисая поверх инструкции
getPopupContainer
=
{(
trigger
)
=>
trigger
.
parentElement
}
title
=
{
title
=
{
<
div
style
=
{{
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
gap
:
8
}}
>
<
div
style
=
{{
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
gap
:
8
}}
>
<
Input
<
Input
...
@@ -648,6 +673,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
...
@@ -648,6 +673,7 @@ const InteractiveImageView = ({ node, updateAttributes, editor, getPos, selected
icon
=
{
null
}
icon
=
{
null
}
key
=
{
idx
}
key
=
{
idx
}
open
=
{
editingIdx
===
idx
}
open
=
{
editingIdx
===
idx
}
getPopupContainer
=
{(
trigger
)
=>
trigger
.
parentElement
}
title
=
{
title
=
{
<
div
style
=
{{
maxWidth
:
250
}}
>
<
div
style
=
{{
maxWidth
:
250
}}
>
<
Input
<
Input
...
...
src/extensions/TrailingNode.js
0 → 100644
View file @
0a1a0a91
import
{
Extension
}
from
'
@tiptap/core
'
// prosemirror-state напрямую, как в остальных расширениях пакета: смешивать
// с '@tiptap/pm/state' нельзя — это разные экземпляры библиотеки
import
{
Plugin
,
PluginKey
,
TextSelection
}
from
'
prosemirror-state
'
/**
* Пустой абзац в конце документа — чтобы после блока было куда встать курсору.
*
* Зачем. Картинка, видео, таблица, кнопка — это блочные узлы, и внутрь них
* курсор не ставится. Если такой узел последний в документе, писать после него
* физически некуда: клик по пустому месту под ним попадал в сам узел (он просто
* выделялся), а Enter вставлял абзац ПЕРЕД картинкой — она уезжала вниз, и
* выглядело это как «редактор сломался». Жалоба владельца 29.08.2026: «как мне
* сделать, чтобы текст под картинкой был — непонятно».
*
* Что делает:
* 1. как только документ изменился и последним оказался неТЕКСТОВЫЙ узел —
* дописывает в конец пустой абзац (вставил картинку — сразу есть строка
* под ней);
* 2. клик по пустому месту НИЖЕ последнего блока ставит туда курсор, при
* необходимости создавая абзац, — это для уже накопленных уроков, которые
* заканчиваются картинкой: их документ при открытии не меняется, поэтому
* трогать его без действия автора нельзя (иначе урок «испачкается»
* и предложит сохранить сам себя).
*
* Абзацы после заголовков и текста НЕ добавляем: там курсор ставится и так,
* а лишняя пустая строка уезжала бы в сохранённый HTML на каждом сохранении.
*/
export
const
TrailingNode
=
Extension
.
create
({
name
:
'
trailingNode
'
,
addProseMirrorPlugins
()
{
return
[
new
Plugin
({
key
:
new
PluginKey
(
'
trailingNode
'
),
appendTransaction
:
(
transactions
,
oldState
,
newState
)
=>
{
if
(
!
transactions
.
some
(
tr
=>
tr
.
docChanged
))
{
return
null
}
const
{
doc
,
schema
,
tr
}
=
newState
const
last
=
doc
.
lastChild
if
(
!
last
||
last
.
isTextblock
||
!
schema
.
nodes
.
paragraph
)
{
return
null
}
return
tr
.
insert
(
doc
.
content
.
size
,
schema
.
nodes
.
paragraph
.
create
())
},
props
:
{
handleClick
:
(
view
,
pos
,
event
)
=>
{
const
last
=
view
.
state
.
doc
.
lastChild
if
(
!
last
||
last
.
isTextblock
)
{
return
false
}
// Клик именно ниже содержимого, а не по самому блоку:
// иначе перехватывали бы обычное выделение картинки
const
lastEl
=
view
.
dom
.
lastElementChild
if
(
!
lastEl
||
event
.
clientY
<=
lastEl
.
getBoundingClientRect
().
bottom
)
{
return
false
}
const
paragraph
=
view
.
state
.
schema
.
nodes
.
paragraph
if
(
!
paragraph
)
{
return
false
}
const
tr
=
view
.
state
.
tr
.
insert
(
view
.
state
.
doc
.
content
.
size
,
paragraph
.
create
())
tr
.
setSelection
(
TextSelection
.
near
(
tr
.
doc
.
resolve
(
tr
.
doc
.
content
.
size
-
1
)))
view
.
dispatch
(
tr
.
scrollIntoView
())
view
.
focus
()
return
true
},
},
}),
]
},
})
export
default
TrailingNode
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