Skip to content

Commit e5cd028

Browse files
authored
feat: add drop indicator plugin (#2097)
* chore: first setup * feat: add drop indicator plugin
1 parent d1ff4f2 commit e5cd028

File tree

13 files changed

+665
-66
lines changed

13 files changed

+665
-66
lines changed

docs/api/plugin-cursor.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,17 @@ Editor.make().use(nord).use(commonmark).use(cursor).create()
1616

1717
@cursor
1818

19-
## Plugins
19+
## Ctx
2020

21-
@dropCursorConfig
22-
@dropCursorPlugin
21+
@dropIndicatorConfig
22+
@dropIndicatorState
23+
24+
## Plugins
2325

26+
@dropIndicatorDOMPlugin
27+
@dropIndicatorPlugin
2428
@gapCursorPlugin
29+
30+
## Deprecated
31+
32+
@dropCursorConfig

packages/crepe/src/feature/cursor/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
22
cursor as cursorPlugin,
3-
dropCursorConfig,
3+
dropIndicatorConfig,
44
} from '@milkdown/kit/plugin/cursor'
55
import { $prose } from '@milkdown/kit/utils'
66
import { createVirtualCursor } from 'prosemirror-virtual-cursor'
@@ -21,7 +21,7 @@ export const cursor: DefineFeature<CursorFeatureConfig> = (editor, config) => {
2121
editor
2222
.config(crepeFeatureConfig(CrepeFeature.Cursor))
2323
.config((ctx) => {
24-
ctx.update(dropCursorConfig.key, () => ({
24+
ctx.update(dropIndicatorConfig.key, () => ({
2525
class: 'crepe-drop-cursor',
2626
width: config?.width ?? 4,
2727
color: config?.color ?? false,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Meta, MilkdownPlugin } from '@milkdown/ctx'
2+
3+
export function withMeta<T extends MilkdownPlugin>(
4+
plugin: T,
5+
meta: Partial<Meta> & Pick<Meta, 'displayName'>
6+
): T {
7+
Object.assign(plugin, {
8+
meta: {
9+
package: '@milkdown/plugin-cursor',
10+
...meta,
11+
},
12+
})
13+
14+
return plugin
15+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import type { Line } from './types'
2+
3+
export function drawIndicator(
4+
element: HTMLElement,
5+
lineWidth: number,
6+
line: Line
7+
) {
8+
const {
9+
p1: { x: x1, y: y1 },
10+
p2: { x: x2, y: y2 },
11+
} = line
12+
const horizontal = y1 === y2
13+
14+
let width: number
15+
let height: number
16+
let top: number = y1
17+
let left: number = x1
18+
19+
if (horizontal) {
20+
width = x2 - x1
21+
height = lineWidth
22+
top -= lineWidth / 2
23+
} else {
24+
width = lineWidth
25+
height = y2 - y1
26+
left -= lineWidth / 2
27+
}
28+
29+
top = Math.round(top)
30+
left = Math.round(left)
31+
32+
Object.assign(element.style, {
33+
position: 'fixed',
34+
pointerEvents: 'none',
35+
width: `${width}px`,
36+
height: `${height}px`,
37+
transform: `translate(${left}px, ${top}px)`,
38+
left: '0px',
39+
top: '0px',
40+
})
41+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { Ctx } from '@milkdown/ctx'
2+
3+
import { Plugin, PluginKey } from '@milkdown/prose/state'
4+
import { $prose } from '@milkdown/utils'
5+
6+
import { withMeta } from '../__internal__/with-meta'
7+
import {
8+
dropIndicatorConfig,
9+
dropIndicatorState,
10+
type DropIndicatorState,
11+
} from './state'
12+
13+
const key = new PluginKey('MILKDOWN_DROP_INDICATOR_DOM')
14+
15+
/// The drop indicator DOM plugin to render the drop indicator as a DOM element.
16+
export const dropIndicatorDOMPlugin = $prose(
17+
(ctx: Ctx) =>
18+
new Plugin({
19+
key,
20+
view: (view) => {
21+
const config = ctx.get(dropIndicatorConfig.key)
22+
const dom = document.createElement('div')
23+
Object.assign(dom.style, {
24+
position: 'fixed',
25+
pointerEvents: 'none',
26+
display: 'none',
27+
backgroundColor: config.color,
28+
top: '0',
29+
left: '0',
30+
})
31+
dom.classList.add(config.class)
32+
dom.classList.add('milkdown-drop-indicator')
33+
34+
view.dom.parentNode?.appendChild(dom)
35+
const stateSlice = ctx.use(dropIndicatorState.key)
36+
37+
const onUpdate = (state: DropIndicatorState) => {
38+
renderIndicator(dom, state, config)
39+
}
40+
41+
stateSlice.on(onUpdate)
42+
43+
return {
44+
destroy: () => {
45+
stateSlice.off(onUpdate)
46+
dom.remove()
47+
},
48+
}
49+
},
50+
})
51+
)
52+
53+
withMeta(dropIndicatorDOMPlugin, {
54+
displayName: 'Prose<dropIndicatorDOM>',
55+
})
56+
57+
function renderIndicator(
58+
dom: HTMLDivElement,
59+
state: DropIndicatorState,
60+
config: dropIndicatorConfig
61+
) {
62+
if (!state) {
63+
Object.assign(dom.style, { display: 'none' })
64+
return
65+
}
66+
67+
const { line } = state
68+
const { width: lineWidth } = config
69+
70+
const {
71+
p1: { x: x1, y: y1 },
72+
p2: { x: x2, y: y2 },
73+
} = line
74+
const horizontal = y1 === y2
75+
76+
let width: number
77+
let height: number
78+
let top: number = y1
79+
let left: number = x1
80+
81+
if (horizontal) {
82+
width = x2 - x1
83+
height = lineWidth
84+
top -= lineWidth / 2
85+
} else {
86+
width = lineWidth
87+
height = y2 - y1
88+
left -= lineWidth / 2
89+
}
90+
91+
top = Math.round(top)
92+
left = Math.round(left)
93+
94+
Object.assign(dom.style, {
95+
display: 'block',
96+
width: `${width}px`,
97+
height: `${height}px`,
98+
transform: `translate(${left}px, ${top}px)`,
99+
})
100+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import type { ResolvedPos } from '@milkdown/prose/model'
2+
import type { EditorView } from '@milkdown/prose/view'
3+
4+
import {
5+
NodeSelection,
6+
Plugin,
7+
PluginKey,
8+
TextSelection,
9+
type PluginView,
10+
} from '@milkdown/prose/state'
11+
12+
import type { DragEventHandler, ShowHandler, ViewDragging } from './types'
13+
14+
import { buildGetTarget, type GetTarget } from './drop-target'
15+
16+
interface DropIndicatorPluginOptions {
17+
onDrag: DragEventHandler
18+
onShow: ShowHandler
19+
onHide: VoidFunction
20+
}
21+
22+
const key = new PluginKey('MILKDOWN_DROP_INDICATOR')
23+
24+
export function createDropIndicatorPlugin(
25+
options: DropIndicatorPluginOptions
26+
): Plugin {
27+
let getTarget: GetTarget | undefined
28+
29+
return new Plugin({
30+
key,
31+
view: (view) => {
32+
getTarget = buildGetTarget(view, options.onDrag)
33+
return createDropIndicatorView(view, getTarget, options)
34+
},
35+
props: {
36+
handleDrop(view, event, slice, move): boolean {
37+
if (!getTarget) return false
38+
39+
const target = getTarget([event.clientX, event.clientY], event)
40+
41+
if (!target) return false
42+
43+
event.preventDefault()
44+
let insertPos = target[0]
45+
46+
let tr = view.state.tr
47+
if (move) {
48+
let { node } = (view.dragging as ViewDragging | null) || {}
49+
if (node) node.replace(tr)
50+
else tr.deleteSelection()
51+
}
52+
53+
let pos = tr.mapping.map(insertPos)
54+
let isNode =
55+
slice.openStart == 0 &&
56+
slice.openEnd == 0 &&
57+
slice.content.childCount == 1
58+
let beforeInsert = tr.doc
59+
if (isNode) tr.replaceRangeWith(pos, pos, slice.content.firstChild!)
60+
else tr.replaceRange(pos, pos, slice)
61+
if (tr.doc.eq(beforeInsert)) {
62+
return true
63+
}
64+
65+
let $pos = tr.doc.resolve(pos)
66+
if (
67+
isNode &&
68+
NodeSelection.isSelectable(slice.content.firstChild!) &&
69+
$pos.nodeAfter &&
70+
$pos.nodeAfter.sameMarkup(slice.content.firstChild!)
71+
) {
72+
tr.setSelection(new NodeSelection($pos))
73+
} else {
74+
let end = tr.mapping.map(insertPos)
75+
tr.mapping.maps[tr.mapping.maps.length - 1]?.forEach(
76+
(_from, _to, _newFrom, newTo) => (end = newTo)
77+
)
78+
tr.setSelection(selectionBetween(view, $pos, tr.doc.resolve(end)))
79+
}
80+
view.focus()
81+
view.dispatch(tr.setMeta('uiEvent', 'drop'))
82+
return true
83+
},
84+
},
85+
})
86+
}
87+
88+
function selectionBetween(
89+
view: EditorView,
90+
$anchor: ResolvedPos,
91+
$head: ResolvedPos,
92+
bias?: number
93+
) {
94+
return (
95+
view.someProp('createSelectionBetween', (f) => f(view, $anchor, $head)) ||
96+
TextSelection.between($anchor, $head, bias)
97+
)
98+
}
99+
100+
function createDropIndicatorView(
101+
view: EditorView,
102+
getTarget: GetTarget,
103+
options: DropIndicatorPluginOptions
104+
): PluginView {
105+
const dom = view.dom
106+
let hideId: ReturnType<typeof setTimeout> | undefined
107+
let prevX: number | undefined
108+
let prevY: number | undefined
109+
let hasDragOverEvent: boolean = false
110+
111+
const scheduleHide = () => {
112+
if (hideId) {
113+
clearTimeout(hideId)
114+
}
115+
116+
hasDragOverEvent = false
117+
hideId = setTimeout(() => {
118+
if (hasDragOverEvent) return
119+
options.onHide()
120+
}, 30)
121+
}
122+
123+
const handleDragOver = (event: DragEvent): void => {
124+
hasDragOverEvent = true
125+
126+
const { clientX, clientY } = event
127+
if (prevX === clientX && prevY === clientY) {
128+
return
129+
}
130+
prevX = clientX
131+
prevY = clientY
132+
133+
let target = getTarget([clientX, clientY], event)
134+
135+
if (!target) {
136+
scheduleHide()
137+
return
138+
} else {
139+
const [pos, [x1, y1, x2, y2]] = target
140+
const line = { p1: { x: x1, y: y1 }, p2: { x: x2, y: y2 } }
141+
options.onShow({ view, pos, line })
142+
}
143+
}
144+
145+
dom.addEventListener('dragover', handleDragOver)
146+
dom.addEventListener('dragend', scheduleHide)
147+
dom.addEventListener('drop', scheduleHide)
148+
dom.addEventListener('dragleave', scheduleHide)
149+
150+
const destroy = () => {
151+
dom.removeEventListener('dragover', handleDragOver)
152+
dom.removeEventListener('dragend', scheduleHide)
153+
dom.removeEventListener('drop', scheduleHide)
154+
dom.removeEventListener('dragleave', scheduleHide)
155+
}
156+
157+
return { destroy }
158+
}

0 commit comments

Comments
 (0)