Skip to content

Commit 2d20b3d

Browse files
author
codewec
committed
feat: upload and attach files
1 parent 8bf8352 commit 2d20b3d

14 files changed

Lines changed: 385 additions & 17 deletions

File tree

app/components/editoro/MainContent.vue

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const props = defineProps<{
2323
editorSuggestionItems: EditorSuggestionItems
2424
canUploadImage: boolean
2525
uploadImage: (file: File) => Promise<string | null>
26+
uploadFile: (file: File) => Promise<string | null>
2627
}>()
2728
2829
const emit = defineEmits<{
@@ -44,12 +45,15 @@ const isMarkdownFileSelected = computed(() => {
4445
4546
const {
4647
imageInputRef,
48+
fileInputRef,
4749
editorHandlers,
4850
bindEditor,
49-
onImageInputChange
51+
onImageInputChange,
52+
onFileInputChange
5053
} = useEditoroMainContentMedia({
5154
canUploadImage: () => props.canUploadImage,
52-
uploadImage: props.uploadImage
55+
uploadImage: props.uploadImage,
56+
uploadFile: props.uploadFile
5357
})
5458
5559
const richEditorHandlers = computed(() => ({
@@ -238,6 +242,13 @@ const editorExtensions = [TaskList, TaskItem, ListKeymap]
238242
class="editoro-file-input"
239243
@change="onImageInputChange"
240244
>
245+
246+
<input
247+
ref="fileInputRef"
248+
type="file"
249+
class="editoro-file-input"
250+
@change="onFileInputChange"
251+
>
241252
</div>
242253
</template>
243254

@@ -357,6 +368,30 @@ const editorExtensions = [TaskList, TaskItem, ListKeymap]
357368
line-height: 1.5;
358369
}
359370
371+
.editoro-editor :deep(.tiptap a[href*='/api/files/file?path=']) {
372+
position: relative;
373+
padding-left: 1.1rem;
374+
}
375+
376+
.editoro-editor :deep(.tiptap a[href*='/api/files/file?path=']::before) {
377+
content: '';
378+
position: absolute;
379+
left: 0;
380+
top: 50%;
381+
width: 14px;
382+
height: 14px;
383+
transform: translateY(-50%);
384+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m21.44 11.05-8.49 8.49a5.5 5.5 0 0 1-7.78-7.78l8.49-8.48a3.5 3.5 0 1 1 4.95 4.95l-8.49 8.49a1.5 1.5 0 0 1-2.12-2.12l8.49-8.49'/%3E%3C/svg%3E");
385+
background-repeat: no-repeat;
386+
background-position: center;
387+
background-size: 14px 14px;
388+
pointer-events: none;
389+
}
390+
391+
.editoro-editor :deep(.editoro-link-modifier a[href]) {
392+
cursor: pointer;
393+
}
394+
360395
.editoro-raw {
361396
height: 100%;
362397
width: 100%;

app/composables/api/buildEditorApi.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export function buildEditorApi(options: BuildEditoroApiOptions) {
2222
modeTooltip: editorRefs.editorModeTooltip,
2323
pinnedFilePaths: editorRefs.pinnedFilePaths,
2424
uploadImage: editorStore.uploadImage,
25+
uploadFile: editorStore.uploadFile,
2526
toggleMode: editorStore.toggleEditorMode,
2627
isPinned: editorStore.isPinned,
2728
pinFile: editorStore.pinFile,

app/composables/editor/useEditoroEditorUploads.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55
import type { Ref } from 'vue'
66
import type { Translator } from '~/types/editoro'
7-
import { uploadImageApi } from '~/services/files-api'
7+
import { uploadFileApi, uploadImageApi } from '~/services/files-api'
88

99
type EditoroEditorUploadsOptions = {
1010
t: Translator
@@ -29,7 +29,24 @@ export function useEditoroEditorUploads(options: EditoroEditorUploadsOptions) {
2929
}
3030
}
3131

32+
async function uploadFile(file: File) {
33+
if (!options.activeFilePath.value) {
34+
options.notifyError(options.t('errors.openMarkdownFirst'))
35+
return null
36+
}
37+
38+
try {
39+
const result = await uploadFileApi(options.activeFilePath.value, file)
40+
return result.url
41+
} catch (error) {
42+
console.error(error)
43+
options.notifyError(options.t('errors.uploadFile'))
44+
return null
45+
}
46+
}
47+
3248
return {
33-
uploadImage
49+
uploadImage,
50+
uploadFile
3451
}
3552
}

app/composables/editor/useEditoroMainContentMedia.ts

Lines changed: 142 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type EditorLike = {
1818
setImage?: (payload: { src: string, alt?: string }) => {
1919
run: () => boolean
2020
}
21-
insertContent?: (content: string) => {
21+
insertContent?: (content: string | object) => {
2222
run: () => boolean
2323
}
2424
}
@@ -27,26 +27,30 @@ type EditorLike = {
2727

2828
type EditorHandlerLike = {
2929
canExecute: (editor: EditorLike) => boolean
30-
execute: (editor: EditorLike) => unknown
30+
execute: (editor: EditorLike) => boolean
3131
isActive: (editor: EditorLike) => boolean
3232
isDisabled?: (editor: EditorLike) => boolean
3333
}
3434

3535
type EditoroMainContentMediaOptions = {
3636
canUploadImage: () => boolean
3737
uploadImage: (file: File) => Promise<string | null>
38+
uploadFile: (file: File) => Promise<string | null>
3839
}
3940

4041
/**
41-
* Encapsulates image upload interactions for editor toolbar, DnD and paste flows.
42+
* Encapsulates media/file upload interactions for editor toolbar, DnD and paste flows.
43+
* Also handles Ctrl/Cmd+Click on links inside editor content.
4244
* Used by `app/components/editoro/MainContent.vue`.
4345
*/
4446
export function useEditoroMainContentMedia(options: EditoroMainContentMediaOptions) {
4547
const imageInputRef = ref<HTMLInputElement>()
48+
const fileInputRef = ref<HTMLInputElement>()
4649
const pendingEditor = ref<EditorLike>()
50+
const pendingFileEditor = ref<EditorLike>()
4751
const currentEditor = ref<EditorLike>()
4852

49-
let detachEditorDnDListeners: (() => void) | null = null
53+
let detachEditorListeners: (() => void) | null = null
5054

5155
const editorHandlers: Record<string, EditorHandlerLike> = {
5256
uploadImage: {
@@ -57,6 +61,15 @@ export function useEditoroMainContentMedia(options: EditoroMainContentMediaOptio
5761
},
5862
isActive: () => false,
5963
isDisabled: () => !options.canUploadImage()
64+
},
65+
uploadFile: {
66+
canExecute: () => options.canUploadImage(),
67+
execute: (editor) => {
68+
openFilePicker(editor)
69+
return true
70+
},
71+
isActive: () => false,
72+
isDisabled: () => !options.canUploadImage()
6073
}
6174
}
6275

@@ -70,6 +83,16 @@ export function useEditoroMainContentMedia(options: EditoroMainContentMediaOptio
7083
imageInputRef.value?.click()
7184
}
7285

86+
function openFilePicker(editor: EditorLike) {
87+
if (!options.canUploadImage()) {
88+
return
89+
}
90+
91+
pendingFileEditor.value = editor
92+
currentEditor.value = editor
93+
fileInputRef.value?.click()
94+
}
95+
7396
function isImageFile(file: File) {
7497
return file.type.startsWith('image/')
7598
}
@@ -95,6 +118,27 @@ export function useEditoroMainContentMedia(options: EditoroMainContentMediaOptio
95118
}
96119
}
97120

121+
function insertUploadedFileLink(editor: EditorLike, fileUrl: string, fileName: string) {
122+
const content = {
123+
type: 'text',
124+
text: fileName,
125+
marks: [
126+
{
127+
type: 'link',
128+
attrs: { href: fileUrl }
129+
}
130+
]
131+
}
132+
133+
const chain = editor.chain().focus()
134+
if (chain.insertContent) {
135+
chain.insertContent(content).run()
136+
return
137+
}
138+
139+
editor.chain().focus().insertContent?.(`[${fileName}](${fileUrl})`).run()
140+
}
141+
98142
async function uploadAndInsertImages(editor: EditorLike, files: File[], position?: number) {
99143
for (const file of files) {
100144
const imageUrl = await options.uploadImage(file)
@@ -131,17 +175,48 @@ export function useEditoroMainContentMedia(options: EditoroMainContentMediaOptio
131175
input.value = ''
132176
}
133177

178+
async function onFileInputChange(event: Event) {
179+
const input = event.target as HTMLInputElement
180+
const editor = pendingFileEditor.value
181+
const file = input.files?.[0]
182+
183+
if (!file || !editor) {
184+
input.value = ''
185+
return
186+
}
187+
188+
const fileUrl = await options.uploadFile(file)
189+
if (fileUrl) {
190+
insertUploadedFileLink(editor, fileUrl, file.name)
191+
}
192+
193+
input.value = ''
194+
}
195+
196+
function isExternalHref(href: string) {
197+
try {
198+
const url = new URL(href, window.location.href)
199+
return url.origin !== window.location.origin
200+
} catch {
201+
return false
202+
}
203+
}
204+
134205
watch(currentEditor, (editor) => {
135-
if (detachEditorDnDListeners) {
136-
detachEditorDnDListeners()
137-
detachEditorDnDListeners = null
206+
if (detachEditorListeners) {
207+
detachEditorListeners()
208+
detachEditorListeners = null
138209
}
139210

140211
const target = editor?.view?.dom
141212
if (!target) {
142213
return
143214
}
144215

216+
const setModifierCursorState = (isPressed: boolean) => {
217+
target.classList.toggle('editoro-link-modifier', isPressed)
218+
}
219+
145220
const onDrop = async (event: DragEvent) => {
146221
const files = getImageFilesFromDataTransfer(event.dataTransfer || null)
147222
if (files.length === 0) {
@@ -167,26 +242,81 @@ export function useEditoroMainContentMedia(options: EditoroMainContentMediaOptio
167242
await uploadAndInsertImages(editor, files)
168243
}
169244

245+
const onClick = (event: MouseEvent) => {
246+
if (!event.ctrlKey && !event.metaKey) {
247+
return
248+
}
249+
250+
const targetNode = event.target
251+
if (!(targetNode instanceof HTMLElement)) {
252+
return
253+
}
254+
255+
const anchor = targetNode.closest('a')
256+
if (!anchor) {
257+
return
258+
}
259+
260+
const href = anchor.getAttribute('href') || ''
261+
if (!href) {
262+
return
263+
}
264+
265+
event.preventDefault()
266+
if (isExternalHref(href)) {
267+
window.open(href, '_blank', 'noopener,noreferrer')
268+
} else {
269+
window.location.assign(href)
270+
}
271+
}
272+
273+
const onKeyDown = (event: KeyboardEvent) => {
274+
if (event.ctrlKey || event.metaKey) {
275+
setModifierCursorState(true)
276+
}
277+
}
278+
279+
const onKeyUp = (event: KeyboardEvent) => {
280+
if (!event.ctrlKey && !event.metaKey) {
281+
setModifierCursorState(false)
282+
}
283+
}
284+
285+
const onWindowBlur = () => {
286+
setModifierCursorState(false)
287+
}
288+
170289
target.addEventListener('drop', onDrop)
171290
target.addEventListener('paste', onPaste)
291+
target.addEventListener('click', onClick)
292+
window.addEventListener('keydown', onKeyDown)
293+
window.addEventListener('keyup', onKeyUp)
294+
window.addEventListener('blur', onWindowBlur)
172295

173-
detachEditorDnDListeners = () => {
296+
detachEditorListeners = () => {
174297
target.removeEventListener('drop', onDrop)
175298
target.removeEventListener('paste', onPaste)
299+
target.removeEventListener('click', onClick)
300+
window.removeEventListener('keydown', onKeyDown)
301+
window.removeEventListener('keyup', onKeyUp)
302+
window.removeEventListener('blur', onWindowBlur)
303+
setModifierCursorState(false)
176304
}
177305
}, { flush: 'post' })
178306

179307
onBeforeUnmount(() => {
180-
if (detachEditorDnDListeners) {
181-
detachEditorDnDListeners()
182-
detachEditorDnDListeners = null
308+
if (detachEditorListeners) {
309+
detachEditorListeners()
310+
detachEditorListeners = null
183311
}
184312
})
185313

186314
return {
187315
imageInputRef,
316+
fileInputRef,
188317
editorHandlers,
189318
bindEditor,
190-
onImageInputChange
319+
onImageInputChange,
320+
onFileInputChange
191321
}
192322
}

app/composables/workspace/useEditoroMainContentBindings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export function useEditoroMainContentBindings(options: WorkspaceBindingOptions)
2222
editorContent: state.editor.content.value,
2323
canUploadImage: !!state.editor.activeFilePath.value,
2424
uploadImage: state.editor.uploadImage,
25+
uploadFile: state.editor.uploadFile,
2526
editorToolbarItems: options.editorToolbarItems.value,
2627
editorSuggestionItems: options.editorSuggestionItems.value
2728
}))

app/constants/editoro.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function createEditorToolbarItems(t: Translator) {
3131
{ 'kind': 'toggleRawMode', 'icon': 'i-lucide-file-text', 'label': 'Raw', 'tooltip': { text: t('toolbar.raw') }, 'aria-label': t('toolbar.raw') }
3232
],
3333
[
34+
{ 'kind': 'uploadFile', 'icon': 'i-lucide-paperclip', 'tooltip': { text: t('toolbar.uploadFile') }, 'aria-label': t('toolbar.uploadFile') },
3435
{ 'kind': 'uploadImage', 'icon': 'i-lucide-image-plus', 'tooltip': { text: t('toolbar.uploadImage') }, 'aria-label': t('toolbar.uploadImage') }
3536
]
3637
] satisfies EditorToolbarItems
@@ -53,6 +54,7 @@ export function createEditorSuggestionItems(t: Translator) {
5354
],
5455
[
5556
{ type: 'label', label: t('suggestion.insert') },
57+
{ kind: 'uploadFile', label: t('suggestion.file'), icon: 'i-lucide-paperclip' },
5658
{ kind: 'uploadImage', label: t('suggestion.image'), icon: 'i-lucide-image-plus' },
5759
{ kind: 'blockquote', label: t('toolbar.quote'), icon: 'i-lucide-text-quote' },
5860
{ kind: 'codeBlock', label: t('toolbar.codeBlock'), icon: 'i-lucide-square-code' },

0 commit comments

Comments
 (0)