BETA

nehan.js version5系による左右中央寄せ・縦書きカルーセルの実装例

投稿日:2019-12-09
最終更新:2020-02-03

何の記事?

趣味で短歌の投稿サイトをやっていて、それで実装した縦書きのカルーセルの話です。

tategakibunko/nehan.jsを使ってHTML文字列を差し込んでつくった縦書きのブロック要素をSwiperでカルーセルにしています。

なぜこんなものを作ったのでしょう。まあようするにこういうUIを作りたかったからなのですが、我ながらもっとやり方があるのではないかと思うところもあります。

なぜ nehan.js か

CSS3の縦書きはとても便利に進化しています。とくにこだわりがないならwriting-modeプロパティを使えばいいと思います。しかし、縦書きに関連するCSSプロパティの実装状況にはブラウザによってまだ足並みの揃っていない部分があり、いろいろな環境を用意しつつ実際の表示を確認するのはとても大変です。

文芸作品を扱うサイトにとってとりわけ致命的なのが縦中横の表示です。一般にtext-orientationプロパティの指定に関係なく複数の英数字や記号を1文字分に詰めて表示するにはtext-combine-uprightプロパティを個別に指定する必要があります。つまり、ふつうに考えると、縦中横にしたい部分にスタイルを当てるためにはいちいちタグで囲む必要があります。このあたりをCSSだけでどうにかするために「3桁までの数字は縦中横にする」といった指定ができるtext-combine-upright: digits 3という風な書き方が用意されていますが、これは2019年12月現在でほとんどのブラウザが壊滅的に対応していません。

自分でHTMLを書くなら縦中横にしたい部分をマークアップすればよいのですが、ユーザーが投稿したコンテンツを差し込む場合にはそうもいきません。というわけで、縦中横を適用したいときにはしかるべき文字列だけを動的にタグで囲むみたいな処理がほしくなるのですが、

縦中横の規則前提:

  • 「東アジアの文字」と「東アジア以外の文字」に分ける
  • 原則: 「東アジア以外の文字」の並びが
    • 4文字以下なら1文字ずつ縦に積む
    • 5文字以上ならそのまま(横に寝る)
  • 例外:
    • 3文字以下の数字の並びは縦中横
    • 3文字以下の「!」「?」の並びは縦中横
    • 「(」「)」「→」など縦に積むと不自然になる文字はそのまま
    • 縦組みでも正立になる文字はそのまま

補足

などとカクヨムでの縦組み表示の実装と、縦書きWebの将来に向けて (builderscon tokyo 2018) - Hatena Developer Blogにつらつらと書かれているのを見ると、これを全部自分で書くのはつらいわと思うわけです。

技術的にがんばったところ

nehan.jsの採用例が見つからない

nehan.jsはHTMLを縦書きにするためのJSライブラリのひとつです。nehanという名前のライブラリはtategakibunko/nehan.jsとして公開されているversion<=5.系からtategakibunko/nehanとして公開されているversion>6.系へと開発が移行しており、5.系は、まあいい意味に捉えるとstableなライブラリです。minifyされているのにJSファイルだけで236kbもありますが、縦中横から禁則処理までよしなにこなしてくれます。

ただ、情報がないです。JSDocこそちゃんと見つかるのですが、逆にこれとデモくらいしか情報がなかったので雰囲気で使いました。つらかったです。

v-nehanカスタムディレクティブを定義したVueコンポーネントの実装

件の短歌の投稿サイトはNuxt.js+Expressのプロダクトです。nehan.jsによる縦書きのカルーセルを実装するにあたって、まず以下のようなNehanコンポーネントを用意しています。もっとVanillaに書ける気がしますが、横着なのでjQueryとUnderscoreはすでに読み込んでいる前提です。

<template>  
    <div>  
        <client-only>  
            <div class="flex">  
                <div ref="nehan" class="pull" style="height: 90vh" role="presentation" v-nehan="display"></div>  
            </div>  
        </client-only>  
    </div>  
</template>  

<script>  
export default {  
    data () {  
        return {  
            nehan: undefined  
        };  
    },  
    props: [  
        "item"  
    ],  
    mounted () {  
        const nehan = new Nehan.Document();  
        this.nehan = nehan;  
    },  
    directives: {  
        nehan: {  
            bind (el, binding, vnode) {  
                vnode.context.$nextTick(() => {  
                    if (vnode.context.checkHTML(binding.value)) {  
                        const rows = binding.value.split("\n");  
                        const item = _.reduce(rows, (memo, row) => { return memo + vnode.context.compiled({ row: row }) }, "");  
                        vnode.context.nehan.setContent(vnode.context.wrapper({ item: item }));  
                        vnode.context.nehan.setStyle("body", {  
                            display: "inline-block",  
                            flow: "tb-rl",  
                            height: $(el).height()  
                        });  
                        vnode.context.nehan.setStyle(".serif", {  
                            "font-family": "'Noto Serif JP', 'Yu Mincho', YuMincho, 'Hiragino Mincho ProN', 'Hiragino Mincho Pro', 'HGP明朝B', sans-serif"  
                        });  
                        vnode.context.nehan.render({  
                            onPage: (page, ctx) => {  
                                $(vnode.context.$refs.nehan).html(page.element);  
                            }  
                        });  
                    } else {  
                        return null;  
                    }  
                });  
            }  
        }  
    },  
    computed: {  
        display () {  
            if (_.isUndefined(this.item)) {  
                return "";  
            } else {  
                return this.item.body;  
            }  
        },  
        wrapper () {  
            return _.template(`<div class="disp-iblock"><%= item %></div>`);  
        },  
        compiled () {  
            return  _.template(`<h6 class="serif"><%= row %></h6>`);  
        }  
    },  
    methods: {  
        checkHTML (html) {  
            const doc = document.createElement("div");  
            doc.innerHTML = html;  
            return ( doc.innerHTML === html );  
        }  
    }  
}  
</script>  

<style scoped>  
    .flex {  
        display: flex;  
        justify-content: center;  
        align-items: center;  
        min-height: 90vh;  
    }  
    .pull {  
        text-align: center;  
        -webkit-transform: translate(-49%);  
        -ms-transform: translate(-49%);  
        transform: translate(-49%);  
    }  
</style>  

肝心のnehanオブジェクトはdataのなかに持っています。描画したいHTML文字列はitemというpropsとして受け取り、computed: { display () {...} }のなかで文字列にcoerceしたうえでv-nehanというカスタムディレクティブに渡して描画します。computed: { display () {...} }という無駄っぽい何かを挟んでいるのは、これを書いた当初にitempropsを非同期に差し替えたりしていた名残です。

v-nehanディレクティブは、まあ、見たままな感じです。ユーザーが投稿した文字列は当然サーバサイドでサニタイズしていますが、vnode.context.checkHTML(binding.value)でvalidなHTML文字列かどうかを簡単にチェックしています。気持ち的な問題です。

なお、CSSが気持ち悪い点には眼をつぶっています。

Swiperでカルーセルにする

Swiperは言わずとしれたリッチなスライダーを設置できるJSライブラリです。SSR対応であり、surmon-china/vue-awesome-swiperといったVueコンポーネントも開発されています。ただ、nehan.jsは当然SSR非対応だし、vue-awesome-swiperはスライドそのものがswiperSlideというコンポーネントになっていて、これに自前のコンポーネントをネストするメリットはとくにない気がしたため、今回は生のSwiperを触っています。

slideとして登録したのが上のNehanコンポーネントです。

<template>  
<div>  
    <section>  
        <div id="swiper" class="swiper-container">  
            <div class="swiper-wrapper">  
                <div is="slide" class="swiper-slide" v-for="item in slides"  
                    :item="item"  
                    :key="item.id"  
                ></div>  
            </div>  
            <!-- <div class="swiper-pagination"></div> -->  
            <div class="swiper-button-prev"></div>  
            <div class="swiper-button-next"></div>  
            <!-- <div class="swiper-scrollbar"></div> -->  
        </div>  
    </section>  
</div>  
</template>  
<script>  
import swiper from "~/plugins/swiper.js"  
import slide from "~/components/Nehan.vue"  

export default {  
    mixins: [swiper],  
    components: {  
        "slide": slide,  
    },  
    data () {  
        return {  
            slides: [  
                { id: "id",  body: "body"}  
            ]  
        }  
    },  
    mounted () {  
        this.mySwiper(this.slides)  
    }  
</script>  

mixinは以下のような感じです。今回はslidesの中身が動的に書き換わることはないのですが、slidesを差し替えたりする場合にはそのタイミングでmySwiper(this.slides)を呼んでSwiperインスタンスを更新する必要があります(swiperInstance.destory()してからnewしていますが、ドキュメントを確認したら
swiperInstance.update()というメソッドがふつうにあったのでこれを使ったほうがよいかもしれません)。

なお、雰囲気的に$data.slidesではなくここで書いた$data.virtualData.slidesのほうをv-forするのが正しい気がしますが、カスタムディレクティブのhookがよくわからなくてv-nehanディレクティブを上手く更新できなかったので$data.slidesをv-forしています。

import Swiper from "swiper"  

export default {  
    data () {  
        return {  
            swiper: undefined,  
            virtualData: {  
                offset: undefined,  
                from: undefined,  
                to: undefined,  
                slides: []  
            }  
        };  
    },  
    methods: {  
        mySwiper (slides) {  
            if (this.swiper !== undefined) {  
                this.swiper = this.swiper.destroy();  
            }  
            this.swiper = new Swiper("#swiper", {  
                loop: false,  
                freeMode: true,  
                virtual: {  
                    slides: slides,  
                    cache: false,  
                    renderExternal: (slides) => {  
                        this.virtualData = {  
                            offset: slides.offset,  
                            from: slides.from,  
                            to: slides.to,  
                            slides: slides  
                        };  
                    },  
                    addSlidesAfter: slides.length  
                },  
                slidesPerRow: 1,  
                slidesPerView: 1,  
                spaceBetween: 0,  
                centeredSlides : true,  
                navigation: {  
                    nextEl: ".swiper-button-next",  
                    prevEl: ".swiper-button-prev",  
                    hideOnClick: true  
                },  
                keyboard: {  
                    enabled: true,  
                    onlyInViewport: false  
                },  
                on: {  
                    init: () => {  
                        console.info("Swiper instance initialized")  
                    }  
                }  
            })  
        }  
    }  
}  
技術ブログをはじめよう Qrunch(クランチ)は、プログラマの技術アプトプットに特化したブログサービスです
駆け出しエンジニアからエキスパートまで全ての方々のアウトプットを歓迎しております!
or 外部アカウントで 登録 / ログイン する
クランチについてもっと詳しく

この記事が掲載されているブログ

よく一緒に読まれる記事

0件のコメント

ブログ開設 or ログイン してコメントを送ってみよう