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
8752466c
Commit
8752466c
authored
Jun 17, 2026
by
Яков
Browse files
update fix issue
parent
2bf763b4
Changes
4
Hide whitespace changes
Inline
Side-by-side
package.json
View file @
8752466c
{
{
"name"
:
"react-ag-qeditor"
,
"name"
:
"react-ag-qeditor"
,
"version"
:
"1.1.7
7
"
,
"version"
:
"1.1.7
8
"
,
"description"
:
"WYSIWYG html editor"
,
"description"
:
"WYSIWYG html editor"
,
"author"
:
"atma"
,
"author"
:
"atma"
,
"license"
:
"
MIT
"
,
"license"
:
"
MIT
"
,
...
...
src/QEditor.jsx
View file @
8752466c
...
@@ -746,11 +746,6 @@ const QEditor = ({
...
@@ -746,11 +746,6 @@ const QEditor = ({
const
{
state
,
view
}
=
editor
const
{
state
,
view
}
=
editor
const
{
$from
}
=
state
.
selection
const
{
$from
}
=
state
.
selection
// Всегда вставляем после текущего блока верхнего уровня,
// а не в позицию курсора — иначе insertContent может «съесть»
// пустой параграф, разделяющий два раскрывающихся списка.
const
insertPos
=
$from
.
after
(
Math
.
min
(
$from
.
depth
,
1
))
const
schema
=
state
.
schema
const
schema
=
state
.
schema
const
toggleNode
=
schema
.
nodes
.
toggleBlock
.
create
(
const
toggleNode
=
schema
.
nodes
.
toggleBlock
.
create
(
{
title
:
'
Заголовок
'
,
open
:
true
},
{
title
:
'
Заголовок
'
,
open
:
true
},
...
@@ -761,7 +756,18 @@ const QEditor = ({
...
@@ -761,7 +756,18 @@ const QEditor = ({
)
)
const
paraNode
=
schema
.
nodes
.
paragraph
.
create
()
const
paraNode
=
schema
.
nodes
.
paragraph
.
create
()
view
.
dispatch
(
state
.
tr
.
insert
(
insertPos
,
[
toggleNode
,
paraNode
]))
const
depth
=
Math
.
min
(
$from
.
depth
,
1
)
const
currentNode
=
$from
.
node
(
depth
)
// Если текущий блок — пустой параграф, заменяем его
if
(
currentNode
.
type
.
name
===
'
paragraph
'
&&
currentNode
.
childCount
===
0
)
{
const
from
=
$from
.
before
(
depth
)
const
to
=
$from
.
after
(
depth
)
view
.
dispatch
(
state
.
tr
.
replaceWith
(
from
,
to
,
[
toggleNode
,
paraNode
]))
}
else
{
const
insertPos
=
$from
.
after
(
depth
)
view
.
dispatch
(
state
.
tr
.
insert
(
insertPos
,
[
toggleNode
,
paraNode
]))
}
},
},
},
},
...
...
src/extensions/ToggleBlock.js
View file @
8752466c
import
{
Node
}
from
'
@tiptap/core
'
import
{
Node
}
from
'
@tiptap/core
'
import
{
ReactNodeViewRenderer
,
NodeViewWrapper
,
NodeViewContent
}
from
'
@tiptap/react
'
import
{
ReactNodeViewRenderer
,
NodeViewWrapper
,
NodeViewContent
}
from
'
@tiptap/react
'
import
React
,
{
useEffect
,
useRef
,
useState
}
from
'
react
'
import
React
,
{
useEffect
,
useRef
,
useState
}
from
'
react
'
import
{
TextSelection
,
NodeSelection
,
Plugin
,
PluginKey
}
from
'
prosemirror-state
'
import
{
TextSelection
,
Plugin
,
PluginKey
}
from
'
prosemirror-state
'
const
DragHandleIcon
=
()
=>
(
const
DragHandleIcon
=
()
=>
(
<
svg
width
=
"
10
"
height
=
"
16
"
viewBox
=
"
0 0 10 16
"
fill
=
"
none
"
xmlns
=
"
http://www.w3.org/2000/svg
"
>
<
svg
width
=
"
10
"
height
=
"
16
"
viewBox
=
"
0 0 10 16
"
fill
=
"
none
"
xmlns
=
"
http://www.w3.org/2000/svg
"
>
...
@@ -15,7 +15,7 @@ const DragHandleIcon = () => (
...
@@ -15,7 +15,7 @@ const DragHandleIcon = () => (
)
)
// React компонент NodeView
// React компонент NodeView
export
const
ToggleBlockComponent
=
({
node
,
updateAttributes
,
getPos
,
editor
,
selected
})
=>
{
export
const
ToggleBlockComponent
=
({
node
,
updateAttributes
,
getPos
,
editor
})
=>
{
const
open
=
node
.
attrs
.
open
const
open
=
node
.
attrs
.
open
const
title
=
node
.
attrs
.
title
const
title
=
node
.
attrs
.
title
...
@@ -33,65 +33,135 @@ export const ToggleBlockComponent = ({node, updateAttributes, getPos, editor, se
...
@@ -33,65 +33,135 @@ export const ToggleBlockComponent = ({node, updateAttributes, getPos, editor, se
}
}
},
[
title
])
},
[
title
])
const
handleDragPointerDown
=
(
e
)
=>
{
e
.
preventDefault
()
const
nodePos
=
getPos
?.()
if
(
typeof
nodePos
!==
'
number
'
)
return
const
dragNode
=
editor
.
state
.
doc
.
nodeAt
(
nodePos
)
if
(
!
dragNode
||
dragNode
.
type
.
name
!==
'
toggleBlock
'
)
return
const
editorDom
=
editor
.
view
.
dom
// Индикатор места вставки
const
indicator
=
document
.
createElement
(
'
div
'
)
indicator
.
style
.
cssText
=
'
position:fixed;left:0;right:0;height:2px;background:#1677ff;z-index:9999;pointer-events:none;display:none;border-radius:1px;
'
document
.
body
.
appendChild
(
indicator
)
document
.
body
.
style
.
userSelect
=
'
none
'
document
.
body
.
classList
.
add
(
'
toggle-dragging
'
)
let
currentTargetPos
=
null
const
getBlockInfos
=
()
=>
{
const
blocks
=
[]
editor
.
state
.
doc
.
forEach
((
blockNode
,
offset
)
=>
{
try
{
const
domResult
=
editor
.
view
.
domAtPos
(
offset
+
1
)
let
el
=
domResult
.
node
instanceof
Element
?
domResult
.
node
:
domResult
.
node
.
parentElement
while
(
el
&&
el
.
parentElement
!==
editorDom
)
{
el
=
el
.
parentElement
}
if
(
el
)
{
blocks
.
push
({
offset
,
nodeSize
:
blockNode
.
nodeSize
,
rect
:
el
.
getBoundingClientRect
()
})
}
}
catch
{}
})
return
blocks
}
const
onPointerMove
=
(
moveEvent
)
=>
{
const
mouseY
=
moveEvent
.
clientY
const
blocks
=
getBlockInfos
()
let
bestY
=
null
let
bestPos
=
null
let
bestDist
=
Infinity
for
(
const
block
of
blocks
)
{
const
{
rect
,
offset
,
nodeSize
}
=
block
const
midY
=
(
rect
.
top
+
rect
.
bottom
)
/
2
if
(
mouseY
<=
midY
)
{
const
dist
=
Math
.
abs
(
mouseY
-
rect
.
top
)
if
(
dist
<
bestDist
)
{
bestDist
=
dist
bestY
=
rect
.
top
bestPos
=
offset
}
}
else
{
const
dist
=
Math
.
abs
(
mouseY
-
rect
.
bottom
)
if
(
dist
<
bestDist
)
{
bestDist
=
dist
bestY
=
rect
.
bottom
bestPos
=
offset
+
nodeSize
}
}
}
if
(
bestPos
!==
null
)
{
const
isNoOp
=
bestPos
===
nodePos
||
bestPos
===
nodePos
+
dragNode
.
nodeSize
const
isOnSelf
=
bestPos
>
nodePos
&&
bestPos
<
nodePos
+
dragNode
.
nodeSize
if
(
isNoOp
||
isOnSelf
)
{
indicator
.
style
.
display
=
'
none
'
currentTargetPos
=
null
}
else
{
indicator
.
style
.
display
=
'
block
'
indicator
.
style
.
top
=
bestY
+
'
px
'
currentTargetPos
=
bestPos
}
}
}
const
cleanup
=
()
=>
{
if
(
document
.
body
.
contains
(
indicator
))
document
.
body
.
removeChild
(
indicator
)
document
.
body
.
style
.
userSelect
=
''
document
.
body
.
classList
.
remove
(
'
toggle-dragging
'
)
document
.
removeEventListener
(
'
pointermove
'
,
onPointerMove
)
document
.
removeEventListener
(
'
pointerup
'
,
onPointerUp
)
}
const
onPointerUp
=
()
=>
{
const
targetPos
=
currentTargetPos
cleanup
()
if
(
targetPos
===
null
)
return
const
currentState
=
editor
.
view
.
state
const
movingNode
=
currentState
.
doc
.
nodeAt
(
nodePos
)
if
(
!
movingNode
||
movingNode
.
type
.
name
!==
'
toggleBlock
'
)
return
const
from
=
nodePos
const
to
=
nodePos
+
movingNode
.
nodeSize
const
tr
=
currentState
.
tr
if
(
targetPos
<=
from
)
{
tr
.
insert
(
targetPos
,
movingNode
)
tr
.
delete
(
from
+
movingNode
.
nodeSize
,
to
+
movingNode
.
nodeSize
)
}
else
{
tr
.
delete
(
from
,
to
)
tr
.
insert
(
targetPos
-
movingNode
.
nodeSize
,
movingNode
)
}
if
(
tr
.
steps
.
length
>
0
)
{
editor
.
view
.
dispatch
(
tr
)
}
}
document
.
addEventListener
(
'
pointermove
'
,
onPointerMove
)
document
.
addEventListener
(
'
pointerup
'
,
onPointerUp
)
}
return
(
return
(
<
NodeViewWrapper
className
=
"
toggle-block
"
data
-
open
=
{
open
}
>
<
NodeViewWrapper
className
=
"
toggle-block
"
data
-
open
=
{
open
}
>
{
selected
&&
(
<
button
type
=
"
button
"
onClick
=
{(
e
)
=>
{
e
.
stopPropagation
()
try
{
const
pos
=
getPos
?.()
if
(
typeof
pos
===
'
number
'
)
{
editor
.
view
.
dispatch
(
editor
.
view
.
state
.
tr
.
delete
(
pos
,
pos
+
node
.
nodeSize
)
)
}
}
catch
(
err
)
{
console
.
warn
(
'
Ошибка удаления toggleBlock:
'
,
err
)
}
}}
style
=
{{
position
:
'
absolute
'
,
top
:
4
,
right
:
4
,
zIndex
:
10
,
background
:
'
white
'
,
border
:
'
1px solid #d9d9d9
'
,
borderRadius
:
'
50%
'
,
width
:
20
,
height
:
20
,
fontSize
:
12
,
lineHeight
:
1
,
cursor
:
'
pointer
'
,
padding
:
'
0px 0px 2px 0px
'
,
color
:
'
#ff4d4f
'
}}
>
×
<
/button
>
)}
<
div
className
=
"
toggle-block-inner
"
>
<
div
className
=
"
toggle-block-inner
"
>
<
div
className
=
"
toggle-header-wrapper
"
>
<
div
className
=
"
toggle-header-wrapper
"
>
<
div
<
div
className
=
"
toggle-drag-handle
"
className
=
"
toggle-drag-handle
"
contentEditable
=
{
false
}
contentEditable
=
{
false
}
draggable
=
{
true
}
data
-
drag
-
handle
=
""
title
=
"
Перетащить
"
title
=
"
Перетащить
"
onDragStart
=
{(
e
)
=>
{
onPointerDown
=
{
handleDragPointerDown
}
try
{
const
pos
=
getPos
?.()
if
(
typeof
pos
===
'
number
'
)
{
editor
.
view
.
dispatch
(
editor
.
view
.
state
.
tr
.
setSelection
(
NodeSelection
.
create
(
editor
.
view
.
state
.
doc
,
pos
)
)
)
}
}
catch
{}
}}
>
>
<
DragHandleIcon
/>
<
DragHandleIcon
/>
<
/div
>
<
/div
>
...
@@ -116,6 +186,25 @@ export const ToggleBlockComponent = ({node, updateAttributes, getPos, editor, se
...
@@ -116,6 +186,25 @@ export const ToggleBlockComponent = ({node, updateAttributes, getPos, editor, se
}
}
}}
}}
/
>
/
>
<
button
type
=
"
button
"
className
=
"
toggle-block-delete
"
onClick
=
{(
e
)
=>
{
e
.
stopPropagation
()
try
{
const
pos
=
getPos
?.()
if
(
typeof
pos
===
'
number
'
)
{
editor
.
view
.
dispatch
(
editor
.
view
.
state
.
tr
.
delete
(
pos
,
pos
+
node
.
nodeSize
)
)
}
}
catch
(
err
)
{
console
.
warn
(
'
Ошибка удаления toggleBlock:
'
,
err
)
}
}}
>
×
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
div
<
div
...
@@ -169,7 +258,7 @@ const ToggleBlock = Node.create({
...
@@ -169,7 +258,7 @@ const ToggleBlock = Node.create({
name
:
'
toggleBlock
'
,
name
:
'
toggleBlock
'
,
group
:
'
block
'
,
group
:
'
block
'
,
content
:
'
block+
'
,
content
:
'
block+
'
,
draggable
:
tru
e
,
draggable
:
fals
e
,
addAttributes
()
{
addAttributes
()
{
return
{
return
{
...
@@ -216,6 +305,74 @@ const ToggleBlock = Node.create({
...
@@ -216,6 +305,74 @@ const ToggleBlock = Node.create({
return
ReactNodeViewRenderer
(
ToggleBlockComponent
)
return
ReactNodeViewRenderer
(
ToggleBlockComponent
)
},
},
addKeyboardShortcuts
()
{
return
{
Backspace
:
({
editor
})
=>
{
const
{
state
}
=
editor
const
{
$from
,
empty
}
=
state
.
selection
if
(
!
empty
||
$from
.
parentOffset
!==
0
)
return
false
// Случай А: курсор в начале блока, предыдущий сосед — toggleBlock
// Если блок пустой — удаляем его; в любом случае запрещаем слияние с toggleBlock
if
(
$from
.
depth
===
1
)
{
const
blockPos
=
$from
.
before
(
1
)
const
docIndex
=
$from
.
index
(
0
)
if
(
docIndex
>
0
)
{
const
prevSibling
=
state
.
doc
.
child
(
docIndex
-
1
)
if
(
prevSibling
.
type
.
name
===
'
toggleBlock
'
)
{
if
(
$from
.
parent
.
childCount
===
0
)
{
editor
.
view
.
dispatch
(
state
.
tr
.
delete
(
blockPos
,
blockPos
+
$from
.
parent
.
nodeSize
)
)
}
return
true
}
}
}
// Случай Б: курсор в самом начале первого дочернего элемента toggleBlock
// → запрещаем слияние toggleBlock с предыдущим блоком
for
(
let
d
=
$from
.
depth
;
d
>=
1
;
d
--
)
{
if
(
$from
.
node
(
d
).
type
.
name
===
'
toggleBlock
'
)
{
const
togglePos
=
$from
.
before
(
d
)
if
(
$from
.
pos
<=
togglePos
+
2
)
{
return
true
}
break
}
}
return
false
},
Delete
:
({
editor
})
=>
{
const
{
state
}
=
editor
const
{
$from
,
empty
}
=
state
.
selection
if
(
!
empty
)
return
false
// Курсор в конце блока, следующий сосед — toggleBlock
// → запрещаем слияние (Delete на пустом параграфе перед toggleBlock)
if
(
$from
.
depth
===
1
&&
$from
.
parentOffset
===
$from
.
parent
.
content
.
size
)
{
const
docIndex
=
$from
.
index
(
0
)
if
(
docIndex
<
state
.
doc
.
childCount
-
1
)
{
const
nextSibling
=
state
.
doc
.
child
(
docIndex
+
1
)
if
(
nextSibling
.
type
.
name
===
'
toggleBlock
'
)
{
if
(
$from
.
parent
.
childCount
===
0
)
{
const
blockPos
=
$from
.
before
(
1
)
editor
.
view
.
dispatch
(
state
.
tr
.
delete
(
blockPos
,
blockPos
+
$from
.
parent
.
nodeSize
)
)
}
return
true
}
}
}
return
false
},
}
},
addProseMirrorPlugins
()
{
addProseMirrorPlugins
()
{
// Миграция: убираем пустые параграфы в начале toggle-блоков (артефакт старого бага)
// Миграция: убираем пустые параграфы в начале toggle-блоков (артефакт старого бага)
const
cleanLeadingEmptyParagraphs
=
(
state
)
=>
{
const
cleanLeadingEmptyParagraphs
=
(
state
)
=>
{
...
...
src/index.scss
View file @
8752466c
...
@@ -1639,6 +1639,30 @@ body{
...
@@ -1639,6 +1639,30 @@ body{
}
}
.toggle-block {
.toggle-block {
margin-bottom: 12px;
margin-bottom: 12px;
&:hover .toggle-block-delete {
opacity: 1;
}
}
.toggle-block-delete {
flex-shrink: 0;
margin-left: 8px;
background: white;
border: 1px solid #d9d9d9;
border-radius: 50%;
width: 20px;
height: 20px;
font-size: 13px;
line-height: 1;
cursor: pointer;
padding: 0;
color: #ff4d4f;
opacity: 0;
transition: opacity 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
}
.toggle-header-wrapper {
.toggle-header-wrapper {
position: relative;
position: relative;
...
@@ -1664,6 +1688,10 @@ body{
...
@@ -1664,6 +1688,10 @@ body{
cursor: grabbing;
cursor: grabbing;
}
}
body.toggle-dragging & {
cursor: grabbing;
}
&:hover {
&:hover {
color: #888;
color: #888;
background: rgba(0,0,0,0.05);
background: rgba(0,0,0,0.05);
...
...
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