Skip to content

Instantly share code, notes, and snippets.

@bangsite
Forked from dodgydre/00 - Quill Vue Component.md
Created November 23, 2024 09:26
Show Gist options
  • Save bangsite/b8f5cf1dc4cb70c212c5bb43bf9215b9 to your computer and use it in GitHub Desktop.
Save bangsite/b8f5cf1dc4cb70c212c5bb43bf9215b9 to your computer and use it in GitHub Desktop.
Vue 3 Quill 2

Quill 2.0.0 component for vue 3

Using - "quill": "^2.0.0-rc.2"

Adjust app.js depending on your use case - I am using it in a Laravel/Inertia project so I've got the Editor component registered in the CreateInertiaApp.

The other files I've got in a quill folder (quill_options.js is quill/options.js, etc.)

<script setup>
import { ref } from "vue"
const editor = ref(null)
const body = ref("")
</script>
<template>
<div>
<Editor
ref="editor"
:options="{ placeholder: '' }"
v-model:content="body"
contentType="html"
>
</Editor>
</div>
</template>
import "./bootstrap";
import "../css/app.css";
import { createApp, h } from "vue";
import { createInertiaApp } from "@inertiajs/vue3";
import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers";
import { ZiggyVue } from "../../vendor/tightenco/ziggy/dist/vue.m";
import Editor from "./quill/" // Vue 3 Quill 2.0.0 Editor Component
import "quill/dist/quill.snow.css";
import "quill/dist/quill.bubble.css";
createInertiaApp({
title: (title) => `${title} - ${appName}`,
resolve: (name) =>
resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob("./Pages/**/*.vue"),
),
setup({ el, App, props, plugin }) {
return createApp({ render: () => h(App, props) })
.use(plugin)
.use(ZiggyVue, Ziggy)
.component("Editor", Editor) // Register the component globally Editor Component
.mount(el);
},
progress: {
color: "#4B5563",
},
});
import {
TextChangeHandler,
SelectionChangeHandler,
EditorChangeHandler,
QuillOptionsStatic,
RangeStatic,
Sources,
Module,
} from 'quill'
import { PropType, nextTick, defineComponent, onBeforeUnmount, onMounted, ref, watch, h } from "vue";
import Quill from "quill";
import Delta from "quill-delta";
import { toolbarOptions, ToolbarOptions } from "./options";
type ContentPropType = string | Delta | undefined | null
export const Editor = defineComponent({
name: 'Editor',
inheritAttrs: false,
props: {
content: {
type: [String, Object] as PropType<ContentPropType>,
default: null,
},
contentType: {
type: String as PropType<'delta' | 'html' | 'text'>,
default: "delta",
validator: (value: string) => {
return ["delta", "html", "text"].includes(value);
},
},
enable: {
type: Boolean,
default: true,
},
readOnly: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
required: false,
},
theme: {
type: String as PropType<'snow' | 'bubble' | ''>,
default: "snow",
validator: (value: string) => {
return ["snow", "bubble", ""].includes(value);
},
},
toolbar: {
type: [String, Array, Object],
required: false,
validator: (value: string | unknown) => {
if (typeof value === "string" && value !== "") {
return value.charAt(0) === "#"
? true
: Object.keys(toolbarOptions).indexOf(value) !== -1;
}
return true
},
},
modules: {
type: Object as PropType<Module | Module[]>,
required: false,
},
options: {
type: Object as PropType<QuillOptionsStatic>,
required: false,
},
globalOptions: {
type: Object as PropType<QuillOptionsStatic>,
required: false,
},
},
emits: [
"textChange",
"selectionChange",
"editorChange",
"update:content",
"blur",
"focus",
"ready",
],
setup: (props, ctx) => {
onMounted(() => {
initialize();
});
onBeforeUnmount(() => {
quill = null;
});
let quill: Quill | null;
let options: QuillOptionsStatic;
const editor = ref<HTMLElement>();
const registerModule = (moduleName: string, module: any) => {
if (Quill?.imports && moduleName in Quill.imports) {
return
}
Quill.register(moduleName, module)
}
const initialize = () => {
if (!editor.value) return
options = composeOptions()
// Register modules
if (props.modules) {
if (Array.isArray(props.modules)) {
for (const module of props.modules) {
registerModule(`modules/${module.name}`, module.module)
}
} else {
registerModule(`modules/${props.modules.name}`, props.modules.module)
}
}
// Create new Quill instance
quill = new Quill(editor.value, options)
// Set editor content
setContents(props.content)
// Set event handlers
quill.on('text-change', handleTextChange)
quill.on('selection-change', handleSelectionChange)
quill.on('editor-change', handleEditorChange)
// Remove editor class when theme changes
if (props.theme !== 'bubble') editor.value.classList.remove('ql-bubble')
if (props.theme !== 'snow') editor.value.classList.remove('ql-snow')
// Fix clicking the quill toolbar is detected as blur event
quill
.getModule('toolbar')?.container.addEventListener('mousedown', (e: MouseEvent) => {
e.preventDefault()
})
// Emit ready event
ctx.emit('ready', quill)
}
const composeOptions = (): QuillOptionsStatic => {
const clientOptions: QuillOptionsStatic = {};
if (props.theme !== "") clientOptions.theme = props.theme;
if (props.readOnly) clientOptions.readOnly = props.readOnly;
if (props.placeholder) clientOptions.placeholder = props.placeholder;
if (props.toolbar && props.toolbar !== "") {
clientOptions.modules = {
toolbar: (() => {
if (typeof props.toolbar === "object") {
return props.toolbar;
} else if (typeof props.toolbar === "string") {
const str = props.toolbar;
return str.charAt(0) === "#"
? props.toolbar
: toolbarOptions[props.toolbar as keyof ToolbarOptions];
}
return;
})(),
};
}
if (props.modules) {
const modules = (() => {
const modulesOption = {};
if (Array.isArray(props.modules)) {
for (const module of props.modules) {
modulesOption[module.name] = module.options ?? {};
}
} else {
modulesOption[props.modules.name] = props.modules.options ?? {};
}
return modulesOption;
})();
clientOptions.modules = Object.assign({}, clientOptions.modules, modules);
}
return Object.assign({}, props.globalOptions, props.options, clientOptions);
};
const maybeClone = (delta: ContentPropType) => {
return typeof delta === "object" && delta ? delta.slice() : delta;
};
const deltaHasValuesOtherThanRetain = (delta: Delta) => {
return Object.values(delta.ops).some(
(v) => !v.retain || Object.keys(v).length !== 1
);
};
let internalModel: typeof props.content;
const internalModelEquals = (against: ContentPropType) => {
if (typeof internalModel === typeof against) {
if (against === internalModel) {
return true;
}
// Ref/Proxy does not support instanceof, so do a loose check
if (
typeof against === "object" &&
against &&
typeof internalModel === "object" &&
internalModel
) {
return !deltaHasValuesOtherThanRetain(internalModel.diff(against as Delta));
}
}
return false;
};
const handleTextChange = (delta: Delta, oldContents: Delta, source: Sources) => {
internalModel = maybeClone(getContents() as string | Delta);
// Update v-model:content when text changes
if (!internalModelEquals(props.content)) {
ctx.emit("update:content", internalModel);
}
ctx.emit("textChange", { delta, oldContents, source });
};
const isEditorFocus = ref<Boolean>();
const handleSelectionChange = (range, oldRange, source) => {
// Set isEditorFocus if quill.hasFocus()
isEditorFocus.value = !!quill?.hasFocus();
ctx.emit("selectionChange", { range, oldRange, source });
};
watch(isEditorFocus, (focus) => {
if (focus) ctx.emit("focus", editor);
else ctx.emit("blur", editor);
});
const handleEditorChange = (...args:
| [
name: 'text-change',
delta: Delta,
oldContents: Delta,
source: Sources
]
| [
name: 'selection-change',
range: RangeStatic,
oldRange: RangeStatic,
source: Sources
]) => {
if (args[0] === "text-change")
ctx.emit("editorChange", {
name: args[0],
delta: args[1],
oldContents: args[2],
source: args[3],
});
if (args[0] === "selection-change")
ctx.emit("editorChange", {
name: args[0],
range: args[1],
oldRange: args[2],
source: args[3],
});
};
const getEditor = (): HTMLElement => {
return editor.value as HTMLElement;
};
const getToolbar = (): HTMLElement => {
return quill?.getModule("toolbar")?.container;
};
const getQuill = (): Quill => {
if (quill) return quill;
else
throw `The quill editor hasn't been instantiated yet,
make sure to call this method when the editor ready
or use v-on:ready="onReady(quill)" event instead.`;
};
const getContents = (index?: number, length?: number) => {
if (props.contentType === "html") {
return getHTML();
} else if (props.contentType === "text") {
return getText(index, length);
}
return quill?.getContents(index, length);
};
const setContents = (content: ContentPropType, source: Sources = 'api') => {
const normalizedContent = !content
? props.contentType === "delta"
? new Delta()
: ""
: content;
if (props.contentType === "html") {
setHTML(normalizedContent as string);
} else if (props.contentType === "text") {
setText(normalizedContent as string, source);
} else {
quill?.setContents(normalizedContent as Delta, source);
}
internalModel = maybeClone(normalizedContent);
};
const getText = (index?: number, length?: number): string => {
return quill?.getText(index, length) ?? "";
};
const setText = (text: string, source: Sources = "api") => {
quill?.setText(text, source);
};
const getHTML = (): string => {
return quill?.root.innerHTML ?? "";
};
const setHTML = (html: string) => {
if (quill) quill.root.innerHTML = html;
};
const pasteHTML = (html: string, source: Sources = "api") => {
const delta = quill?.clipboard.convert(html as {});
if (delta) quill?.setContents(delta, source);
};
const focus = () => {
quill?.focus();
};
const reinit = () => {
nextTick(() => {
if (!ctx.slots.toolbar && quill)
quill.getModule("toolbar")?.container.remove()
initialize();
});
};
watch(
() => props.content,
(newContent) => {
if (!quill || !newContent || internalModelEquals(newContent)) return;
// Restore the selection and cursor position after updating the content
const selection = quill.getSelection();
if (selection) {
nextTick(() => quill?.setSelection(selection));
}
setContents(newContent);
},
{ deep: true }
);
watch(
() => props.enable,
(newValue) => {
if (quill) quill.enable(newValue);
}
);
return {
editor,
getEditor,
getToolbar,
getQuill,
getContents,
setContents,
getHTML,
setHTML,
pasteHTML,
focus,
getText,
setText,
reinit,
};
},
render() {
return [
this.$slots.toolbar?.(),
h('div', { ref: 'editor', ...this.$attrs})
]
},
})
import { Editor } from "./Editor.ts";
const globalOptions = {
debug: "warn",
modules: {
toolbar: {
container: [
"bold",
"italic",
"underline",
{ list: "ordered" },
{ list: "bullet" },
{ indent: "-1" },
{ indent: "+1" },
{ size: ["small", false, "large", "huge"] },
{ color: [] },
{ background: [] },
"clean",
"image",
"video",
],
handlers: {
},
},
},
placeholder: "Something to add...",
theme: "snow",
};
Editor.props.globalOptions.default = () => globalOptions;
export default Editor;
// Quill toolbar options
export type ToolbarOptions = typeof toolbarOptions
export const toolbarOptions = {
essential: [
[{ header: [1, 2, 3, 4, 5, 6, false] }],
["bold", "italic", "underline"],
[{ list: "ordered" }, { list: "bullet" }, { align: [] }],
["blockquote", "code-block", "link"],
[{ color: [] }, "clean"],
],
minimal: [
[{ header: 1 }, { header: 2 }],
["bold", "italic", "underline"],
[{ list: "ordered" }, { list: "bullet" }, { align: [] }],
],
full: [
["bold", "italic", "underline", "strike"], // toggled buttons
["blockquote", "code-block"],
[{ header: 1 }, { header: 2 }], // custom button values
[{ list: "ordered" }, { list: "bullet" }],
[{ script: "sub" }, { script: "super" }], // superscript/subscript
[{ indent: "-1" }, { indent: "+1" }], // outdent/indent
[{ direction: "rtl" }], // text direction
[{ size: ["small", false, "large", "huge"] }], // custom dropdown
[{ header: [1, 2, 3, 4, 5, 6, false] }],
[{ color: [] }, { background: [] }], // dropdown with defaults from theme
[{ font: [] }],
[{ align: [] }],
["link", "video", "image"],
["clean"], // remove formatting button
],
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment