@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.
Files changed (48) hide show
  1. package/lib/__internal__/components/image-input.d.ts.map +1 -1
  2. package/lib/code-block/index.js +12 -3382
  3. package/lib/code-block/index.js.map +1 -1
  4. package/lib/image-block/convert-plugin.d.ts +2 -0
  5. package/lib/image-block/convert-plugin.d.ts.map +1 -0
  6. package/lib/image-block/index.d.ts +2 -0
  7. package/lib/image-block/index.d.ts.map +1 -1
  8. package/lib/image-block/index.js +345 -75
  9. package/lib/image-block/index.js.map +1 -1
  10. package/lib/image-block/paste-rule.d.ts +2 -0
  11. package/lib/image-block/paste-rule.d.ts.map +1 -0
  12. package/lib/image-block/schema.d.ts.map +1 -1
  13. package/lib/image-block/view/components/image-block.d.ts +1 -0
  14. package/lib/image-block/view/components/image-block.d.ts.map +1 -1
  15. package/lib/image-block/view/components/image-viewer.d.ts.map +1 -1
  16. package/lib/image-block/view/index.d.ts.map +1 -1
  17. package/lib/image-inline/index.js +27 -6
  18. package/lib/image-inline/index.js.map +1 -1
  19. package/lib/link-tooltip/edit/component.d.ts.map +1 -1
  20. package/lib/link-tooltip/index.js +19 -1861
  21. package/lib/link-tooltip/index.js.map +1 -1
  22. package/lib/list-item-block/index.js +1 -1857
  23. package/lib/list-item-block/index.js.map +1 -1
  24. package/lib/table-block/dnd/drag-over-handler.d.ts.map +1 -1
  25. package/lib/table-block/index.js +140 -2984
  26. package/lib/table-block/index.js.map +1 -1
  27. package/lib/table-block/view/component.d.ts.map +1 -1
  28. package/lib/table-block/view/drag.d.ts +3 -0
  29. package/lib/table-block/view/drag.d.ts.map +1 -1
  30. package/lib/table-block/view/utils.d.ts.map +1 -1
  31. package/lib/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +10 -10
  33. package/src/__internal__/components/image-input.tsx +45 -12
  34. package/src/image-block/__tests__/paste-rule.spec.ts +20 -0
  35. package/src/image-block/convert-plugin.ts +147 -0
  36. package/src/image-block/index.ts +6 -0
  37. package/src/image-block/paste-rule.ts +138 -0
  38. package/src/image-block/schema.ts +15 -0
  39. package/src/image-block/view/components/image-block.tsx +5 -0
  40. package/src/image-block/view/components/image-viewer.tsx +4 -0
  41. package/src/image-block/view/index.ts +8 -0
  42. package/src/link-tooltip/edit/component.tsx +27 -3
  43. package/src/table-block/dnd/create-drag-handler.ts +5 -1
  44. package/src/table-block/dnd/drag-over-handler.ts +29 -1
  45. package/src/table-block/dnd/preview.ts +3 -3
  46. package/src/table-block/view/component.tsx +121 -39
  47. package/src/table-block/view/drag.ts +29 -16
  48. 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.4",
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.4",
54
- "@jvs-milkdown/ctx": "^1.1.4",
55
- "@jvs-milkdown/exception": "^1.1.4",
56
- "@jvs-milkdown/plugin-tooltip": "^1.1.4",
57
- "@jvs-milkdown/preset-commonmark": "^1.1.4",
58
- "@jvs-milkdown/preset-gfm": "^1.1.4",
59
- "@jvs-milkdown/prose": "^1.1.4",
60
- "@jvs-milkdown/transformer": "^1.1.4",
61
- "@jvs-milkdown/utils": "^1.1.4",
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
- 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
  },
@@ -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
- onConfirm(link.value)
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
- onConfirmEdit()
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="button confirm"
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) event.dataTransfer.effectAllowed = 'move'
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
- return throttle((e: DragEvent) => {
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 { width: tableWidth, height: tableHeight } = tableContent
13
- .querySelector('tbody')!
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]