@jvs-milkdown/components 1.1.4 → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/__internal__/components/image-input.d.ts.map +1 -1
- package/lib/code-block/index.js +12 -3382
- package/lib/code-block/index.js.map +1 -1
- package/lib/image-block/convert-plugin.d.ts +2 -0
- package/lib/image-block/convert-plugin.d.ts.map +1 -0
- package/lib/image-block/index.d.ts +2 -0
- package/lib/image-block/index.d.ts.map +1 -1
- package/lib/image-block/index.js +345 -75
- package/lib/image-block/index.js.map +1 -1
- package/lib/image-block/paste-rule.d.ts +2 -0
- package/lib/image-block/paste-rule.d.ts.map +1 -0
- package/lib/image-block/schema.d.ts.map +1 -1
- package/lib/image-block/view/components/image-block.d.ts +1 -0
- package/lib/image-block/view/components/image-block.d.ts.map +1 -1
- package/lib/image-block/view/components/image-viewer.d.ts.map +1 -1
- package/lib/image-block/view/index.d.ts.map +1 -1
- package/lib/image-inline/index.js +27 -6
- package/lib/image-inline/index.js.map +1 -1
- package/lib/link-tooltip/edit/component.d.ts.map +1 -1
- package/lib/link-tooltip/index.js +19 -1861
- package/lib/link-tooltip/index.js.map +1 -1
- package/lib/list-item-block/index.js +1 -1857
- package/lib/list-item-block/index.js.map +1 -1
- package/lib/table-block/dnd/drag-over-handler.d.ts.map +1 -1
- package/lib/table-block/index.js +140 -2984
- package/lib/table-block/index.js.map +1 -1
- package/lib/table-block/view/component.d.ts.map +1 -1
- package/lib/table-block/view/drag.d.ts +3 -0
- package/lib/table-block/view/drag.d.ts.map +1 -1
- package/lib/table-block/view/utils.d.ts.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/__internal__/components/image-input.tsx +45 -12
- package/src/image-block/__tests__/paste-rule.spec.ts +20 -0
- package/src/image-block/convert-plugin.ts +147 -0
- package/src/image-block/index.ts +6 -0
- package/src/image-block/paste-rule.ts +138 -0
- package/src/image-block/schema.ts +15 -0
- package/src/image-block/view/components/image-block.tsx +5 -0
- package/src/image-block/view/components/image-viewer.tsx +4 -0
- package/src/image-block/view/index.ts +8 -0
- package/src/link-tooltip/edit/component.tsx +27 -3
- package/src/table-block/dnd/create-drag-handler.ts +5 -1
- package/src/table-block/dnd/drag-over-handler.ts +29 -1
- package/src/table-block/dnd/preview.ts +3 -3
- package/src/table-block/view/component.tsx +121 -39
- package/src/table-block/view/drag.ts +29 -16
- package/src/table-block/view/utils.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jvs-milkdown/components",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
4
4
|
"keywords": [
|
|
5
5
|
"milkdown",
|
|
6
6
|
"milkdown plugin"
|
|
@@ -50,15 +50,15 @@
|
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@floating-ui/dom": "^1.5.1",
|
|
53
|
-
"@jvs-milkdown/core": "^1.1.
|
|
54
|
-
"@jvs-milkdown/ctx": "^1.1.
|
|
55
|
-
"@jvs-milkdown/exception": "^1.1.
|
|
56
|
-
"@jvs-milkdown/plugin-tooltip": "^1.1.
|
|
57
|
-
"@jvs-milkdown/preset-commonmark": "^1.1.
|
|
58
|
-
"@jvs-milkdown/preset-gfm": "^1.1.
|
|
59
|
-
"@jvs-milkdown/prose": "^1.1.
|
|
60
|
-
"@jvs-milkdown/transformer": "^1.1.
|
|
61
|
-
"@jvs-milkdown/utils": "^1.1.
|
|
53
|
+
"@jvs-milkdown/core": "^1.1.6",
|
|
54
|
+
"@jvs-milkdown/ctx": "^1.1.6",
|
|
55
|
+
"@jvs-milkdown/exception": "^1.1.6",
|
|
56
|
+
"@jvs-milkdown/plugin-tooltip": "^1.1.6",
|
|
57
|
+
"@jvs-milkdown/preset-commonmark": "^1.1.6",
|
|
58
|
+
"@jvs-milkdown/preset-gfm": "^1.1.6",
|
|
59
|
+
"@jvs-milkdown/prose": "^1.1.6",
|
|
60
|
+
"@jvs-milkdown/transformer": "^1.1.6",
|
|
61
|
+
"@jvs-milkdown/utils": "^1.1.6",
|
|
62
62
|
"@types/lodash-es": "^4.17.12",
|
|
63
63
|
"clsx": "^2.0.0",
|
|
64
64
|
"dompurify": "^3.2.5",
|
|
@@ -93,14 +93,34 @@ export const ImageInput = defineComponent<ImageInputProps>({
|
|
|
93
93
|
currentLink.value = value
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
const isValidUrl = (url: string) => {
|
|
97
|
+
if (!url) return false
|
|
98
|
+
const trimmedUrl = url.trim()
|
|
99
|
+
return /^(https?:\/\/|\/|\.\.?\/|data:image\/|blob:|[a-zA-Z0-9-]+\.[a-zA-Z]+)/i.test(
|
|
100
|
+
trimmedUrl
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
96
104
|
const onKeydown = (e: KeyboardEvent) => {
|
|
97
105
|
if (e.key === 'Enter') {
|
|
98
|
-
|
|
106
|
+
const val = linkInputRef.value?.value?.trim() ?? ''
|
|
107
|
+
|
|
108
|
+
// Stop bubbling so ProseMirror doesn't handle the Enter key
|
|
109
|
+
// regardless of whether the URL is valid or not.
|
|
110
|
+
e.preventDefault()
|
|
111
|
+
e.stopPropagation()
|
|
112
|
+
|
|
113
|
+
if (isValidUrl(val)) {
|
|
114
|
+
setLink(val)
|
|
115
|
+
}
|
|
99
116
|
}
|
|
100
117
|
}
|
|
101
118
|
|
|
102
119
|
const onConfirmLinkInput = () => {
|
|
103
|
-
|
|
120
|
+
const val = linkInputRef.value?.value?.trim() ?? ''
|
|
121
|
+
if (isValidUrl(val)) {
|
|
122
|
+
setLink(val)
|
|
123
|
+
}
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
const onUploadFile = (e: Event) => {
|
|
@@ -161,16 +181,29 @@ export const ImageInput = defineComponent<ImageInputProps>({
|
|
|
161
181
|
</div>
|
|
162
182
|
{currentLink.value && (
|
|
163
183
|
<>
|
|
164
|
-
|
|
165
|
-
<
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
184
|
+
{isValidUrl(currentLink.value) && (
|
|
185
|
+
<div class="image-preview">
|
|
186
|
+
<img
|
|
187
|
+
src={currentLink.value}
|
|
188
|
+
alt=""
|
|
189
|
+
onError={(e) =>
|
|
190
|
+
Promise.resolve(onImageLoadError?.(e)).catch(() => {})
|
|
191
|
+
}
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
<div
|
|
196
|
+
class={clsx(
|
|
197
|
+
'confirm',
|
|
198
|
+
!isValidUrl(currentLink.value) && 'disabled'
|
|
199
|
+
)}
|
|
200
|
+
onClick={() => onConfirmLinkInput()}
|
|
201
|
+
style={
|
|
202
|
+
!isValidUrl(currentLink.value)
|
|
203
|
+
? { opacity: 0.5, cursor: 'not-allowed' }
|
|
204
|
+
: undefined
|
|
205
|
+
}
|
|
206
|
+
>
|
|
174
207
|
<Icon icon={confirmButton} />
|
|
175
208
|
</div>
|
|
176
209
|
</>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { imageSchema, paragraphSchema } from '@jvs-milkdown/preset-commonmark'
|
|
2
|
+
import { DOMParser, Schema, Fragment } from '@jvs-milkdown/prose/model'
|
|
3
|
+
import { describe, it, expect } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { imageBlockPasteRule } from './paste-rule'
|
|
6
|
+
import { imageBlockSchema } from './schema'
|
|
7
|
+
|
|
8
|
+
describe('paste rule', () => {
|
|
9
|
+
it('splits paragraph correctly', () => {
|
|
10
|
+
// We mock ctx context enough to return strings for schema
|
|
11
|
+
const ctx = {
|
|
12
|
+
get: () => ({
|
|
13
|
+
type: (ctx: any) => {
|
|
14
|
+
// just mock type based on calls
|
|
15
|
+
},
|
|
16
|
+
}),
|
|
17
|
+
}
|
|
18
|
+
expect(true).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Node as ProsemirrorNode } from '@jvs-milkdown/prose/model'
|
|
2
|
+
|
|
3
|
+
import { imageSchema, paragraphSchema } from '@jvs-milkdown/preset-commonmark'
|
|
4
|
+
import { Plugin, PluginKey } from '@jvs-milkdown/prose/state'
|
|
5
|
+
import { $prose } from '@jvs-milkdown/utils'
|
|
6
|
+
|
|
7
|
+
import { withMeta } from '../__internal__/meta'
|
|
8
|
+
import { imageBlockConfig } from './config'
|
|
9
|
+
import { imageBlockSchema } from './schema'
|
|
10
|
+
|
|
11
|
+
export const imageBlockConvertPlugin = $prose((ctx) => {
|
|
12
|
+
const imageType = imageSchema.type(ctx)
|
|
13
|
+
const imageBlockType = imageBlockSchema.type(ctx)
|
|
14
|
+
const paragraphType = paragraphSchema.type(ctx)
|
|
15
|
+
|
|
16
|
+
const pluginKey = new PluginKey('MILKDOWN_IMAGE_BLOCK_CONVERT')
|
|
17
|
+
|
|
18
|
+
let uploading = false
|
|
19
|
+
|
|
20
|
+
return new Plugin({
|
|
21
|
+
key: pluginKey,
|
|
22
|
+
appendTransaction(transactions, _oldState, newState) {
|
|
23
|
+
if (!transactions.some((tr) => tr.docChanged)) return null
|
|
24
|
+
|
|
25
|
+
const replacements: {
|
|
26
|
+
from: number
|
|
27
|
+
to: number
|
|
28
|
+
blocks: ProsemirrorNode[]
|
|
29
|
+
}[] = []
|
|
30
|
+
|
|
31
|
+
// Debug: log all paragraphs with their children
|
|
32
|
+
let foundImages = 0
|
|
33
|
+
newState.doc.descendants((node, pos) => {
|
|
34
|
+
if (node.type === imageType) foundImages++
|
|
35
|
+
})
|
|
36
|
+
if (foundImages > 0) {
|
|
37
|
+
// inline images found
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
newState.doc.descendants((node, pos) => {
|
|
41
|
+
if (node.type !== paragraphType) return
|
|
42
|
+
|
|
43
|
+
const images: ProsemirrorNode[] = []
|
|
44
|
+
let hasOtherContent = false
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
47
|
+
const child = node.child(i)
|
|
48
|
+
if (child.type === imageType) {
|
|
49
|
+
images.push(child)
|
|
50
|
+
} else if (child.type.name === 'hardbreak') {
|
|
51
|
+
continue
|
|
52
|
+
} else if (child.isText && child.text?.trim() === '') {
|
|
53
|
+
continue
|
|
54
|
+
} else {
|
|
55
|
+
hasOtherContent = true
|
|
56
|
+
break
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (hasOtherContent || images.length === 0) return
|
|
61
|
+
|
|
62
|
+
const blocks = images
|
|
63
|
+
.map((img) =>
|
|
64
|
+
imageBlockType.create({
|
|
65
|
+
src: img.attrs.src,
|
|
66
|
+
caption: img.attrs.alt || img.attrs.title || '',
|
|
67
|
+
ratio: 1,
|
|
68
|
+
})
|
|
69
|
+
)
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
|
|
72
|
+
if (blocks.length > 0) {
|
|
73
|
+
replacements.push({ from: pos, to: pos + node.nodeSize, blocks })
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
if (replacements.length === 0) return null
|
|
78
|
+
|
|
79
|
+
const { tr } = newState
|
|
80
|
+
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
81
|
+
const r = replacements[i]!
|
|
82
|
+
tr.replaceWith(r.from, r.to, r.blocks)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return tr
|
|
86
|
+
},
|
|
87
|
+
view() {
|
|
88
|
+
return {
|
|
89
|
+
update(view) {
|
|
90
|
+
if (uploading) return
|
|
91
|
+
|
|
92
|
+
const config = ctx.get(imageBlockConfig.key)
|
|
93
|
+
const imagesToUpload: { pos: number; src: string }[] = []
|
|
94
|
+
|
|
95
|
+
view.state.doc.descendants((node, pos) => {
|
|
96
|
+
if (node.type.name !== 'image-block') return
|
|
97
|
+
const src: string = node.attrs.src
|
|
98
|
+
if (!src || src.startsWith('data:') || src.startsWith('blob:'))
|
|
99
|
+
return
|
|
100
|
+
if (!/^https?:\/\//i.test(src)) return
|
|
101
|
+
imagesToUpload.push({ pos, src })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
if (imagesToUpload.length === 0) return
|
|
105
|
+
|
|
106
|
+
uploading = true
|
|
107
|
+
|
|
108
|
+
void Promise.allSettled(
|
|
109
|
+
imagesToUpload.map(async ({ pos, src }) => {
|
|
110
|
+
const resp = await fetch(src)
|
|
111
|
+
const blob = await resp.blob()
|
|
112
|
+
const ext = blob.type.split('/')[1] || 'png'
|
|
113
|
+
const file = new File([blob], `pasted-image.${ext}`, {
|
|
114
|
+
type: blob.type,
|
|
115
|
+
})
|
|
116
|
+
const uploadedUrl = await config.onUpload(file)
|
|
117
|
+
|
|
118
|
+
const $pos = view.state.doc.resolve(pos)
|
|
119
|
+
const nodeAtPos = $pos.nodeAfter
|
|
120
|
+
if (
|
|
121
|
+
nodeAtPos &&
|
|
122
|
+
nodeAtPos.type.name === 'image-block' &&
|
|
123
|
+
nodeAtPos.attrs.src === src
|
|
124
|
+
) {
|
|
125
|
+
view.dispatch(
|
|
126
|
+
view.state.tr
|
|
127
|
+
.setNodeMarkup(pos, undefined, {
|
|
128
|
+
...nodeAtPos.attrs,
|
|
129
|
+
src: uploadedUrl,
|
|
130
|
+
})
|
|
131
|
+
.setMeta(pluginKey, { uploaded: true })
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
).finally(() => {
|
|
136
|
+
uploading = false
|
|
137
|
+
})
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
withMeta(imageBlockConvertPlugin, {
|
|
145
|
+
displayName: 'Prose<image-block-convert>',
|
|
146
|
+
group: 'ImageBlock',
|
|
147
|
+
})
|
package/src/image-block/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { MilkdownPlugin } from '@jvs-milkdown/ctx'
|
|
2
2
|
|
|
3
3
|
import { imageBlockConfig } from './config'
|
|
4
|
+
import { imageBlockConvertPlugin } from './convert-plugin'
|
|
5
|
+
import { imageBlockPasteRule } from './paste-rule'
|
|
4
6
|
import { remarkImageBlockPlugin } from './remark-plugin'
|
|
5
7
|
import { imageBlockSchema } from './schema'
|
|
6
8
|
import { imageBlockView } from './view'
|
|
@@ -9,10 +11,14 @@ export * from './schema'
|
|
|
9
11
|
export * from './remark-plugin'
|
|
10
12
|
export * from './config'
|
|
11
13
|
export * from './view'
|
|
14
|
+
export * from './paste-rule'
|
|
15
|
+
export * from './convert-plugin'
|
|
12
16
|
|
|
13
17
|
export const imageBlockComponent: MilkdownPlugin[] = [
|
|
14
18
|
remarkImageBlockPlugin,
|
|
15
19
|
imageBlockSchema,
|
|
16
20
|
imageBlockView,
|
|
17
21
|
imageBlockConfig,
|
|
22
|
+
imageBlockPasteRule,
|
|
23
|
+
imageBlockConvertPlugin,
|
|
18
24
|
].flat()
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { imageSchema, paragraphSchema } from '@jvs-milkdown/preset-commonmark'
|
|
2
|
+
import {
|
|
3
|
+
type Fragment as FragmentType,
|
|
4
|
+
Fragment,
|
|
5
|
+
type Node as ProsemirrorNode,
|
|
6
|
+
Slice,
|
|
7
|
+
} from '@jvs-milkdown/prose/model'
|
|
8
|
+
import { $pasteRule } from '@jvs-milkdown/utils'
|
|
9
|
+
|
|
10
|
+
import { withMeta } from '../__internal__/meta'
|
|
11
|
+
import { imageBlockSchema } from './schema'
|
|
12
|
+
|
|
13
|
+
function toImageBlock(
|
|
14
|
+
imageNode: ProsemirrorNode,
|
|
15
|
+
imageBlockType: ProsemirrorNode['type']
|
|
16
|
+
): ProsemirrorNode | null {
|
|
17
|
+
return imageBlockType.create({
|
|
18
|
+
src: imageNode.attrs.src,
|
|
19
|
+
caption: imageNode.attrs.alt || imageNode.attrs.title || '',
|
|
20
|
+
ratio: 1,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// Check if a paragraph contains only images (and trailing hard_breaks / empty text).
|
|
25
|
+
/// Returns the list of image nodes found, or null if there's other content.
|
|
26
|
+
function extractParagraphImages(
|
|
27
|
+
node: ProsemirrorNode,
|
|
28
|
+
imageType: ProsemirrorNode['type']
|
|
29
|
+
): ProsemirrorNode[] | null {
|
|
30
|
+
const images: ProsemirrorNode[] = []
|
|
31
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
32
|
+
const child = node.child(i)
|
|
33
|
+
if (child.type === imageType) {
|
|
34
|
+
images.push(child)
|
|
35
|
+
} else if (child.type.name === 'hardbreak') {
|
|
36
|
+
continue
|
|
37
|
+
} else if (child.isText && child.text?.trim() === '') {
|
|
38
|
+
continue
|
|
39
|
+
} else {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return images.length > 0 ? images : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// A paste rule that converts standalone inline images to image-block nodes.
|
|
47
|
+
/// Handles the slice structures produced when pasting HTML from external pages
|
|
48
|
+
/// or from another instance of the same editor:
|
|
49
|
+
/// 1. Bare `image` at fragment top-level (after Slice.maxOpen unwraps)
|
|
50
|
+
/// 2. `paragraph > image` (paragraph with single image child)
|
|
51
|
+
/// 3. `paragraph > [image, hard_break, ...]` (image with trailing breaks)
|
|
52
|
+
export const imageBlockPasteRule = $pasteRule((ctx) => ({
|
|
53
|
+
priority: 90,
|
|
54
|
+
run: (slice, _view, isPlainText) => {
|
|
55
|
+
if (isPlainText) return slice
|
|
56
|
+
|
|
57
|
+
const paragraphType = paragraphSchema.type(ctx)
|
|
58
|
+
const imageType = imageSchema.type(ctx)
|
|
59
|
+
const imageBlockType = imageBlockSchema.type(ctx)
|
|
60
|
+
|
|
61
|
+
function convertFragment(fragment: FragmentType): FragmentType {
|
|
62
|
+
const nodes: ProsemirrorNode[] = []
|
|
63
|
+
let changed = false
|
|
64
|
+
|
|
65
|
+
fragment.forEach((node) => {
|
|
66
|
+
// Case 1: bare inline image at top level (after Slice.maxOpen unwraps paragraph)
|
|
67
|
+
if (node.type === imageType) {
|
|
68
|
+
const imageBlock = toImageBlock(node, imageBlockType)
|
|
69
|
+
if (imageBlock) {
|
|
70
|
+
nodes.push(imageBlock)
|
|
71
|
+
changed = true
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Case 2, 3 & Mixed: Any paragraph containing images
|
|
77
|
+
if (node.type === paragraphType) {
|
|
78
|
+
let hasImage = false
|
|
79
|
+
node.content.forEach((child) => {
|
|
80
|
+
if (child.type === imageType) hasImage = true
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
if (hasImage) {
|
|
84
|
+
let currentParaNodes: ProsemirrorNode[] = []
|
|
85
|
+
let isFirstInsideParent = nodes.length === 0
|
|
86
|
+
|
|
87
|
+
node.content.forEach((child) => {
|
|
88
|
+
if (child.type === imageType) {
|
|
89
|
+
// If it's the first element in the parent slice (meaning we might need an openStart anchor)
|
|
90
|
+
// OR we have accumulated text nodes, push a paragraph.
|
|
91
|
+
if (currentParaNodes.length > 0 || isFirstInsideParent) {
|
|
92
|
+
nodes.push(node.copy(Fragment.from(currentParaNodes)))
|
|
93
|
+
currentParaNodes = []
|
|
94
|
+
}
|
|
95
|
+
const imageBlock = toImageBlock(child, imageBlockType)
|
|
96
|
+
if (imageBlock) {
|
|
97
|
+
nodes.push(imageBlock)
|
|
98
|
+
}
|
|
99
|
+
isFirstInsideParent = false
|
|
100
|
+
} else {
|
|
101
|
+
currentParaNodes.push(child)
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
// ALWAYS push the trailing paragraph at the end, even if empty.
|
|
105
|
+
// This guarantees that if the fragment replacement ends here, it's a paragraph,
|
|
106
|
+
// which safegaurds openEnd anchors from throwing ProseMirror slice geometry errors.
|
|
107
|
+
nodes.push(node.copy(Fragment.from(currentParaNodes)))
|
|
108
|
+
|
|
109
|
+
changed = true
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Recurse into children
|
|
115
|
+
if (node.content.size > 0) {
|
|
116
|
+
const fixedContent = convertFragment(node.content)
|
|
117
|
+
if (fixedContent !== node.content) {
|
|
118
|
+
changed = true
|
|
119
|
+
nodes.push(node.copy(fixedContent))
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
nodes.push(node)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
return changed ? Fragment.from(nodes) : fragment
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const fragment = convertFragment(slice.content)
|
|
131
|
+
return new Slice(fragment, slice.openStart, slice.openEnd)
|
|
132
|
+
},
|
|
133
|
+
}))
|
|
134
|
+
|
|
135
|
+
withMeta(imageBlockPasteRule, {
|
|
136
|
+
displayName: 'PasteRule<image-block>',
|
|
137
|
+
group: 'ImageBlock',
|
|
138
|
+
})
|
|
@@ -19,6 +19,7 @@ export const imageBlockSchema = $nodeSchema('image-block', () => {
|
|
|
19
19
|
src: { default: '', validate: 'string' },
|
|
20
20
|
caption: { default: '', validate: 'string' },
|
|
21
21
|
ratio: { default: 1, validate: 'number' },
|
|
22
|
+
align: { default: null },
|
|
22
23
|
},
|
|
23
24
|
parseDOM: [
|
|
24
25
|
{
|
|
@@ -30,6 +31,20 @@ export const imageBlockSchema = $nodeSchema('image-block', () => {
|
|
|
30
31
|
src: dom.getAttribute('src') || '',
|
|
31
32
|
caption: dom.getAttribute('caption') || '',
|
|
32
33
|
ratio: Number(dom.getAttribute('ratio') ?? 1),
|
|
34
|
+
align: dom.getAttribute('align') || null,
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
tag: 'img[src]',
|
|
40
|
+
getAttrs: (dom) => {
|
|
41
|
+
if (!(dom instanceof HTMLElement)) throw expectDomTypeError(dom)
|
|
42
|
+
if (dom.getAttribute('data-type') === IMAGE_DATA_TYPE) return false
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
src: dom.getAttribute('src') || '',
|
|
46
|
+
caption: dom.getAttribute('alt') || dom.getAttribute('title') || '',
|
|
47
|
+
ratio: 1,
|
|
33
48
|
}
|
|
34
49
|
},
|
|
35
50
|
},
|
|
@@ -12,6 +12,7 @@ type Attrs = {
|
|
|
12
12
|
src: string
|
|
13
13
|
caption: string
|
|
14
14
|
ratio: number
|
|
15
|
+
align: string | null
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export type MilkdownImageBlockProps = {
|
|
@@ -37,6 +38,10 @@ export const MilkdownImageBlock = defineComponent<MilkdownImageBlockProps>({
|
|
|
37
38
|
type: Object,
|
|
38
39
|
required: true,
|
|
39
40
|
},
|
|
41
|
+
align: {
|
|
42
|
+
type: Object,
|
|
43
|
+
required: true,
|
|
44
|
+
},
|
|
40
45
|
selected: {
|
|
41
46
|
type: Object,
|
|
42
47
|
required: true,
|
|
@@ -17,6 +17,7 @@ export const imageBlockView = $view(
|
|
|
17
17
|
const src = ref(initialNode.attrs.src)
|
|
18
18
|
const caption = ref(initialNode.attrs.caption)
|
|
19
19
|
const ratio = ref(initialNode.attrs.ratio)
|
|
20
|
+
const align = ref(initialNode.attrs.align)
|
|
20
21
|
const selected = ref(false)
|
|
21
22
|
const readonly = ref(!view.editable)
|
|
22
23
|
const setAttr = (attr: string, value: unknown) => {
|
|
@@ -36,6 +37,7 @@ export const imageBlockView = $view(
|
|
|
36
37
|
src,
|
|
37
38
|
caption,
|
|
38
39
|
ratio,
|
|
40
|
+
align,
|
|
39
41
|
selected,
|
|
40
42
|
readonly,
|
|
41
43
|
setAttr,
|
|
@@ -43,6 +45,7 @@ export const imageBlockView = $view(
|
|
|
43
45
|
})
|
|
44
46
|
const dom = document.createElement('div')
|
|
45
47
|
dom.className = 'milkdown-image-block'
|
|
48
|
+
dom.dataset.align = initialNode.attrs.align || 'center'
|
|
46
49
|
const disposeSelectedWatcher = watchEffect(() => {
|
|
47
50
|
const isSelected = selected.value
|
|
48
51
|
if (isSelected) {
|
|
@@ -51,6 +54,9 @@ export const imageBlockView = $view(
|
|
|
51
54
|
dom.classList.remove('selected')
|
|
52
55
|
}
|
|
53
56
|
})
|
|
57
|
+
const disposeAlignWatcher = watchEffect(() => {
|
|
58
|
+
dom.dataset.align = align.value || 'center'
|
|
59
|
+
})
|
|
54
60
|
const proxyDomURL = config.proxyDomURL
|
|
55
61
|
const bindAttrs = (node: Node) => {
|
|
56
62
|
if (!proxyDomURL) {
|
|
@@ -69,6 +75,7 @@ export const imageBlockView = $view(
|
|
|
69
75
|
}
|
|
70
76
|
ratio.value = node.attrs.ratio
|
|
71
77
|
caption.value = node.attrs.caption
|
|
78
|
+
align.value = node.attrs.align
|
|
72
79
|
|
|
73
80
|
readonly.value = !view.editable
|
|
74
81
|
}
|
|
@@ -97,6 +104,7 @@ export const imageBlockView = $view(
|
|
|
97
104
|
},
|
|
98
105
|
destroy: () => {
|
|
99
106
|
disposeSelectedWatcher()
|
|
107
|
+
disposeAlignWatcher()
|
|
100
108
|
app.unmount()
|
|
101
109
|
dom.remove()
|
|
102
110
|
},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import clsx from 'clsx'
|
|
1
2
|
import { defineComponent, ref, watch, type Ref, h } from 'vue'
|
|
2
3
|
|
|
3
4
|
import type { LinkTooltipConfig } from '../slices'
|
|
@@ -40,15 +41,30 @@ export const EditLink = defineComponent<EditLinkProps>({
|
|
|
40
41
|
link.value = value
|
|
41
42
|
})
|
|
42
43
|
|
|
44
|
+
const isValidUrl = (url: string) => {
|
|
45
|
+
if (!url) return false
|
|
46
|
+
const trimmedUrl = url.trim()
|
|
47
|
+
// Accept standard http/https links, relative links, anchors, or mailto
|
|
48
|
+
return /^(https?:\/\/|\/|\.\.?\/|mailto:|#|[a-zA-Z0-9-]+\.[a-zA-Z]+)/i.test(
|
|
49
|
+
trimmedUrl
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
43
53
|
const onConfirmEdit = () => {
|
|
44
|
-
|
|
54
|
+
const val = link.value?.trim() ?? ''
|
|
55
|
+
if (isValidUrl(val)) {
|
|
56
|
+
onConfirm(val)
|
|
57
|
+
}
|
|
45
58
|
}
|
|
46
59
|
|
|
47
60
|
const onKeydown = (e: KeyboardEvent) => {
|
|
48
61
|
e.stopPropagation()
|
|
49
62
|
if (e.key === 'Enter') {
|
|
50
63
|
e.preventDefault()
|
|
51
|
-
|
|
64
|
+
const val = link.value?.trim() ?? ''
|
|
65
|
+
if (isValidUrl(val)) {
|
|
66
|
+
onConfirmEdit()
|
|
67
|
+
}
|
|
52
68
|
}
|
|
53
69
|
if (e.key === 'Escape') {
|
|
54
70
|
e.preventDefault()
|
|
@@ -70,9 +86,17 @@ export const EditLink = defineComponent<EditLinkProps>({
|
|
|
70
86
|
/>
|
|
71
87
|
{link.value ? (
|
|
72
88
|
<Icon
|
|
73
|
-
class=
|
|
89
|
+
class={clsx(
|
|
90
|
+
'button confirm',
|
|
91
|
+
!isValidUrl(link.value) && 'disabled'
|
|
92
|
+
)}
|
|
74
93
|
icon={config.value.confirmButton}
|
|
75
94
|
onClick={onConfirmEdit}
|
|
95
|
+
style={
|
|
96
|
+
!isValidUrl(link.value)
|
|
97
|
+
? { opacity: 0.5, cursor: 'not-allowed' }
|
|
98
|
+
: undefined
|
|
99
|
+
}
|
|
76
100
|
/>
|
|
77
101
|
) : null}
|
|
78
102
|
</div>
|
|
@@ -73,7 +73,11 @@ function handleDrag(
|
|
|
73
73
|
if (!view?.editable) return
|
|
74
74
|
|
|
75
75
|
event.stopPropagation()
|
|
76
|
-
if (event.dataTransfer)
|
|
76
|
+
if (event.dataTransfer) {
|
|
77
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
78
|
+
// Required by some browsers to properly initiate the drag; empty string might be rejected
|
|
79
|
+
event.dataTransfer.setData('text/plain', 'milkdown-table-drag')
|
|
80
|
+
}
|
|
77
81
|
|
|
78
82
|
const context = prepareDndContext(refs)
|
|
79
83
|
|
|
@@ -8,7 +8,7 @@ import { getDragOverColumn, getDragOverRow } from './calc-drag-over'
|
|
|
8
8
|
import { prepareDndContext } from './prepare-dnd-context'
|
|
9
9
|
|
|
10
10
|
export function createDragOverHandler(refs: Refs): (e: DragEvent) => void {
|
|
11
|
-
|
|
11
|
+
const updatePosition = throttle((e: DragEvent) => {
|
|
12
12
|
const context = prepareDndContext(refs)
|
|
13
13
|
if (!context) return
|
|
14
14
|
const { preview, content, contentRoot, xHandle, yHandle } = context
|
|
@@ -110,4 +110,32 @@ export function createDragOverHandler(refs: Refs): (e: DragEvent) => void {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
}, 20)
|
|
113
|
+
|
|
114
|
+
return (e: DragEvent) => {
|
|
115
|
+
const context = prepareDndContext(refs)
|
|
116
|
+
if (!context) return
|
|
117
|
+
const { preview, contentRoot } = context
|
|
118
|
+
if (preview.dataset.show === 'false') return
|
|
119
|
+
|
|
120
|
+
e.preventDefault()
|
|
121
|
+
e.stopPropagation()
|
|
122
|
+
|
|
123
|
+
// SYNCHRONOUSLY update the endIndex so that rapid drops don't miss the final position
|
|
124
|
+
const info = refs.dragInfo.value
|
|
125
|
+
if (info) {
|
|
126
|
+
if (info.type === 'col') {
|
|
127
|
+
const dragOverColumn = getDragOverColumn(contentRoot, e.clientX)
|
|
128
|
+
if (dragOverColumn) {
|
|
129
|
+
info.endIndex = dragOverColumn[1]
|
|
130
|
+
}
|
|
131
|
+
} else if (info.type === 'row') {
|
|
132
|
+
const dragOverRow = getDragOverRow(contentRoot, e.clientY)
|
|
133
|
+
if (dragOverRow) {
|
|
134
|
+
info.endIndex = dragOverRow[1]
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
updatePosition(e)
|
|
140
|
+
}
|
|
113
141
|
}
|
|
@@ -9,9 +9,9 @@ export function renderPreview(
|
|
|
9
9
|
tableContent: HTMLElement,
|
|
10
10
|
index: number
|
|
11
11
|
) {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
.getBoundingClientRect()
|
|
12
|
+
const tableBodyOrContent = tableContent.querySelector('tbody') || tableContent
|
|
13
|
+
const { width: tableWidth, height: tableHeight } =
|
|
14
|
+
tableBodyOrContent.getBoundingClientRect()
|
|
15
15
|
if (axis === 'y') {
|
|
16
16
|
const rows = tableContent.querySelectorAll('tr')
|
|
17
17
|
const row = rows[index]
|