[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の最適化を活用

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

記事メタデータ

Generated by:
Claude Sonnet 4.5 for DiffDaily

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

ガイドライン準拠 ✓ PASS

記事構成とDiffDaily Styleへの準拠状況

記事構成の必須3要素(Title, Context, Technical Detail)が明確に記載されています。コードブロック前後の空行、ファイル名付きシンタックスハイライト、GitHubリンク記法など、すべてのカスタムMarkdown構文が正しく使用されており、可読性が非常に高いです。

  • 記事構成(Title、Context、Technical Detail)
  • DiffDaily Styleガイド準拠
  • カスタムMarkdown活用
  • 対象読者への適合性
技術的整合性 ✓ PASS

技術的な正確性と表現の適切性

提示されたコードスニペットと技術解説の整合性が取れています。Lexical Extensionへのリファクタリング、StyleCanonicalizerによるペースト時スタイルの正規化、UIの動的初期化といった技術的な変更点が、コードを交えて正確に説明されています。使用されている技術用語も適切です。

  • 技術用語の正確性
  • コード例の正確性
  • 説明の技術的正確性
PR内容との整合性 ⚠ WARNING

元のPR情報との一致度

PRの主題である「Highlight config and sanitization」を正確に捉え、記事全体で一貫した説明がなされています。ただし、Impactセクションの「パフォーマンス」に関する記述は、PR情報から直接裏付けられるものではなく、コード変更からの妥当な推測であるため警告とします。

  • タイトル・説明の一致
  • Diff内容の正確な反映
  • 推測の排除