🏷️

@tiptap/extension-mentionをvue3対応する

2023/10/09に公開

@tiptap/extension-mention、便利なんだけどサンプルがちょっと古いな。Vue3でも問題なく動くんですけど、いかんせんTypeScript対応してないし、OptionAPIだな、と。なので、軽く書き換えてみました。


こういうやつ。

useMentionSuggestion.ts

まずは、本家サンプルのsuggestion.js。こちらで、tiptapのエディター上でのイベントをハンドリングして、サジェストのポップアップ表示のために用意したコンポーネントMentionListを更新します。コンポーザブルに押し込んであります。

import { SuggestionOptions } from '@tiptap/suggestion';
import { VueRenderer } from '@tiptap/vue-3';
import tippy from 'tippy.js';
import { GetReferenceClientRect } from 'tippy.js';
import MentionList from '~~/components/MemtionList/index.vue';

type ClientRect = GetReferenceClientRect | null;

export const useMemtionSuggestion = () => {

  let component: VueRenderer;
  let popup: ReturnType<typeof tippy>;

  const entities = [
    'Lea Thompson', 'Cyndi Lauper', 'Tom Cruise', 'Madonna', 'Jerry Hall', 'Joan Collins', 'Winona Ryder', 'Christina Applegate', 'Alyssa Milano', 'Molly Ringwald', 'Ally Sheedy', 'Debbie Harry', 'Olivia Newton-John', 'Elton John', 'Michael J. Fox', 'Axl Rose', 'Emilio Estevez', 'Ralph Macchio', 'Rob Lowe', 'Jennifer Grey', 'Mickey Rourke', 'John Cusack', 'Matthew Broderick', 'Justine Bateman', 'Lisa Bonet',
  ];

  const items = computed(() => entities.filter(e => e.toLowerCase().startsWith(query.value.toLowerCase())).slice(0, 5));
  const query = ref<string>('');

  const suggestion: Omit<SuggestionOptions, 'editor'> = {
    items: ({ query: q }) => {
      query.value = q;
      return items.value;
    },
    render: () => {
      return {
        onStart: props => {
          component = new VueRenderer(MentionList, {
            props,
            editor: props.editor,
          });

          if (!props.clientRect) return;

          popup = tippy('body', {
            getReferenceClientRect: props.clientRect as ClientRect,
            appendTo: () => document.body,
            content: component.element,
            showOnCreate: true,
            interactive: true,
            trigger: 'manual',
            placement: 'bottom-start',
          });
        },
        onUpdate(props) {
          component.updateProps(props);
          if (!props.clientRect) return;
          popup[0].setProps({
            getReferenceClientRect: props.clientRect as ClientRect,
          });
        },
        onKeyDown(props) {
          if (props.event.key === 'Escape') {
            popup[0].hide();
            return true;
          }
          return component.ref?.onKeyDown(props);
        },
        onExit() {
          popup[0].destroy()
          component.destroy()
        },
      }
    },
  }

  return {
    suggestion,
  }
}

MentionList.vue

MemtionListは、表示周りの処理を担当しつつ、選択中のアイテムの更新用メソッドを提供します。(この辺の責務の分離が設計的に気になるところではあるが)

<template>
  <div class="items">
    <template v-if="items.length">
      <button class="item" :class="classes(index)" v-for="(item, index) in items" :key="index" @click="selectItem(index)">
        {{ item }}
      </button>
    </template>
    <div v-else class="item">
      No result
    </div>
  </div>
</template>
<script setup lang="ts">
import { SuggestionKeyDownProps } from '@tiptap/suggestion';

type Item = string;

const props = defineProps<{
  items: Item[];
  command: (item: {
    id: Item
  }) => void;
}>();

const classes = (index: number) => {
  return {
    'is-selected': index === selectedIndex.value,
  };
};

const items = computed(() => props.items);
const selectedIndex = ref<number>(0);

watch(items, () => selectedIndex.value = 0);

const onKeyDown = ref(({ event }: SuggestionKeyDownProps) => {

  if (event.key === 'ArrowUp') {
    upHandler();
    return true;
  }

  if (event.key === 'ArrowDown') {
    downHandler();
    return true;
  }

  if (event.key === 'Enter') {
    enterHandler();
    return true;
  }

  return false;
});

const upHandler = () => {
  selectedIndex.value = ((selectedIndex.value + items.value.length) - 1) % items.value.length
};

const downHandler = () => {
  selectedIndex.value = (selectedIndex.value + 1) % items.value.length
};

const enterHandler = () => {
  selectItem(selectedIndex.value);
};

const selectItem = (index: number) => {
  const item = items.value[index];

  if (item) {
    props.command({ id: item });
  }
};

defineExpose({
  onKeyDown,
});
</script>

<style lang="scss">
.items {
  padding: 0.2rem;
  position: relative;
  border-radius: 0.5rem;
  background: #FFF;
  color: rgba(0, 0, 0, 0.8);
  overflow: hidden;
  font-size: 0.9rem;
  box-shadow:
    0 0 0 1px rgba(0, 0, 0, 0.05),
    0px 10px 20px rgba(0, 0, 0, 0.1),
  ;
}

.item {
  display: block;
  margin: 0;
  width: 100%;
  text-align: left;
  background: transparent;
  border-radius: 0.4rem;
  border: 1px solid transparent;
  padding: 0.2rem 0.4rem;

  &.is-selected {
    border-color: #000;
  }
}
</style>

なんてことない移植だったんですが、ポイントはdefineExpose()です。本家サンプルでは、MentionListからcomponent.ref?.onKeyDownを呼び出して実行しているのですが、Vue3ではrefsをエクスポートしないので、そのままだと外から利用することができません。そこで、利用したいメソッドをdefineExpose()で外出しすると、コンポーザブル内で利用できるようになります。

Editor

あとは、TipTapのエディターで、Mentionを登録してやるだけです。

Mention.configure({
  HTMLAttributes: {
    class: 'mention',
  },
  suggestion,
}),

おなじみconfigure()

const { suggestion } = useMentionSuggestion();

suggestionオブジェクト自体は、コンポーザブルから呼んできます(本家通り、utilでもいいんですけど)。

といった感じです!実際は、useMentionSuggestion内で、entitiesを動的に呼び出してやったりといった処理が必要になると思いますが、とりあえず土台まで。

Discussion