frappe-ui 0.1.135 → 0.1.136
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 +6 -1
- package/src/components/ErrorMessage.vue +14 -14
- package/src/components/Progress.vue +1 -1
- package/src/components/TextEditor/TextEditor.vue +2 -3
- package/src/components/TextEditor/extensions/image/ImageNodeView.vue +263 -0
- package/src/components/TextEditor/{image-extension.ts → extensions/image/image-extension.ts} +53 -26
- package/src/components/TextEditor/extensions/image/index.ts +5 -0
- package/src/index.ts +1 -1
- package/src/tailwind/plugin.js +8 -0
- package/src/utils/file-to-base64.ts +13 -0
- package/src/components/TextEditor/ImageNodeView.vue +0 -125
- package/src/utils/file-to-base64.js +0 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "frappe-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.136",
|
|
4
4
|
"description": "A set of components and utilities for rapid UI development",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"scripts": {
|
|
@@ -27,10 +27,12 @@
|
|
|
27
27
|
"author": "Frappe Technologies Pvt. Ltd.",
|
|
28
28
|
"license": "MIT",
|
|
29
29
|
"dependencies": {
|
|
30
|
+
"@floating-ui/vue": "^1.1.6",
|
|
30
31
|
"@headlessui/vue": "^1.7.14",
|
|
31
32
|
"@popperjs/core": "^2.11.2",
|
|
32
33
|
"@tailwindcss/forms": "^0.5.3",
|
|
33
34
|
"@tailwindcss/typography": "^0.5.16",
|
|
35
|
+
"@tiptap/core": "^2.11.7",
|
|
34
36
|
"@tiptap/extension-code-block-lowlight": "^2.11.5",
|
|
35
37
|
"@tiptap/extension-color": "^2.0.3",
|
|
36
38
|
"@tiptap/extension-highlight": "^2.0.3",
|
|
@@ -58,6 +60,9 @@
|
|
|
58
60
|
"lucide-static": "^0.479.0",
|
|
59
61
|
"ora": "5.4.1",
|
|
60
62
|
"prettier": "^3.3.2",
|
|
63
|
+
"prosemirror-model": "^1.25.1",
|
|
64
|
+
"prosemirror-state": "^1.4.3",
|
|
65
|
+
"prosemirror-view": "^1.39.2",
|
|
61
66
|
"radix-vue": "^1.5.3",
|
|
62
67
|
"reka-ui": "^2.0.2",
|
|
63
68
|
"showdown": "^2.1.0",
|
|
@@ -7,18 +7,18 @@
|
|
|
7
7
|
></div>
|
|
8
8
|
</template>
|
|
9
9
|
|
|
10
|
-
<script>
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
10
|
+
<script setup lang="ts">
|
|
11
|
+
import { computed } from 'vue'
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
message?: string | Error
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const errorMessage = computed(() => {
|
|
18
|
+
if (!props.message) return ''
|
|
19
|
+
if (props.message instanceof Error) {
|
|
20
|
+
return (props.message as any).messages || props.message.message
|
|
21
|
+
}
|
|
22
|
+
return props.message
|
|
23
|
+
})
|
|
24
24
|
</script>
|
|
@@ -15,8 +15,7 @@
|
|
|
15
15
|
</template>
|
|
16
16
|
|
|
17
17
|
<script>
|
|
18
|
-
import { normalizeClass } from 'vue'
|
|
19
|
-
import { computed } from '@vue/reactivity'
|
|
18
|
+
import { normalizeClass, computed } from 'vue'
|
|
20
19
|
import { Editor, EditorContent, VueNodeViewRenderer } from '@tiptap/vue-3'
|
|
21
20
|
import StarterKit from '@tiptap/starter-kit'
|
|
22
21
|
import Placeholder from '@tiptap/extension-placeholder'
|
|
@@ -25,7 +24,7 @@ import Table from '@tiptap/extension-table'
|
|
|
25
24
|
import TableCell from '@tiptap/extension-table-cell'
|
|
26
25
|
import TableHeader from '@tiptap/extension-table-header'
|
|
27
26
|
import TableRow from '@tiptap/extension-table-row'
|
|
28
|
-
import ImageExtension from './image
|
|
27
|
+
import { ImageExtension } from './extensions/image'
|
|
29
28
|
import ImageViewerExtension from './image-viewer-extension'
|
|
30
29
|
import VideoExtension from './video-extension'
|
|
31
30
|
import LinkExtension from './link-extension'
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted } from 'vue'
|
|
3
|
+
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
|
|
4
|
+
import LoadingIndicator from '../../../LoadingIndicator.vue'
|
|
5
|
+
import ErrorMessage from '../../../ErrorMessage.vue'
|
|
6
|
+
import LucideMoveDiagonal2 from '~icons/lucide/move-diagonal-2'
|
|
7
|
+
import LucideAlignLeft from '~icons/lucide/align-left'
|
|
8
|
+
import LucideAlignCenter from '~icons/lucide/align-center'
|
|
9
|
+
import LucideAlignRight from '~icons/lucide/align-right'
|
|
10
|
+
|
|
11
|
+
const props = defineProps(nodeViewProps)
|
|
12
|
+
|
|
13
|
+
const imageRef = ref<HTMLImageElement | null>(null)
|
|
14
|
+
const containerRef = ref<HTMLDivElement | null>(null)
|
|
15
|
+
const isResizing = ref(false)
|
|
16
|
+
const startDragX = ref(0)
|
|
17
|
+
const startWidth = ref(0)
|
|
18
|
+
const originalAspectRatio = ref(1)
|
|
19
|
+
|
|
20
|
+
function selectImage() {
|
|
21
|
+
props.editor.commands.setNodeSelection(props.getPos())
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const caption = ref(props.node.attrs.alt || '')
|
|
25
|
+
const isEditable = ref(false)
|
|
26
|
+
|
|
27
|
+
onMounted(() => {
|
|
28
|
+
isEditable.value = props.editor.isEditable
|
|
29
|
+
if (imageRef.value) {
|
|
30
|
+
// Ensure initial aspect ratio is captured if dimensions are available
|
|
31
|
+
const initialWidth = props.node.attrs.width || imageRef.value.naturalWidth
|
|
32
|
+
const initialHeight =
|
|
33
|
+
props.node.attrs.height || imageRef.value.naturalHeight
|
|
34
|
+
if (initialWidth && initialHeight) {
|
|
35
|
+
originalAspectRatio.value = initialHeight / initialWidth
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
props.editor.on('update', () => {
|
|
41
|
+
isEditable.value = props.editor.isEditable
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
function updateCaption(event: Event) {
|
|
45
|
+
const newCaption = (event.target as HTMLInputElement).value
|
|
46
|
+
caption.value = newCaption
|
|
47
|
+
props.updateAttributes({ alt: newCaption })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
51
|
+
if (event.key === 'Enter') {
|
|
52
|
+
event.preventDefault()
|
|
53
|
+
createParagraphAfterImage()
|
|
54
|
+
} else if (event.key === 'Escape' || event.key === 'ArrowDown') {
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
setCursorAfterImage()
|
|
57
|
+
}
|
|
58
|
+
if (event.key === 'ArrowUp') {
|
|
59
|
+
event.preventDefault()
|
|
60
|
+
setCursorBeforeImage()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function setCursorAt(pos: number) {
|
|
65
|
+
props.editor.commands.focus()
|
|
66
|
+
props.editor.chain().setTextSelection(pos).scrollIntoView().run()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createParagraphAfterImage() {
|
|
70
|
+
const pos = props.getPos()
|
|
71
|
+
props.editor.commands.focus()
|
|
72
|
+
props.editor
|
|
73
|
+
.chain()
|
|
74
|
+
.setTextSelection(pos + 1)
|
|
75
|
+
.createParagraphNear()
|
|
76
|
+
.scrollIntoView()
|
|
77
|
+
.run()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function setCursorAfterImage() {
|
|
81
|
+
const pos = props.getPos()
|
|
82
|
+
setCursorAt(pos + 1)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function setCursorBeforeImage() {
|
|
86
|
+
const pos = props.getPos()
|
|
87
|
+
setCursorAt(pos - 1)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function startResize(event: MouseEvent) {
|
|
91
|
+
if (!isEditable.value) return
|
|
92
|
+
selectImage()
|
|
93
|
+
isResizing.value = true
|
|
94
|
+
startDragX.value = event.clientX
|
|
95
|
+
startWidth.value = imageRef.value?.offsetWidth || props.node.attrs.width || 0
|
|
96
|
+
|
|
97
|
+
// Calculate aspect ratio from current attributes or natural dimensions
|
|
98
|
+
const width = props.node.attrs.width || imageRef.value?.naturalWidth
|
|
99
|
+
const height = props.node.attrs.height || imageRef.value?.naturalHeight
|
|
100
|
+
if (width && height) {
|
|
101
|
+
originalAspectRatio.value = height / width
|
|
102
|
+
} else {
|
|
103
|
+
originalAspectRatio.value = 1
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
window.addEventListener('mousemove', handleResize)
|
|
107
|
+
window.addEventListener('mouseup', stopResize)
|
|
108
|
+
document.body.style.cursor = 'ew-resize'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function handleResize(event: MouseEvent) {
|
|
112
|
+
if (!isResizing.value || !imageRef.value || !containerRef.value) return
|
|
113
|
+
|
|
114
|
+
const editorElement = props.editor.view.dom
|
|
115
|
+
const editorWidth = editorElement.clientWidth
|
|
116
|
+
|
|
117
|
+
const deltaX = event.clientX - startDragX.value
|
|
118
|
+
let newWidth = startWidth.value + deltaX
|
|
119
|
+
|
|
120
|
+
// Add constraints (e.g., minimum width and maximum width based on editor)
|
|
121
|
+
newWidth = Math.max(50, Math.min(newWidth, editorWidth))
|
|
122
|
+
|
|
123
|
+
const newHeight = newWidth * originalAspectRatio.value
|
|
124
|
+
|
|
125
|
+
// Apply temporary styles for visual feedback
|
|
126
|
+
imageRef.value.style.width = `${newWidth}px`
|
|
127
|
+
imageRef.value.style.height = `${newHeight}px`
|
|
128
|
+
containerRef.value.style.width = `${newWidth}px`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stopResize() {
|
|
132
|
+
if (!isResizing.value) return
|
|
133
|
+
|
|
134
|
+
isResizing.value = false
|
|
135
|
+
window.removeEventListener('mousemove', handleResize)
|
|
136
|
+
window.removeEventListener('mouseup', stopResize)
|
|
137
|
+
document.body.style.cursor = ''
|
|
138
|
+
|
|
139
|
+
if (imageRef.value && containerRef.value) {
|
|
140
|
+
const finalWidth = imageRef.value.offsetWidth
|
|
141
|
+
const finalHeight = imageRef.value.offsetHeight
|
|
142
|
+
props.updateAttributes({ width: finalWidth, height: finalHeight })
|
|
143
|
+
|
|
144
|
+
// Clear temporary styles after updating attributes
|
|
145
|
+
imageRef.value.style.width = ''
|
|
146
|
+
imageRef.value.style.height = ''
|
|
147
|
+
containerRef.value.style.width = ''
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function setAlignment(align: 'left' | 'center' | 'right') {
|
|
152
|
+
props.editor.commands.setImageAlign(align)
|
|
153
|
+
}
|
|
154
|
+
</script>
|
|
155
|
+
|
|
156
|
+
<template>
|
|
157
|
+
<NodeViewWrapper>
|
|
158
|
+
<div
|
|
159
|
+
ref="containerRef"
|
|
160
|
+
class="relative overflow-hidden not-prose my-6 rounded-[2px] block max-w-full"
|
|
161
|
+
:class="[
|
|
162
|
+
{ 'ring-2 ring-outline-gray-3 ring-offset-2': selected },
|
|
163
|
+
node.attrs.align === 'center' ? 'mx-auto' : '',
|
|
164
|
+
node.attrs.align === 'right' ? 'ml-auto mr-0' : '',
|
|
165
|
+
node.attrs.align === 'left' ? 'mr-auto ml-0' : '',
|
|
166
|
+
]"
|
|
167
|
+
:style="{ width: node.attrs.width ? `${node.attrs.width}px` : 'auto' }"
|
|
168
|
+
>
|
|
169
|
+
<div class="relative">
|
|
170
|
+
<img
|
|
171
|
+
v-if="node.attrs.src"
|
|
172
|
+
ref="imageRef"
|
|
173
|
+
class="rounded-[2px]"
|
|
174
|
+
:src="node.attrs.src"
|
|
175
|
+
:alt="node.attrs.alt || ''"
|
|
176
|
+
:width="node.attrs.width"
|
|
177
|
+
:height="node.attrs.height"
|
|
178
|
+
@click.stop="selectImage"
|
|
179
|
+
/>
|
|
180
|
+
|
|
181
|
+
<div class="absolute bottom-2 right-2 flex items-center gap-2">
|
|
182
|
+
<!-- Alignment Controls -->
|
|
183
|
+
<div
|
|
184
|
+
v-if="selected && isEditable"
|
|
185
|
+
class="flex divide-x divide-outline-gray-5 rounded bg-black/65"
|
|
186
|
+
>
|
|
187
|
+
<button
|
|
188
|
+
@click.stop="setAlignment('left')"
|
|
189
|
+
:class="[
|
|
190
|
+
'px-1.5 py-1 hover:text-ink-white',
|
|
191
|
+
node.attrs.align === 'left'
|
|
192
|
+
? 'text-ink-white'
|
|
193
|
+
: 'text-ink-gray-4',
|
|
194
|
+
]"
|
|
195
|
+
>
|
|
196
|
+
<LucideAlignLeft class="size-4" />
|
|
197
|
+
</button>
|
|
198
|
+
<button
|
|
199
|
+
@click.stop="setAlignment('center')"
|
|
200
|
+
:class="[
|
|
201
|
+
'px-1.5 py-1 hover:text-ink-white',
|
|
202
|
+
node.attrs.align === 'center'
|
|
203
|
+
? 'text-ink-white'
|
|
204
|
+
: 'text-ink-gray-4',
|
|
205
|
+
]"
|
|
206
|
+
>
|
|
207
|
+
<LucideAlignCenter class="size-4" />
|
|
208
|
+
</button>
|
|
209
|
+
<button
|
|
210
|
+
@click.stop="setAlignment('right')"
|
|
211
|
+
:class="[
|
|
212
|
+
'px-1.5 py-1 hover:text-ink-white',
|
|
213
|
+
node.attrs.align === 'right'
|
|
214
|
+
? 'text-ink-white'
|
|
215
|
+
: 'text-ink-gray-4',
|
|
216
|
+
]"
|
|
217
|
+
>
|
|
218
|
+
<LucideAlignRight class="size-4" />
|
|
219
|
+
</button>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<!-- Resize Handle -->
|
|
223
|
+
<button
|
|
224
|
+
v-if="selected && isEditable"
|
|
225
|
+
class="cursor-nw-resize bg-black/65 rounded p-1"
|
|
226
|
+
@mousedown.prevent="startResize"
|
|
227
|
+
>
|
|
228
|
+
<LucideMoveDiagonal2 class="text-white size-4" />
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<!-- Loading indicator overlay -->
|
|
233
|
+
<div
|
|
234
|
+
v-if="node.attrs.loading"
|
|
235
|
+
class="inset-0 absolute flex items-center justify-center z-10"
|
|
236
|
+
>
|
|
237
|
+
<div
|
|
238
|
+
class="bg-gray-900/80 p-2 inset-0 leading-none rounded-sm flex flex-col items-center justify-center gap-2"
|
|
239
|
+
>
|
|
240
|
+
<div class="flex items-center gap-2">
|
|
241
|
+
<LoadingIndicator class="text-gray-100 size-4" />
|
|
242
|
+
<span class="text-gray-100">Uploading...</span>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<input
|
|
249
|
+
v-if="(isEditable || node.attrs.alt) && !node.attrs.error"
|
|
250
|
+
v-model="caption"
|
|
251
|
+
class="w-full text-center bg-transparent text-sm text-ink-gray-6 h-7 border-none focus:ring-0 placeholder-ink-gray-4"
|
|
252
|
+
placeholder="Add caption"
|
|
253
|
+
:disabled="!isEditable"
|
|
254
|
+
@change="updateCaption"
|
|
255
|
+
@keydown="handleKeydown"
|
|
256
|
+
/>
|
|
257
|
+
|
|
258
|
+
<div v-if="node.attrs.error" class="w-full py-1.5">
|
|
259
|
+
<ErrorMessage :message="`Upload Failed: ${node.attrs.error}`" />
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
</NodeViewWrapper>
|
|
263
|
+
</template>
|
package/src/components/TextEditor/{image-extension.ts → extensions/image/image-extension.ts}
RENAMED
|
@@ -8,9 +8,9 @@ import ImageNodeView from './ImageNodeView.vue'
|
|
|
8
8
|
import { Plugin, Selection, Transaction, EditorState } from 'prosemirror-state'
|
|
9
9
|
import { EditorView } from 'prosemirror-view'
|
|
10
10
|
import { Node } from '@tiptap/pm/model'
|
|
11
|
-
import fileToBase64 from '
|
|
11
|
+
import { fileToBase64 } from '../../../../index'
|
|
12
12
|
|
|
13
|
-
export interface
|
|
13
|
+
export interface ImageExtensionOptions {
|
|
14
14
|
/**
|
|
15
15
|
* Function to handle image uploads
|
|
16
16
|
* @default null
|
|
@@ -51,6 +51,11 @@ declare module '@tiptap/core' {
|
|
|
51
51
|
* Select an image file using the file picker and upload it
|
|
52
52
|
*/
|
|
53
53
|
selectAndUploadImage: () => ReturnType
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Set image alignment
|
|
57
|
+
*/
|
|
58
|
+
setImageAlign: (align: 'left' | 'center' | 'right') => ReturnType
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
61
|
}
|
|
@@ -58,10 +63,9 @@ declare module '@tiptap/core' {
|
|
|
58
63
|
/**
|
|
59
64
|
* Matches markdown image syntax: 
|
|
60
65
|
*/
|
|
61
|
-
|
|
62
|
-
/(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/
|
|
66
|
+
const inputRegex = /(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/
|
|
63
67
|
|
|
64
|
-
export
|
|
68
|
+
export const ImageExtension = NodeExtension.create<ImageExtensionOptions>({
|
|
65
69
|
name: 'image',
|
|
66
70
|
|
|
67
71
|
group: 'block',
|
|
@@ -79,6 +83,26 @@ export default NodeExtension.create<ImageOptions>({
|
|
|
79
83
|
default: false,
|
|
80
84
|
parseHTML: () => false,
|
|
81
85
|
},
|
|
86
|
+
align: {
|
|
87
|
+
default: 'left',
|
|
88
|
+
parseHTML: (element) => {
|
|
89
|
+
const align = (
|
|
90
|
+
element.getAttribute('data-align') ||
|
|
91
|
+
element.getAttribute('align') ||
|
|
92
|
+
'left'
|
|
93
|
+
).toLowerCase()
|
|
94
|
+
|
|
95
|
+
if (['left', 'center', 'right'].includes(align)) {
|
|
96
|
+
return align as 'left' | 'center' | 'right'
|
|
97
|
+
}
|
|
98
|
+
return 'left'
|
|
99
|
+
},
|
|
100
|
+
renderHTML: (attributes) => {
|
|
101
|
+
return {
|
|
102
|
+
'data-align': attributes.align || 'left',
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
},
|
|
82
106
|
uploadId: {
|
|
83
107
|
default: null,
|
|
84
108
|
parseHTML: () => null,
|
|
@@ -129,6 +153,12 @@ export default NodeExtension.create<ImageOptions>({
|
|
|
129
153
|
|
|
130
154
|
addCommands() {
|
|
131
155
|
return {
|
|
156
|
+
setImageAlign:
|
|
157
|
+
(align: 'left' | 'center' | 'right') =>
|
|
158
|
+
({ commands }) => {
|
|
159
|
+
return commands.updateAttributes(this.name, { align })
|
|
160
|
+
},
|
|
161
|
+
|
|
132
162
|
setImage:
|
|
133
163
|
(attributes: SetImageOptions) =>
|
|
134
164
|
({ commands, editor }) => {
|
|
@@ -139,12 +169,7 @@ export default NodeExtension.create<ImageOptions>({
|
|
|
139
169
|
|
|
140
170
|
if (result && attributes.src) {
|
|
141
171
|
findImageNodeBySource(editor.view, attributes.src, (node, pos) => {
|
|
142
|
-
updateNodeWithDimensions(
|
|
143
|
-
attributes.src,
|
|
144
|
-
editor.view,
|
|
145
|
-
pos,
|
|
146
|
-
node.attrs,
|
|
147
|
-
)
|
|
172
|
+
updateNodeWithDimensions(attributes.src, editor.view, pos)
|
|
148
173
|
})
|
|
149
174
|
}
|
|
150
175
|
|
|
@@ -292,12 +317,7 @@ export default NodeExtension.create<ImageOptions>({
|
|
|
292
317
|
newImageNodes.forEach(({ node, pos }) => {
|
|
293
318
|
const editor = extensionThis.editor
|
|
294
319
|
if (editor) {
|
|
295
|
-
updateNodeWithDimensions(
|
|
296
|
-
node.attrs.src,
|
|
297
|
-
editor.view,
|
|
298
|
-
pos,
|
|
299
|
-
node.attrs,
|
|
300
|
-
)
|
|
320
|
+
updateNodeWithDimensions(node.attrs.src, editor.view, pos)
|
|
301
321
|
}
|
|
302
322
|
})
|
|
303
323
|
|
|
@@ -491,19 +511,26 @@ function updateNodeWithDimensions(
|
|
|
491
511
|
src: string,
|
|
492
512
|
view: EditorView,
|
|
493
513
|
pos: number,
|
|
494
|
-
|
|
495
|
-
) {
|
|
514
|
+
): void {
|
|
496
515
|
getImageDimensions(src)
|
|
497
516
|
.then((dimensions) => {
|
|
498
|
-
const
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
517
|
+
const node = view.state.doc.nodeAt(pos)
|
|
518
|
+
if (!node || node.type.name !== 'image') {
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
const currentAttrs = node.attrs
|
|
522
|
+
|
|
523
|
+
if (currentAttrs.width == null || currentAttrs.height == null) {
|
|
524
|
+
const transaction = view.state.tr.setNodeMarkup(pos, undefined, {
|
|
525
|
+
...currentAttrs,
|
|
526
|
+
width: currentAttrs.width ?? dimensions.width,
|
|
527
|
+
height: currentAttrs.height ?? dimensions.height,
|
|
528
|
+
})
|
|
529
|
+
view.dispatch(transaction)
|
|
530
|
+
}
|
|
504
531
|
})
|
|
505
532
|
.catch((error) => {
|
|
506
|
-
|
|
533
|
+
// Don't log error if it's just about dimensions for an existing node
|
|
507
534
|
})
|
|
508
535
|
}
|
|
509
536
|
|
package/src/index.ts
CHANGED
|
@@ -83,7 +83,7 @@ export { default as visibilityDirective } from './directives/visibility.js'
|
|
|
83
83
|
// utilities
|
|
84
84
|
export { default as call, createCall } from './utils/call.js'
|
|
85
85
|
export { default as debounce } from './utils/debounce'
|
|
86
|
-
export { default as fileToBase64 } from './utils/file-to-base64
|
|
86
|
+
export { default as fileToBase64 } from './utils/file-to-base64'
|
|
87
87
|
export { default as FileUploadHandler } from './utils/fileUploadHandler'
|
|
88
88
|
export { usePageMeta } from './utils/pageMeta.js'
|
|
89
89
|
export { dayjsLocal, dayjs } from './utils/dayjs.js'
|
package/src/tailwind/plugin.js
CHANGED
|
@@ -328,6 +328,14 @@ module.exports = plugin(
|
|
|
328
328
|
'h5 strong': {
|
|
329
329
|
fontWeight: 600,
|
|
330
330
|
},
|
|
331
|
+
'img[data-align=right]': {
|
|
332
|
+
marginLeft: 'auto',
|
|
333
|
+
marginRight: '0',
|
|
334
|
+
},
|
|
335
|
+
'img[data-align=center]': {
|
|
336
|
+
marginLeft: 'auto',
|
|
337
|
+
marginRight: 'auto',
|
|
338
|
+
},
|
|
331
339
|
},
|
|
332
340
|
},
|
|
333
341
|
sm: {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export default (file: File): Promise<string> => {
|
|
2
|
+
return new Promise((resolve, reject) => {
|
|
3
|
+
const reader = new FileReader()
|
|
4
|
+
reader.onloadend = () => {
|
|
5
|
+
if (reader.result == null) {
|
|
6
|
+
reject(new Error('FileReader result is null'))
|
|
7
|
+
} else if (typeof reader.result === 'string') {
|
|
8
|
+
resolve(reader.result)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
reader.readAsDataURL(file)
|
|
12
|
+
})
|
|
13
|
+
}
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
|
|
3
|
-
import LoadingIndicator from '../LoadingIndicator.vue'
|
|
4
|
-
import { ref, onMounted } from 'vue'
|
|
5
|
-
import ErrorMessage from '../ErrorMessage.vue'
|
|
6
|
-
|
|
7
|
-
const props = defineProps(nodeViewProps)
|
|
8
|
-
|
|
9
|
-
function selectImage() {
|
|
10
|
-
props.editor.commands.setNodeSelection(props.getPos())
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const caption = ref(props.node.attrs.alt || '')
|
|
14
|
-
const isEditable = ref(false)
|
|
15
|
-
|
|
16
|
-
onMounted(() => {
|
|
17
|
-
isEditable.value = props.editor.isEditable
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
props.editor.on('update', () => {
|
|
21
|
-
isEditable.value = props.editor.isEditable
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
function updateCaption(event: Event) {
|
|
25
|
-
const newCaption = (event.target as HTMLInputElement).value
|
|
26
|
-
caption.value = newCaption
|
|
27
|
-
props.updateAttributes({ alt: newCaption })
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function handleKeydown(event: KeyboardEvent) {
|
|
31
|
-
if (event.key === 'Enter') {
|
|
32
|
-
event.preventDefault()
|
|
33
|
-
createParagraphAfterImage()
|
|
34
|
-
} else if (event.key === 'Escape' || event.key === 'ArrowDown') {
|
|
35
|
-
event.preventDefault()
|
|
36
|
-
setCursorAfterImage()
|
|
37
|
-
}
|
|
38
|
-
if (event.key === 'ArrowUp') {
|
|
39
|
-
event.preventDefault()
|
|
40
|
-
setCursorBeforeImage()
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function setCursorAt(pos: number) {
|
|
45
|
-
props.editor.commands.focus()
|
|
46
|
-
props.editor.chain().setTextSelection(pos).scrollIntoView().run()
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function createParagraphAfterImage() {
|
|
50
|
-
const pos = props.getPos()
|
|
51
|
-
props.editor.commands.focus()
|
|
52
|
-
props.editor
|
|
53
|
-
.chain()
|
|
54
|
-
.setTextSelection(pos + 1)
|
|
55
|
-
.createParagraphNear()
|
|
56
|
-
.scrollIntoView()
|
|
57
|
-
.run()
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function setCursorAfterImage() {
|
|
61
|
-
const pos = props.getPos()
|
|
62
|
-
setCursorAt(pos + 1)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function setCursorBeforeImage() {
|
|
66
|
-
const pos = props.getPos()
|
|
67
|
-
setCursorAt(pos - 1)
|
|
68
|
-
}
|
|
69
|
-
</script>
|
|
70
|
-
|
|
71
|
-
<template>
|
|
72
|
-
<NodeViewWrapper>
|
|
73
|
-
<div class="relative overflow-hidden not-prose my-6">
|
|
74
|
-
<div class="relative">
|
|
75
|
-
<img
|
|
76
|
-
v-if="node.attrs.src"
|
|
77
|
-
class="rounded-[2px]"
|
|
78
|
-
:src="node.attrs.src"
|
|
79
|
-
:alt="node.attrs.alt || ''"
|
|
80
|
-
:width="node.attrs.width"
|
|
81
|
-
:height="node.attrs.height"
|
|
82
|
-
@click="selectImage"
|
|
83
|
-
/>
|
|
84
|
-
|
|
85
|
-
<!-- Loading indicator overlay -->
|
|
86
|
-
<div
|
|
87
|
-
v-if="node.attrs.loading"
|
|
88
|
-
class="inset-0 absolute flex items-center justify-center z-10"
|
|
89
|
-
>
|
|
90
|
-
<div
|
|
91
|
-
class="bg-gray-900/80 p-2 inset-0 leading-none rounded-sm flex flex-col items-center justify-center gap-2"
|
|
92
|
-
>
|
|
93
|
-
<div class="flex items-center gap-2">
|
|
94
|
-
<LoadingIndicator class="text-gray-100 size-4" />
|
|
95
|
-
<span class="text-gray-100">Uploading...</span>
|
|
96
|
-
</div>
|
|
97
|
-
</div>
|
|
98
|
-
</div>
|
|
99
|
-
|
|
100
|
-
<!-- Selection overlay -->
|
|
101
|
-
<div
|
|
102
|
-
class="absolute pointer-events-none inset-0 rounded-[2px] bg-black/20 dark:bg-white/20 z-5 transition-opacity"
|
|
103
|
-
:class="{
|
|
104
|
-
'opacity-100': selected,
|
|
105
|
-
'opacity-0': !selected,
|
|
106
|
-
}"
|
|
107
|
-
></div>
|
|
108
|
-
</div>
|
|
109
|
-
|
|
110
|
-
<input
|
|
111
|
-
v-if="(isEditable || node.attrs.alt) && !node.attrs.error"
|
|
112
|
-
v-model="caption"
|
|
113
|
-
class="w-full text-center bg-transparent text-sm text-ink-gray-6 h-7 border-none focus:ring-0 placeholder-ink-gray-4"
|
|
114
|
-
placeholder="Add caption"
|
|
115
|
-
:disabled="!isEditable"
|
|
116
|
-
@change="updateCaption"
|
|
117
|
-
@keydown="handleKeydown"
|
|
118
|
-
/>
|
|
119
|
-
|
|
120
|
-
<div v-if="node.attrs.error" class="w-full py-1.5">
|
|
121
|
-
<ErrorMessage :message="`Upload Failed: ${node.attrs.error}`" />
|
|
122
|
-
</div>
|
|
123
|
-
</div>
|
|
124
|
-
</NodeViewWrapper>
|
|
125
|
-
</template>
|