feat(notes): add image upload button when editing a markdown note

This commit is contained in:
Julien Calixte
2026-05-25 21:17:46 +02:00
parent 0b3411626c
commit 6a0f0d08d2
2 changed files with 167 additions and 1 deletions

View File

@@ -10,6 +10,7 @@ import {
import { useEditionMode } from "@/hooks/useEditionMode"
import { useFile } from "@/hooks/useFile.hook"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { useImageUpload } from "@/hooks/useImageUpload.hook"
import { useLinks } from "@/hooks/useLinks.hook"
import { renderCodeFile } from "@/hooks/useMarkdown.hook"
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
@@ -109,6 +110,27 @@ const { updateFile } = useGitHubContent({
repo: repo.value
})
const { uploadImage } = useImageUpload({
user: user.value,
repo: repo.value,
notePath: path
})
const fileInput = ref<HTMLInputElement | null>(null)
const editKey = ref(0)
const onImagePicked = async (e: Event) => {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ""
if (!file || !path.value) return
const result = await uploadImage(file)
if (!result) return
const suffix = rawContent.value.endsWith("\n") ? "" : "\n\n"
rawContent.value = `${rawContent.value}${suffix}![](${result.filename})\n`
editKey.value++
}
const {
status: freshnessStatus,
lastCheckedAt,
@@ -273,6 +295,42 @@ const onBadgeClick = async () => {
@click="onBadgeClick"
class="action"
/>
<button
v-if="isMarkdown && mode === 'edit'"
class="action button is-text is-light"
title="Upload image"
@click="fileInput?.click()"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-photo-plus"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M15 8h.01" />
<path
d="M12.5 21h-6.5a3 3 0 0 1 -3 -3v-12a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v6.5"
/>
<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l4 4" />
<path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l2 2" />
<path d="M16 19h6" />
<path d="M19 16v6" />
</svg>
</button>
<input
ref="fileInput"
type="file"
accept="image/*"
class="hidden-input"
@change="onImagePicked"
/>
<button
v-if="isMarkdown"
class="action button is-text is-light"
@@ -337,7 +395,7 @@ const onBadgeClick = async () => {
</div>
<section class="text-content">
<div v-if="mode === 'edit' && isMarkdown" class="edit">
<edit-note v-model="rawContent" />
<edit-note :key="editKey" v-model="rawContent" />
</div>
<div
v-if="mode === 'read'"
@@ -410,6 +468,10 @@ $border-color: rgba(18, 19, 58, 0.2);
gap: 0.25rem;
}
.hidden-input {
display: none;
}
.action {
margin: 0;

View File

@@ -0,0 +1,104 @@
import { Ref, toValue } from "vue"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { errorMessage } from "@/utils/notif"
import { uniqueFilename } from "@/utils/uniqueFilename"
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
const bytes = new Uint8Array(buffer)
const chunkSize = 0x8000
let binary = ""
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize)
binary += String.fromCharCode(...chunk)
}
return btoa(binary)
}
const splitPath = (fullPath: string): { directory: string; filename: string } => {
const lastSlash = fullPath.lastIndexOf("/")
if (lastSlash === -1) return { directory: "", filename: fullPath }
return {
directory: fullPath.slice(0, lastSlash),
filename: fullPath.slice(lastSlash + 1)
}
}
const stripMarkdownExtension = (filename: string): string =>
filename.replace(/\.(md|markdown|mdx)$/i, "")
const extractExtension = (filename: string): string => {
const dot = filename.lastIndexOf(".")
if (dot <= 0) return ".png"
return filename.slice(dot).toLowerCase()
}
export const useImageUpload = ({
user,
repo,
notePath
}: {
user: string
repo: string
notePath: Ref<string | undefined> | string | undefined
}) => {
const store = useUserRepoStore()
const { uploadBinaryFile } = useGitHubContent({ user, repo })
const uploadImage = async (
file: File
): Promise<{ filename: string } | null> => {
const currentNotePath = toValue(notePath)
if (!currentNotePath) {
errorMessage("❌ Image upload failed")
return null
}
try {
const { directory, filename: noteFilename } = splitPath(currentNotePath)
const basename = stripMarkdownExtension(noteFilename)
const extension = extractExtension(file.name)
const existingPaths = store.files
.map((f) => f.path)
.filter((p): p is string => typeof p === "string")
const filename = uniqueFilename({
basename,
extension,
existingPaths,
directory
})
const targetPath = directory ? `${directory}/${filename}` : filename
const buffer = await file.arrayBuffer()
const base64 = arrayBufferToBase64(buffer)
const { sha, conflict } = await uploadBinaryFile({
base64,
path: targetPath
})
if (conflict || !sha) {
return null
}
if (!store.files.some((f) => f.path === targetPath)) {
store.files = [
...store.files,
{ path: targetPath, sha, type: "blob", size: file.size }
]
}
return { filename }
} catch (error) {
console.warn("image upload failed", error)
errorMessage("❌ Image upload failed")
return null
}
}
return { uploadImage }
}