DiffDaily

Deep & Concise - OSS変更の定点観測

[basecamp/lexxy] ハイライト機能の設定可能化とペースト時のスタイル正規化

basecamp/lexxy

Context

LexxyはLexicalベースのリッチテキストエディタですが、これまでハイライト(テキスト色・背景色)機能の色設定がハードコードされており、カスタマイズができませんでした。また、他のエディタからコンテンツをペーストした際に、任意のスタイルがそのまま保持されてしまう問題がありました。

#549 では、ハイライト機能を設定可能にし、ペースト時のスタイルをサニタイズ・正規化する仕組みを実装しています。これにより、アプリケーション側で許可する色のみを制御でき、ペーストされたRGB値を設定済みのCSS変数に正規化できるようになりました。

Technical Detail

Lexical Extension化による構造の整理

従来は HighlightNode という偽のLexicalノードとして実装されていましたが、今回 Lexical Extension として再実装されました。これにより、以下の3層構造に整理されています。

変更前(HighlightNode):

export class HighlightNode extends TextNode {
  static importDOM() {
    return {
      mark: () => ({
        conversion: extendTextNodeConversion("mark", applyHighlightStyle),
        priority: 1
      })
    }
  }
}

変更後(Extension):

export const HighlightExtension = defineExtension({
  dependencies: [ RichTextExtension ],
  name: "lexxy/highlight",
  config: {
    color: { buttons: [], permit: [] },
    "background-color": { buttons: [], permit: [] }
  },
  html: {
    import: {
      mark: $markConversion
    }
  },
  register(editor, config) {
    const canonicalizers = buildCanonicalizers(config)
    editor.registerCommand(TOGGLE_HIGHLIGHT_COMMAND, $toggleSelectionStyles, COMMAND_PRIORITY_NORMAL)
    editor.registerNodeTransform(TextNode, $syncHighlightWithStyle)
    editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers))
  }
})

Extension化により、html.import 設定だけでHTMLインポート時の変換を定義でき、コードが簡潔になりました。

設定可能なハイライト色

デフォルトではCSS変数 --highlight-1--highlight-9 および --highlight-bg-1--highlight-bg-9 を使用しますが、アプリケーション側でカスタマイズできます。

const presets = new Configuration({
  default: {
    highlight: {
      buttons: {
        color: range(1, 9).map(n => `var(--highlight-${n})`),
        "background-color": range(1, 9).map(n => `var(--highlight-bg-${n})`),
      },
      permit: {
        color: [],
        "background-color": []
      }
    }
  }
})

カスタマイズ例:

Lexxy.configure({
  default: {
    highlight: {
      buttons: {
        color: [ "red", "rgb(255, 0, 0)", "var(--text-color-1)" ],
        "background-color": [ "red", "rgba(0, 255, 0, 0.5)", "var(--bg-color-1)" ]
      },
      permit: {
        color: [ "pink", "blue", "var(--legacy-text-color)" ],
        "background-color": [ "light-pink", "light-blue", "var(--legacy-bg-color)" ]
      }
    }
  }
})
  • buttons: ツールバーに表示される色
  • permit: ツールバーには表示しないが、ペースト時に保持を許可する色

スタイル正規化の仕組み

他のエディタからペーストされたコンテンツは、計算済みのRGB/RGBA値を持っています。これを設定済みのCSS変数に変換するため、StyleCanonicalizer クラスが導入されました。

export class StyleCanonicalizer {
  constructor(property, allowedValues= []) {
    this._property = property
    this._allowedValues = allowedValues
    this._canonicalValues = this.#allowedValuesIdentityObject
  }

  applyCanonicalization(css) {
    const styles = { ...getStyleObjectFromCSS(css) }
    styles[this._property] = this.getCanonicalAllowedValue(styles[this._property])
    if (!styles[this._property]) {
      delete styles[this._property]
    }
    return getCSSFromStyleObject(styles)
  }

  getCanonicalAllowedValue(value) {
    return this._canonicalValues[value] ||= this.#resolveCannonicalValue(value)
  }
}

正規化処理は Node Transform として実装されており、ペースト時だけでなく、他のコードから $applyHighlight を呼び出した場合にも適用されます。

register(editor, config) {
  const canonicalizers = buildCanonicalizers(config)
  editor.registerNodeTransform(TextNode, (textNode) => $canonicalizePastedStyles(textNode, canonicalizers))
}

ペーストされたノードは hasPastedStyles という状態でマークされ、Transform処理で正規化されます。この遅延処理により、グローバル設定が利用可能になる前に html 設定を定義できるという設計上の制約を回避しています。

UIの動的初期化

ツールバーのドロップダウンボタンは、設定から動的に生成されるようになりました。

変更前:

<div data-button-group="color" data-values="var(--highlight-1); var(--highlight-2); ..."></div>

変更後:

<div data-button-group="color"></div>
<div data-button-group="background-color"></div>
#populateButtonGroup(buttonGroup) {
  const attribute = buttonGroup.dataset.buttonGroup
  const values = this.editorElement.config.get(`highlight.buttons.${attribute}`) || []
  values.forEach((value, index) => {
    buttonGroup.appendChild(this.#createButton(attribute, value, index))
  })
}

ドロップダウンが開かれたタイミングで初期化されるため、設定の読み込みと描画のタイミングが最適化されています。

テストの追加

正規化処理の動作を検証するテストが追加されました。

test "canonicalizes RGB color value matching canonical color" do
  paste_with_style "color: #{highlight_1_rgb}"
  assert_canonicalized_to "color: var(--highlight-1)"
end

test "canonicalizes RGBA background-color value matching canonical color" do
  paste_with_style "background-color: rgba(229, 223, 6, 0.3)"
  assert_canonicalized_to "background-color: var(--highlight-bg-1)"
end

Impact

この変更により、Lexxyのハイライト機能は以下の点で改善されました。

  1. カスタマイズ性: アプリケーション側でハイライト色を自由に設定可能
  2. 一貫性: ペーストされたコンテンツのスタイルを設定済みの色に正規化
  3. 拡張性: Extension構造により、将来的な機能追加が容易に
  4. パフォーマンス: DOM要素の作成をキャッシュし、Lexicalの最適化を活用

ハイライト機能を持つエディタを実装する際の参考になる、優れたアーキテクチャ設計例と言えるでしょう。