Skip to content

Instantly share code, notes, and snippets.

@murdercode
Last active October 13, 2021 15:02
Show Gist options
  • Save murdercode/e57217145c605dcb9859b20eeddcd9d9 to your computer and use it in GitHub Desktop.
Save murdercode/e57217145c605dcb9859b20eeddcd9d9 to your computer and use it in GitHub Desktop.
Tiptap + VueJS3 + Laravel + InertiaJS

This is an excerpt of code I quickly wrote to integrate image upload to tiptap with VueJS3. I apologize for not being cleaned up and ready for distribution, however I trust that the tiptap team will integrate this feature sooner or later, making this gist unnecessary. Until then I hope it can serve those who need it.

** This is not a copy-paste code, take only what you need **

<?
public function uploadImage()
{
// Maybe in the future TODO: remove when deleting those or article itself
request()->validate([
'file' => 'required|image|mimes:jpeg,png,jpg,gif,svg|max:4096',
]);
// WORKING: Store Original
$original = request()->file('file')->store('public/articlesInline');
$value = str_replace('public/', '', $original);
// Resize and optimize original image
$new = Image::make(public_path("storage") . "/" . $value);
// Don't resize < maxsize
if ($new->width() > 896) {
$new->resize(896, null, function ($constraint) {
$constraint->aspectRatio();
});
}
$new->save(public_path("storage") . "/" . $value);
ImageOptimizer::optimize(public_path("storage") . "/" . $value);
return response()->json(['file' => $value]); // This is the path of the processed image
}
<template>
<!-- Editor -->
<div class="main-editor">
<div v-if="editor" class="editor-buttons">
<div
class="button"
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
>
h2
</div>
<div
class="button"
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
>
h3
</div>
<div
class="button"
:class="{ 'is-active': editor.isActive('paragraph') }"
@click="editor.chain().focus().setParagraph().run()"
>
paragraph
</div>
<div
class="button"
:class="{ 'is-active': editor.isActive('bold') }"
@click="editor.chain().focus().toggleBold().run()"
>
<strong>bold</strong>
</div>
<div
class="button"
:class="{ 'is-active': editor.isActive('italic') }"
@click="editor.chain().focus().toggleItalic().run()"
>
<em>italic</em>
</div>
<div
class="button"
:class="{ 'is-active': editor.isActive('strike') }"
@click="editor.chain().focus().toggleStrike().run()"
>
<strike>strike</strike>
</div>
<!-- Image Button -->
<UploadImageModal />
</div>
<div class="w-full">
<editor-content :editor="editor" />
</div>
</div>
</template>
<script>
import { Editor, EditorContent } from "@tiptap/vue-3";
import Image from "@tiptap/extension-image";
import StarterKit from "@tiptap/starter-kit";
import UploadImageModal from "@/Components/UploadImageModal";
export default {
components: {
EditorContent,
UploadImageModal,
},
props: {
modelValue: {
type: String,
default: "",
},
},
data() {
return {
editor: null,
};
},
watch: {
modelValue(value) {
// HTML
const isSame = this.editor.getHTML() === value;
// JSON
// const isSame = this.editor.getJSON().toString() === value.toString()
if (isSame) {
return;
}
this.editor.commands.setContent(value, false);
},
},
mounted() {
this.editor = new Editor({
extensions: [StarterKit, Image],
content: this.modelValue,
onUpdate: () => {
// HTML
this.$emit("update:modelValue", this.editor.getHTML());
},
});
},
beforeUnmount() {
this.editor.destroy();
},
};
</script>
<style>
.main-editor {
border: 3px solid black;
border-radius: 6px;
}
.editor-buttons {
padding-left: 4px;
display: flex;
border-bottom: 3px solid #000;
}
.editor-buttons div {
padding: 2px 6px;
border-right: 3px solid #000;
cursor: pointer;
}
.editor-buttons div.button:hover {
background: #e5e7eb;
}
.editor-buttons div.is-active {
background: #000;
color: #fff;
}
.editor-buttons div.is-active strong {
color: #fff;
}
.ProseMirror {
padding: 10px 20px;
--tw-text-opacity: 1;
color: rgba(17, 24, 39, var(--tw-text-opacity));
}
</style>
<template>
<!-- Button -->
<div @click="showModal">[img]</div>
<TransitionRoot as="template" :show="open">
<Dialog as="div" class="fixed z-10 inset-0 overflow-y-auto" @close="open = false">
<div
class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<DialogOverlay
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
/>
</TransitionChild>
<!-- This element is to trick the browser into centering the modal contents. -->
<span
class="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>&#8203;</span
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div
class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6"
>
<div>
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-indigo-100"
>
<CloudUploadIcon class="h-6 w-6 text-indigo-600" aria-hidden="true" />
</div>
<div class="mt-3 text-center sm:mt-5">
<DialogTitle as="h3" class="text-lg leading-6 font-medium text-gray-900">
Carica immagine
</DialogTitle>
<div class="mt-2">
<p class="text-sm text-gray-500 p-4">
<span class="text-red-500">JPEG, Max 4M</span>
</p>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6">
<fieldset>
<legend class="text-base font-medium text-gray-900">Immagine</legend>
<div class="flex space-x-4">
<div class="bg-gray-100 my-auto">
<input
type="file"
accept="image/*"
@change="uploadImage($event)"
id="file-input"
/>
</div>
</div>
</fieldset>
</div>
</div>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
</template>
<script>
import { ref } from "vue";
import {
Dialog,
DialogOverlay,
DialogTitle,
TransitionChild,
TransitionRoot,
} from "@headlessui/vue";
import { CloudUploadIcon } from "@heroicons/vue/outline";
export default {
components: {
Dialog,
DialogOverlay,
DialogTitle,
TransitionChild,
TransitionRoot,
CloudUploadIcon,
},
methods: {
showModal() {
this.open = true;
},
uploadImage(event) {
const URL = "/articles/upload-image";
let data = new FormData();
data.append("name", "my-picture");
data.append("file", event.target.files[0]);
axios.post(URL, data).then((response) => {
console.log("image upload response > ", response);
this.$parent.editor
.chain()
.focus()
.setImage({ src: "/storage/" + response.data.file }) // hard coding
.run();
this.open = false;
});
},
},
setup() {
const open = ref(false);
return {
open,
};
},
};
</script>
<?
// For Articles/ImageUploadModal.vue
Route::post('/upload-image', [ArticleController::class, 'uploadImage'])->name('uploadImage');
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment