封装

<script setup lang="ts">
import Quill, { Delta, type EmitterSource, type QuillOptions, Range } from "quill";
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import "quill/dist/quill.snow.css";

const props = defineProps<{
  // HTML model value, supports v-model
  modelValue?: string | null;
  // Quill initialization options
  options?: QuillOptions;
  semantic?: boolean;
}>();

const emit = defineEmits<{
  (e: "update:modelValue", value: string): void;
  (e: "text-change", { delta, oldContent, source }: { delta: Delta; oldContent: Delta; source: EmitterSource }): void;
  (e: "selection-change", { range, oldRange, source }: { range: Range; oldRange: Range; source: EmitterSource }): void;
  (e: "editor-change", eventName: "text-change" | "selection-change"): void;
  (e: "blur", quill: Quill): void;
  (e: "focus", quill: Quill): void;
  (e: "ready", quill: Quill): void;
}>();

let quillInstance: Quill | null = null;
const container = ref<HTMLElement>();
const model = ref<string | null>();

// Convert modelValue HTML to Delta and replace editor content
const pasteHTML = (quill: Quill) => {
  model.value = props.modelValue;
  const oldContent = quill.getContents();
  const delta = quill.clipboard.convert({ html: props.modelValue ?? "" });
  quill.setContents(delta);
  emit("text-change", { delta, oldContent, source: "api" });
};

// Editor initialization, returns Quill instance
const initialize = () => {
  const quill = new Quill(container.value as HTMLElement, props.options);

  // Set editor initial model
  if (props.modelValue) {
    pasteHTML(quill);
  }

  // Handle editor selection change, emit blur and focus
  quill.on("selection-change", (range, oldRange, source) => {
    if (!range) {
      emit("blur", quill);
    } else {
      emit("focus", quill);
    }
    emit("selection-change", { range, oldRange, source });
  });

  // Handle editor text change
  quill.on("text-change", (delta, oldContent, source) => {
    model.value = props.semantic ? quill.getSemanticHTML() : quill.root.innerHTML;
    emit("text-change", { delta, oldContent, source });
  });

  // Handle editor change
  quill.on("editor-change", (eventName: "text-change" | "selection-change") => {
    emit("editor-change", eventName);
  });

  emit("ready", quill);

  quillInstance = quill;

  return quill;
};

// Watch modelValue and paste HTML if has changes
watch(
  () => props.modelValue,
  (newValue) => {
    if (!quillInstance) return;
    if (newValue && newValue !== model.value) {
      pasteHTML(quillInstance);
      // Update HTML model depending on type
      model.value = props.semantic ? quillInstance.getSemanticHTML() : quillInstance.root.innerHTML;
    } else if (!newValue) {
      quillInstance.setContents([]);
    }
  }
);

// Watch model and update modelValue if has changes
watch(model, (newValue, oldValue) => {
  if (!quillInstance) return;
  if (newValue && newValue !== oldValue) {
    emit("update:modelValue", newValue);
  } else if (!newValue) {
    quillInstance.setContents([]);
  }
});

function getQuillInstance() {
  return quillInstance;
}

onMounted(() => {
  initialize();
  console.log("init");
});

onBeforeUnmount(() => {
  quillInstance = null;
});

// Expose init function
defineExpose<{
  getQuillInstance: () => Quill | null;
}>({
  getQuillInstance,
});
</script>

<template>
  <div ref="container"></div>
</template>

<style>
.ql-container {
  width: 100%;
  min-height: 300px;
}

.ql-editor {
}
</style>

使用

<script setup lang="ts">
import { ref } from "vue";
import type { QuillOptions } from "quill";
import Quill from "quill";
import quill from "./components/quill.vue";
import { Mention, MentionBlot } from "quill-mention"; // 插件
import "quill-mention/dist/quill.mention.min.css"; // 样式

Quill.register(MentionBlot);
Quill.register("modules/mention", Mention);

const model = ref("");
const quillRef = ref();

const options: QuillOptions = {
  theme: "snow",
  placeholder: "Start your story here...",
  readOnly: false,
  modules: {
    toolbar: {
      container: [["bold", "italic", "underline"], [{ list: "ordered" }, { list: "bullet" }], [{ align: [] }], ["link", "image"]],
      handlers: {
        image: imageHandler, // 正确放置 handlers
      },
    },
    mention: {
      allowedChars: /^[\u4e00-\u9fa5\w]*$/, // 允许中文/英文/数字/下划线
      mentionDenotationChars: ["@"], // 触发字符
      source(searchTerm: any, renderList: any, mentionChar: any) {
        // 这里可以换成后端接口
        const users = [
          { id: 1, value: "张三" },
          { id: 2, value: "李四" },
        ];
        const matches = users.filter((u) => u.value.toLowerCase().includes(searchTerm.toLowerCase()));
        renderList(matches, searchTerm);
      },
      renderItem(item: any) {
        return `${item.value}`;
      },
      onSelect(item: any, insertItem: any) {
        // 插入后自动加个空格,体验更好
        insertItem({ ...item, value: `@${item.value} ` });
      },
    },
  },
};

function imageHandler() {
  const input = document.createElement("input");
  input.setAttribute("type", "file");
  input.setAttribute("accept", "image/*");
  input.click();

  input.onchange = async () => {
    const file = input.files?.[0];
    if (!file) return;

    // 模拟上传逻辑,实际应替换为真实上传接口
    const formData = new FormData();
    formData.append("image", file);

    const quillInstance = quillRef.value.getQuillInstance();
    const range = quillInstance.getSelection();
    quillInstance.insertEmbed(range.index, "image", "xxxxx");
  };
}
</script>

<template>
  <div class="container">
    <quill ref="quillRef" v-model="model" :options="options"></quill>
  </div>
</template>

<style scoped>
.container {
  width: 100vw;
  height: 100vh;
  box-sizing: border-box;
  padding: 20px;
}
</style>