Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 32 additions & 13 deletions src/components/TextEditor/components/MediaNodeView.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { ref, onMounted, onUnmounted, computed, h } from 'vue'
import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
import LoadingIndicator from '../../LoadingIndicator.vue'
import Tooltip from '../../Tooltip/Tooltip.vue'
import { localFileMap } from '../extensions/image/image-extension'
import { ErrorMessage } from '../../ErrorMessage'
import LucideAlignLeft from '~icons/lucide/align-left'
import LucideAlignCenter from '~icons/lucide/align-center'
Expand All @@ -13,6 +14,7 @@ import LucideFloatRight from '~icons/lucide/align-horizontal-justify-end'
import LucideNoFloat from '~icons/lucide/align-vertical-space-around'
import LucideCaptions from '~icons/lucide/captions'
import LucideMoveDiagonal2 from '~icons/lucide/move-diagonal-2'
import LucideRotateCw from '~icons/lucide/rotate-cw'

const props = defineProps(nodeViewProps)

Expand All @@ -30,6 +32,8 @@ const floatButtonRef = ref<HTMLElement | null>(null)

const showCaption = ref(props.node.attrs.alt ? true : false)
const isVideo = computed(() => props.node.type.name === 'video')
const isUploaded = computed(() => Boolean(props.node.attrs.src))
const fileContent = computed(() => localFileMap.get(props.node.attrs.uploadId)?.b64)

const currentAlignIcon = computed(() => {
return (
Expand Down Expand Up @@ -273,14 +277,16 @@ const wrapperClasses = (float: string) => [
]" :style="{
width: node.attrs.width ? `${node.attrs.width}px` : 'auto',
}">
<div v-if="node.attrs.src" class="relative">
<img v-if="!isVideo" ref="mediaRef" class="rounded-[2px]" :src="node.attrs.src" :alt="node.attrs.alt || ''"
:width="node.attrs.width" :height="node.attrs.height" @click.stop="selectMedia" @load="handleMediaLoaded" />
<video v-else ref="mediaRef" class="rounded-[2px]" :src="node.attrs.src" :width="node.attrs.width"
:height="node.attrs.height" :autoplay="node.attrs.autoplay" :loop="node.attrs.loop" :muted="node.attrs.muted"
controls @click.stop="selectMedia" @loadedmetadata="handleMediaLoaded" />

<div class="absolute top-2 right-2 items-center bg-black/65 px-1.5 py-1 gap-2 rounded group-hover:flex"
<div v-if="isUploaded || fileContent" class="relative">
<img v-if="!isVideo" ref="mediaRef" class="rounded-[2px]" :class="!isUploaded && 'opacity-40'" :src="node.attrs.src || fileContent"
:alt="node.attrs.alt || ''" :width="node.attrs.width" :height="node.attrs.height"
@click.stop="selectMedia" @load="handleMediaLoaded" />
<video v-else ref="mediaRef" class="rounded-[2px]" :class="!isUploaded && 'opacity-40'" :src="node.attrs.src || fileContent"
:width="node.attrs.width" :height="node.attrs.height" :autoplay="node.attrs.autoplay"
:loop="node.attrs.loop" :muted="node.attrs.muted" :controls="isUploaded" @click.stop="selectMedia"
@loadedmetadata="handleMediaLoaded" />
Comment on lines +281 to +287
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no prettier formatting?


<div v-if="isUploaded" class="absolute top-2 right-2 items-center bg-black/65 px-1.5 py-1 gap-2 rounded group-hover:flex"
:class="selected && isEditable ? 'flex' : 'hidden'">
<button>
<LucideCaptions @click="toggleCaptions" class="size-4"
Expand Down Expand Up @@ -350,7 +356,16 @@ const wrapperClasses = (float: string) => [
</div>
</div>

<button v-if="selected && isEditable" class="absolute bottom-2 right-2 cursor-nw-resize bg-black/65 rounded p-1"
<Button
v-else
variant="solid"
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
:icon-left="h(LucideRotateCw, { class: 'size-4' })"
label="Try again"
@click="isVideo ? editor.commands.reuploadVideo(node.attrs.uploadId) : editor.commands.reuploadImage(node.attrs.uploadId)"
/>

<button v-if="selected && isEditable && isUploaded" class="absolute bottom-2 right-2 cursor-nw-resize bg-black/65 rounded p-1"
@mousedown.prevent="startResize">
<LucideMoveDiagonal2 class="text-white size-4" />
</button>
Expand All @@ -364,14 +379,18 @@ const wrapperClasses = (float: string) => [
</div>
</div>
</div>
<div v-else class="flex flex-col items-center justify-center gap-2 border rounded text-ink-gray-6 text-sm py-5 max-w-full" :class="{ 'border-none': selected }" :style="{ width: node.attrs.width + 'px', aspectRatio: node.attrs.width && node.attrs.height ? `${node.attrs.width} / ${node.attrs.height}` : undefined,}">
<div class="text-ink-gray-8 text-base">This {{ isVideo ? 'video' : 'image' }} hasn't yet been uploaded.</div>
<div v-if="node.attrs.error" class="text-sm text-ink-red-4">Upload failed: {{ node.attrs.error }}</div>
</div>

<input v-if="(node.attrs.alt || showCaption) && !node.attrs.error" v-model="caption"
class="w-full text-center bg-transparent text-sm text-ink-gray-6 h-7 border-none focus:ring-0 placeholder-ink-gray-4"
placeholder="Add caption" :disabled="!isEditable" @change="updateCaption" @keydown="handleKeydown" />

<div v-if="node.attrs.error" class="w-full py-1.5">
<ErrorMessage :message="`Upload Failed: ${node.attrs.error}`" />
<div v-if="node.attrs.error && fileContent" class="w-full py-1.5 text-center">
<ErrorMessage :message="`Upload failed: ${node.attrs.error}`" />
</div>
</div>
</NodeViewWrapper>
</template>
</template>
73 changes: 56 additions & 17 deletions src/components/TextEditor/extensions/image/image-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { Node } from '@tiptap/pm/model'
import { fileToBase64 } from '../../../../index'
import { UploadedFile } from '../../../../utils/useFileUpload'

export const localFileMap = new Map()

export interface ImageExtensionOptions {
/**
* Function to handle image uploads
Expand Down Expand Up @@ -58,6 +60,10 @@ declare module '@tiptap/core' {
* Set image float for text wrapping
*/
setImageFloat: (float: 'left' | 'right' | null) => ReturnType
/**
* Re-upload a failed image using the file stored in localFileMap
*/
reuploadImage: (uploadId: string) => ReturnType
}
}
}
Expand Down Expand Up @@ -218,6 +224,45 @@ export const ImageExtension = NodeExtension.create<ImageExtensionOptions>({
input.click()
return true
},

reuploadImage:
(uploadId: string) =>
({ editor }) => {
const fileData = localFileMap.get(uploadId)
if (!fileData) {
console.error('reuploadImage: no file with uploadId', uploadId)
return false
}

// Find the node position
let nodePos: number | null = null
editor.view.state.doc.descendants((node, pos) => {
if (
node.type.name === 'image' &&
node.attrs.uploadId === uploadId
) {
nodePos = pos
return false
}
})

if (nodePos === null) {
console.error(
'reuploadImage: could not find node with uploadId',
uploadId,
)
return false
}

// Re-run the upload using the stored file, replacing the node at its position
return uploadImageBase(
fileData.file,
editor.view,
nodePos,
this.options,
'replace',
)
},
}
},

Expand Down Expand Up @@ -369,6 +414,7 @@ function findInsertPosition(
}

// Base upload function shared by all image upload methods
type ImageDimensions = { width: number | null; height: number | null }
function uploadImageBase(
file: File,
view: EditorView,
Expand All @@ -387,10 +433,19 @@ function uploadImageBase(

fileToBase64(file)
.then((base64Result: string) => {
localFileMap.set(uploadId, { b64: base64Result, file })

return getImageDimensions(base64Result)
.catch(() => ({ width: null, height: null }))
.then((dimensions) => dimensions)
})
.then((dimensions: ImageDimensions) => {
const node = view.state.schema.nodes.image.create({
loading: true,
uploadId,
src: base64Result,
src: null,
width: dimensions.width,
height: dimensions.height,
})

const tr = view.state.tr
Expand Down Expand Up @@ -438,22 +493,6 @@ function uploadImageBase(

return options.uploadFunction(file)
})
.then((uploadedImage: UploadedFile) => {
return getImageDimensions(uploadedImage.file_url)
.then((dimensions) => {
return {
...uploadedImage,
width: dimensions.width,
height: dimensions.height,
} as UploadedFile & { width: number; height: number }
})
.catch(() => {
return uploadedImage as UploadedFile & {
width: number
height: number
}
})
})
.then((uploadedImage) => {
const transaction = view.state.tr

Expand Down
84 changes: 66 additions & 18 deletions src/components/TextEditor/extensions/video-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { EditorView } from '@tiptap/pm/view'
import { Node } from '@tiptap/pm/model'
import { fileToBase64 } from '../../../index'
import { UploadedFile } from '../../../utils/useFileUpload'
import { localFileMap } from './image/image-extension'

export interface VideoExtensionOptions {
/**
Expand Down Expand Up @@ -58,6 +59,11 @@ declare module '@tiptap/core' {
* Set video floating
*/
setVideoFloat: (float: 'left' | 'right' | null) => ReturnType

/**
* Re-upload a failed video using the file stored in localFileMap
*/
reuploadVideo: (uploadId: string) => ReturnType
}
}
}
Expand Down Expand Up @@ -206,6 +212,48 @@ export const VideoExtension = NodeExtension.create<VideoExtensionOptions>({
input.click()
return true
},

reuploadVideo:
(uploadId: string) =>
({ editor }) => {
const fileData = localFileMap.get(uploadId)
if (!fileData) {
console.error(
'reuploadVideo: no file found in localFileMap for uploadId',
uploadId,
)
return false
}

// Find the node position
let nodePos: number | null = null
editor.view.state.doc.descendants((node, pos) => {
if (
node.type.name === 'video' &&
node.attrs.uploadId === uploadId
) {
nodePos = pos
return false
}
})

if (nodePos === null) {
console.error(
'reuploadVideo: could not find node with uploadId',
uploadId,
)
return false
}

// Re-run the upload using the stored file, replacing the node at its position
return uploadVideoBase(
fileData.file,
editor.view,
nodePos,
this.options,
'replace',
)
},
}
},

Expand Down Expand Up @@ -353,6 +401,8 @@ function findInsertPosition(
}

// Base upload function shared by all video upload methods
type VideoDimensions = { width: number | null; height: number | null }

function uploadVideoBase(
file: File,
view: EditorView,
Expand All @@ -371,10 +421,23 @@ function uploadVideoBase(

fileToBase64(file)
.then((base64Result: string) => {
localFileMap.set(uploadId, { b64: base64Result, file })

const objectUrl = URL.createObjectURL(file)
return getVideoDimensions(objectUrl)
.catch((): VideoDimensions => ({ width: null, height: null }))
.then((dimensions: VideoDimensions) => {
URL.revokeObjectURL(objectUrl)
return dimensions
})
})
.then((dimensions: VideoDimensions) => {
const node = view.state.schema.nodes.video.create({
loading: true,
uploadId,
src: base64Result,
src: null,
width: dimensions.width,
height: dimensions.height,
})

const tr = view.state.tr
Expand Down Expand Up @@ -422,22 +485,6 @@ function uploadVideoBase(

return options.uploadFunction(file)
})
.then((uploadedVideo: UploadedFile) => {
return getVideoDimensions(uploadedVideo.file_url)
.then((dimensions) => {
return {
...uploadedVideo,
width: dimensions.width,
height: dimensions.height,
} as UploadedFile & { width: number; height: number }
})
.catch(() => {
return uploadedVideo as UploadedFile & {
width: number
height: number
}
})
})
.then((uploadedVideo) => {
const transaction = view.state.tr

Expand Down Expand Up @@ -466,6 +513,7 @@ function uploadVideoBase(

view.state.doc.descendants((node, pos) => {
if (node.type.name === 'video' && node.attrs.uploadId === uploadId) {
// width/height are preserved from ...node.attrs (pre-fetched from local file)
transaction.setNodeMarkup(pos, undefined, {
...node.attrs,
loading: false,
Expand Down Expand Up @@ -541,7 +589,7 @@ function updateNodeWithDimensions(
}
})
.catch((error) => {
console.error('Could not upload video', error)
console.error('Could not get video dimensions', error)
})
}

Expand Down