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 +2 -1
- package/src/components/Dialog/Dialog.vue +10 -0
- package/src/components/Dialog/types.ts +1 -0
- package/src/components/ListView/ListRow.vue +29 -7
- package/src/components/TextEditor/LinkPopup.vue +104 -0
- package/src/components/TextEditor/TextEditor.vue +30 -12
- package/src/components/TextEditor/extensions/code-block.ts +118 -0
- package/src/components/TextEditor/extensions/content-paste-extension.ts +78 -9
- package/src/components/TextEditor/extensions/heading/heading.ts +1 -1
- package/src/components/TextEditor/extensions/image/image-extension.ts +11 -7
- package/src/components/TextEditor/extensions/mention/mention-extension.ts +3 -5
- package/src/components/TextEditor/extensions/mention/style.css +5 -1
- package/src/components/TextEditor/extensions/tag/tag-extension.ts +4 -2
- package/src/components/TextEditor/link-extension.ts +21 -18
- package/vite/melonProxy.js +1 -1
- package/src/components/TextEditor/EditLink.vue +0 -63
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pimelon-ui",
|
|
3
|
-
"version": "0.1.
|
|
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'
|
|
@@ -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:
|
|
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="
|
|
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
|
|
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 = (
|
|
148
|
-
if (list.value.options.onRowClick)
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
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(
|
|
239
|
+
return h(LinkPopup, {
|
|
237
240
|
href,
|
|
238
241
|
onClose: () => {
|
|
239
242
|
settlePromise('reject', 'Link editing cancelled')
|
package/vite/melonProxy.js
CHANGED
|
@@ -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.
|
|
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>
|