@jvs-milkdown/components 1.1.5 → 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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/lib/__internal__/components/image-input.d.ts.map +1 -1
  3. package/lib/image-block/convert-plugin.d.ts +2 -0
  4. package/lib/image-block/convert-plugin.d.ts.map +1 -0
  5. package/lib/image-block/index.d.ts +2 -0
  6. package/lib/image-block/index.d.ts.map +1 -1
  7. package/lib/image-block/index.js +345 -75
  8. package/lib/image-block/index.js.map +1 -1
  9. package/lib/image-block/paste-rule.d.ts +2 -0
  10. package/lib/image-block/paste-rule.d.ts.map +1 -0
  11. package/lib/image-block/schema.d.ts.map +1 -1
  12. package/lib/image-block/view/components/image-block.d.ts +1 -0
  13. package/lib/image-block/view/components/image-block.d.ts.map +1 -1
  14. package/lib/image-block/view/components/image-viewer.d.ts.map +1 -1
  15. package/lib/image-block/view/index.d.ts.map +1 -1
  16. package/lib/image-inline/index.js +27 -6
  17. package/lib/image-inline/index.js.map +1 -1
  18. package/lib/link-tooltip/edit/component.d.ts.map +1 -1
  19. package/lib/link-tooltip/index.js +18 -4
  20. package/lib/link-tooltip/index.js.map +1 -1
  21. package/lib/table-block/dnd/drag-over-handler.d.ts.map +1 -1
  22. package/lib/table-block/index.js +139 -53
  23. package/lib/table-block/index.js.map +1 -1
  24. package/lib/table-block/view/component.d.ts.map +1 -1
  25. package/lib/table-block/view/drag.d.ts +3 -0
  26. package/lib/table-block/view/drag.d.ts.map +1 -1
  27. package/lib/table-block/view/utils.d.ts.map +1 -1
  28. package/lib/tsconfig.tsbuildinfo +1 -1
  29. package/package.json +53 -79
  30. package/src/__internal__/components/image-input.tsx +45 -12
  31. package/src/image-block/__tests__/paste-rule.spec.ts +20 -0
  32. package/src/image-block/convert-plugin.ts +147 -0
  33. package/src/image-block/index.ts +6 -0
  34. package/src/image-block/paste-rule.ts +138 -0
  35. package/src/image-block/schema.ts +15 -0
  36. package/src/image-block/view/components/image-block.tsx +5 -0
  37. package/src/image-block/view/components/image-viewer.tsx +4 -0
  38. package/src/image-block/view/index.ts +8 -0
  39. package/src/link-tooltip/edit/component.tsx +27 -3
  40. package/src/table-block/dnd/create-drag-handler.ts +5 -1
  41. package/src/table-block/dnd/drag-over-handler.ts +29 -1
  42. package/src/table-block/dnd/preview.ts +3 -3
  43. package/src/table-block/view/component.tsx +121 -39
  44. package/src/table-block/view/drag.ts +29 -16
  45. 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.5",
3
+ "version": "1.1.6",
4
4
  "keywords": [
5
5
  "milkdown",
6
6
  "milkdown plugin"
@@ -17,101 +17,48 @@
17
17
  ],
18
18
  "type": "module",
19
19
  "sideEffects": false,
20
- "main": "./src/index.ts",
20
+ "main": "./lib/index.js",
21
21
  "exports": {
22
22
  ".": {
23
- "import": "./src/index.ts"
23
+ "types": "./lib/index.d.ts",
24
+ "import": "./lib/index.js"
24
25
  },
25
26
  "./image-block": {
26
- "import": "./src/image-block/index.ts"
27
+ "types": "./lib/image-block/index.d.ts",
28
+ "import": "./lib/image-block/index.js"
27
29
  },
28
30
  "./code-block": {
29
- "import": "./src/code-block/index.ts"
31
+ "types": "./lib/code-block/index.d.ts",
32
+ "import": "./lib/code-block/index.js"
30
33
  },
31
34
  "./list-item-block": {
32
- "import": "./src/list-item-block/index.ts"
35
+ "types": "./lib/list-item-block/index.d.ts",
36
+ "import": "./lib/list-item-block/index.js"
33
37
  },
34
38
  "./link-tooltip": {
35
- "import": "./src/link-tooltip/index.ts"
39
+ "types": "./lib/link-tooltip/index.d.ts",
40
+ "import": "./lib/link-tooltip/index.js"
36
41
  },
37
42
  "./image-inline": {
38
- "import": "./src/image-inline/index.ts"
43
+ "types": "./lib/image-inline/index.d.ts",
44
+ "import": "./lib/image-inline/index.js"
39
45
  },
40
46
  "./table-block": {
41
- "import": "./src/table-block/index.ts"
47
+ "types": "./lib/table-block/index.d.ts",
48
+ "import": "./lib/table-block/index.js"
42
49
  }
43
50
  },
44
- "publishConfig": {
45
- "exports": {
46
- ".": {
47
- "types": "./lib/index.d.ts",
48
- "import": "./lib/index.js"
49
- },
50
- "./image-block": {
51
- "types": "./lib/image-block/index.d.ts",
52
- "import": "./lib/image-block/index.js"
53
- },
54
- "./code-block": {
55
- "types": "./lib/code-block/index.d.ts",
56
- "import": "./lib/code-block/index.js"
57
- },
58
- "./list-item-block": {
59
- "types": "./lib/list-item-block/index.d.ts",
60
- "import": "./lib/list-item-block/index.js"
61
- },
62
- "./link-tooltip": {
63
- "types": "./lib/link-tooltip/index.d.ts",
64
- "import": "./lib/link-tooltip/index.js"
65
- },
66
- "./image-inline": {
67
- "types": "./lib/image-inline/index.d.ts",
68
- "import": "./lib/image-inline/index.js"
69
- },
70
- "./table-block": {
71
- "types": "./lib/table-block/index.d.ts",
72
- "import": "./lib/table-block/index.js"
73
- }
74
- },
75
- "main": "./lib/index.js",
76
- "types": "./lib/index.d.ts",
77
- "typesVersions": {
78
- "*": {
79
- "image-block": [
80
- "./lib/image-block/index.d.ts"
81
- ],
82
- "code-block": [
83
- "./lib/code-block/index.d.ts"
84
- ],
85
- "list-item-block": [
86
- "./lib/list-item-block/index.d.ts"
87
- ],
88
- "link-tooltip": [
89
- "./lib/link-tooltip/index.d.ts"
90
- ],
91
- "image-inline": [
92
- "./lib/image-inline/index.d.ts"
93
- ],
94
- "table-block": [
95
- "./lib/table-block/index.d.ts"
96
- ]
97
- }
98
- }
99
- },
100
- "scripts": {
101
- "build": "rollup -c",
102
- "test": "vitest run"
103
- },
104
51
  "dependencies": {
105
52
  "@floating-ui/dom": "^1.5.1",
106
- "@jvs-milkdown/core": "^1.1.5",
107
- "@jvs-milkdown/ctx": "^1.1.5",
108
- "@jvs-milkdown/exception": "^1.1.5",
109
- "@jvs-milkdown/plugin-tooltip": "^1.1.5",
110
- "@jvs-milkdown/preset-commonmark": "^1.1.5",
111
- "@jvs-milkdown/preset-gfm": "^1.1.5",
112
- "@jvs-milkdown/prose": "^1.1.5",
113
- "@jvs-milkdown/transformer": "^1.1.5",
114
- "@jvs-milkdown/utils": "^1.1.5",
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",
115
62
  "@types/lodash-es": "^4.17.12",
116
63
  "clsx": "^2.0.0",
117
64
  "dompurify": "^3.2.5",
@@ -132,5 +79,32 @@
132
79
  "@codemirror/language": "^6",
133
80
  "@codemirror/state": "^6",
134
81
  "@codemirror/view": "^6"
82
+ },
83
+ "scripts": {
84
+ "build": "rollup -c",
85
+ "test": "vitest run"
86
+ },
87
+ "types": "./lib/index.d.ts",
88
+ "typesVersions": {
89
+ "*": {
90
+ "image-block": [
91
+ "./lib/image-block/index.d.ts"
92
+ ],
93
+ "code-block": [
94
+ "./lib/code-block/index.d.ts"
95
+ ],
96
+ "list-item-block": [
97
+ "./lib/list-item-block/index.d.ts"
98
+ ],
99
+ "link-tooltip": [
100
+ "./lib/link-tooltip/index.d.ts"
101
+ ],
102
+ "image-inline": [
103
+ "./lib/image-inline/index.d.ts"
104
+ ],
105
+ "table-block": [
106
+ "./lib/table-block/index.d.ts"
107
+ ]
108
+ }
135
109
  }
136
- }
110
+ }
@@ -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
- setLink(linkInputRef.value?.value ?? '')
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
- setLink(linkInputRef.value?.value ?? '')
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
- <div class="image-preview">
165
- <img
166
- src={currentLink.value}
167
- alt=""
168
- onError={(e) =>
169
- Promise.resolve(onImageLoadError?.(e)).catch(() => {})
170
- }
171
- />
172
- </div>
173
- <div class="confirm" onClick={() => onConfirmLinkInput()}>
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
+ })
@@ -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,
@@ -22,6 +22,10 @@ export const ImageViewer = defineComponent<MilkdownImageBlockProps>({
22
22
  type: Object,
23
23
  required: true,
24
24
  },
25
+ align: {
26
+ type: Object,
27
+ required: true,
28
+ },
25
29
  selected: {
26
30
  type: Object,
27
31
  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
  },