MERYの記事作成ツールをNuxt.js × Atomic Designで作り直した話(機能編)

前回は、レガシーなMERYの記事作成ツールのフロントエンドをNuxt.jsとAtomic Designで作り直した際のコンポーネント設計の話を紹介しました。
今回は、記事作成ツールの具体的な機能や記事内容を構成するアイテムコンポーネントの中身を紹介していこうと思います。

記事作成ツールを構成する要素

MERYの記事作成ツールは、メインカラムにサムネイル設定、タイトルと説明文を入力する箇所、そして記事内容を構成する箇所があり、サイドカラムには入稿ボタンやカテゴリ、タグの設定などがあります。
一般的なブログサービスのエディターで使われるような要素でできており、特殊な要素などはありません。
以前の記事作成ツールと全体の要素自体はほとんど変わっていませんが、記事内容を構成する箇所が大きく変わったので、その部分を親コンポーネントとアイテムコンポーネントに分けて説明します。

親コンポーネント

MERYの記事はさまざまなアイテムを組み合わせながら1つの記事を作っています。
アイテムは、見出しやテキスト、画像、動画、商品など全部で12個のタイプに分類されており、それらを配置したり入れ替えていくことで記事が構成されていきます。
新しい記事作成ツールではタイプごとにコンポーネント化を行い、アイテム群を囲う親コンポーネントで出し分けを行っています。

<template>
  <div class="item-list">
    // アイテムコンポーネントの出し分け
    <section class="writing-contents" v-if="!index.visible">
      <template v-for="item in items">
        <component
          :is="componentType(item.contentType)"
          :itemData="item"
          :editingItemId="editingItemId"
          :key="item.editId"
        ></component>
      </template>
    </section>
    // indexモードと並び替え
    <section class="writing-index" v-else>
      <button-base
        class="batchDelete"
        v-bind:disabled="index.removeSelectedItemEditId.length === 0"
        @click="removeItems"
        type="normal"
        color="pink"
        size="small"
      >一括削除</button-base>
      <draggable v-model="computedItems" :options="dragOptions">
          <item-index
            v-for="item in items"
            :itemData="item"
            :key="item.editId"
            v-model="index.removeSelectedItemEditId"
          ></item-index>
      </draggable>
    </section>
  </div>
</template>

<script lang="ts">

    ・
    ・ // componentや型ファイルのimport
    ・

const namespace = 'article'
@Component({
  components: {
    HeadlineItem,
    ・
    ・ // アイテムコンポーネント
    ・
    EmbeddedMovieItem,
    ItemIndex,
    Draggable,
    ButtonBase,
  },
})
export default class ItemList extends Vue {
  //アイテムコンポーネントを返すメソッド
  componentType(contentType: string) {
    switch (contentType) {
      case 'description':
        return 'description-item'
      case 'headline':
        return 'headline-item'
      case 'item_image':
        return 'image-item'
      case 'instagram_photo':
        return 'instagram-photo-item'
      case 'shop_item':
        return 'shop-item'
      case 'product':
        return 'product-item'
      case 'quotation':
        return 'quotation-item'
      case 'link':
        return 'link-item'
      case 'video':
        return 'video-item'
      case 'embedded_movie':
        return 'embedded-movie-item'
      default:
        throw new Error()
    }
  }

  removeItems() {
    this.$store.dispatch(
      'article/openRemoveConfirmModal',
      this.index.removeSelectedItemEditId
    )
  }

  // Vue.Draggable のオプション
  get dragOptions() {
    return {
      animation: 150,
    }
  }

  get computedItems() {
    return this.items
  }

  set computedItems(items: Item<ItemContent>[]) {
    this.$store.dispatch('article/sortItems', items)
  }

  @Getter('articleItem', { namespace })
  items!: Item<ItemContent>[]

  @Getter('editingItemId', { namespace })
  editingItemId!: string

  @Getter('index', { namespace })
  index!: Index
}
</script>

アイテムコンポーネントの出し分け

アイテムコンポーネントの出し分けには動的コンポーネントを利用し、propsでアイテムのデータと、編集しているアイテムを特定するeditingItemIdを渡しています。

indexモードと並び替え

親コンポーネントのtemplateタグ内のコードを見てみると、「indexモードと並び替え」と書かれた箇所があると思います。
MERYの記事は30個から多いものだと50個以上のアイテムを組み合わせて作られるため、記事を作っていくにつれて縦長になってしまい記事の構成を確認したりするのが難しくなってきます。
新しい記事作成ツールでは、目次のように記事を一覧で確認できるindexモードを用意して、アイテムをドラッグ&ドロップで入れ替えられるようにしました。

ドラッグ&ドロップにはVue.Draggableを使用しています。
Vue.Draggableは、draggableタグで囲った上でコンポーネントをループして渡すだけで並び替えが実現でき、getとsetを使用すれば、Vuex stateのデータも扱うことが可能です。
今回はシンプルな配列でしたが、多次元配列にも対応しているので複雑なドラッグ&ドロップを実装する際にも非常に重宝できるライブラリだと感じました。

アイテムコンポーネント

それでは次にアイテムのコンポーネントを見てみましょう。
12個のタイプそれぞれのコンポーネントを用意していますが、今回はImageのアイテムコンポーネントのコードを用いながら機能を紹介していきます。

<template>
  <div
    :id="itemData.editId"
    class="image-item"
    :class="{'-active': editingItemId === itemData.editId}"
    @click="editStart()"
  >
    <div class="main">
      <div>
        ・ 
        ・ // 画像と参照元へのリンクの記述
        ・
      </div>
      <div
        class="edit-area"
        v-if="editingItemId === itemData.editId"
        @click.stop
        @keydown.13.shift.ctrl.exact="finishEditing"
        @keydown.13.shift.prevent.exact="addNextItem"
      >
        <textarea
          class="imageText"
          :value="localContentData.comment"
          @input="updateComment"
          placeholder="テキストを入力"
        ></textarea>
        <text-counter :textCount="textCount" />
        <label class="pageUrlLabel">
          参照元URL
        </label>
        <input
          :value="localContentData.image.pageUrl"
          class="pageUrl"
          @input="updatePageUrl"
          placeholder="参照元URL"
        />
      </div>
      <div v-else class="plain-area">
        <p class="imageText">{{ localContentData.comment }}</p>
      </div>
    </div>
  </div>
</template>
<script lang="ts">
export default class ImageItem extends Vue
  implements ItemEditable, ItemTextEditable {
  @Prop() itemData!: Item<ItemImage>
  @Prop() editingItemId!: string

  // deepcopy itemData
  localContentData!: ItemImage
  textCount!: number

  ・
  ・ // 共通処理のメソッドなど
  ・

  componentType(type: CollaborationLogo | CollaborationText) {
    if (collaborationHasText(type)) {
      return 'collaboration-text-component'
    }
    return 'collaboration-logo-component'
  }

  updatePageUrl(e: Event) {
    if (e.target instanceof HTMLInputElement) {
      this.localContentData.image.pageUrl = e.target.value
    }
    this.$store.dispatch('article/itemUpdate', {
      itemEditId: this.itemData.editId,
      content: this.localContentData,
    })
  }
}
</script>

プレーンモードと編集モード

アイテムコンポーネントにはプレーンモードと編集モードの2つの状態があります。

これは要素をクリックすると編集ができるようになるというよくあるものです。
すべてのアイテムにはeditIdというユニークなIDを付与しており、アイテムをクリックした際にはそのeditIdをVuex stateのeditingItemIdとして反映させ、その値によってどのアイテムが編集中かどうかの判断を行っています。

アイテムの共通処理

アイテムコンポーネントではアイテム同士で共通の処理が多いため、共通の処理をまとめたvueファイルを用意して、それをmixinさせています。
具体的には、編集開始や編集中アイテムをtabキーで移動できるようにする処理などを記述しています。

アイテムの変換

これまでの記事作成ツールでは、テキストアイテムで文章を書いた後に、その文章の横に画像を挿入したいと思っても、画像のアイテムを別で追加して、そこのコメントにテキストアイテムの内容をコピーして持ってこなくてはいけませんでした。
新しい記事作成ツールでは、基本となるテキストアイテムから他のアイテムに相互に変換できるような仕組みにすることで無駄な作業が発生しないようになっています。

実装方法としてはそれぞれのアイテムでベースとなるClassを用意しておき、
変換ボタンが押された場合は引数に既存のアイテムから引き継ぐデータと、変換後のアイテムに必要なデータを指定してnewすることで、別のタイプのアイテムに変換できるようになっています。

export class ImageItemBase {
  id?: number
  editId?: string
  contentType: string
  content: ItemImage

  constructor(comment: string, image: Image) {
    this.editId = uuid.v4()
    this.contentType = 'item_image'
    this.content = {
      comment,
      image,
    }
  }
}

おわりに

今回は、新しい記事作成ツールの機能や記事内容を構成するコンポーネントの紹介をしました。

普段のNuxt.jsの開発で利用できる内容もあれば、そうではない部分もあるかと思いますが、どこか参考になる部分があれば嬉しいです。

次回は最後となりますが、CSS設計について紹介をしていこうと思います。

comy

comy

    フロントエンドエンジニアです

    関連記事
    MERYの記事作成ツールをNuxt.js × Atomic Designで作り直した話(CSS設計編)
    トップへ戻る