pimelon-ui 0.1.204 → 0.1.207

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pimelon-ui",
3
- "version": "0.1.204",
3
+ "version": "0.1.207",
4
4
  "description": "A set of components and utilities for rapid UI development",
5
5
  "main": "./src/index.ts",
6
6
  "type": "module",
@@ -37,6 +37,7 @@
37
37
  "@tailwindcss/line-clamp": "^0.4.4",
38
38
  "@tailwindcss/typography": "^0.5.16",
39
39
  "@tiptap/core": "^2.26.1",
40
+ "@tiptap/extension-code": "^2.26.1",
40
41
  "@tiptap/extension-code-block": "^2.26.1",
41
42
  "@tiptap/extension-code-block-lowlight": "^2.26.1",
42
43
  "@tiptap/extension-color": "^2.26.1",
@@ -9,6 +9,7 @@
9
9
  <div
10
10
  class="flex min-h-screen flex-col items-center px-4 py-4 text-center"
11
11
  :class="dialogPositionClasses"
12
+ :style="dialogPositionStyles"
12
13
  >
13
14
  <DialogContent
14
15
  class="my-8 inline-block w-full transform overflow-hidden rounded-xl bg-surface-modal text-left align-middle shadow-xl dialog-content focus-visible:outline-none"
@@ -216,6 +217,8 @@ const icon = computed(() => {
216
217
  })
217
218
 
218
219
  const dialogPositionClasses = computed(() => {
220
+ if (props.options?.paddingTop) return ''
221
+
219
222
  const position = props.options?.position || 'center'
220
223
  const classMap: Record<string, string> = {
221
224
  center: 'justify-center',
@@ -224,6 +227,13 @@ const dialogPositionClasses = computed(() => {
224
227
  return classMap[position]
225
228
  })
226
229
 
230
+ const dialogPositionStyles = computed(() => {
231
+ if (props.options?.paddingTop) {
232
+ return { paddingTop: props.options.paddingTop }
233
+ }
234
+ return {}
235
+ })
236
+
227
237
  const dialogIconBgClasses = computed(() => {
228
238
  const appearance = icon.value?.appearance
229
239
  if (!appearance) return 'bg-surface-gray-2'
@@ -25,6 +25,7 @@ type DialogOptions = {
25
25
  actions?: Array<DialogAction>
26
26
  // default position = 'center'
27
27
  position?: 'top' | 'center'
28
+ paddingTop?: string | number
28
29
  }
29
30
 
30
31
  export type DialogActionContext = {
@@ -14,7 +14,7 @@
14
14
  class="flex flex-col transition-all duration-300 ease-in-out"
15
15
  v-bind="{
16
16
  to: list.options.getRowRoute ? list.options.getRowRoute(row) : undefined,
17
- onClick: (e) => onRowClick(row, e),
17
+ onClick: onRowClick,
18
18
  }"
19
19
  >
20
20
  <component
@@ -40,7 +40,7 @@
40
40
  <Checkbox
41
41
  :modelValue="isSelected"
42
42
  class="cursor-pointer duration-300"
43
- @click.stop="list.toggleRow(row[list.rowKey])"
43
+ @click.stop="handleCheckboxClick"
44
44
  />
45
45
  </div>
46
46
  <div
@@ -89,7 +89,7 @@
89
89
  import Checkbox from '../Checkbox/Checkbox.vue'
90
90
  import ListRowItem from './ListRowItem.vue'
91
91
  import { alignmentMap, getGridTemplateColumns } from './utils'
92
- import { computed, inject, ref } from 'vue'
92
+ import { computed, inject } from 'vue'
93
93
 
94
94
  const props = defineProps({
95
95
  row: {
@@ -144,12 +144,34 @@ const roundedClass = computed(() => {
144
144
  }
145
145
  })
146
146
 
147
- const onRowClick = (row, e) => {
148
- if (list.value.options.onRowClick) list.value.options.onRowClick(row, e)
149
- if (list.value.activeRow.value === row.name) {
147
+ const onRowClick = (event) => {
148
+ if (list.value.options.onRowClick)
149
+ list.value.options.onRowClick(props.row, event)
150
+ if (list.value.activeRow.value === props.row.name) {
150
151
  list.value.activeRow.value = null
151
152
  } else {
152
- list.value.activeRow.value = row.name
153
+ list.value.activeRow.value = props.row.name
154
+ }
155
+ }
156
+
157
+ const handleCheckboxClick = (event) => {
158
+ const value = props.row[list.value.rowKey]
159
+ if (event.shiftKey && !list.value.selections.has(value)) {
160
+ const lastSelected = Array.from(list.value.selections).pop()
161
+ const rows = list.value.rows.find((k) => k.group)
162
+ ? list.value.rows.reduce((acc, curr) => acc.concat(curr.rows), [])
163
+ : list.value.rows
164
+ const lastIndex = rows.findIndex(
165
+ (k) => lastSelected === k[list.value.rowKey],
166
+ )
167
+ const curIndex = rows.findIndex((k) => value === k[list.value.rowKey])
168
+ const start = Math.min(lastIndex, curIndex)
169
+ const end = Math.max(lastIndex, curIndex)
170
+ for (let i = start; i <= end; i++) {
171
+ list.value.selections.add(rows[i][list.value.rowKey])
172
+ }
173
+ } else {
174
+ list.value.toggleRow(value)
153
175
  }
154
176
  }
155
177
  </script>
@@ -0,0 +1,104 @@
1
+ <template>
2
+ <div
3
+ class="p-2 w-72 flex items-center gap-2 bg-surface-white shadow-xl rounded"
4
+ >
5
+ <TextInput
6
+ v-if="edit"
7
+ ref="input"
8
+ type="text"
9
+ class="w-full"
10
+ placeholder="https://example.com"
11
+ v-model="_href"
12
+ @keydown.enter="submitLink"
13
+ @keydown.esc="$emit('close')"
14
+ />
15
+ <a
16
+ v-else
17
+ class="text-ink-gray-700 underline text-sm flex-1 truncate pl-1"
18
+ :title="_href"
19
+ :href="_href"
20
+ target="_blank"
21
+ >
22
+ {{ _href }}
23
+ </a>
24
+ <div class="shrink-0 flex items-center gap-1.5 ml-auto">
25
+ <template v-if="edit">
26
+ <Button
27
+ @click="submitLink"
28
+ tooltip="Submit"
29
+ :icon="LucideCheck"
30
+ variant="subtle"
31
+ />
32
+ <Button
33
+ @click="props.href ? (edit = false) : $emit('updateHref', '')"
34
+ tooltip="Exit"
35
+ :icon="LucideX"
36
+ variant="subtle"
37
+ />
38
+ </template>
39
+ <template v-else>
40
+ <Button
41
+ @click="copyLink"
42
+ tooltip="Copy"
43
+ :icon="LucideCopy"
44
+ variant="subtle"
45
+ />
46
+ <Button
47
+ @click="edit = true"
48
+ tooltip="Edit"
49
+ :icon="LucidePencil"
50
+ variant="subtle"
51
+ />
52
+ <Button
53
+ tooltip="Remove"
54
+ variant="subtle"
55
+ @click="$emit('updateHref', '')"
56
+ :icon="Link2Off"
57
+ />
58
+ </template>
59
+ </div>
60
+ </div>
61
+ </template>
62
+
63
+ <script setup lang="ts">
64
+ import { onMounted, ref, useTemplateRef, nextTick } from 'vue'
65
+ import Button from '../Button/Button.vue'
66
+ import TextInput from '../TextInput/TextInput.vue'
67
+ import LucideCopy from '~icons/lucide/copy'
68
+ import LucideCheck from '~icons/lucide/check'
69
+ import LucidePencil from '~icons/lucide/pencil'
70
+ import LucideX from '~icons/lucide/x'
71
+ import Link2Off from '~icons/lucide/link-2-off'
72
+ import { isValidUrl } from '../../utils/url-validation'
73
+
74
+ const props = defineProps<{
75
+ href: string
76
+ }>()
77
+
78
+ const emit = defineEmits<{
79
+ (e: 'updateHref', href: string): void
80
+ (e: 'close'): void
81
+ }>()
82
+
83
+ const _href = ref(props.href)
84
+ const input = useTemplateRef('input')
85
+ const edit = ref(props.href === '')
86
+
87
+ const submitLink = () => {
88
+ if (_href.value === '' || isValidUrl(_href.value)) {
89
+ emit('updateHref', _href.value)
90
+ }
91
+ }
92
+
93
+ const copyLink = async () => {
94
+ if (_href.value) await navigator.clipboard.writeText(_href.value)
95
+ }
96
+
97
+ onMounted(async () => {
98
+ await nextTick()
99
+ if (input.value?.el) {
100
+ input.value.el.focus()
101
+ input.value.el.select()
102
+ }
103
+ })
104
+ </script>
@@ -4,6 +4,8 @@
4
4
  class="relative w-full"
5
5
  :class="attrsClass"
6
6
  :style="attrsStyle"
7
+ v-bind="attrsWithoutClassStyle"
8
+ ref="rootRef"
7
9
  >
8
10
  <TextEditorBubbleMenu :buttons="bubbleMenu" :options="bubbleMenuOptions" />
9
11
  <TextEditorFixedMenu
@@ -30,11 +32,12 @@ import {
30
32
  provide,
31
33
  ref,
32
34
  useAttrs,
35
+ useTemplateRef,
33
36
  } from 'vue'
34
37
 
35
38
  defineOptions({ inheritAttrs: false })
36
39
 
37
- import { Editor, EditorContent, VueNodeViewRenderer } from '@tiptap/vue-3'
40
+ import { Editor, EditorContent } from '@tiptap/vue-3'
38
41
  import StarterKit from '@tiptap/starter-kit'
39
42
  import Placeholder from '@tiptap/extension-placeholder'
40
43
  import TextAlign from '@tiptap/extension-text-align'
@@ -53,9 +56,7 @@ import TaskItem from '@tiptap/extension-task-item'
53
56
  import TaskList from '@tiptap/extension-task-list'
54
57
  import NamedColorExtension from './extensions/color'
55
58
  import NamedHighlightExtension from './extensions/highlight'
56
- import { common, createLowlight } from 'lowlight'
57
- import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
58
- import CodeBlockComponent from './CodeBlockComponent.vue'
59
+
59
60
  import { MentionExtension } from './extensions/mention'
60
61
  import TextEditorFixedMenu from './TextEditorFixedMenu.vue'
61
62
  import TextEditorBubbleMenu from './TextEditorBubbleMenu.vue'
@@ -66,11 +67,10 @@ import { ContentPasteExtension } from './extensions/content-paste-extension'
66
67
  import { TagNode, TagExtension } from './extensions/tag/tag-extension'
67
68
  import { Heading } from './extensions/heading/heading'
68
69
  import { ImageGroup } from './extensions/image-group/image-group-extension'
70
+ import { ExtendedCode, ExtendedCodeBlock } from './extensions/code-block'
69
71
  import { useFileUpload } from '../../utils/useFileUpload'
70
72
  import { TextEditorEmits, TextEditorProps } from './types'
71
73
 
72
- const lowlight = createLowlight(common)
73
-
74
74
  function defaultUploadFunction(file: File) {
75
75
  // useFileUpload is melon specific
76
76
  let fileUpload = useFileUpload()
@@ -100,6 +100,11 @@ const editor = ref<Editor | null>(null)
100
100
  const attrs = useAttrs()
101
101
  const attrsClass = computed(() => normalizeClass(attrs.class))
102
102
  const attrsStyle = computed(() => normalizeStyle(attrs.style))
103
+ const attrsWithoutClassStyle = computed(() => {
104
+ return Object.fromEntries(
105
+ Object.entries(attrs).filter(([key]) => key !== 'class' && key !== 'style'),
106
+ )
107
+ })
103
108
 
104
109
  const editorProps = computed(() => {
105
110
  return {
@@ -154,8 +159,23 @@ onMounted(() => {
154
159
  extensions: [
155
160
  StarterKit.configure({
156
161
  ...props.starterkitOptions,
162
+ code: false,
157
163
  codeBlock: false,
158
164
  heading: false,
165
+ }).extend({
166
+ addKeyboardShortcuts() {
167
+ return {
168
+ Backspace: () => {
169
+ const { $from } = this.editor.view.state.selection
170
+ if (
171
+ !this.editor.can().liftListItem('listItem') ||
172
+ $from.parentOffset > 0
173
+ )
174
+ return false
175
+ return this.editor.commands.liftListItem('listItem')
176
+ },
177
+ }
178
+ },
159
179
  }),
160
180
  Heading.configure({
161
181
  ...(typeof props.starterkitOptions?.heading === 'object' &&
@@ -180,11 +200,8 @@ onMounted(() => {
180
200
  TextStyle,
181
201
  NamedColorExtension,
182
202
  NamedHighlightExtension,
183
- CodeBlockLowlight.extend({
184
- addNodeView() {
185
- return VueNodeViewRenderer(CodeBlockComponent)
186
- },
187
- }).configure({ lowlight }),
203
+ ExtendedCode,
204
+ ExtendedCodeBlock,
188
205
  ImageExtension.configure({
189
206
  uploadFunction: props.uploadFunction || defaultUploadFunction,
190
207
  }),
@@ -222,7 +239,6 @@ onMounted(() => {
222
239
  }),
223
240
  ContentPasteExtension.configure({
224
241
  enabled: true,
225
- showConfirmation: true,
226
242
  uploadFunction: props.uploadFunction || defaultUploadFunction,
227
243
  }),
228
244
  ...(props.extensions || []),
@@ -254,8 +270,10 @@ provide(
254
270
  computed(() => editor.value),
255
271
  )
256
272
 
273
+ const rootRef = useTemplateRef('rootRef')
257
274
  defineExpose({
258
275
  editor,
276
+ rootRef,
259
277
  })
260
278
  </script>
261
279
 
@@ -0,0 +1,118 @@
1
+ import { common, createLowlight } from 'lowlight'
2
+ import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
3
+ import Code from '@tiptap/extension-code'
4
+ import CodeBlockComponent from '../CodeBlockComponent.vue'
5
+ import { VueNodeViewRenderer } from '@tiptap/vue-3'
6
+ import { markInputRule } from '@tiptap/core'
7
+
8
+ const INDENT = ' '.repeat(4)
9
+ export const inputRegex = /(?<=^|[^`])`([^`]+)`(?!`)$/
10
+
11
+ function getCodeBlockCtx(state: any) {
12
+ const { $from, from, to } = state.selection
13
+ let d = $from.depth
14
+ while (d > 0 && $from.node(d).type.name !== 'codeBlock') d--
15
+ if (d === 0) return null
16
+
17
+ const node = $from.node(d)
18
+ const start = $from.start(d)
19
+ const text: string = node.textContent
20
+ const fromOffset = from - start
21
+ const toOffset = to - start
22
+ return { start, text, fromOffset, toOffset }
23
+ }
24
+
25
+ function lineStartsBetween(text: string, fromOffset: number, toOffset: number) {
26
+ let startOff = text.lastIndexOf('\n', Math.max(0, fromOffset - 1)) + 1
27
+ let endOff = toOffset
28
+ if (endOff > 0 && text[endOff - 1] === '\n') endOff--
29
+
30
+ const starts: number[] = [startOff]
31
+ let i = startOff
32
+ while (true) {
33
+ const nl = text.indexOf('\n', i)
34
+ if (nl === -1 || nl >= endOff) break
35
+ starts.push(nl + 1)
36
+ i = nl + 1
37
+ }
38
+ return starts
39
+ }
40
+
41
+ const lowlight = createLowlight(common)
42
+
43
+ export const ExtendedCodeBlock = CodeBlockLowlight.extend({
44
+ addNodeView() {
45
+ return VueNodeViewRenderer(CodeBlockComponent)
46
+ },
47
+ addKeyboardShortcuts() {
48
+ return {
49
+ Tab: () => {
50
+ const { state, dispatch } = this.editor.view
51
+ const ctx = getCodeBlockCtx(state)
52
+ if (!ctx) return false
53
+
54
+ const { start, text, fromOffset, toOffset } = ctx
55
+ const multiline = text.slice(fromOffset, toOffset).includes('\n')
56
+ const tr = state.tr
57
+ if (multiline) {
58
+ for (const off of lineStartsBetween(text, fromOffset, toOffset)) {
59
+ const pos = tr.mapping.map(start + off)
60
+ tr.insertText(INDENT, pos)
61
+ }
62
+ } else {
63
+ tr.insertText(INDENT, state.selection.from)
64
+ }
65
+ dispatch(tr)
66
+ return true
67
+ },
68
+ 'Shift-Tab': () => {
69
+ const { state, dispatch } = this.editor.view
70
+ const ctx = getCodeBlockCtx(state)
71
+ if (!ctx) return false
72
+
73
+ const { start, text, fromOffset, toOffset } = ctx
74
+ const tr = state.tr
75
+ for (const off of lineStartsBetween(text, fromOffset, toOffset)) {
76
+ let len = 0
77
+ if (text.substr(off, 4) === INDENT) len = 4
78
+ else if (text[off] === '\t') len = 1
79
+
80
+ if (len > 0) {
81
+ const s = tr.mapping.map(start + off)
82
+ const e = tr.mapping.map(start + off + len)
83
+ tr.delete(s, e)
84
+ }
85
+ }
86
+
87
+ dispatch(tr)
88
+ return true
89
+ },
90
+ '`': () => {
91
+ const { from, to } = this.editor.state.selection
92
+ if (from === to) return false
93
+ return this.editor.commands.toggleCode()
94
+ },
95
+ }
96
+ },
97
+ }).configure({ lowlight })
98
+
99
+ export const ExtendedCode = Code.extend({
100
+ addKeyboardShortcuts() {
101
+ return {
102
+ '`': () => {
103
+ const { from, to } = this.editor.state.selection
104
+ if (from === to) return false
105
+ return this.editor.commands.toggleCode()
106
+ },
107
+ }
108
+ },
109
+
110
+ addInputRules() {
111
+ return [
112
+ markInputRule({
113
+ find: inputRegex,
114
+ type: this.type,
115
+ }),
116
+ ]
117
+ },
118
+ })
@@ -7,7 +7,6 @@ import { processMultipleImages } from './image/image-extension'
7
7
 
8
8
  export interface ContentPasteOptions {
9
9
  enabled: boolean
10
- showConfirmation: boolean
11
10
  uploadFunction: Function | null
12
11
  }
13
12
 
@@ -17,7 +16,6 @@ export const ContentPasteExtension = Extension.create<ContentPasteOptions>({
17
16
  addOptions() {
18
17
  return {
19
18
  enabled: true,
20
- showConfirmation: true,
21
19
  uploadFunction: null,
22
20
  }
23
21
  },
@@ -28,6 +26,29 @@ export const ContentPasteExtension = Extension.create<ContentPasteOptions>({
28
26
  new Plugin({
29
27
  key: new PluginKey('contentPaste'),
30
28
  props: {
29
+ handleDOMEvents: {
30
+ copy: (view, event) => {
31
+ const selection = window.getSelection()
32
+ if (!selection) return false
33
+
34
+ const container = document.createElement('div')
35
+ for (let i = 0; i < selection.rangeCount; i++) container.appendChild(selection.getRangeAt(i).cloneContents())
36
+
37
+ // Update relative image srcs
38
+ const images = container.querySelectorAll('img')
39
+ images.forEach((img) => {
40
+ const src = img.getAttribute('src')
41
+ if (src && src.startsWith('/')) {
42
+ img.setAttribute('src', `${window.location.origin}${src}`)
43
+ }
44
+ })
45
+
46
+ // Override clipboard HTML
47
+ event.clipboardData?.setData('text/html', container.innerHTML)
48
+ event.preventDefault()
49
+ return true
50
+ },
51
+ },
31
52
  handlePaste: (
32
53
  view: EditorView,
33
54
  event: ClipboardEvent,
@@ -47,19 +68,19 @@ export const ContentPasteExtension = Extension.create<ContentPasteOptions>({
47
68
  return true
48
69
  }
49
70
 
71
+ // handle html with media
72
+ const htmlData = event.clipboardData?.getData('text/html')
73
+ if (htmlData) {
74
+ processHTMLImages(htmlData, view, this.options)
75
+ return true
76
+ }
77
+
50
78
  // handle markdown pasting
51
79
  const text = event.clipboardData?.getData('text/plain')
52
80
  if (!text) return false
53
81
 
54
82
  if (!detectMarkdown(text)) return false
55
83
 
56
- if (this.options.showConfirmation) {
57
- const shouldConvert = confirm(
58
- 'Do you want to convert markdown content to HTML before pasting?',
59
- )
60
- if (!shouldConvert) return false
61
- }
62
-
63
84
  const htmlContent = markdownToHTML(text)
64
85
  const tempDiv = document.createElement('div')
65
86
  tempDiv.innerHTML = htmlContent
@@ -79,3 +100,51 @@ export const ContentPasteExtension = Extension.create<ContentPasteOptions>({
79
100
  ]
80
101
  },
81
102
  })
103
+
104
+ async function processHTMLImages(
105
+ html: string,
106
+ view: EditorView,
107
+ extensionOptions: ContentPasteOptions,
108
+ ): Promise<undefined> {
109
+ const tempDiv = document.createElement('div')
110
+ tempDiv.innerHTML = html
111
+ const images = tempDiv.querySelectorAll('img')
112
+
113
+ const parser = DOMParser.fromSchema(view.state.schema)
114
+ const parsedSlice = parser.parseSlice(tempDiv, {
115
+ preserveWhitespace: true,
116
+ })
117
+ const tr = view.state.tr.replaceSelection(parsedSlice)
118
+ view.dispatch(tr)
119
+
120
+ const imageInfo: Array<{ src: string; pos: number }> = []
121
+ view.state.doc.descendants((node, pos) => {
122
+ for (let img of images) {
123
+ const src = img.getAttribute('src')
124
+ if (node.type.name === 'image' && node.attrs['src'] === src) {
125
+ imageInfo.push([src, pos])
126
+ }
127
+ }
128
+ })
129
+
130
+ // Process each image
131
+ const imagePromises = Array.from(imageInfo).map(async ([src, pos]) => {
132
+ if (
133
+ src.startsWith('data:') ||
134
+ src.startsWith('blob:')
135
+ ) {
136
+ try {
137
+ const response = await fetch(src)
138
+ const blob = await response.blob()
139
+ const file = new File([blob], 'pasted-data-image.png', {
140
+ type: blob.type,
141
+ })
142
+ processMultipleImages([file], view, pos, extensionOptions)
143
+ } catch (error) {
144
+ console.error('Failed to process image:', error)
145
+ }
146
+ }
147
+ })
148
+
149
+ await Promise.all(imagePromises)
150
+ }
@@ -9,7 +9,7 @@ import { textblockTypeInputRule } from '@tiptap/core'
9
9
  export const Heading = TiptapHeading.extend({
10
10
  addInputRules() {
11
11
  return this.options.levels.map((level) => {
12
- let regexp = new RegExp(`^(#{${level}}) $`)
12
+ let regexp = new RegExp(`^(#{${level}})( |\\u00A0)$`)
13
13
  return textblockTypeInputRule({
14
14
  find: regexp,
15
15
  type: this.type,
@@ -373,14 +373,18 @@ function uploadImageBase(
373
373
  })
374
374
 
375
375
  const tr = view.state.tr
376
-
377
- if (pos != null) {
378
- tr.insert(pos, node)
379
- } else if (insertMode === 'replace') {
380
- tr.replaceSelectionWith(node)
376
+ if (insertMode === 'replace') {
377
+ if (pos === null) tr.replaceSelectionWith(node)
378
+ else {
379
+ const nodeAtPos = view.state.doc.nodeAt(pos)
380
+ if (nodeAtPos) tr.replaceWith(pos, pos + nodeAtPos.nodeSize, node)
381
+ }
381
382
  } else {
382
- const insertPos = view.state.selection.from
383
- tr.insert(insertPos, node)
383
+ if (pos != null) tr.insert(pos, node)
384
+ else {
385
+ const insertPos = view.state.selection.from
386
+ tr.insert(insertPos, node)
387
+ }
384
388
  }
385
389
 
386
390
  view.dispatch(tr)
@@ -24,7 +24,6 @@ function createMentionNode(component?: Component) {
24
24
  inline: true,
25
25
  selectable: true,
26
26
  atom: true,
27
-
28
27
  addOptions() {
29
28
  return {
30
29
  component: undefined,
@@ -82,6 +81,9 @@ function createMentionNode(component?: Component) {
82
81
  `@${HTMLAttributes['data-label'] || HTMLAttributes.id || ''}`,
83
82
  ]
84
83
  },
84
+ renderText({ node }: any) {
85
+ return `@${node.attrs.label || node.attrs.id || ''}`
86
+ }
85
87
  }
86
88
 
87
89
  if (component) {
@@ -139,10 +141,6 @@ const MentionSuggestionExtension =
139
141
  type: 'mention',
140
142
  attrs: attributes,
141
143
  },
142
- {
143
- type: 'text',
144
- text: ' ',
145
- },
146
144
  ])
147
145
  .run()
148
146
  },
@@ -1,7 +1,11 @@
1
1
  .mention {
2
2
  font-weight: 600;
3
3
  box-decoration-break: clone;
4
- padding: 1px 3px;
4
+ padding: 0 1px;
5
5
  border-radius: 4px;
6
6
  display: inline-block;
7
7
  }
8
+
9
+ .mention.ProseMirror-selectednode, .mention:hover {
10
+ background-color: var(--surface-gray-1, #f8f8f8);
11
+ }
@@ -5,7 +5,7 @@ import {
5
5
  BaseSuggestionItem,
6
6
  } from '../suggestion/createSuggestionExtension'
7
7
  import SuggestionList from '../suggestion/SuggestionList.vue'
8
- import { toValue, unref } from 'vue'
8
+ import { toValue } from 'vue'
9
9
 
10
10
  export const TagNode = Node.create({
11
11
  name: 'tagItem',
@@ -59,7 +59,9 @@ export const TagNode = Node.create({
59
59
  `#${HTMLAttributes['data-tag-label']}`,
60
60
  ]
61
61
  },
62
-
62
+ renderText({ node }: any) {
63
+ return `#${node.attrs.tagLabel || ''}`
64
+ },
63
65
  addCommands() {
64
66
  return {
65
67
  setTag:
@@ -4,7 +4,7 @@ import tippy, { type Instance as TippyInstance } from 'tippy.js'
4
4
  import { getMarkRange, Range, Editor } from '@tiptap/core'
5
5
  import { MarkType, Mark as ProseMirrorMark } from '@tiptap/pm/model'
6
6
  import { Plugin, PluginKey } from '@tiptap/pm/state'
7
- import EditLink from './EditLink.vue'
7
+ import LinkPopup from './LinkPopup.vue'
8
8
  import { linkPasteHandler } from './linkPasteHandler'
9
9
 
10
10
  declare module '@tiptap/core' {
@@ -50,10 +50,8 @@ export const LinkExtension = Link.extend({
50
50
  const markRange = getMarkRange($pos, this.type)
51
51
  if (markRange) {
52
52
  range = markRange
53
- mark = doc
54
- .resolve(markRange.from)
55
- .marks()
56
- .find((m) => m.type === this.type)
53
+ const node = doc.nodeAt($pos.pos)
54
+ if(node) mark = node.marks.find((m) => m.type === this.type)
57
55
 
58
56
  // Select the link text
59
57
  editor
@@ -113,16 +111,6 @@ export const LinkExtension = Link.extend({
113
111
  return true
114
112
  })
115
113
 
116
- const posAfterLink = selectionTo
117
- const charAfter =
118
- posAfterLink < doc.content.size
119
- ? doc.textBetween(posAfterLink, posAfterLink + 1)
120
- : null
121
-
122
- if (charAfter === null || charAfter !== ' ') {
123
- chain = chain.insertContent(' ')
124
- }
125
-
126
114
  chain.run()
127
115
  })
128
116
  .catch(() => {})
@@ -147,7 +135,6 @@ export const LinkExtension = Link.extend({
147
135
 
148
136
  addProseMirrorPlugins() {
149
137
  let plugins = this.parent?.() || []
150
-
151
138
  plugins.push(
152
139
  linkPasteHandler({
153
140
  editor: this.editor,
@@ -162,7 +149,23 @@ export const LinkExtension = Link.extend({
162
149
  type: this.type,
163
150
  }),
164
151
  )
165
-
152
+ plugins.push(
153
+ new Plugin({
154
+ props: {
155
+ handleClick: (view, pos, event) => {
156
+ if (!this.editor.isEditable) return
157
+ if (!this.editor.isActive('link')) return false
158
+ event.preventDefault()
159
+ if (event.metaKey) {
160
+ const url = event.target?.getAttribute('href')
161
+ if (url) window.open(url, '_blank')
162
+ } else {
163
+ this.editor.commands.openLinkEditor()
164
+ }
165
+ },
166
+ },
167
+ }),
168
+ )
166
169
  return plugins
167
170
  },
168
171
  })
@@ -233,7 +236,7 @@ function openLinkEditor(href: string, anchor: HTMLElement): Promise<string> {
233
236
 
234
237
  app = createApp({
235
238
  render() {
236
- return h(EditLink, {
239
+ return h(LinkPopup, {
237
240
  href,
238
241
  onClose: () => {
239
242
  settlePromise('reject', 'Link editing cancelled')
@@ -5,7 +5,7 @@ export function melonProxy({
5
5
  source = '^/(app|login|api|assets|files|private)',
6
6
  } = {}) {
7
7
  const commonSiteConfig = getCommonSiteConfig()
8
- const env_web_server_port = process.env.MELON_WEB_SERVER_PORT
8
+ const env_web_server_port = process.env.FRAPPE_WEB_SERVER_PORT
9
9
  const webserver_port =
10
10
  env_web_server_port ||
11
11
  (commonSiteConfig ? commonSiteConfig.webserver_port : 8000)
@@ -1,63 +0,0 @@
1
- <template>
2
- <div
3
- class="p-2 flex min-w-72 items-center gap-2 bg-surface-white shadow-xl rounded-lg"
4
- >
5
- <TextInput
6
- ref="input"
7
- type="text"
8
- class="w-full"
9
- placeholder="https://example.com"
10
- v-model="_href"
11
- @keydown.enter="submitLink"
12
- @keydown.esc="$emit('close')"
13
- />
14
- <div class="shrink-0 flex items-center gap-2">
15
- <Tooltip text="Submit" placement="top">
16
- <Button label="Submit" @click="submitLink">
17
- <template #icon><LucideCheck class="size-4" /></template>
18
- </Button>
19
- </Tooltip>
20
- <Tooltip text="Remove link" placement="top">
21
- <Button label="Remove link" @click="$emit('updateHref', '')">
22
- <template #icon><LucideX class="size-4" /></template>
23
- </Button>
24
- </Tooltip>
25
- </div>
26
- </div>
27
- </template>
28
-
29
- <script setup lang="ts">
30
- import { onMounted, ref, useTemplateRef, nextTick } from 'vue'
31
- import Button from '../Button/Button.vue'
32
- import TextInput from '../TextInput/TextInput.vue'
33
- import Tooltip from '../Tooltip/Tooltip.vue'
34
- import LucideCheck from '~icons/lucide/check'
35
- import LucideX from '~icons/lucide/x'
36
- import { isValidUrl } from '../../utils/url-validation'
37
-
38
- const props = defineProps<{
39
- href: string
40
- }>()
41
-
42
- const emit = defineEmits<{
43
- (e: 'updateHref', href: string): void
44
- (e: 'close'): void
45
- }>()
46
-
47
- const _href = ref(props.href)
48
- const input = useTemplateRef('input')
49
-
50
- const submitLink = () => {
51
- if (_href.value === '' || isValidUrl(_href.value)) {
52
- emit('updateHref', _href.value)
53
- }
54
- }
55
-
56
- onMounted(async () => {
57
- await nextTick()
58
- if (input.value?.el) {
59
- input.value.el.focus()
60
- input.value.el.select()
61
- }
62
- })
63
- </script>