最新のhtml+javascript+cssにおける「真のカスタムエレメント」を考える

やりたかったこと

ここ最近,vscode上でmarkdownをひたすら使い潰すためだけに色々とやってきているのだが(以下はこれまでの記事),

soluna-eureka.hatenablog.com

soluna-eureka.hatenablog.com

soluna-eureka.hatenablog.com

これまでの研究によりmarkdown pdfextension.jsを書き換えることで任意の.html.js.cssファイルをプラグインとして利用できる環境を作れてしまうことが判明し,これにkatexを混ぜることで概ね万能な数式の表示すら可能となった.

しかし大元の発端は「latexが面倒だからmarkdownを使おう」というものであり,つまるところlatexにある機能がkatexさらにはそれが対応できるmarkdownには搭載し得ないもので,これは下位互換にすぎない不完全な環境であったのだ.
そこで私が考えついたのが,「latexに備わる組版用の各種コマンドをhtmlの機能で代替できないか?」というものである.

目標

完全に独立した名前を持ち,余計な指定をする必要がなく,かつidstyleが自在に設定できる「真のカスタムエレメント」を実現する.これによりlatexコマンドに対応するカスタムエレメントを自由に作成,柔軟な組版markdownで実現する.

方針

かつてはdocument.registerElementで好き放題にできていたらしく,その手法を扱った記事がいまだに検索にヒットするが,

developer.mozilla.org

現在では全ブラウザ非対応の憂き目に合っている(リンク先は英語ページのみ).ゆえに代替であるCustomElementRegistry

developer.mozilla.org

インターフェースが持つcustomElements.define(あっちはElementなのにこっちはElementsなの,控えめに言って最悪)

developer.mozilla.org

を使用せざるを得ないが,現在では数多くの記事においてこのページが示す「自律型」「拡張型」の2種類のみを扱っている.

しかし前者はタグの間の文字を扱えない(DOMパースがシカトされる)・後者はis=''の指定が必要(DOMパースしてくれる)ため,私の要求には満たない.他方でwhatwghtml系開発コミュニティ)によるcustom elementsのリファレンスでは,

triple-underscore.github.io

カスタム要素定義を登録するのは、 それに関連する要素が初期時に — 構文解析器などにより — 作成された後の方が,好ましいこともある。 昇格は、 そのような用法を可能化して,[ カスタム要素の内容を漸進的に増強する ]ことを許容する。

という内容もあり,つまりは「名前に-が入っているエレメントはカスタム要素として解析が後回しにされる」程度には動作の許容がある.更には「任意のhtml要素を指す」HTMLElement(任意?あっ…)が現在でも全てのブラウザで実装されている

developer.mozilla.org

ため,この2つを足がかりにして攻略していった結果,なぜだか無事に攻略に成功した.

困難

カスタムエレメントなだけあって処理順の設定が難しく,上記2点の仕様が変わらなくても他の関数の仕様が変わるだけで死ぬ可能性は否定できない.これはあくまで2021年12月現在の情報であることはお許しいただきたい(未来のことは知らない).

やり方

まずは上の3つの記事を読むことこそお勧めする(プラグイン管理が格段に楽になるので)が,実際は読まなくても大丈夫だ.

原理

以下では<div></div>由来のボックスとしての<d-box></d-box>を作成する.

class DBox2 extends HTMLDivElement {
    constructor() {
        super();
        this.id = 'box';
    }
}

customElements.define('d-box2', DBox2, { extends: 'div' });


class DBox extends HTMLElement {
    constructor() {
        super();
        let wrapper = document.createElement('div');
        wrapper.id = 'box';
        let content = this.innerHTML;
        console.log(content);
        this.innerHTML = '';
        wrapper.insertAdjacentHTML("afterbegin", content);
        this.insertAdjacentElement("beforeend", wrapper);
    }
}

document.addEventListener("DOMContentLoaded", function() {
    customElements.define('d-box', DBox);
});
body {
    color: #222;
    background-color: #fff;
}

#box {
    color: #444;
    border: 5px solid #666;
    border-radius: 10px;
    padding: 5px;
    margin: 5px;
}
# これはなんですの?

## `custom element`ですわ!

マニュアル通りの手法では
<div is='d-box2'>
sasa
</div>

わたくしが極めた手法では
<d-box>
二重動作もOKですわ!
<d-box>
$$
    \begin{align}
    e=mc^2 \\\\
    e^{i\pi}+1=0
    \end{align}
$$
</d-box>
</d-box>

f:id:Soluna_Eureka:20211210032740p:plain

何をやったか

なんの情報も持たない<d-box></d-box>の中身を丸ごと文字列で抜き取り<div id='box'></div>を埋め込んで更にそこに元の文字列を入れただけ,要するに作業としては大したことはやっていない…が,処理同期手法の試行錯誤が面倒だったことに加え,そもそも「そういうやり方しかない」という結論に至るまでに半日ほど要してしまったことがあり,少し辛かった.

ちなみに<div is='d-box2'></div>はよく紹介されているやり方である,どちらも同じ結果を得ている以上これは成功だろう.

クラス化

superのリファレンスに目を通した時に「この処理はチェーン化できるのでは」と気づいたので,実際に実装に成功してみた.

developer.mozilla.org

結果は省略するが以下にクラス処理に関する記述を載せる,例としてAncestorOfAllからフォールバックし3段組を実現する.

class AncestorOfAll extends HTMLElement {
    constructor(element, purpose) {
        super();
        let wrapper = document.createElement(element);
        wrapper.id = purpose;
        let content = this.innerHTML;
        console.log(content);
        this.innerHTML = '';
        wrapper.insertAdjacentHTML("afterbegin", content);
        this.insertAdjacentElement("beforeend", wrapper);
    }
}

class DCol3 extends AncestorOfAll {
    constructor() {
        super('div', 'col3');
    }
}

document.addEventListener("DOMContentLoaded", function() {
    customElements.define('d-col3', DCol3);
});
#col3 {
    column-count: 3;
    padding: 20px;
    margin: 10px;
}

要はsuper()super()を呼び出しているに過ぎず,あとは先程と同じように<d-col3></d-col3>で呼び出せば良いだろう.

立つ鳥跡を濁さず

カスタムエレメントを使用した痕跡を全く残さないということも可能ではあり(自分でデバッグできなくなるが),これを活用するとkatexの環境や関数をelement風に呼び出せるため非常に可読性が高まってとても良い.ただし環境と関数の定義の順番を間違えると動作しなくなったり,一部の無駄だと思える要素を省略すると動作しなくなったりするため,注意が必要である.

// define your custom math func
// これを後にすると動かない
class AncestorOfMathFunc extends HTMLElement {
    constructor(purpose) {
        super();
        let mathHead = '\\begin{' + purpose + '}';
        let mathTale = '\\end{' + purpose + '}';
        this.insertAdjacentHTML('afterbegin', mathHead);
        this.insertAdjacentHTML('beforeend', mathTale);
        let content = this.innerHTML;
        this.insertAdjacentHTML('beforebegin', content)
        this.remove();
    }
}

class PMatrix extends AncestorOfMathFunc { //丸括弧の行列表記
    constructor() {
        super('pmatrix');
    }
}
document.addEventListener('DOMContentLoaded', function() {
    customElements.define('g-pmatrix', PMatrix); //<g-pmatrix></g-pmatrix>で呼び出し
});

class Cases extends AncestorOfMathFunc { //場合分け表記
    constructor() {
        super('cases');
    }
}
document.addEventListener('DOMContentLoaded', function() {
    customElements.define('g-cases', Cases); //<g-cases></g-cases>で呼び出し
});


// define your custom math mode
// これを先にすると動かない
class AncestorOfMathMode extends HTMLElement {
    constructor(element, purpose) {
        super();
        let breaking = document.createElement('br');
        let mathHead = '\\begin{' + purpose + '}';
        let mathTale = '\\end{' + purpose + '}';
        let mathWrapper = '$$';
        this.insertAdjacentHTML('afterbegin', mathHead);
        this.insertAdjacentHTML('afterbegin', mathWrapper);
        this.insertAdjacentHTML('beforeend', mathTale);
        this.insertAdjacentHTML('beforeend', mathWrapper);
        this.insertAdjacentElement('beforeend', breaking); //これを無効化すると動かない
        let wrapper = document.createElement(element);
        wrapper.id = purpose;
        let content = this.innerHTML;
        this.innerHTML = '';
        wrapper.insertAdjacentHTML('afterbegin', content);
        this.insertAdjacentElement('beforebegin', wrapper);
        this.remove();
    }
}

class DAlign extends AncestorOfMathMode { //align環境
    constructor() {
        super('div', 'align');
    }
}
document.addEventListener('DOMContentLoaded', function() {
    customElements.define('d-align', DAlign); //<d-align></d-align>で呼び出し
});

class DAlignN extends AncestorOfMathMode { //align*環境
    constructor() {
        super('div', 'align*');
    }
}
document.addEventListener('DOMContentLoaded', function() {
    customElements.define('d-alignn', DAlignN); //<d-alignN></d-alignN>で呼び出し
});

この環境定義の動作手順は

  1. タグの中身の文字列を操作
  2. 数式環境の呼び出しに対応
  3. <div></div>を生成
  4. idを設置し書式設定が可能な状態に
  5. タグの中身をinnerHTMLと解釈
  6. 生成した<div></div>にコピペ
  7. 痕跡を消去

という流れで,関数定義の動作手順とは<div></div>を生成するかどうかに違いがある.
かつdocument.addEventListener('DOMContentLoaded', function() {}は,その定義順に実行される.
そこで何かしらkatexの解釈に不都合が事象が発生している…と考えられるだろう.

欠点

本文や数式の分量増えるほどに動作が遅くなる(DOMを操作する量が多くなるから),markdownプレビューがkatexを含め機能しなくなる(vscode上の機能が解釈できないから)…等がある.正直katexレンダリング速度に割と助けられている.

感想

恐らく初めてjavascriptのクラスのお勉強をしたし,処理の同期や非同期について考えた.template.js<head><\head>に入っているし,恐らくどこに置いても正常に動作すると考えられる(一部のリファレンスは末尾設置を推奨しているけど). ただし念のためにkatexよりも前に読み込む設定にはした方が良い,レンダリングのどこかで不都合が起きるかもしれない.

今後よりlatexライクな環境に近づけようなどと考えているが,もしこれに使い道があれば是非とも使ってもらえると嬉しい.