@ablogcms/plugins 3.2.38 → 3.2.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOOKS.md +1251 -0
- package/README.md +4 -2
- package/dist/core/index.mjs +2 -2
- package/dist/core/index.mjs.map +1 -1
- package/package.json +11 -10
package/HOOKS.md
ADDED
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
# HOOKS.md — 管理画面 フロントエンド拡張ポイント一覧
|
|
2
|
+
|
|
3
|
+
a-blog cms 管理画面(React SPA)が提供する フロントエンド側拡張ポイントの一覧。PHP 側の
|
|
4
|
+
`extension/acms/Hook.php`(`ACMS_Hook` が呼ぶ既知メソッド名の雛形)に相当する参照資料であり、
|
|
5
|
+
「今どんな拡張ポイントが存在するか」をここだけ見れば把握できることを目指す。
|
|
6
|
+
|
|
7
|
+
設計の背景・kintone / WordPress との比較・方針転換の経緯は
|
|
8
|
+
[docs/plan/admin-extensibility-api/design.md](../../../docs/plan/admin-extensibility-api/design.md)、
|
|
9
|
+
受け入れ条件は [requirements.md](../../../docs/plan/admin-extensibility-api/requirements.md) を参照。
|
|
10
|
+
本書はそれらの実装結果を「一覧」として引く用途に絞り、設計判断の理由(Why)は繰り返さない。
|
|
11
|
+
|
|
12
|
+
## この文書の置き場所について
|
|
13
|
+
|
|
14
|
+
`docs/foundation/` は基本設計が変わらない限り更新しない恒久ドキュメント、`docs/steering/` は作業単位の
|
|
15
|
+
一時ドキュメントと役割が決まっている([AGENTS.md](../../../AGENTS.md))。拡張ポイント一覧は今後の機能追加
|
|
16
|
+
のたびに項目が増減する「コードに従属する参照資料」であり、恒久でも作業単位でもないため、どちらにも
|
|
17
|
+
馴染まない。PHP 側の `Hook.php` 雛形がコア(`ablogcms/extension/acms/`)に同居し `docs/` に置かれていないのと
|
|
18
|
+
対称に、JS 側の雛形に相当する本書も `docs/` の外、拡張の「玄関」である `@ablogcms/plugins`
|
|
19
|
+
(`setup(ctx)` がイベント・レジストリ・UI 差し込みをすべて束ねるパッケージ)直下に置く。
|
|
20
|
+
新しい拡張ポイントを追加した PR は、対応する `AcmsEventMap` 宣言と合わせて本書を更新すること
|
|
21
|
+
([AGENTS.md](../../../AGENTS.md)「拡張ポイント(フック)の方針」参照)。
|
|
22
|
+
|
|
23
|
+
## 目次
|
|
24
|
+
|
|
25
|
+
- [命名規約](#命名規約)
|
|
26
|
+
- [イベント一覧(実行できる操作の対応表)](#イベント一覧実行できる操作の対応表)
|
|
27
|
+
- [登録タイミング制約](#登録タイミング制約)
|
|
28
|
+
- [失敗時の扱い(横断表)](#失敗時の扱い横断表)
|
|
29
|
+
- [Slot 一覧](#slot-一覧)
|
|
30
|
+
- [レジストリ一覧](#レジストリ一覧)
|
|
31
|
+
- [既存の購読可能イベント(DOM CustomEvent・レガシー名前空間)](#既存の購読可能イベントdom-customeventレガシー名前空間)
|
|
32
|
+
- [レガシー API の非推奨一覧と移行例](#レガシー-api-の非推奨一覧と移行例)
|
|
33
|
+
- [`ctx.config` の制約](#ctxconfig-の制約)
|
|
34
|
+
- [`ctx.ui.fill` で fillProps を受け取る(withFillProps)](#ctxuifill-で-fillprops-を受け取るwithfillprops)
|
|
35
|
+
- [プラグイン側のビルド設定(React / Tiptap の共有)](#プラグイン側のビルド設定react--tiptap-の共有)
|
|
36
|
+
- [新しい拡張ポイントを追加するときのルール](#新しい拡張ポイントを追加するときのルール)
|
|
37
|
+
- [ユースケース実装例: メディア alt の AI 生成ボタン](#ユースケース実装例-メディア-alt-の-ai-生成ボタン)
|
|
38
|
+
- [カスタムブロックの作り方](#カスタムブロックの作り方)
|
|
39
|
+
- [ブロックツールバーの拡張](#ブロックツールバーの拡張)
|
|
40
|
+
- [カスタムユニットの作り方](#カスタムユニットの作り方)
|
|
41
|
+
|
|
42
|
+
## 命名規約
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
{domain}.{action}.{phase} 例: media.upload.before / media.upload.success
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- `domain`・各セグメントは kebab-case(`media` / `unit-editor` / `dataview` 等)。`features/*` の
|
|
49
|
+
機能ディレクトリ名に揃える。この規約はイベント名・Slot 名・レジストリの登録名すべてに適用される。
|
|
50
|
+
- action が複合語になる場合も kebab-case で 1 セグメントに収める(例:
|
|
51
|
+
`unit-editor.inplace-dialog-open.after`)。セグメント数は常に 3。
|
|
52
|
+
- `phase` は次の 6 種。**ゲート判定(キャンセル・値変更・非同期待機が効くかどうか)はイベント名の
|
|
53
|
+
文字列パターンでは決まらない**。`AcmsEventMap` にそのイベントを `AcmsGateEvent<P>` /
|
|
54
|
+
`AcmsNotifyEvent<P>` のどちらで宣言したかだけで決まり、対応して `ACMS.events.gate(...)` /
|
|
55
|
+
`ACMS.events.notify(...)` のどちらで発火するかが型レベルで強制される(`.before` で終わる名前は
|
|
56
|
+
「ゲートらしい」ことを示す可読性のためのドキュメント的な取り決めであり、`unit-editor.command.before`
|
|
57
|
+
のように名前が `.before` でも通知として宣言されているイベントもある。下記表・注記参照)。
|
|
58
|
+
|
|
59
|
+
| phase | 意味 | ゲート? | 例 |
|
|
60
|
+
|---|---|---|---|
|
|
61
|
+
| `.before` | 処理の直前。キャンセル・値変更・直列 await が効く | ゲート | `media.upload.before`(実在) |
|
|
62
|
+
| `.success` | 対応する `.before` ゲートがある処理の正常完了後(API 呼び出し等、失敗しうる非同期処理) | 通知 | `media.upload.success`(実在) |
|
|
63
|
+
| `.failure` | 処理の失敗後(API エラー等) | 通知 | `validator.submit.failure`(命名例。未実装) |
|
|
64
|
+
| `.change` | 監視対象の値・選択状態が更新されたことの通知 | 通知 | `category.select.change`(実在) |
|
|
65
|
+
| `.after` | 対応する `.before` ゲートを持たない UI 操作が完了した直後の通知。例外的な失敗を除き必ず完了する(成功/失敗の分岐がそもそも無い)操作向け | 通知 | `dialog.open.after`(実在) |
|
|
66
|
+
| `.ready` | 特定の初期化が一度きり完了したことの通知。`on()` の素の再生されない購読ではなく、必ず `ACMS.readyState` 等のプロパティで発火済みかどうかを先に確認してから購読する(「起動系イベント」節参照) | 通知 | `acms.state.ready`(実在) |
|
|
67
|
+
|
|
68
|
+
`.success` と `.after` はどちらも「処理が完了した」通知だが、区別の基準は**対応する `.before`
|
|
69
|
+
ゲートの有無**(=失敗して分岐しうる処理かどうか)であって、実装上の確実性の高さではない。
|
|
70
|
+
`.change` と `.after` はどちらも成功/失敗の概念が無い点で共通するが、`.change` は「値が
|
|
71
|
+
新しくなった」ことを、`.after` は「値の話ではなく操作そのものが完了した」ことを表す
|
|
72
|
+
(迷った場合の目安: ペイロードに「新しい値」を持つなら `.change`、要素の参照や作成物のような
|
|
73
|
+
「完了の証跡」しか持たないなら `.after`)。
|
|
74
|
+
|
|
75
|
+
- ハブのイベント名に `acms.` プレフィックスは付けない(ハブ自体が名前空間)。既存の DOM CustomEvent
|
|
76
|
+
(`acms.media-updated` 等)とは名前空間が別。
|
|
77
|
+
- Slot 名は `{domain}.{場所}`(phase なし)の慣例。場所が複合する場合は 3 セグメントも可
|
|
78
|
+
(例: `media.edit-modal.footer`)。
|
|
79
|
+
|
|
80
|
+
## イベント一覧(実行できる操作の対応表)
|
|
81
|
+
|
|
82
|
+
`ACMS.events`(`@ablogcms/events`)に登録済みのイベント。「通知(`notify()`)」と「ゲート(`gate()`)」を
|
|
83
|
+
別々のメソッドで表現する。ゲートは `ctx.cancel(reason?)`(キャンセル)・ペイロードの直接書き換え
|
|
84
|
+
(値変更・waterfall)・ハンドラの Promise 待機(非同期ゲート)に対応し、通知は戻り値・`ctx` の
|
|
85
|
+
どちらも持たず購読するだけの一方通行になる(DOM の `Event.cancelable` + `preventDefault()` と同型)。
|
|
86
|
+
|
|
87
|
+
| イベント名 | 種別 | 通知 | 値変更 | キャンセル | async | ペイロード型(`AcmsEventMap`) | 発火箇所 |
|
|
88
|
+
|---|---|---|---|---|---|---|---|
|
|
89
|
+
| `media.upload.before` | ゲート (`AcmsGateEvent`) | - | △(`payload.files` の**絞り込みのみ**可。差し替え不可。下記注記参照) | ○(`ctx.cancel(reason?)` を呼ぶ) | ○(Promise を返すと待つ) | `{ files: File[] }` | `packages/frontend/media/src/components/media-uploader/media-uploader.tsx` の `uploadItems()` 入口 |
|
|
90
|
+
| `media.upload.success` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視。ハンドラ内で await すること自体は可) | `Readonly<{ items: MediaItem[] }>` | 同ファイル、`runWithConcurrency` 完了直後 |
|
|
91
|
+
| `media.update.before` | ゲート (`AcmsGateEvent`) | - | ○(`payload.formData` を直接書き換える) | ○(`ctx.cancel(reason?)` を呼ぶ) | ○(Promise を返すと待つ) | `{ item: MediaItem; formData: FormData }` | `packages/frontend/media/src/components/media-modal/media-modal.tsx` の `update()` 冒頭(`buildFormData` 後、既存のファイル変更確認 `confirm()` の後)。ユーザーが `confirm()` をキャンセルした場合はこのゲート自体が発火しない |
|
|
92
|
+
| `media.update.success` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ item: MediaItem }>` | 同ファイル、保存成功直後。既存の DOM CustomEvent `acms.media-updated` と二重発火する(新バス → レガシーの順。決定事項「レガシーとの併走方針」) |
|
|
93
|
+
| `media.update-as-new.before` | ゲート (`AcmsGateEvent`) | - | ○(`payload.formData` を直接書き換える) | ○(`ctx.cancel(reason?)` を呼ぶ) | ○(Promise を返すと待つ) | `{ item: MediaItem; formData: FormData }` | 同ファイル、`uploadAsNew()` 冒頭(`buildFormData` 後) |
|
|
94
|
+
| `media.update-as-new.success` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ item: MediaItem }>` | 同ファイル、別名保存成功直後。別名保存はレガシー DOM イベント(`acms.media-updated`)を飛ばさないため、二重発火はない |
|
|
95
|
+
| `media.delete.before` | ゲート (`AcmsGateEvent`) | - | △(`payload.mediaIds` の差し替えは後続ハンドラ・`.success` 通知には伝わるが、実際の削除対象はチェック状態から構築される FormData に基づくため絞り込みには反映されない。除外したい場合は cancel で一括拒否する) | ○(`ctx.cancel(reason?)` を呼ぶ) | ○(Promise を返すと待つ) | `{ mediaIds: string[] }`(`mediaIds` はチェックボックスが実際に描画されるアイテムのみを数える。`mode==='edit'` かつ `media_editable` が false の行はチェックボックス自体が無いため対象外) | `packages/frontend/media/src/components/media-list/media-list.tsx` の `removeImgs()` 冒頭(確認ダイアログ通過後) |
|
|
96
|
+
| `media.delete.success` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ mediaIds: string[] }>` | 同ファイル、一括削除成功直後 |
|
|
97
|
+
| `entry.trash.before` | ゲート (`AcmsGateEvent`) | - | ○(`payload.entry` を直接書き換えると、その差し替え後のエントリーが実際の削除対象になる。`entry.trash.success` の通知ペイロードにも反映される) | ○(`ctx.cancel(reason?)` を呼ぶ) | ○(Promise を返すと待つ) | `{ entry: RowData }`(`RowData` は行の型を消去した `@ablogcms/components/dataview/types` の型。実体は `EntryType`) | `ablogcms/js/src/features/entry/hooks/use-entry-admin-actions.tsx` の trash アクション、`window.confirm` 通過後 |
|
|
98
|
+
| `entry.trash.success` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ entry: RowData }>` | 同ファイル、trashEntry 成功直後 |
|
|
99
|
+
| `entry.duplicate.success` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ entry: RowData }>` | 同ファイル、duplicate アクション。確認ダイアログが無い操作のため before ゲートは設けない |
|
|
100
|
+
| `entry.bulk-status.before` | ゲート (`AcmsGateEvent`) | - | ○(`payload.formData` を直接書き換える) | ○(`ctx.cancel(reason?)` を呼ぶ) | ○(Promise を返すと待つ) | `{ formData: FormData }` | `use-entry-admin-bulk-actions.tsx` の status 一括アクション冒頭。公開/非公開切替の割り込み点として最有用 |
|
|
101
|
+
| `entry.bulk-status.success` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ formData: FormData }>` | 同ファイル、bulkChangeStatus 成功直後 |
|
|
102
|
+
| `entry.bulk-trash.success` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ formData: FormData }>` | 同ファイル、trash 一括アクション成功直後 |
|
|
103
|
+
| `entry.bulk-duplicate.success` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ formData: FormData }>` | 同ファイル、duplicate 一括アクション成功直後 |
|
|
104
|
+
| `module.bulk-status.before` | ゲート (`AcmsGateEvent`) | - | ○(`payload.formData` を直接書き換える) | ○(`ctx.cancel(reason?)` を呼ぶ) | ○(Promise を返すと待つ) | `{ formData: FormData }` | `ablogcms/js/src/features/module/hooks/use-module-admin-bulk-actions.tsx` の status 一括アクション冒頭。entry 側 `entry.bulk-status.*` と同型の横展開 |
|
|
105
|
+
| `module.bulk-status.success` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ formData: FormData }>` | 同ファイル、bulkChangeStatus 成功直後 |
|
|
106
|
+
| `preview.share.before` | ゲート (`AcmsGateEvent`) | - | ○(`payload.formData` を直接書き換える) | ○(`ctx.cancel(reason?)` を呼ぶ) | ○(Promise を返すと待つ) | `{ formData: FormData }` | `ablogcms/js/src/features/preview/hooks/use-share-preview.ts` の `createPreviewShareUrl` 実行直前。**発火元がガード付き**(`ACMS.events?.gate(...)`。後述) |
|
|
107
|
+
| `preview.share.success` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ shareUrl: string }>` | 同ファイル、共有 URL 発行成功直後。同上ガード付き |
|
|
108
|
+
| `preview.mode.change` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ enabled: boolean }>` | `use-preview-mode.ts` の enable/disable 両方。**発火元がガード付き**(`ACMS.events?.notify(...)`)。プレビュー共有ページ(`admin/preview/share.html`)は `Preview::isPreviewShareAdmin` により管理画面バンドル(`ACMS.events` を初期化する `admin.js`)が読み込まれず、公開エントリの `built-in/dispatch.js`(`ACMS.Dispatch.Preview`)経由でこのフックへ到達しうるため、`ACMS.events` が未定義の可能性がある |
|
|
109
|
+
| `timemachine.mode.change` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ enabled: boolean }>` | `timemachine-modal.tsx` の enable/disable 両方。`preview.mode.change` と同型の横展開 |
|
|
110
|
+
| `category.select.change` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ target: HTMLElement }>` | `ablogcms/js/src/features/category/mount-category-select.tsx`。DOM CustomEvent `acms.category.change` と二重発火(新バス→レガシーの順)。バス化により全インスタンスに届くため `target` でフィルタすること |
|
|
111
|
+
| `sub-category.select.change` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ target: HTMLElement }>` | `ablogcms/js/src/features/sub-category/mount-sub-category-select.tsx`。DOM CustomEvent `acms.sub-category.change` と二重発火。`target` フィルタ注意は上記と同様 |
|
|
112
|
+
| `tag.select.change` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ target: HTMLElement }>` | `ablogcms/js/src/features/tag/mount-tag-select.tsx`。DOM CustomEvent `acms.tag.change` と二重発火。`target` フィルタ注意は上記と同様 |
|
|
113
|
+
| `related-entry.select.change` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ target: HTMLElement; entries: unknown[]; type: string; title: string; moduleId: string; ctx: string; maxItem: number }>` | `ablogcms/js/src/features/related-entry/mount-related-entry.tsx`。DOM CustomEvent `acms.related-entry.change` と二重発火。`target` フィルタ注意は上記と同様 |
|
|
114
|
+
| `entry.main-image.change` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ target: HTMLElement; mediaId: number \| string \| null; thumbnail: string }>` | `packages/frontend/block-editor/src/extensions/ImageBlock/components/ImageBlockMenuSection.tsx`(BlockMenu 統合で `ImageBlockMenu.tsx` から抽出された側。旧ファイルは deprecated ラッパーで発火を持たない)。DOM CustomEvent `acms.set-main-image` と二重発火。レガシー側の `detail.media_type`(常に固定値 `'image'`。ImageBlock 専用の発火経路のため)は新バス側では意図的に省略している |
|
|
115
|
+
| `media-field.change` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ target: HTMLElement }>` | `ablogcms/js/src/dispatch/media/dispatch-media-field.tsx`(4 箇所)。DOM CustomEvent `acms.media-field.change` と二重発火(自社購読なし・ミラーのみ) |
|
|
116
|
+
| `media-unit.change` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ target: HTMLElement; mediaItems: MediaItem[] }>` | `packages/frontend/media/src/setup-media-unit.tsx`。DOM CustomEvent `acms.media-unit.change` と二重発火(自社購読なし・ミラーのみ) |
|
|
117
|
+
| `admin.delayed-contents.change` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ target: EventTarget; detail: unknown }>` | `ablogcms/js/src/admin.js` の中継ブロック(4 箇所)。レガシー `acmsAdminDelayedContents` と二重発火 |
|
|
118
|
+
| `custom-field-maker.preview.after` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ element: HTMLElement }>` | `ablogcms/js/src/admin.js` の中継ブロック(1 箇所)。発火元は npm 依存 `custom-field-maker`(v1.6.8)側のレガシー `acmsCustomFieldMakerPreview`(`window.ACMS.dispatchEvent` 経由)。購読側 4 箇所(a-table field / rich editor / lite editor field / media field の各再初期化)はこの新バスイベントに移行済み。custom-field-maker 自体が将来 `window.ACMS.events.notify(...)` を直接呼ぶようになれば中継ブロックは不要になる |
|
|
119
|
+
| `dialog.open.after` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ element: HTMLElement }>` | ダイアログ/モーダル/ドロワーの発火箇所計 8 箇所。レガシー `acmsDialogOpened` と二重発火 |
|
|
120
|
+
| `field-group.add.after` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ element: HTMLElement }>` | `ablogcms/js/dispatch.js:1145`。レガシー `acmsAddCustomFieldGroup` と二重発火。購読は `ablogcms/js/src/built-in/dispatch.js` の wysiwyg/flatpicker 初期化 2 箇所が `on(...)` 経由に移行済み |
|
|
121
|
+
| `tab.select.change` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ tab: HTMLElement }>` | `ablogcms/js/dispatch/admin.js`。レガシー `acmsAdminSelectTab` と二重発火 |
|
|
122
|
+
| `tab.show.after` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ tab: HTMLElement; panel: HTMLElement \| null }>` | `ablogcms/js/dispatch/admin.js` / `packages/frontend/unit-editor/src/hooks/use-tab.ts`。レガシー `acmsAdminShowTabPanel` と二重発火 |
|
|
123
|
+
| `tab.hide.after` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ tab: HTMLElement; panel: HTMLElement \| null }>` | 同上。レガシー `acmsAdminHideTabPanel` と二重発火 |
|
|
124
|
+
| `geo.info-add.after` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ element: HTMLElement }>` | `ablogcms/js/src/admin.js`(地図ピッカー)。レガシー `onGeoInfoAdded` と二重発火 |
|
|
125
|
+
| `lazy-load.load.after` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ element: HTMLElement }>` | `ablogcms/js/config.js` の `lazyLoadConfig.loaded`。レガシー `acmsLazyLoaded` と二重発火 |
|
|
126
|
+
| `validator.invalid.after` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ results: ValidationResult[]; element: HTMLElement }>` | `ablogcms/js/config.js` の `validatorOptions.onInvalid`。レガシー `acmsFormInvalid` と二重発火 |
|
|
127
|
+
| `validator.valid.after` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ results: ValidationResult[]; element: HTMLElement }>` | 同ファイルの `onValid`。レガシー `acmsFormValid` と二重発火 |
|
|
128
|
+
| `validator.validated.after` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ results: ValidationResult[]; element: HTMLElement }>` | 同ファイルの `onValidated`。レガシー `acmsFormValidated` と二重発火 |
|
|
129
|
+
| `validator.submit-fail.after` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ results: ValidationResult[]; form: HTMLFormElement }>` | 同ファイルの `onSubmitFailed`。レガシー `acmsValidateFailed` と二重発火 |
|
|
130
|
+
| `unit-editor.inplace-dialog-open.after` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ element: HTMLElement }>` | `ablogcms/js/dispatch/edit/_inplace.js:548`(レガシー jQuery 領域)。レガシー `acmsInplaceDialogOpen` と二重発火 |
|
|
131
|
+
| `unit-editor.create.after` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ editor: Editor; settings: UnitEditorSettings }>` | `packages/frontend/unit-editor/src/components/unit-editor/index.tsx`。レガシー `acmsUnitEditorCreate` と二重発火(テーマ側が購読する公開仕様のためレガシー側も維持) |
|
|
132
|
+
| `unit-editor.command.before` | 通知 (`AcmsNotifyEvent`)。命名は `.before` だが観測専用(下記注記参照) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ commands: string[]; editor: Editor }>` | `packages/frontend/unit-editor/src/core/command-manager.ts` はエディタ自身の内部イベント(`editor.emit('beforeTransaction'/'transaction', ...)`)のみを発火し、グローバルバスは直接呼ばない。実際の発火は、エディターを使う側の 3 箇所(`unit-editor` / `inplace-editor` コンポーネント、`use-config-editor` フック)が `onBeforeTransaction` オプション経由で `emit-command-events.ts` の `emitCommandBefore` を呼ぶ形(`unit-editor.create.after` 等、既存の `onCreate` 系オプションと同じパターン)。ユニットの追加/削除/移動/複製/更新など全コマンド共通。`commands.xxx()` / `chain().run()` は package 内 60 箇所以上から同期呼び出しされており、真にキャンセル可能にするには全呼び出し元を async 化する必要があるため、影響範囲・回帰リスクの大きさからゲート化は見送り、`AcmsEventMap` でも通知(`AcmsNotifyEvent`)として宣言している。命名の `.before` は「他のコマンド発火順序と合わせた慣習」であり、ゲート/通知の判定要因ではない(判定は `AcmsEventMap` の宣言そのもの。命名規約は可読性のためのドキュメント的な取り決めに過ぎない) |
|
|
133
|
+
| `unit-editor.command.success` | 通知 (`AcmsNotifyEvent`) | ○ | ×(型で拒否) | ×(型で拒否) | ×(戻り値は無視) | `Readonly<{ commands: string[]; editor: Editor }>` | 同上。`onTransaction` オプション経由で `emitCommandSuccess` を呼ぶ |
|
|
134
|
+
|
|
135
|
+
- ペイロード型の宣言(module augmentation)は機能ごとの `events.ts` に置く(media ドメインは
|
|
136
|
+
`packages/frontend/media/src/events.ts`、unit-editor パッケージ内で発火するものは
|
|
137
|
+
`packages/frontend/unit-editor/src/events.ts`、特定の機能パッケージを持たない管理画面コア/
|
|
138
|
+
レガシー系は `packages/frontend/lib/src/events.ts`、entry/module admin のライフサイクル操作は
|
|
139
|
+
`packages/frontend/plugins/src/events.ts` に集約)。
|
|
140
|
+
Why not(アプリ側 `ablogcms/js/src/` 配下に置かない理由): 実測で、アプリ側のファイルから
|
|
141
|
+
`declare module '@ablogcms/events'` で `AcmsEventMap` を拡張すると、他パッケージ側の
|
|
142
|
+
`AcmsEventOf<K>` 解決が `AcmsLooseEvent` に落ちるビルド不整合が発生したため(コメント記法や
|
|
143
|
+
型の複雑さとは無関係に単一の空エントリでも再現する)、この種の宣言は `packages/frontend/*/src`
|
|
144
|
+
配下に統一して置く(詳細は `packages/frontend/lib/src/events.ts` のコメント参照)。
|
|
145
|
+
`EntryType`/`ModuleType` のようなアプリ側専用の型はパッケージ側から参照できないため、
|
|
146
|
+
`packages/frontend/plugins/src/events.ts` では行の型を消去した `RowData`
|
|
147
|
+
(`@ablogcms/components/dataview/types`。`ctx.dataview.registerAction` 等と同じ型)で宣言し、
|
|
148
|
+
消費側の feature が実際の型へキャストする(`packages/frontend/plugins/src/events.ts` のコメント参照)。
|
|
149
|
+
- `AcmsGateEvent<P>` / `AcmsNotifyEvent<P>`(`packages/frontend/events/src/core/types.ts`)は
|
|
150
|
+
`{ kind: 'gate'; payload: P }` / `{ kind: 'notify'; payload: Readonly<P> }` の判別可能なユニオン型。
|
|
151
|
+
`AcmsEventMap` にどちらで宣言されているかによって、`ACMS.events.gate(type, ...)` /
|
|
152
|
+
`ACMS.events.notify(type, ...)` のうち呼べる方がコンパイル時に決まる(宣言と異なるメソッドで呼ぶと
|
|
153
|
+
型エラーになる)。ゲートハンドラは `(payload, ctx)` の2引数、通知ハンドラは `(payload)` の1引数のみを
|
|
154
|
+
受け取る(`NotifyHandler<K>` / `GateHandler<K>`)。**通知ハンドラの型に `ctx`(キャンセル手段)は
|
|
155
|
+
そもそも存在しない**ため、通知のつもりで登録したハンドラが誤ってキャンセルしてしまう事故が型レベルで
|
|
156
|
+
起きない。
|
|
157
|
+
- 未宣言のイベント名(プラグイン独自イベント)は `Record<string, unknown>` 相当の緩い型
|
|
158
|
+
(`AcmsLooseEvent`)で使え、コンパイルエラーにはならない。`gate()`/`notify()` のどちらでも呼べる
|
|
159
|
+
(種別不明のため相互運用性を優先)。
|
|
160
|
+
- **キャンセルは `ctx.cancel(reason?)` を呼んだ瞬間に効果を持つ**(DOM の `event.preventDefault()` と
|
|
161
|
+
同じ発想。戻り値でキャンセルを表現する旧方式は廃止した。`return false;` は jQuery のイベントハンドラで
|
|
162
|
+
「デフォルト動作の抑止」を意味する定番のイディオムであり、既存コードは jQuery と併走しているため、
|
|
163
|
+
戻り値ベースのキャンセルは「他の目的で書いた `return false;` が意図せずゲートをキャンセルさせる」
|
|
164
|
+
事故と隣り合わせだった。`ctx.cancel(reason?)` は明示的なメソッド呼び出しのため、`return` を挟まず
|
|
165
|
+
呼んだ時点で確定する):
|
|
166
|
+
```ts
|
|
167
|
+
ACMS.events.on('media.upload.before', async (payload, ctx) => {
|
|
168
|
+
if (!(await isAllowed(payload.files))) {
|
|
169
|
+
ctx.cancel('この操作は許可されていません'); // 理由ありキャンセル。return は不要
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (payload.files.length === 0) {
|
|
173
|
+
ctx.cancel(); // 理由なしキャンセル
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
payload.files = payload.files.filter((f) => f.size < LIMIT); // 値変更(waterfall。フィールドを直接書き換える)
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
- `gate()` の戻り値 `GateResult<P>` は `reason?: string`(ビジネスキャンセルの理由。`ctx.cancel(reason)`
|
|
180
|
+
経由でのみ入る)と `error?: unknown`(ハンドラが throw / reject した場合の生例外。異常中断)を
|
|
181
|
+
分離して持つ。
|
|
182
|
+
```ts
|
|
183
|
+
const result = await ACMS.events.gate('media.upload.before', { files });
|
|
184
|
+
if (result.canceled) {
|
|
185
|
+
if (result.reason) notify.error(result.reason); // ビジネスキャンセル → ユーザーに理由を表示
|
|
186
|
+
// result.error があれば異常中断(console にはバス側が記録済み)
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// result.payload には waterfall 後の最終ペイロードが入る
|
|
190
|
+
```
|
|
191
|
+
- **`media.upload.before` は絞り込み専用(除外のみ可・差し替え不可)**: ハンドラが差し替えた `payload.files`
|
|
192
|
+
は `Set<File>` の参照一致でのみフィルタされるため、ハンドラが新しい `File` インスタンスを生成して
|
|
193
|
+
差し替えても反映されない。既存ファイルを間引く用途にのみ使うこと。
|
|
194
|
+
- パイプライン仕様(`packages/frontend/events/src/core/create-event-bus.ts` に実装・テスト済み):
|
|
195
|
+
- ハンドラは**登録順に逐次実行**(`for...of` + `await`)。並列実行はしない。
|
|
196
|
+
- ゲートはペイロードを共有参照として全ハンドラに渡す。ハンドラがフィールドを直接書き換えると
|
|
197
|
+
次のハンドラ・最終的な `result.payload` にその変更が反映される(waterfall)。
|
|
198
|
+
- `ctx.cancel(reason?)` の呼び出し・throw / reject のいずれかで**残りのハンドラをスキップ**し、
|
|
199
|
+
`{ canceled: true, reason?, error?, payload }` を返す。throw/reject は `console.error` にハンドラ名
|
|
200
|
+
付きで記録したうえでキャンセル扱いにする(`error` に生例外が入る)。
|
|
201
|
+
- 通知イベントはハンドラの戻り値を無視する(そもそも `ctx` を受け取らないためキャンセル自体ができない)。
|
|
202
|
+
ハンドラの例外はそのハンドラだけを分離してログし、他のハンドラ・コアの動作は継続する。
|
|
203
|
+
- `priority` や `namespace` の概念はない。実行順は登録順のみ(PHP Hook・Vite/Rollup と同じ設計)。
|
|
204
|
+
- コア側の発火は `await ACMS.events.gate(type, payload)` / `await ACMS.events.notify(type, payload)`。
|
|
205
|
+
プラグイン側からの呼び出しはコア専用 API という位置づけだが、独自イベントを自分で発火すること自体は
|
|
206
|
+
禁止しない。
|
|
207
|
+
- 開発モード(`NODE_ENV !== 'production'`)限定の警告 2 種:
|
|
208
|
+
- 一度でも発火済みのイベント名への遅延購読 → `console.warn`(eventPool のような後追い再生はしない
|
|
209
|
+
仕様の裏返し。過去の発火は再生されず、以後の発火にのみ反応する)。
|
|
210
|
+
- ゲートハンドラの所要時間が閾値(既定 3000ms)を超えた場合 → `console.warn`
|
|
211
|
+
(強制打ち切りはしない。VS Code の `waitUntil` バジェットのような硬い制約は未導入)。
|
|
212
|
+
|
|
213
|
+
## 登録タイミング制約
|
|
214
|
+
|
|
215
|
+
- ハンドラ登録・レジストリ登録・`ctx.ui.fill` はすべて**スクリプト読み込み時にトップレベルで同期的に**
|
|
216
|
+
行うこと。`DOMContentLoaded` 待ちや非同期コールバック内での登録は不可(kintone と同じ運用ルール)。
|
|
217
|
+
- プラグイン JS は **`<script type="module">`(または `defer`)で読み込むこと**。HTML 仕様上 DOM 解析完了後・
|
|
218
|
+
`DOMContentLoaded` 前に出現順で同期実行されるため、「プラグインの登録 → core の消費(`notify`/`gate` /
|
|
219
|
+
レジストリ読み出し)」の順序が構造的に保証される。
|
|
220
|
+
- **`InjectTemplate` の登録順 = `<script>` の出力順 = ハンドラ登録順**。複数プラグインの実行順を
|
|
221
|
+
`InjectTemplate::add()` の呼び出し順(= ServiceProvider の初期化順)で制御できる(kintone がプラグイン間
|
|
222
|
+
順序を保証していない弱点を仕様で塞ぐ)。
|
|
223
|
+
- `ACMS.events` / `ACMS.plugins` は `admin.js` の初期化時に生成される(型定義は `@ablogcms/events/acms` /
|
|
224
|
+
`@ablogcms/plugins/acms` が `@ablogcms/types/acms` の `Acms` へ追記する `events` / `plugins`
|
|
225
|
+
プロパティ参照。registry パターンの詳細は [PACKAGING.md](../PACKAGING.md) 参照)。これより前に
|
|
226
|
+
読み込まれたスクリプトからは `window.ACMS.events` 等が undefined になる。
|
|
227
|
+
|
|
228
|
+
## 失敗時の扱い(横断表)
|
|
229
|
+
|
|
230
|
+
拡張点ごとに「登録・宣言が不正だったとき」「実行時に例外が出たとき」の扱いが異なる。個々の判断には
|
|
231
|
+
理由があるが、プラグイン作者が 1 箇所で見比べられるようここにまとめる。
|
|
232
|
+
|
|
233
|
+
| 場面 | 挙動 | 実装 |
|
|
234
|
+
|---|---|---|
|
|
235
|
+
| レジストリへ同じ `name` を二重登録(`registerUnitDefinition` / `registerColumnType` / `registerBlockDefinition` / `registerExtension` / dataview 系) | **例外を投げる**(`[@ablogcms/plugins] {label} "{name}" は既に登録されています。`)。警告に留めない strict 方針 | `packages/frontend/plugins/src/core/create-registry.ts` |
|
|
236
|
+
| `registerBlockDefinition` / `registerExtension` のファクトリが例外を投げた | **そのエントリだけスキップ**し `console.error`。他のプラグイン・エディター本体は動き続ける | `ablogcms/js/src/features/block-editor/block-definitions.ts` |
|
|
237
|
+
| 拡張内 `blockMenu` 宣言が不正(配列でない・データ型/コンポーネント型のどちらの形にも一致しない・`scope` が未知) | **その項目だけ無視**し `console.warn`(拡張名付き)。例外は投げない | `packages/frontend/block-editor/src/components/menus/BlockMenu/collectBlockMenuItems.ts` |
|
|
238
|
+
| 同じ Slot に同じ id の Fill を再登録(`ctx.ui.fill` は プラグイン名 + 連番で id を採番) | **同じ位置で差し替え**(upsert)。エラーにはしない | `packages/frontend/components/src/slot-fill/slot-fill-store.ts` |
|
|
239
|
+
| ゲートハンドラが throw / reject | そのゲートは**キャンセル扱い**(`result.error` に生例外)。バス側が `console` に記録する | `packages/frontend/events/src/core/create-event-bus.ts` |
|
|
240
|
+
| 通知ハンドラが throw | **そのハンドラだけ分離**してログし、他のハンドラ・コアの動作は継続 | 同上 |
|
|
241
|
+
|
|
242
|
+
「重複登録は例外/宣言の不正は警告」という粒度の違いは意図的なもので、前者は**起動時に必ず気づける
|
|
243
|
+
設定ミス**、後者は**1 項目を落としてもエディターを使い続けられる実行時データ**という区別による。
|
|
244
|
+
|
|
245
|
+
## Slot 一覧
|
|
246
|
+
|
|
247
|
+
`Slot`/`Fill`(`@ablogcms/components/slot-fill`)は UI 差し込み位置の名前付きレジストリ。既存の
|
|
248
|
+
`@components/slot`(Radix 由来の `asChild` 合成パターン、単一要素へのプロパティ委譲)とは無関係の別物なので
|
|
249
|
+
import パスを混同しないこと(`@components/slot` vs `@ablogcms/components/slot-fill`)。
|
|
250
|
+
|
|
251
|
+
| Slot 名 | 場所 | `fillProps` の型 | 備考 |
|
|
252
|
+
|---|---|---|---|
|
|
253
|
+
| `unit-editor.unit-toolbar` | `packages/frontend/unit-editor/src/components/unit-toolbar/presets/common-toolbar.tsx`(`CommonUnitToolbar`) | `UnitToolbarSlotFillProps`(`{ editor: Editor; unit: UnitTreeNode }`。同ファイルでエクスポート) | 全ユニット共通ツールバーへの独自ボタン/メニュー注入口。`unit-editor` / `inplace-editor` / `config-editor` の 3 つの独立 React ルート(`ablogcms/js/src/features/unit-editor/mount-unit-{editor,inplace-editor,config-editor}.tsx`)がそれぞれ `SlotFillProvider`(グローバル Fill ブリッジ経由)でラップしている |
|
|
254
|
+
| `block-editor.menus` | `packages/frontend/block-editor/src/components/BlockEditor/BlockEditor.tsx`(`BlockEditor`) | `BlockEditorMenusSlotFillProps`(`{ editor: Editor; appendTo: RefObject<HTMLElement \| null> }`。同ファイルでエクスポート) | エディター直下に並ぶ既定メニュー(`ContentItemMenu` / `LinkMenu` / `BlockMenu` / `TableRowMenu` / `TableColumnMenu` の 5 つ)の直後に置かれた追加口。既定メニューの差し替え・削除には対応しない(追加のみ)。`ablogcms/js/src/features/block-editor/mount-block-editor.tsx`(フィールド用スタンドアロン)と unit-editor 経由の 3 ルート(`BlockEditorExtensionsProvider` 配線済み)の両方で有効。スタンドアロン経路は `SlotFillProvider`(グローバル Fill ブリッジ経由)でラップ済み |
|
|
255
|
+
| `block-editor.block-menu` | `packages/frontend/block-editor/src/components/menus/BlockMenu/BlockMenu.tsx`(`BlockMenu`)、共通区画(`BlockCommonSection` の直後) | `BlockMenuSlotFillProps`(`{ editor: Editor; variant: BlockMenuVariant }`。同ファイルでエクスポート) | BlockMenu(統合ブロックツールバー)の共通区画への UI 差し込み口。ブロック種別(`variant`)を問わず常に描画される。UI だけを差し込みたい場合の低レベル経路で、ノード・コマンドを伴う拡張を作るなら「拡張内 `blockMenu` 宣言」(レジストリ一覧の `ctx.blockEditor.registerExtension` 参照)の方がコロケーションの観点で自然。使い分けの詳細は「ブロックツールバーの拡張」節参照 |
|
|
256
|
+
| `block-editor.block-menu.text` | 同上、テキスト系ブロックの種別別区画の末尾(`TextFormatSection` と拡張の `blockMenu` 宣言項目の後) | `BlockMenuSlotFillProps`(`block-editor.block-menu` と同じ型。`variant` は常に `'text'`) | テキスト系ブロック(段落・見出し・引用等)がアクティブなときだけ描画される種別別区画 |
|
|
257
|
+
| `block-editor.block-menu.image` | 同上、画像ブロックの種別別区画の末尾(`ImageBlockMenuSection` と拡張の `blockMenu` 宣言項目の後) | `BlockMenuSlotFillProps`(`variant` は常に `'image'`) | 画像ブロックがアクティブなときだけ描画される種別別区画 |
|
|
258
|
+
| `block-editor.block-menu.file` | 同上、ファイルブロックの種別別区画の末尾(`FileBlockMenuSection` と拡張の `blockMenu` 宣言項目の後) | `BlockMenuSlotFillProps`(`variant` は常に `'file'`) | ファイルブロックがアクティブなときだけ描画される種別別区画 |
|
|
259
|
+
| `block-editor.block-menu.linkButton` | 同上、リンクボタンブロックの種別別区画の末尾(`LinkButtonMenuSection` と拡張の `blockMenu` 宣言項目の後) | `BlockMenuSlotFillProps`(`variant` は常に `'linkButton'`) | リンクボタンブロックがアクティブなときだけ描画される種別別区画 |
|
|
260
|
+
| `block-editor.block-menu.table` | 同上、テーブルの種別別区画の末尾(`TableMenuSection` と拡張の `blockMenu` 宣言項目の後) | `BlockMenuSlotFillProps`(`variant` は常に `'table'`) | テーブル(セル選択)がアクティブなときだけ描画される種別別区画 |
|
|
261
|
+
| `media.edit-modal.field.alt` | `packages/frontend/media/src/components/media-modal/media-modal.tsx`(`MediaModal`)、alt textarea 直後 | `MediaEditModalAltFillProps`(`{ item: MediaItem; previewSrc: string; getAlt(): string; setAlt(v: string): void }`。同ファイルでエクスポート) | メディア詳細モーダルの alt 欄への独自ボタン注入口(AI alt 生成等)。`setAlt` は `AutoResizeTextarea` の `setValue`(imperative handle)経由で内部 state・DOM 値・自動リサイズをまとめて更新する。`ACMS.Config.mediaShowAltAndCaptionOnModal` が off のとき alt 行ごと非表示になり、この Slot も一緒に消える仕様(alt 生成という文脈から離れる置き場所を避けるため行外には置かない)。MediaModal は共有コンポーネントのため、メディア管理・カスタムフィールド・ブロックエディター画像/ファイル・リッチエディター・バナー・ナビゲーション・ユニットの 8 経路すべてに同時に効く |
|
|
262
|
+
| `media.edit-modal.fields` | 同上、編集タブの `</Table>` 直後(`<TableBody>` の外。table の子要素制約により行内には置けない) | `MediaEditModalFieldsFillProps`(`{ item: MediaItem }`。同ファイルでエクスポート) | 独自メタパネルの受け皿。config 非依存で常時表示 |
|
|
263
|
+
| `media.edit-modal.footer` | 同上、保存ボタン後・`</HStack>` 直前 | `MediaEditModalFooterFillProps`(`{ item: MediaItem; getFormData(): FormData }`。同ファイルでエクスポート) | `getFormData()` は `buildFormData('ACMS_POST_Media_Update')` と同じ FormData を組み立てて返す(保存直前の内容を読み取れる)。`item` が null になりうる箇所のため `{item && <Slot ... />}` でガードしている |
|
|
264
|
+
| `media.list.bulk-actions` | `packages/frontend/media/src/components/media-list/media-list.tsx`(`MediaList`)、削除・タグ付与の両分岐の外(常時表示) | `MediaListBulkActionsFillProps`(`{ selectedItems: MediaItem[] }`。同ファイルでエクスポート) | メディア一覧の一括操作エリアへの独自ボタン注入口(一覧は元々拡張口ゼロだった)。`editMode` の値に関わらず常に描画される。`selectedItems` はチェックボックスが実際に描画されるアイテムのみ(`getSelectedItems()`、`media.delete.before` と同じ絞り込み) |
|
|
265
|
+
| `preview.actions` | `ablogcms/js/src/features/preview/components/preview-actions/preview-actions.tsx`(`PreviewActions`)、既存ボタンの後(capture/share のいずれかが有効なときのみ描画) | fillProps なし | プレビューモーダル(`preview-modal.tsx`)とインラインプレビュー(`inline-preview.tsx`)の両方から共有される単一コンポーネントのため、ここに置くだけで両経路に効く。両 dispatcher(`dispatch-preview-mode.tsx` / `dispatch-inline-preview.tsx`)を `renderWithProviders` に切り替えて Slot を有効化した |
|
|
266
|
+
| `unit-editor.top` | `packages/frontend/unit-editor/src/components/unit-editor/index.tsx`(`UnitEditor`)、`EditorContent` 直前 | `UnitEditorSlotFillProps`(`{ editor: Editor }`。同ファイルでエクスポート) | AI 補助バー・テンプレ挿入等の受け皿。`unit-editor` / `inplace-editor` / `config-editor` の 3 ルートは既に `SlotFillProvider` でラップ済み(`unit-editor.unit-toolbar` と同じ配線) |
|
|
267
|
+
| `unit-editor.bottom` | 同上、`EditorContent` 直後 | `UnitEditorSlotFillProps`(`unit-editor.top` と同じ型) | 同上 |
|
|
268
|
+
|
|
269
|
+
- 同名 Slot に複数の Fill が登録された場合、すべて**登録順に描画**される。
|
|
270
|
+
- `Fill` の `children` は通常の `ReactNode`、または `(fillProps) => ReactNode` の render-prop 形式の
|
|
271
|
+
どちらも渡せる(`FillRenderer<T>`)。`<Fill>` を JSX で直接書く場合はどちらの形式も渡せる。
|
|
272
|
+
`ctx.ui.fill()` 経由の登録から render-prop 形式(fillProps を受け取る形)を使いたい場合は
|
|
273
|
+
`withFillProps` でラップする(下記「`ctx.ui.fill` で fillProps を受け取る(withFillProps)」参照)。
|
|
274
|
+
- DOM コールバック Fill(`(el: HTMLElement) => void | (() => void)`)は `DomFillMount`
|
|
275
|
+
(`@ablogcms/components/slot-fill`)が空の `div` を描画し ref で `el` を払い出すことで実現している。
|
|
276
|
+
`el` の中身は React の reconcile 対象にならない(JSX 上 `children` を持たないため、命令的に追加した
|
|
277
|
+
DOM を再レンダーで消したり書き換えたりしない)。戻り値(クリーンアップ関数)はアンマウント時に呼ばれる。
|
|
278
|
+
- React コンポーネント(props なし)か `DomFill` かの判別は実行時の**仮引数の個数**
|
|
279
|
+
(`Function.prototype.length`)で行う(`packages/frontend/plugins/src/core/resolve-fill-entry.ts`):
|
|
280
|
+
0 個 = 引数なしの React コンポーネント、1 個以上 = `DomFill`。**props を要求する関数コンポーネントを
|
|
281
|
+
bare 参照のまま渡すことは想定しない**(仮引数 1 個になり `DomFill` 側に誤判定される)。fillProps を
|
|
282
|
+
受け取りたい場合は bare 参照のまま渡さず `withFillProps` でラップすること(マーカー付きオブジェクトに
|
|
283
|
+
なるため、この arity 判別より前で見分けがつき衝突しない)。
|
|
284
|
+
|
|
285
|
+
### React Fill の例(props なし)
|
|
286
|
+
|
|
287
|
+
```tsx
|
|
288
|
+
ctx.ui.fill('unit-editor.unit-toolbar', function MyToolbarButton() {
|
|
289
|
+
return <button type="button">My Button</button>;
|
|
290
|
+
});
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### React Fill の例(withFillProps で Slot の fillProps を受け取る)
|
|
294
|
+
|
|
295
|
+
```tsx
|
|
296
|
+
import { withFillProps } from '@ablogcms/plugins';
|
|
297
|
+
import type { UnitToolbarSlotFillProps } from '@ablogcms/unit-editor/components/unit-toolbar/presets/common-toolbar';
|
|
298
|
+
|
|
299
|
+
ctx.ui.fill(
|
|
300
|
+
'unit-editor.unit-toolbar',
|
|
301
|
+
withFillProps<UnitToolbarSlotFillProps>(({ editor, unit }) => (
|
|
302
|
+
<button type="button">{unit.type}</button>
|
|
303
|
+
))
|
|
304
|
+
);
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### DOM コールバック Fill の例
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
ctx.ui.fill('unit-editor.unit-toolbar', (el) => {
|
|
311
|
+
const badge = document.createElement('span');
|
|
312
|
+
badge.textContent = 'Sample(DOM)';
|
|
313
|
+
el.append(badge);
|
|
314
|
+
return () => badge.remove(); // アンマウント時のクリーンアップ
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
## レジストリ一覧
|
|
319
|
+
|
|
320
|
+
「種類の登録」はイベント(収集パターン)ではなく列挙可能なレジストリで提供する
|
|
321
|
+
(`packages/frontend/plugins/src/core/create-registry.ts`)。
|
|
322
|
+
|
|
323
|
+
| レジストリ | API | 実体 | コアの組み込み定義とのマージ |
|
|
324
|
+
|---|---|---|---|
|
|
325
|
+
| ユニットタイプ | `ctx.unitEditor.registerUnitDefinition(name, def: UnitDefInterface)` | `createRegistry<UnitDefInterface>('unit-definition')` | `ablogcms/js/src/features/unit-editor/unit-definitions.ts` が `mergeRegistryEntries(coreUnitDefs, getUnitDefinitionRegistry())` でマージ(`{ 'rich-editor': RichEditorUnitDef, custom: CustomUnitDef, ...registry.getAll() }`。`rich-editor`/`custom` は本体固有の実装に依存するため `@ablogcms/unit-editor` のコアには置かず本体側から注入する。キー衝突時はレジストリ側が勝つ) |
|
|
326
|
+
| dataview カラム型 | `ctx.dataview.registerColumnType(name, type: ColumnTypeDef)` | `createRegistry<ColumnTypeDef>('column-type')` | `features/entry/utils/index.ts` / `features/module/utils/index.ts` が `{ ...ENTRY_COLUMN_TYPES, ...getColumnTypeRegistry().getAll() }` の形でマージ |
|
|
327
|
+
| dataview 行アクション | `ctx.dataview.registerAction(view, name, def: Action<RowData>)`(`view` は `'entry' \| 'module'`。将来 `'user'`/`'category'` 等へ拡張可能な文字列 union) | `createViewedRegistry<Action<RowData>>('dataview-action')`(`view` ごとに独立した名前空間を持つ。同じ `name` でも `view` が違えば衝突しない) | `use-entry-admin-actions.tsx` / `use-module-admin-actions.tsx` が `[...coreActions, ...Object.values(getDataviewActionRegistry().getAll(view))]` をコア定義 → レジストリの順で連結し、その結果をレガシー `getActions`(`ACMS.Config.entryAdminConfig.getActions` 等)へそのまま渡す(コア → レジストリ → レガシー getter の3段マージ。既存 getter 利用者は要素数が増えるだけで挙動は変わらない) |
|
|
328
|
+
| dataview 一括アクション | `ctx.dataview.registerBulkAction(view, name, def: BulkAction<RowData>)` | `createViewedRegistry<BulkAction<RowData>>('dataview-bulk-action')` | `use-entry-admin-bulk-actions.tsx` / `use-module-admin-bulk-actions.tsx` が上記と同じ3段マージ(`getBulkActions` へ連結後の配列を渡す) |
|
|
329
|
+
| dataview メニュー | `ctx.dataview.registerMenu(view, name, def: Menu<RowData>)` | `createViewedRegistry<Menu<RowData>>('dataview-menu')` | `use-entry-admin-menus.tsx` / `use-module-admin-menus.tsx` が上記と同じ3段マージ(`getMenus` へ連結後の配列を渡す) |
|
|
330
|
+
| 翻訳(i18n) | `ctx.i18n.addResources(lng, resources)` | `index.js` が生成する `ACMS.i18nResources`(`@ablogcms/i18n/core` の受け皿) | 名前空間はプラグイン名に固定され、参照は `ACMS.i18n('my-plugin:key')` または `import { t } from '@ablogcms/i18n'` の `t('my-plugin:key')`(i18next 標準の `ns:key` 記法)。同じ言語へ何度でも追加でき既存とマージされる。`setup(ctx)` の時点では i18next 未初期化のことがあるため、登録は受け皿に溜まり初期化側がまとめて取り込む。`unregister` 時に名前空間ごと破棄される |
|
|
331
|
+
| キーボードショートカット一覧 | `ctx.keyboardShortcuts.registerCategory(id, category)` / `ctx.keyboardShortcuts.registerShortcut(id, shortcut)` | 管理画面コアが所有する `@ablogcms/keyboard-shortcut` のレジストリ | `dispatch-keyboard-shortcut-modal.tsx` が開くモーダルでカテゴリー別に表示。プラグイン名で内部的に名前空間を分離するため、`shortcut.categoryId` には同じプラグイン内で登録したカテゴリーの `id` を指定する。`unregister` 時にはカテゴリー・ショートカットとも自動解除される |
|
|
332
|
+
| ブロックタイプ(BLOCK_DEFINITIONS) | `ctx.blockEditor.registerBlockDefinition(name, factory: (tiptap: Tiptap) => BlockDefinition)` | `createRegistry<BlockDefinitionFactory>('block-definition')` | `ablogcms/js/src/features/block-editor/block-definitions.ts` の `getBlockDefinitions(tiptap)` がファクトリを tiptap で評価し、`@ablogcms/block-editor` の `normalizeSettings(raw, blockDefinitions)` / `resolveCommands(commands, recordUsage, blockDefinitions)` が `{ ...BLOCK_DEFINITIONS, ...blockDefinitions }` でマージ(キー衝突時はレジストリ側が勝つ) |
|
|
333
|
+
| Tiptap 拡張(スキーマ層) | `ctx.blockEditor.registerExtension(name, factory: (tiptap: Tiptap) => AnyExtension \| AnyExtension[])` | `createRegistry<BlockExtensionFactory>('extension')` | 同ファイルの `getBlockExtensions(tiptap)` がファクトリを tiptap で評価しフラット化したものを、`useBlockEditor` の `extensions` prop 経由で `ExtensionKit` の後段に追加登録 |
|
|
334
|
+
|
|
335
|
+
- `add(name, item)` は **`name` が既に登録済みなら throw** する(`[@ablogcms/plugins] {label} "{name}" は
|
|
336
|
+
既に登録されています。`)。警告に留めず例外にする点が WordPress(console warning のみ)と異なる、
|
|
337
|
+
a-blog cms の strict 方針。
|
|
338
|
+
- `getAll()` で登録済み項目を列挙できる。名前・登録内容のデバッグは `ACMS.plugins.getAll()`
|
|
339
|
+
(どのプラグインが何を登録したか。`RegisteredPlugin.registrations`)でも追える。
|
|
340
|
+
- キーボードショートカットは、管理画面コアが所有するレジストリへ本番 bootstrap(`admin.js`)で明示的に
|
|
341
|
+
注入する。外部プラグインは `@ablogcms/keyboard-shortcut` を直接 import せず、`setup(ctx)` の
|
|
342
|
+
`ctx.keyboardShortcuts` を使うことで別バンドルでもコアと同じレジストリを共有できる。
|
|
343
|
+
- 実体(`Registry<T>` インスタンス)はページ全体で 1 つのシングルトンに固定される
|
|
344
|
+
(`packages/frontend/plugins/src/core/global-singletons.ts` の `getUnitDefinitionRegistry()` /
|
|
345
|
+
`getColumnTypeRegistry()` / `getBlockDefinitionRegistry()` / `getBlockExtensionRegistry()`)。
|
|
346
|
+
`createPluginRegistry()` 自体は依存注入可能な素の工場関数のままで、本番の結線(`admin.js`)だけが
|
|
347
|
+
このシングルトンを明示的に注入する。
|
|
348
|
+
- **ブロックタイプ / Tiptap 拡張だけは「値そのもの」ではなく「ファクトリ」を登録する**点が他の
|
|
349
|
+
レジストリと異なる。ブロックエディターは `webpackChunkName: "block-editor"` の lazy チャンクであ
|
|
350
|
+
り、プラグイン登録時点(`<script type="module">` 読み込み時)ではまだチャンクがロードされておらず
|
|
351
|
+
Tiptap の値を要求できないため。ファクトリ(`(tiptap: Tiptap) => ...`)は、実際にブロック
|
|
352
|
+
エディターのチャンクがロードされた時点(`ablogcms/js/src/features/block-editor/block-definitions.ts`
|
|
353
|
+
の `getBlockDefinitions`/`getBlockExtensions` 経由)で呼ばれる。ファクトリが throw した場合は
|
|
354
|
+
そのエントリだけスキップし `console.error` でログする(1 プラグインの不具合でエディター全体を
|
|
355
|
+
止めない)。`Tiptap` 型・`tiptap` 値は `@ablogcms/block-editor/tiptap`
|
|
356
|
+
を参照(詳細は [PACKAGING.md](../PACKAGING.md)「Tiptap / ProseMirror の再エクスポート」節)。
|
|
357
|
+
- **ファクトリは Promise を返してもよい**(解決側が `await` する)。公式 Tiptap 拡張
|
|
358
|
+
(`@tiptap/extension-*`)は `export const X = Node.create({...})` のように**モジュール評価時**に
|
|
359
|
+
Tiptap のクラスを使うため、プラグイン読み込み時点(チャンク未ロード)に評価されると成立しない。
|
|
360
|
+
ファクトリの中で `await import('@tiptap/extension-details')` と書けば、評価自体をチャンクロード後
|
|
361
|
+
(=ランタイムが揃った後)まで遅らせられる。**登録そのものは従来どおり `setup()` 内で同期的に
|
|
362
|
+
完了する**ため、「登録 → コアの消費」の順序保証は変わらない。reject した場合の扱いは throw と同じ
|
|
363
|
+
(そのエントリだけスキップ)。
|
|
364
|
+
- ファクトリを呼ぶ直前に、ホストの Tiptap ランタイムが `ACMS.Library.tiptap` へ公開される
|
|
365
|
+
(React が `ACMS.Library.React` で共有されているのと同じ置き場)。プラグイン側のバンドラ設定が
|
|
366
|
+
externals の宛先として使う([プラグイン側のビルド設定](#プラグイン側のビルド設定react--tiptap-の共有)参照)。
|
|
367
|
+
**チャンクロード前は `undefined`** で、ファクトリの中でだけ存在が保証される。
|
|
368
|
+
- カスタムブロックの作り方(`registerBlockDefinition` + `registerExtension` + 編集設定への type
|
|
369
|
+
追加の 3 点セット)は「新しい拡張ポイントを追加するときのルール」の後にサンプル付きで別掲する
|
|
370
|
+
([カスタムブロックの作り方](#カスタムブロックの作り方)参照)。
|
|
371
|
+
- **`registerExtension` で登録した Tiptap 拡張は、`addOptions()` 内で `blockMenu` プロパティを宣言する
|
|
372
|
+
ことで BlockMenu(統合ブロックツールバー)にツールバー項目を追加できる**。これは新しいレジストリ
|
|
373
|
+
ではなく、既存の Tiptap 拡張登録の延長線上にある宣言(コロケーション)で、BlockMenu が
|
|
374
|
+
`editor.extensionManager.extensions` を走査して収集・描画する。詳細・使い分けの指針・コード例は
|
|
375
|
+
「[ブロックツールバーの拡張](#ブロックツールバーの拡張)」節を参照。
|
|
376
|
+
|
|
377
|
+
## 既存の購読可能イベント(DOM CustomEvent・レガシー名前空間)
|
|
378
|
+
|
|
379
|
+
以下は a-blog cms が従来から使ってきた「DOM CustomEvent 経由」「レガシー `ACMS.dispatchEvent` 経由」の
|
|
380
|
+
連携イベント一覧。**撤廃はしない**。テーマ側・プラグイン側の既存購読を壊さないため、発火自体は今後も
|
|
381
|
+
継続する。
|
|
382
|
+
|
|
383
|
+
### ミラー方針(レガシーとの併走)
|
|
384
|
+
|
|
385
|
+
Phase 3(`docs/steering/20260731-js-extension-points/plan.html`「レガシーイベントとの併走方針」)で、
|
|
386
|
+
以下の表に載っている DOM CustomEvent・レガシー `ACMS.dispatchEvent` イベントに対応する新バス
|
|
387
|
+
(`ACMS.events`)イベントを追加し、発火箇所には**新バス → レガシーの順**で両方を並記した
|
|
388
|
+
(`void ACMS.events.notify('新イベント名', payload); triggerEvent(...)` / `ACMS.dispatchEvent(...)`)。
|
|
389
|
+
「新バスの同等イベント」列がそれに対応する。
|
|
390
|
+
|
|
391
|
+
同じ方針で `admin.js` 内部の中継系レガシーイベント(`acmsAdminDelayedContents` / `acmsDialogOpened` /
|
|
392
|
+
`acmsAdminSelectTab` / `acmsAdminShowTabPanel` / `acmsAdminHideTabPanel` / `onGeoInfoAdded` /
|
|
393
|
+
`acmsInplaceDialogOpen` / `acmsAddCustomFieldGroup` の8種)も並記済みだが、これらは独立した連携イベント
|
|
394
|
+
表を持たず、対応する新バスイベント(`admin.delayed-contents.change` / `dialog.open.after` /
|
|
395
|
+
`tab.select.change` / `tab.show.after` / `tab.hide.after` / `geo.info-add.after` /
|
|
396
|
+
`unit-editor.inplace-dialog-open.after` / `field-group.add.after`)側の「イベント一覧」表
|
|
397
|
+
(上記)の発火箇所列にレガシー対応を記載している。
|
|
398
|
+
|
|
399
|
+
- **なぜ両方発火するか**: 新バスへ完全移行すると、既存テーマ・既存プラグインの購読(`document.addEventListener`
|
|
400
|
+
や `ACMS.addListener`)が届かなくなり後方互換が壊れる。かといって新バス側を追加しないと、新規プラグインが
|
|
401
|
+
`.before` ゲート(キャンセル・値変更・非同期待機)の恩恵を受けられない。両方式には契約の違い
|
|
402
|
+
(新バスは型安全・ゲート対応・再生なし、レガシーはノーガード・型なし・`ACMS.dispatchEvent` 経由のもの
|
|
403
|
+
だけ `eventPool` 再生あり)があるため、統合ではなく「並記」を選んだ。
|
|
404
|
+
- **どちらを購読すべきか**: 新規のプラグイン・拡張は新バス(`ACMS.events.on(...)`)を使う。既存のテーマ
|
|
405
|
+
カスタマイズ(DOM CustomEvent 購読)はそのままで良く、移行を強制しない。
|
|
406
|
+
- **例外(ミラーしない)**: 起動系イベント(次節)は契約上ミラーできないため対象外。
|
|
407
|
+
- **`acmsCustomFieldMakerPreview` の扱い**: 発火元は npm 依存 `custom-field-maker`(v1.6.8)側で本体では
|
|
408
|
+
制御できないため、レガシー発火そのものは維持する。ただし購読側は `custom-field-maker.preview.after`
|
|
409
|
+
(中継ブロック 1 箇所、`admin.js`)へ移行済み(上記「イベント一覧」参照)。
|
|
410
|
+
|
|
411
|
+
新規拡張ポイントは今後も新バス(`ACMS.events`)側にのみ追加する。以下は「既に存在する連携イベント」と
|
|
412
|
+
してそのまま利用できる(新バス側のイベントは上表「イベント一覧」を参照)。
|
|
413
|
+
|
|
414
|
+
| イベント名 | 発火方法 | 発火箇所 | 新バスの同等イベント | 用途 |
|
|
415
|
+
|---|---|---|---|---|
|
|
416
|
+
| `acms.media-updated` | `triggerEvent`(= `el.dispatchEvent(new CustomEvent(...))`。標準 DOM API) | `packages/frontend/media/src/components/media-modal/media-modal.tsx`(メディア編集の保存後) | `media.update.success` | メディア更新の通知。`block-editor` の Image/FileBlock、`media-unit`、`dispatch-media-field` 等、8 箇所以上で `document.addEventListener` / React `useEffect` cleanup で購読・解除している |
|
|
417
|
+
| `acms.set-main-image` | 標準 `CustomEvent` | `packages/frontend/block-editor/src/extensions/ImageBlock/components/ImageBlockMenuSection.tsx` | `entry.main-image.change` | メイン画像設定の連携 |
|
|
418
|
+
| `acms.category.change` | `triggerEvent` | `ablogcms/js/src/features/category/mount-category-select.tsx` | `category.select.change` | カテゴリー選択変更の連携(`mount-sub-category-select.tsx` 等が購読) |
|
|
419
|
+
| `acms.sub-category.change` | `triggerEvent` | `ablogcms/js/src/features/sub-category/mount-sub-category-select.tsx` | `sub-category.select.change` | サブカテゴリー選択変更の連携 |
|
|
420
|
+
| `acms.tag.change` | `triggerEvent` | `ablogcms/js/src/features/tag/mount-tag-select.tsx` | `tag.select.change` | タグ選択変更の連携 |
|
|
421
|
+
| `acms.related-entry.change` | `triggerEvent` | `ablogcms/js/src/features/related-entry/mount-related-entry.tsx` | `related-entry.select.change` | 関連エントリー変更の連携 |
|
|
422
|
+
| `acms.media-field.change` | `triggerEvent` | `ablogcms/js/src/dispatch/media/dispatch-media-field.tsx` | `media-field.change` | メディアフィールド変更の連携 |
|
|
423
|
+
| `acms.media-unit.change` | `triggerEvent` | `packages/frontend/media/src/setup-media-unit.tsx` | `media-unit.change` | メディアユニット変更の連携(`detail: { mediaItems }` 付き) |
|
|
424
|
+
| `acmsUnitEditorCreate` | **レガシー `ACMS.dispatchEvent`**(`eventPool` 経由。後述の非推奨 API) | `packages/frontend/unit-editor/src/components/unit-editor/index.tsx` | `unit-editor.create.after` | ユニットエディタ生成時の通知。`ACMS.addListener` で購読すると発火済みなら再生される |
|
|
425
|
+
| `acmsAddUnit` | **レガシー `ACMS.dispatchEvent`** | `unit-editor/index.tsx` / `inplace-editor.tsx`(v3.2 未満互換) | なし | サーバサイドユニット描画時の通知 |
|
|
426
|
+
| `acmsCustomFieldMakerPreview` | **npm 依存 `custom-field-maker`(v1.6.8)の `window.ACMS.dispatchEvent`** | `node_modules/custom-field-maker/lib/components/{Field,Unit,FieldGroup,UnitGroup}Source.js` | `custom-field-maker.preview.after` | プレビュー再描画の通知(`payload: { item: HTMLElement }`)。発火元はリポジトリ内ではなく npm 依存パッケージ側のため、発火自体はレガシーのまま維持。`admin.js` の中継ブロック(1 箇所)が新バス `custom-field-maker.preview.after` へ変換し、購読側 4 箇所はそちらに移行済み |
|
|
427
|
+
|
|
428
|
+
- 上 8 件(`acms.media-updated` 〜 `acms.media-unit.change`)は標準 DOM API(`triggerEvent`。
|
|
429
|
+
`@ablogcms/utils` の薄いラッパーで `CustomEvent` + `dispatchEvent` を呼ぶ。`acms.set-main-image` のみ
|
|
430
|
+
`triggerEvent` を介さず素の `new CustomEvent` + `dispatchEvent` を直接呼ぶ)で発火され、標準の
|
|
431
|
+
`document.addEventListener` / `removeEventListener` で購読する。**再生(eventPool)は行われない**ため、
|
|
432
|
+
購読側は発火より前に登録されている必要がある。
|
|
433
|
+
- 続く 3 件(`acmsUnitEditorCreate` / `acmsAddUnit` )は実装上レガシーな `ACMS.dispatchEvent` を経由しており、
|
|
434
|
+
`ACMS.addListener` で購読すると `eventPool` の再生対象になる(後から購読しても過去の発火分が届く)。
|
|
435
|
+
同じ「DOM CustomEvent」でも発火経路によってこの挙動差があることに注意。
|
|
436
|
+
- 最後の `acmsCustomFieldMakerPreview` は発火元が本体のソースコード内に存在しない唯一の例。同じく
|
|
437
|
+
レガシー `ACMS.dispatchEvent` 経由(`eventPool` の再生対象)。購読側は `admin.js` の中継ブロック
|
|
438
|
+
(1 箇所)が新バス `custom-field-maker.preview.after` へ変換する形にすでに移行済み(上記「イベント一覧」参照)。
|
|
439
|
+
- これらの名前・発火は**非推奨にしない**。現役の連携イベントとして維持する。
|
|
440
|
+
|
|
441
|
+
### 起動系イベント(`.ready` フェーズと `ACMS.readyStates`)
|
|
442
|
+
|
|
443
|
+
「特定の初期化が完了した」ことを示す一度きりの起動系イベントは、`ACMS.addListener` の
|
|
444
|
+
`eventPool` 再生(発火後に購読しても過去の発火分がその場で再生される)に依存する契約になっている。
|
|
445
|
+
初期化完了のタイミングは購読側コードの読み込みタイミングより後になるとは限らないため、
|
|
446
|
+
「後から購読しても取りこぼさない」という保証が本質的に必要な唯一の用途であり、`ACMS.events`
|
|
447
|
+
(新バス)は意図的に再生機構を持たない設計(`warnLateSubscription`。上記「レガシー API の
|
|
448
|
+
非推奨一覧」の `ACMS.eventPool` 行参照)のため、単純に `notify`/`on` へ置き換えると「発火後に
|
|
449
|
+
`ACMS.events.on(...)` した購読者には届かない」という契約破りになる。
|
|
450
|
+
|
|
451
|
+
この契約を保ったまま新バスへ移行するため、`ACMS.readyStates`(`document.readyState` と同じ
|
|
452
|
+
発想のプレーンなオブジェクト。各キーが `'loading'` → `'complete'` と遷移する)を導入した。
|
|
453
|
+
購読側は「対応するキー(`ACMS.readyStates.ready` 等)が `'complete'` なら即時実行、そうでなければ
|
|
454
|
+
`ACMS.events.on(...)` で購読する」を自前で分岐すればよく、`ACMS.Ready`(`ablogcms/acms.js`)自身が
|
|
455
|
+
その参照実装。バス全体に汎用の再生機構を持たせてしまうと、繰り返し発火する通常の通知イベント
|
|
456
|
+
(`dialog.open.after` 等)まで誤って再生対象になってしまうため、意図的にイベント単位
|
|
457
|
+
(`readyStates` のキーを個別に用意する形)のオプトインにしている。
|
|
458
|
+
|
|
459
|
+
| イベント名(新バス) | フェーズ | 対応するレガシーイベント | 発火箇所 |
|
|
460
|
+
|---|---|---|---|
|
|
461
|
+
| `acms.state.ready` | `.ready`(`ACMS.readyStates.ready === 'complete'` かどうかを先に見てから購読すること。直接 `on()` しない) | `acmsReady`(`ACMS.Ready(listener)`) | `ablogcms/js/src/index.js`。`ACMS.readyStates.ready = 'complete'` を設定した直後に発火する |
|
|
462
|
+
| `acms.state.admin-ready` | `.ready`(`ACMS.readyStates.adminReady === 'complete'` かどうかを先に見てから購読すること) | `acmsAdminReady` | `ablogcms/js/src/admin.js`。`ACMS.readyStates.adminReady = 'complete'` を設定した直後に発火する |
|
|
463
|
+
| `acms.pending.ready` | `.ready`(`ACMS.pendingReadyState === 'complete'` かどうかを先に見てから購読すること) | なし(**ミラーしない**。下記参照) | `ablogcms/js/src/dispatch/dispatch-pending.tsx`。`ACMS.pendingReadyState = 'complete'` を設定した直後に発火する |
|
|
464
|
+
|
|
465
|
+
`acmsReady`/`ACMS.Ready` を移行できた決め手は発火場所の見直し: `ACMS.events` は元々 `admin.js`
|
|
466
|
+
(ログイン中の投稿権限者にしか読み込まれない)でのみ生成していたが、`acmsReady` を発火する
|
|
467
|
+
`index.js` は非ログイン閲覧者を含むあらゆるセッションで読み込まれるため、`admin.js` を前提に
|
|
468
|
+
すると `ACMS.events` 自体が存在しない状況が構造的に残ってしまう。そこで **バスの生成自体を
|
|
469
|
+
`index.js` 側へ移した**(`admin.js` 側は既存インスタンスがあればそれを使い、無ければ自分でも
|
|
470
|
+
生成する防御的な実装のまま)。`ACMS.Ready` 自体は `acms.js`(同期・ブロッキングで最初に実行
|
|
471
|
+
される、非バンドルのプレーンスクリプト)に定義したまま変えていない。`ACMS.readyStates.ready`/
|
|
472
|
+
`ACMS.events` というプレーンなグローバルを参照するだけなので、`import` を必要とする
|
|
473
|
+
`@ablogcms/events` を持ち込まずに済む。ただし `ACMS.events` が万一まだ存在しない(`index.js`
|
|
474
|
+
より前に実行されるインラインスクリプト等からの呼び出し)場合に備え、レガシー
|
|
475
|
+
`ACMS.addListener('acmsReady', listener)` へのフォールバックも残している。
|
|
476
|
+
`ACMS.dispatchEvent('acmsReady')` 自体も後方互換のため残し、新バスとの二重発火にしている
|
|
477
|
+
(「レガシーとの併走方針」)。`acms.state.admin-ready` も同様に `ACMS.dispatchEvent('acmsAdminReady')`
|
|
478
|
+
と二重発火する。
|
|
479
|
+
|
|
480
|
+
`acms.pending.ready` は例外で、対応するレガシー `acmsPendingReady`(`ACMS.dispatchEvent` 経由)を
|
|
481
|
+
**ミラーせず置き換えている**(`ablogcms/js/src/dispatch/dispatch-pending.tsx`)。社内に
|
|
482
|
+
`acmsPendingReady` の購読側コードが無く、後方互換のためだけに二重発火し続ける動機が薄いため、
|
|
483
|
+
他の起動系イベント(`acms.state.ready` / `acms.state.admin-ready`)とは異なり移行時点で
|
|
484
|
+
レガシー発火自体を廃止した。既存テーマ・プラグインが `acmsPendingReady` を購読していた場合は
|
|
485
|
+
このバージョンで届かなくなる点に注意(`ACMS.Library.pending` の available 時に必要なら
|
|
486
|
+
`acms.pending.ready` へ購読を切り替えること)。
|
|
487
|
+
|
|
488
|
+
以下は同種の「起動系」だが、社内に移行する購読側コードが現時点で無いため、今回は据え置いている。
|
|
489
|
+
将来、社内購読を新設する場合は `acms.state.ready`/`ACMS.readyStates` と同じパターン
|
|
490
|
+
(イベントごとに `Xxx.readyState`(または `readyStates` オブジェクトのプロパティ)を 1 つ用意し、
|
|
491
|
+
`.before` を経由せず先にチェックしてから購読する)で個別に移行を検討すること。
|
|
492
|
+
|
|
493
|
+
| イベント名 | 発火箇所 | 据え置き理由 |
|
|
494
|
+
|---|---|---|
|
|
495
|
+
| `${スクリプトファイル名}Ready`(動的名) | `packages/frontend/lib/src/loader/loader.js:77`。読み込んだ URL のファイル名から動的に組み立てる | 名前が実行時にしか決まらない動的イベントのため、そもそも `AcmsEventMap` のような静的なイベント名の型定義に載せられない |
|
|
496
|
+
|
|
497
|
+
`configLoad`(`ablogcms/js/src/index.js`、公開側の設定読み込み完了)はレガシー `ACMS.dispatchEvent`
|
|
498
|
+
呼び出し自体を削除済み(新バスへの移行・据え置きのどちらでもなく廃止)。社内に購読側コードが無く、
|
|
499
|
+
既存テーマ・プラグインからの購読実績も確認されていないための削除だが、外部の未把握の購読者が
|
|
500
|
+
存在した場合は届かなくなる点に注意すること。
|
|
501
|
+
|
|
502
|
+
## レガシー API の非推奨一覧と移行例
|
|
503
|
+
|
|
504
|
+
**撤廃はしない。挙動・シグネチャも変えない。** `ACMS.dispatchEvent` / `ACMS.addListener` / `ACMS.eventPool` は
|
|
505
|
+
型定義(JSDoc)に `@deprecated` と移行先を明示した(`packages/frontend/types/src/acms.d.ts`、
|
|
506
|
+
`ablogcms/js/src/types/acms.d.ts`)。`entryAdminConfig` / `moduleAdminConfig` の getter 群は、利用者が編集する
|
|
507
|
+
`ablogcms/js/config.js` の素の関数で型定義を持たず `@deprecated` も付けていないため、この表の記述でのみ
|
|
508
|
+
非推奨として扱う。
|
|
509
|
+
|
|
510
|
+
| 対象 | 宣言箇所 | 移行先 | 備考 |
|
|
511
|
+
|---|---|---|---|
|
|
512
|
+
| `ACMS.dispatchEvent` | `ablogcms/acms.js`(実装)/ `packages/frontend/types/src/acms.d.ts`(型。registry 側の骨格メンバー) | `ACMS.events.notify` / `ACMS.events.gate`(`@ablogcms/events`) | 発火の意味論が全く異なる(後述) |
|
|
513
|
+
| `ACMS.addListener` | 同上 | `ACMS.events.on` | 同上 |
|
|
514
|
+
| `ACMS.eventPool` | `ablogcms/acms.js`(実装)/ `ablogcms/js/src/types/acms.d.ts`(型。アプリ内部専用メンバーとして追記) | (代替 API なし。`ACMS.events` は意図的に再生を持たない) | 直接参照している外部コードがあれば、まず無くすことを検討する |
|
|
515
|
+
| `ACMS.Config.entryAdminConfig` の `getActions`/`getBulkActions`/`getMenus` | `ablogcms/js/config.js`(typedef なしの素の関数。`@deprecated` は付けていない) | `ctx.dataview.registerAction`/`registerBulkAction`/`registerMenu`(`view: 'entry'`) | レジストリ登録分はコア定義の後ろに追加された上でこの getter にも渡る(3段マージ)ため、getter は引き続き使えるが新規実装はレジストリを推奨する |
|
|
516
|
+
| `ACMS.Config.entryAdminConfig` の `getColumns`/`getValues` | 同上 | (代替レジストリなし。`getColumns` は列の並び替え/削除、`ctx.dataview.registerColumnType` はカラム**型**の追加であり別の関心事) | 一覧系のこの2つはレジストリ整備が未着手のため、**「非推奨だが唯一の手段」** の注記付き |
|
|
517
|
+
| `ACMS.Config.moduleAdminConfig` の同上 getter 群 | `ablogcms/js/config.js`(typedef なしの素の関数。`@deprecated` は付けていない) | 同上(`view: 'module'`) | 同上 |
|
|
518
|
+
|
|
519
|
+
### getColumns で追加した列の表示位置と表示状態
|
|
520
|
+
|
|
521
|
+
`getColumns` の戻り値に新しい列を足すと、その**配列上の位置がそのまま既定の表示位置**になる。
|
|
522
|
+
列の表示順は「サーバーに永続化されたユーザーの表示項目設定 + 本体の既定順(`ENTRY_COLUMN_ORDER` /
|
|
523
|
+
`MODULE_COLUMN_ORDER`)」で決まり、そこに載っていない id は
|
|
524
|
+
`resolveColumnOrder`(`@ablogcms/components/dataview/utils`)が「配列上で直前にある既知の列の直後」へ
|
|
525
|
+
差し込む。`code` の隣に置きたければ `code` の直後に `splice` する。
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
ACMS.Config.entryAdminConfig.getColumns = (columns) => {
|
|
529
|
+
const index = columns.findIndex((column) => column.id === 'code');
|
|
530
|
+
return [
|
|
531
|
+
...columns.slice(0, index + 1),
|
|
532
|
+
{ id: 'stock', label: '在庫', type: 'display', getValue: (info) => renderStockBadge(info.row.original) },
|
|
533
|
+
...columns.slice(index + 1),
|
|
534
|
+
];
|
|
535
|
+
};
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
- 追加した列は**初期状態で表示**扱いになる(表示項目設定に保存された値が無い列は「表示」と解釈する)。
|
|
539
|
+
管理画面の「表示項目設定」ドロワーにもチェック済みで並び、ユーザーがドラッグで並び替えて保存すれば
|
|
540
|
+
その順序・表示状態が永続化され、以降は保存値が上記の既定位置より優先される。
|
|
541
|
+
- 表示項目設定に出したくない列は `enableHiding: false` を指定する(ドロワーの一覧から除外され、
|
|
542
|
+
ユーザーは非表示にできなくなる)。
|
|
543
|
+
|
|
544
|
+
### 移行例
|
|
545
|
+
|
|
546
|
+
```ts
|
|
547
|
+
// Before(レガシー)
|
|
548
|
+
ACMS.addListener('acmsSomeEvent', (event) => {
|
|
549
|
+
console.log(event.detail);
|
|
550
|
+
});
|
|
551
|
+
ACMS.dispatchEvent('acmsSomeEvent', document, { foo: 1 });
|
|
552
|
+
|
|
553
|
+
// After(新設ハブ)
|
|
554
|
+
declare module '@ablogcms/events' {
|
|
555
|
+
interface AcmsEventMap {
|
|
556
|
+
'some.thing.success': AcmsNotifyEvent<{ foo: number }>;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
ACMS.events.on('some.thing.success', (payload) => {
|
|
560
|
+
console.log(payload.foo);
|
|
561
|
+
});
|
|
562
|
+
await ACMS.events.notify('some.thing.success', { foo: 1 });
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
`ACMS.dispatchEvent`/`addListener` は「発火済みイベントの再生(`eventPool`)」「DOM への実バブリング」を
|
|
566
|
+
前提にした設計であり、`ACMS.events` の「登録順 waterfall・キャンセル・非同期ゲート」とは意味論が違う。
|
|
567
|
+
既存コードの機械的な置換はできないため、移行は新規拡張ポイントから始め、レガシー領域
|
|
568
|
+
(`admin.js` の `acmsAdminDelayedContents` 等)は当面そのまま残す。
|
|
569
|
+
|
|
570
|
+
ランタイム deprecation 警告(WordPress `@wordpress/deprecated` 方式: 一度だけ console 警告)は追加しないことで
|
|
571
|
+
確定した(`docs/steering/20260731-js-extension-points/plan.html` 確定事項 Q8)。`ACMS.dispatchEvent` は
|
|
572
|
+
レガシーとの併走(二重発火)方針により a-blog cms 自身のコードから今後も恒久的に呼び出し続ける設計のため、
|
|
573
|
+
警告を付けると鳴りっぱなしになる。`ACMS.addListener` は内部購読をほぼ新バスへ移行済みだが、残る自社コードの
|
|
574
|
+
呼び出し(次節の ESLint ルールで許可リスト化している箇所)を「警告の出ない内部専用エイリアス」へ書き換える
|
|
575
|
+
追加実装が必要になり、コストに見合わないと判断した。型定義(JSDoc)の `@deprecated` 注記のみで運用する。
|
|
576
|
+
|
|
577
|
+
### ESLint による新規使用の抑止(`ACMS.addListener` のみ)
|
|
578
|
+
|
|
579
|
+
`eslint.config.mjs` の `no-restricted-syntax` に `ACMS.addListener` 呼び出しを検出するセレクタを追加し、
|
|
580
|
+
自社コード(`ablogcms/js/src/**` / `packages/frontend/**/src/**`。ESLint 対象外の `ablogcms/js/dispatch/**`
|
|
581
|
+
等は元々対象外)内での**新規**呼び出しを禁止している。`ACMS.dispatchEvent` は上記の理由(恒久的に
|
|
582
|
+
呼ばれ続ける設計)により対象にしていない。
|
|
583
|
+
|
|
584
|
+
- 既存の維持対象呼び出し(`admin.js` の新バスへの中継ブロック 5 箇所。`acmsAdminDelayedContents` 系
|
|
585
|
+
4 箇所と `acmsCustomFieldMakerPreview` を `custom-field-maker.preview.after` へ変換する 1 箇所)は、
|
|
586
|
+
誤検知ではなく個別に `// eslint-disable-next-line no-restricted-syntax -- <理由>`
|
|
587
|
+
を付けて明示的に許可している。新しくこのパターンを増やす場合も同様にコメントで理由を残すこと。
|
|
588
|
+
`built-in/dispatch.js` の wysiwyg/flatpicker 初期化 2 箇所(旧: `acmsAddCustomFieldGroup` 購読)は
|
|
589
|
+
`on('field-group.add.after', ...)` へ移行済み(この関数は `index.js` から呼ばれる際に
|
|
590
|
+
必ず `ACMS.events` 生成後に実行されるため、`ACMS.events が無い場合がある` という当初の懸念は
|
|
591
|
+
当てはまらないと判明した)。`packages/frontend/lib/src/open-street-map/picker/index.ts` も
|
|
592
|
+
新バス移行後の呼び出し。
|
|
593
|
+
- 新規コードで `ACMS.addListener` が必要になった場合は、まず `ACMS.events.on`(新バス)で要件を満たせないか
|
|
594
|
+
検討する。既存の DOM CustomEvent(前節の「既存の購読可能イベント」)を購読したいだけなら、対応する
|
|
595
|
+
「新バスの同等イベント」があればそちらを使う。
|
|
596
|
+
|
|
597
|
+
## `ctx.config` の制約
|
|
598
|
+
|
|
599
|
+
`jsModule($key, $value)`(PHP)→ `ACMS.Config.{camelCase(プラグイン名)}` → `ctx.config`
|
|
600
|
+
(`packages/frontend/plugins/src/core/create-plugin-registry.ts` の `readConfig`)という経路で、PHP 側の
|
|
601
|
+
設定値をプラグインの `setup(ctx)` から参照できる。実体は `ACMS/GET/Js.php` が
|
|
602
|
+
`http_build_query(Common::getJsModules())` で組み立てたクエリ文字列を `acms.js?...` に付与し、
|
|
603
|
+
`index.js` が `qs.parse()` で読み戻して `ACMS.Config` に展開する URL エンコード方式であり、次の制約がある
|
|
604
|
+
(実測で確認):
|
|
605
|
+
|
|
606
|
+
- **スカラー値はすべて文字列化される**。PHP の `42`(int)は JS 側で `"42"`(string)になる。
|
|
607
|
+
`true`/`false` も `http_build_query` によって `"1"`/`"0"` という文字列に変換される
|
|
608
|
+
(`bool` として届かない)。
|
|
609
|
+
- **`null` はキーごと消失する**。`http_build_query` は値が `null` のキーを出力しないため、
|
|
610
|
+
`ctx.config` にそのキー自体が現れない(`undefined` と区別できない)。
|
|
611
|
+
- **ネストは `qs` の既定 depth(5)までしか保たれない**。6 段目以降のキーは最後の階層が
|
|
612
|
+
`"[key]"` という文字列そのもののプロパティ名として扱われ、意図した入れ子にならない
|
|
613
|
+
(実測: `deep[l1][l2][l3][l4][l5][l6]=x` → `deep.l1.l2.l3.l4.l5['[l6]']` になり `l6` は消えない代わりに
|
|
614
|
+
ネストが崩れる)。**深い設定はネストではなくフラットなキーで渡す**か、JSON 文字列 1 本を値として渡し
|
|
615
|
+
プラグイン側で `JSON.parse` する。
|
|
616
|
+
- **秘匿情報(API キー・トークン等)を `ctx.config` に流さない**。クエリ文字列はブラウザの開発者ツール・
|
|
617
|
+
アクセスログ等から容易に閲覧できる。秘匿が要る連携はサーバ側 PHP(Hook / 独自エンドポイント)で行う
|
|
618
|
+
こと(kintone が `setConfig`(クライアント露出)と `setProxyConfig`(サーバ側秘匿)を分けているのと
|
|
619
|
+
同じ考え方)。専用のプロキシ API は将来課題。
|
|
620
|
+
- `ctx.config` はプラグイン名をキャメルケース変換して `ACMS.Config` を引く(`-`/`_` 区切りの次の文字を
|
|
621
|
+
大文字化)。未初期化・未設定時は例外を投げず空オブジェクト `{}` を返す。
|
|
622
|
+
|
|
623
|
+
## `ctx.ui.fill` で fillProps を受け取る(withFillProps)
|
|
624
|
+
|
|
625
|
+
- `ctx.ui.fill(slotName, entry)` に素の関数コンポーネント(`() => ReactNode`)や `DomFill`
|
|
626
|
+
(`(el) => ...`)をそのまま渡した場合、`Slot` 側の `fillProps` は転送されない
|
|
627
|
+
(`resolveFillEntry` が 0 引数の関数を「props なしの React コンポーネント」として
|
|
628
|
+
`createElement(entry)`(引数なし)で描画するため)。これは既存の後方互換として維持しており、
|
|
629
|
+
0 引数の Fill コンポーネントの挙動は変わらない。
|
|
630
|
+
- `Slot` の `fillProps`(`unit-editor.unit-toolbar` の `editor`/`unit` 等)を `ctx.ui.fill` 経由で
|
|
631
|
+
受け取りたい場合は、コンポーネントを `withFillProps`(`@ablogcms/plugins`)でラップして渡す。
|
|
632
|
+
```tsx
|
|
633
|
+
import { withFillProps } from '@ablogcms/plugins';
|
|
634
|
+
|
|
635
|
+
ctx.ui.fill(
|
|
636
|
+
'unit-editor.unit-toolbar',
|
|
637
|
+
withFillProps<UnitToolbarSlotFillProps>(({ editor, unit }) => <button type="button">{unit.type}</button>)
|
|
638
|
+
);
|
|
639
|
+
```
|
|
640
|
+
- **npm パッケージを import できない外部プラグイン**(`InjectTemplate::add()` で読み込まれる
|
|
641
|
+
`<script type="module">`)は、`ACMS.plugins.withFillProps` としてグローバルからも同じ関数を呼べる
|
|
642
|
+
(`AcmsPlugins` インターフェースのメンバー。実体は `@ablogcms/plugins` の `withFillProps` と同一)。
|
|
643
|
+
```js
|
|
644
|
+
ACMS.plugins.register('my-plugin', {
|
|
645
|
+
setup(ctx) {
|
|
646
|
+
const { withFillProps } = ACMS.plugins;
|
|
647
|
+
ctx.ui.fill('unit-editor.unit-toolbar', withFillProps(({ editor, unit }) => (
|
|
648
|
+
window.React.createElement('button', { type: 'button' }, unit.type)
|
|
649
|
+
)));
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
```
|
|
653
|
+
- `withFillProps` は関数ではなく `{ kind: 'acms-plugins/fill-component', Component }` という
|
|
654
|
+
マーカー付きオブジェクトを返す(`packages/frontend/plugins/src/core/with-fill-props.ts`)。
|
|
655
|
+
Why not(関数のままオーバーロードを増やさない理由): props を要求するコンポーネント
|
|
656
|
+
(`({ editor, unit }) => ...`)は仮引数が 1 個になり、`DomFill`(`(el) => ...`)と実行時に
|
|
657
|
+
`Function.prototype.length` だけでは区別できない。オブジェクトでラップすることで、既存の
|
|
658
|
+
「0 引数 = props なしコンポーネント/1 引数以上 = DomFill」という arity 判別ロジックを一切
|
|
659
|
+
変更せずにこの衝突を避けている。
|
|
660
|
+
- `resolveFillEntry` は `withFillProps` のエントリを受け取ると、`fillProps` を受け取る render-prop
|
|
661
|
+
形式の関数(`FillRenderer<T>`)を返す。`Slot`(`@ablogcms/components/slot-fill`)はもともと
|
|
662
|
+
「render が関数なら fillProps を渡して呼ぶ」実装を持っていたため、`ctx.ui.fill` 側の変更だけで
|
|
663
|
+
Slot 側には手を入れていない。
|
|
664
|
+
- `withFillProps` に渡せるのは関数コンポーネントのみ(クラスコンポーネントは非対応)。本リポジトリは
|
|
665
|
+
新規のクラスコンポーネント追加を禁止する規約(development-guidelines.md)のため制約にはならない。
|
|
666
|
+
|
|
667
|
+
## プラグイン側のビルド設定(React / Tiptap の共有)
|
|
668
|
+
|
|
669
|
+
プラグイン JS は `InjectTemplate::add()` から `<script type="module">` として読み込まれ、npm パッケージを
|
|
670
|
+
import できない。JSX や TypeScript を使うためにプラグイン側でバンドラを用意する場合、**React・Tiptap・
|
|
671
|
+
ProseMirror をプラグインに同梱してはいけない**。同梱するとインスタンスが二重化し、React は hooks が
|
|
672
|
+
「Invalid hook call」で壊れ、ProseMirror は `instanceof`(`NodeSelection` 判定など)や schema 互換が壊れる。
|
|
673
|
+
|
|
674
|
+
| 依存 | 入手経路 | 同梱した場合 |
|
|
675
|
+
|---|---|---|
|
|
676
|
+
| React | グローバル `window.ACMS.Library.React`(`ablogcms/js/src/admin.js` で公開) | hooks を使った時点で壊れる |
|
|
677
|
+
| react-dom | グローバル `window.ACMS.Library.ReactDom`(`react-dom`)と `window.ACMS.Library.ReactDomClient`(`react-dom/client`) | 描画エンジンが二重になる。`@floating-ui/react` が使う `createPortal` / `flushSync` も同梱版を参照してしまう |
|
|
678
|
+
| `@ablogcms/components` | グローバル `window.ACMS.Library.components.<サブパス名>`(下記の一覧) | 部品のコードが二重になり、コアが更新されても見た目・挙動がプラグインのビルド時点のまま残る。`@floating-ui/react` なども同梱される |
|
|
679
|
+
| React の JSX ランタイム | グローバル `window.ACMS.Library.ReactJsxRuntime`(同上) | `react/jsx-runtime` だけが同梱される。React 本体とメジャーが食い違うと要素が別形式になり描画できない |
|
|
680
|
+
| Tiptap / ProseMirror | ファクトリ引数(`{ core, react, suggestion, pm }`)、または externals の宛先としてグローバル `window.ACMS.Library.tiptap`(**中身が入るのはチャンクロード後**。詳細は下記) | PM インスタンスの二重化で `instanceof` 判定・schema 互換が壊れる |
|
|
681
|
+
| 型(`BlockDefinition` 等) | npm の `@ablogcms/block-editor/types` 等を `import type` で(コンパイル時に消える) | — |
|
|
682
|
+
|
|
683
|
+
### 公開している `@ablogcms/components`
|
|
684
|
+
|
|
685
|
+
`ACMS.Library.components` のキーは `@ablogcms/components` のサブパス名と一致する。externals の宛先は
|
|
686
|
+
`var ACMS.Library.components.<サブパス名>`(ハイフンを含む名前は `components['button-v2']` の形)。
|
|
687
|
+
`@ablogcms/components` の公開サブパスは**次の 8 つを除いてすべて**公開している(実体は
|
|
688
|
+
`ablogcms/js/src/features/plugin-library/plugin-library.ts` の `PLUGIN_COMPONENTS`)。
|
|
689
|
+
|
|
690
|
+
- 公開しないサブパス: `dataview` / `date-picker` / `time-picker` / `syntax-highlight` / `rich-select` / `select` /
|
|
691
|
+
`device-mode` / `icon-picker`。flatpickr・highlight.js・react-select などの重い依存を持ち、公開側ページにも
|
|
692
|
+
読み込まれる共通バンドルを太らせるため。これらが必要な場合は要望を出すこと。
|
|
693
|
+
|
|
694
|
+
```js
|
|
695
|
+
// webpack
|
|
696
|
+
externals: {
|
|
697
|
+
react: 'var ACMS.Library.React',
|
|
698
|
+
'react/jsx-runtime': 'var ACMS.Library.ReactJsxRuntime',
|
|
699
|
+
'react-dom': 'var ACMS.Library.ReactDom',
|
|
700
|
+
'react-dom/client': 'var ACMS.Library.ReactDomClient',
|
|
701
|
+
'@ablogcms/components/popover': 'var ACMS.Library.components.popover',
|
|
702
|
+
'@ablogcms/components/button-v2': "var ACMS.Library.components['button-v2']",
|
|
703
|
+
}
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
- 状態(Context・レジストリ)を持つ部品もホストの 1 つだけが使われるため、コピー間で状態が分かれない。
|
|
707
|
+
- `react-dom` は全体を公開している。ホストの React と同一インスタンスなので、`createRoot` で別ルートを作っても
|
|
708
|
+
描画エンジンは二重にならない。
|
|
709
|
+
|
|
710
|
+
### `ACMS.Library` と読み込み順の注意
|
|
711
|
+
|
|
712
|
+
`admin.js` は `async` で読み込まれるため、`<script type="module">` のプラグインが先に実行されることがある
|
|
713
|
+
(`async` は `defer` より優先され、実行順が保証されない)。その場合 `ACMS.plugins` や `ACMS.Library.React` は
|
|
714
|
+
まだ無い。トップレベルでこれらを参照する前に `ACMS.adminReady(() => { ... })` で包むこと。`adminReady` は
|
|
715
|
+
`admin.js` の初期化完了後に呼ばれ、すでに完了していれば即時に呼ばれる。ただしコールバック内の登録は
|
|
716
|
+
「トップレベル同期登録」の前提から外れるため、core が先に参照するレジストリ(`registerUnitDefinition` 等)の
|
|
717
|
+
取りこぼしがないかを確認する。
|
|
718
|
+
|
|
719
|
+
### 出力形式は IIFE にする
|
|
720
|
+
|
|
721
|
+
ESM 出力+`external` にすると、バンドルに `import React from "react"` という bare specifier が残り、
|
|
722
|
+
import map の無いブラウザでは解決できない(実測)。**IIFE で出力**し、`<script type="module">` から
|
|
723
|
+
読み込むこと(module スコープと defer 実行はそのまま得られる)。ソースは通常どおり ESM で書いてよく、
|
|
724
|
+
変えるのは出力形式だけ。
|
|
725
|
+
|
|
726
|
+
コード分割(`await import()`)も両バンドラで機能する(実測)。webpack は IIFE 出力でも別チャンクを
|
|
727
|
+
生成し、Vite(Rollup)は 1 ファイルに畳みつつ**評価だけを遅延**する(`__esmMin` でラップされる)ため、
|
|
728
|
+
「公式 Tiptap 拡張の評価をチャンクロード後まで遅らせる」用途はどちらでも成立する。
|
|
729
|
+
|
|
730
|
+
### JSX は automatic runtime のままでよい
|
|
731
|
+
|
|
732
|
+
`react/jsx-runtime` も `ACMS.Library.ReactJsxRuntime` として共有しているため、
|
|
733
|
+
**`@vitejs/plugin-react` や `@babel/preset-react` の既定(automatic runtime)をそのまま使える**。
|
|
734
|
+
externals に 1 行足すだけでよい。
|
|
735
|
+
|
|
736
|
+
```js
|
|
737
|
+
// webpack
|
|
738
|
+
externals: {
|
|
739
|
+
react: 'var ACMS.Library.React',
|
|
740
|
+
'react/jsx-runtime': 'var ACMS.Library.ReactJsxRuntime',
|
|
741
|
+
}
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
外し忘れると `react/jsx-runtime` だけがプラグイン側に同梱される(実測)。React 本体とメジャーが
|
|
745
|
+
食い違うと要素が別形式になり描画できなくなるため、必ず external にすること。
|
|
746
|
+
なお開発ビルド(`react/jsx-dev-runtime`)は共有していないので、**配布物は production ビルド**で作る。
|
|
747
|
+
|
|
748
|
+
### webpack
|
|
749
|
+
|
|
750
|
+
```js
|
|
751
|
+
// webpack.config.js
|
|
752
|
+
const path = require('path');
|
|
753
|
+
|
|
754
|
+
module.exports = {
|
|
755
|
+
mode: 'production',
|
|
756
|
+
entry: './src/plugin.tsx',
|
|
757
|
+
output: { path: path.resolve(__dirname, 'dist'), filename: 'plugin.js' }, // 既定で IIFE
|
|
758
|
+
externals: {
|
|
759
|
+
react: 'var ACMS.Library.React',
|
|
760
|
+
'react/jsx-runtime': 'var ACMS.Library.ReactJsxRuntime',
|
|
761
|
+
'@tiptap/core': 'var ACMS.Library.tiptap.core',
|
|
762
|
+
'@tiptap/react': 'var ACMS.Library.tiptap.react',
|
|
763
|
+
'@tiptap/suggestion': 'var ACMS.Library.tiptap.suggestion',
|
|
764
|
+
'@tiptap/pm/state': 'var ACMS.Library.tiptap.pm.state',
|
|
765
|
+
'@tiptap/pm/model': 'var ACMS.Library.tiptap.pm.model',
|
|
766
|
+
'@tiptap/pm/view': 'var ACMS.Library.tiptap.pm.view',
|
|
767
|
+
// 必要な pm サブパスを同様に(transform / commands / keymap / inputrules / tables …)
|
|
768
|
+
// externalsType: 'module' にはしないこと(bare import が残る)
|
|
769
|
+
},
|
|
770
|
+
module: {
|
|
771
|
+
rules: [{ test: /\.tsx?$/, use: 'babel-loader' }], // preset-react は既定の automatic でよい
|
|
772
|
+
},
|
|
773
|
+
};
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
### Vite
|
|
777
|
+
|
|
778
|
+
```js
|
|
779
|
+
// vite.config.js
|
|
780
|
+
import { defineConfig } from 'vite';
|
|
781
|
+
import react from '@vitejs/plugin-react';
|
|
782
|
+
|
|
783
|
+
export default defineConfig({
|
|
784
|
+
plugins: [react()], // automatic runtime のままでよい
|
|
785
|
+
build: {
|
|
786
|
+
lib: { entry: 'src/plugin.tsx', name: 'MyPlugin', formats: ['iife'], fileName: () => 'plugin.js' },
|
|
787
|
+
rollupOptions: {
|
|
788
|
+
external: ['react', 'react/jsx-runtime', '@tiptap/core', '@tiptap/pm/state'],
|
|
789
|
+
output: {
|
|
790
|
+
// globals は iife/umd でのみ効く
|
|
791
|
+
globals: {
|
|
792
|
+
react: 'ACMS.Library.React',
|
|
793
|
+
'react/jsx-runtime': 'ACMS.Library.ReactJsxRuntime',
|
|
794
|
+
'@tiptap/core': 'ACMS.Library.tiptap.core',
|
|
795
|
+
'@tiptap/pm/state': 'ACMS.Library.tiptap.pm.state',
|
|
796
|
+
},
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
});
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
どちらの設定でも、出力は `ACMS.Library.*` を参照するだけになりホストと同一インスタンスを共有する
|
|
804
|
+
(実測で確認)。ソース側は通常どおり `import React from 'react'` / `import { Node } from '@tiptap/core'`
|
|
805
|
+
と書いてよい(いずれも devDependency)。
|
|
806
|
+
|
|
807
|
+
> **なぜ Tiptap も externals で足りるのか**: `ACMS.Library.tiptap` は admin.js の時点で
|
|
808
|
+
> 「遅延解決する名前空間」として置かれており、プロパティに触れた瞬間に実体へ委譲する。
|
|
809
|
+
> Rollup の IIFE 出力が externals を読み込み時に 1 度だけ束縛する(`})(ACMS.Library.tiptap.core)`)
|
|
810
|
+
> 実装でも、掴んでいるのが遅延名前空間なので後から正しい実体に届く。
|
|
811
|
+
> ただし**実体が入るのはブロックエディターのチャンクがロードされた後**なので、
|
|
812
|
+
> チャンクロード前に中身へ触ると「まだ使えません」という例外になる(下記参照)。
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
### 公式 Tiptap 拡張(`@tiptap/extension-*`)をそのまま使う
|
|
816
|
+
|
|
817
|
+
公式拡張は内部で `@tiptap/core` / `@tiptap/pm/*` を import しており、素直にバンドルすると Tiptap 一式が
|
|
818
|
+
プラグイン側に取り込まれる(実測: **839KB**・生成された拡張はホストの `Mark` の `instanceof` に**ならない**)。
|
|
819
|
+
`@tiptap/*` を externals にしてホストのランタイム(`ACMS.Library.tiptap`)へ向け、**ファクトリの中で
|
|
820
|
+
`await import()` する**ことで共有できる(実測: 公式拡張は 1.6KB の遅延チャンクに切り出され、
|
|
821
|
+
`@tiptap/core` 本体を含まない)。
|
|
822
|
+
|
|
823
|
+
```js
|
|
824
|
+
ACMS.plugins.register('my-plugin', {
|
|
825
|
+
setup(ctx) {
|
|
826
|
+
// 登録は setup 内で同期的に。評価だけがチャンクロード後まで遅れる
|
|
827
|
+
ctx.blockEditor.registerExtension('details', async () => {
|
|
828
|
+
const { Details } = await import('@tiptap/extension-details');
|
|
829
|
+
return Details;
|
|
830
|
+
});
|
|
831
|
+
},
|
|
832
|
+
});
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
externals は機械的に列挙するだけでよい(`ACMS.Library.tiptap` は `tiptap` 値と同じ形で
|
|
836
|
+
`core` / `react` / `suggestion` / `pm.*` を持つ)。
|
|
837
|
+
|
|
838
|
+
```js
|
|
839
|
+
// webpack
|
|
840
|
+
externals: {
|
|
841
|
+
react: 'var ACMS.Library.React',
|
|
842
|
+
'@tiptap/core': 'var ACMS.Library.tiptap.core',
|
|
843
|
+
'@tiptap/react': 'var ACMS.Library.tiptap.react',
|
|
844
|
+
'@tiptap/suggestion': 'var ACMS.Library.tiptap.suggestion',
|
|
845
|
+
'@tiptap/pm/state': 'var ACMS.Library.tiptap.pm.state',
|
|
846
|
+
'@tiptap/pm/model': 'var ACMS.Library.tiptap.pm.model',
|
|
847
|
+
'@tiptap/pm/view': 'var ACMS.Library.tiptap.pm.view',
|
|
848
|
+
// 必要な pm サブパスを同様に(transform / commands / keymap / inputrules / tables …)
|
|
849
|
+
}
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
```js
|
|
853
|
+
// Vite(rollupOptions.output.globals も同じ宛先で並べる)
|
|
854
|
+
rollupOptions: {
|
|
855
|
+
external: ['react', '@tiptap/core', '@tiptap/pm/state'],
|
|
856
|
+
output: {
|
|
857
|
+
globals: {
|
|
858
|
+
react: 'ACMS.Library.React',
|
|
859
|
+
'@tiptap/core': 'ACMS.Library.tiptap.core',
|
|
860
|
+
'@tiptap/pm/state': 'ACMS.Library.tiptap.pm.state',
|
|
861
|
+
},
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
- **`ACMS.Library.tiptap` の中身が入るのはブロックエディターのチャンクがロードされた後**
|
|
867
|
+
(=ファクトリが呼ばれる時点)。名前空間の取得自体はいつでもできるが、チャンクロード前に
|
|
868
|
+
`Node` などへ触ると `ACMS.Library.tiptap.core は…` という例外になる。トップレベルで
|
|
869
|
+
`import { Node } from '@tiptap/core'` して**即座に使う**コードは書かないこと
|
|
870
|
+
(静的 import 自体は問題なく、ファクトリの中でしか触らないなら動く)。
|
|
871
|
+
- 公式拡張のバージョンは**本体の tiptap カタログ(`pnpm-workspace.yaml` の `catalogs.tiptap`)に合わせる**。
|
|
872
|
+
型だけ利用する場合も同じ。
|
|
873
|
+
- 自分で書くカスタムノードはファクトリ引数からも取れる(`registerExtension('x', ({ core }) => core.Node.create(...))`)。
|
|
874
|
+
externals を設定していない場合はこちらを使う。
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
## 新しい拡張ポイントを追加するときのルール
|
|
878
|
+
|
|
879
|
+
- イベントを追加する場合は `AcmsEventMap` への module augmentation を、機能の `src/events.ts`
|
|
880
|
+
(例: `packages/frontend/media/src/events.ts`)等、対象 feature 内に追加する。
|
|
881
|
+
- Slot を追加する場合は `<Slot name="..." fillProps={...} />` を設置し、`fillProps` の型を
|
|
882
|
+
コンポーネントファイル内で export する(`UnitToolbarSlotFillProps` に倣う)。
|
|
883
|
+
- レジストリを追加する場合は `createRegistry<T>('label')` を `global-singletons.ts` に生やし、
|
|
884
|
+
`PluginContext` に生えている既存の `unitEditor` / `dataview` ネームスペースに倣って
|
|
885
|
+
`ctx.{domain}.register{Something}` の形で公開する。
|
|
886
|
+
- 上記いずれの場合も、**本書(`HOOKS.md`)の対応する一覧に追記する**こと。イベントなら
|
|
887
|
+
「イベント一覧」の対応表に 1 行、Slot なら「Slot 一覧」に 1 行、レジストリなら「レジストリ一覧」に
|
|
888
|
+
1 行。一度公開したイベント名・Slot 名・レジストリ名は外部プラグインが依存しうるため破壊変更しにくい。
|
|
889
|
+
本書に載せたものだけを公開 API とみなす(`docs/plan/admin-extensibility-api/tasklist.md`「進め方の注意」)。
|
|
890
|
+
- 詳細は [AGENTS.md](../../../AGENTS.md)「拡張ポイント(フック)の方針」を参照。
|
|
891
|
+
|
|
892
|
+
## ユースケース実装例: メディア alt の AI 生成ボタン
|
|
893
|
+
|
|
894
|
+
「メディア詳細モーダルの alt テキストを AI で生成するボタンを足したい」という要望を、Slot・ゲート・
|
|
895
|
+
`withFillProps` の組み合わせで実現する例。Slot を `media-modal.tsx` に 1 箇所(`media.edit-modal.field.alt`)
|
|
896
|
+
置くだけで、MediaModal は共有コンポーネントのため、メディア管理・カスタムフィールド・ブロックエディター
|
|
897
|
+
画像/ファイル・リッチエディター・バナー・ナビゲーション・ユニットの 8 経路すべてに同時に効く。
|
|
898
|
+
|
|
899
|
+
外部プラグイン(`InjectTemplate::add()` で `<script type="module">` として読み込まれ、npm パッケージを
|
|
900
|
+
import できない環境)が書くコードの例:
|
|
901
|
+
|
|
902
|
+
```jsx
|
|
903
|
+
// ai-alt-plugin.jsx — InjectTemplate::add() で <script type="module"> として読み込む
|
|
904
|
+
const { withFillProps } = ACMS.plugins;
|
|
905
|
+
|
|
906
|
+
ACMS.plugins.register('ai-alt-generator', {
|
|
907
|
+
version: '1.0.0',
|
|
908
|
+
setup(ctx) {
|
|
909
|
+
// UI 差し込み: alt 欄の直後に「AI で生成」ボタン
|
|
910
|
+
ctx.ui.fill('media.edit-modal.field.alt', withFillProps(({ item, previewSrc, setAlt }) => (
|
|
911
|
+
<button
|
|
912
|
+
type="button"
|
|
913
|
+
className="acms-admin-btn"
|
|
914
|
+
onClick={async () => {
|
|
915
|
+
const alt = await generateAltByAi(previewSrc, ctx.config.apiEndpoint);
|
|
916
|
+
setAlt(alt);
|
|
917
|
+
}}
|
|
918
|
+
>alt を AI で生成</button>
|
|
919
|
+
)));
|
|
920
|
+
|
|
921
|
+
// 保存前検査: alt 未入力の画像を保存させない(ゲートのキャンセル使用例)
|
|
922
|
+
ctx.events.on('media.update.before', (payload, gateCtx) => {
|
|
923
|
+
if (payload.item.media_type === 'image' && !payload.formData.get('field_3')) {
|
|
924
|
+
gateCtx.cancel('alt テキストを入力してください');
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
},
|
|
928
|
+
});
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
- `withFillProps` の入手経路は `ACMS.plugins.withFillProps`(グローバル公開。上記「`ctx.ui.fill` で
|
|
932
|
+
fillProps を受け取る」参照)。内部コード(本体・パッケージ)は `import { withFillProps } from
|
|
933
|
+
'@ablogcms/plugins'` も使える。
|
|
934
|
+
- alt 行は `ACMS.Config.mediaShowAltAndCaptionOnModal` が off のとき行ごと `display:none` になる。Slot も
|
|
935
|
+
一緒に消える仕様(alt 生成という文脈から離れる置き場所を避けるため、行外には置かない)。
|
|
936
|
+
- `setAlt` は `AutoResizeTextarea` の `setValue`(imperative handle)を呼ぶため、内部 state・DOM 値・
|
|
937
|
+
高さの自動リサイズがまとめて更新される(「Slot 一覧」の `media.edit-modal.field.alt` 行参照)。
|
|
938
|
+
- 保存前検査(`media.update.before`)でキャンセルする場合は `ctx.cancel()`(理由なし)ではなく
|
|
939
|
+
`ctx.cancel(reason)`(理由あり)を使うと、`reason` がそのままユーザーへの表示に使われる
|
|
940
|
+
(`media-modal.tsx` の `update()` が `gateResult.reason` を alert する)。
|
|
941
|
+
- MediaModal はクラスコンポーネントだが、`<Slot>` を render() に置くこと自体は問題ない。Fill 側
|
|
942
|
+
(プラグイン)は `withFillProps` の制約により関数コンポーネント必須。
|
|
943
|
+
|
|
944
|
+
## カスタムブロックの作り方
|
|
945
|
+
|
|
946
|
+
カスタムブロックの実体は「BLOCK_DEFINITIONS 相当のエントリ(type 実装マスター)+(新規 node/mark
|
|
947
|
+
type の場合のみ)Tiptap 拡張(スキーマ層)」の 2 層。**「どの type をブロックメニューに出すか」は
|
|
948
|
+
今まで通り管理者が編集設定画面(ブロックエディター設定 → ブロックメニュー)で選び、config として
|
|
949
|
+
保存するサーバー駆動のまま**(Gutenberg の `register_block_type()` と同じく「登録=実装の提供」で
|
|
950
|
+
あって「メニューへの露出」は別の関心事、という設計に倣っている。プラグインは「メニュー登録 API」を
|
|
951
|
+
使わず、type の実装を登録するだけでよい。
|
|
952
|
+
|
|
953
|
+
### 1. type の実装を登録する(JS)
|
|
954
|
+
|
|
955
|
+
```tsx
|
|
956
|
+
import type { BlockDefinition } from '@ablogcms/block-editor/types';
|
|
957
|
+
|
|
958
|
+
ACMS.plugins.register('my-plugin', {
|
|
959
|
+
setup(ctx) {
|
|
960
|
+
// (1) スキーマ層(新規 node type の場合のみ。既存 node の組み合わせだけなら不要)
|
|
961
|
+
ctx.blockEditor.registerExtension('callout', ({ core }) =>
|
|
962
|
+
core.Node.create({
|
|
963
|
+
name: 'callout',
|
|
964
|
+
group: 'block',
|
|
965
|
+
content: 'paragraph+',
|
|
966
|
+
})
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
// (2) type 実装マスター(ほぼすべてのカスタムブロックで必須。これが無いと編集設定に
|
|
970
|
+
// type を書いてもメニュー解決パイプライン(createBlockMenu / resolveCommands)で捨てられる)
|
|
971
|
+
ctx.blockEditor.registerBlockDefinition('callout', () => ({
|
|
972
|
+
label: 'コールアウト',
|
|
973
|
+
description: '注意書きブロック',
|
|
974
|
+
icon: 'info',
|
|
975
|
+
isTextMenu: false,
|
|
976
|
+
createActions: (cmd) => ({
|
|
977
|
+
action: (editor) => editor.chain().focus().insertContent({ type: 'callout' }).run(),
|
|
978
|
+
isDisabled: () => false,
|
|
979
|
+
}),
|
|
980
|
+
}));
|
|
981
|
+
},
|
|
982
|
+
});
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
`registerBlockDefinition` / `registerExtension` の使い分け・ファクトリが `Tiptap` を受け取る
|
|
986
|
+
理由は「レジストリ一覧」参照。
|
|
987
|
+
|
|
988
|
+
### 2. 編集設定画面に type の選択肢を追加する(PHP 側の追加実装は不要)
|
|
989
|
+
|
|
990
|
+
編集設定画面(ブロックエディター設定 → ブロックメニュー)の type セレクトは、コアの type だけを
|
|
991
|
+
`ablogcms/themes/system/admin/config/block-editor.html` にハードコードしている。プラグイン提供の
|
|
992
|
+
type をここに並べるために、**PHP 側でレジストリや Hook を新設する必要はない**。かわりに JS 側
|
|
993
|
+
(`ablogcms/js/src/elements/block-editor-menu-type-select-element.ts`)が、この設定画面を開いた
|
|
994
|
+
タイミングで `registerBlockDefinition` 済みの type を読み出し、「プラグイン」optgroup として
|
|
995
|
+
select に追加する。行の追加(`js-fieldgroup-sortable` の複製)で生成される新しい行にも同じ optgroup
|
|
996
|
+
が反映される(`acmsAddCustomFieldGroup` → `acmsAdminDelayedContents` 経由)。
|
|
997
|
+
|
|
998
|
+
- 管理者は追加された optgroup からプラグインの type を選び、通常どおり保存するだけでよい。
|
|
999
|
+
- 保存された config 値(`block_editor_block_menu_type` 等)は `ACMS_GET_Admin_BlockEditor_SettingsJson`
|
|
1000
|
+
がバリデーションなしでそのまま返す設計のため(`getBlockMenuSetting()` のコメント参照)、未知の
|
|
1001
|
+
type であっても PHP 側は素通しする。プラグインの type がここで弾かれることはない。
|
|
1002
|
+
- **Why not(PHP 側にも type カタログを持たせなかった理由)**: 当初 PHP 側にも
|
|
1003
|
+
`Services/BlockEditor/BlockDefinitionRegistry` のような並行レジストリを試作したが、(1) select の
|
|
1004
|
+
選択肢はラベルさえ分かればよく、ラベルは JS 側の `BlockDefinition.label` に既に存在するため
|
|
1005
|
+
二重管理になる、(2) a-blog cms の管理画面 JS は Gutenberg のように編集画面自体が SPA であり、
|
|
1006
|
+
クライアント側のレジストリを直接列挙できる(`wp.blocks.getBlockTypes()` に相当する構成が既にある)
|
|
1007
|
+
ため、WordPress の「Block Manager」系プラグインのようにサーバー側へ一覧を複製する理由がない。
|
|
1008
|
+
型別カスタムフィールド(type ごとの追加設定項目)の動的レンダリングも同じ理由でスコープ外とし、
|
|
1009
|
+
必要ならプラグイン自身の管理画面(独自のコンフィグ画面・`InjectTemplate::add()`)で提供する方針とする。
|
|
1010
|
+
|
|
1011
|
+
### 3. 保存 HTML のサニタイズ
|
|
1012
|
+
|
|
1013
|
+
カスタムブロックが出力する独自タグ・属性が既定の HTMLPurifier 許可リストに無い場合、公開側表示や
|
|
1014
|
+
保存時に剥がされることがある。必要に応じて PHP Hook `extendsHtmlPurifierDefinition`
|
|
1015
|
+
(`Services/HtmlPurifier`)で許可要素・属性を追加すること([functional-design.md](../../../docs/foundation/functional-design.md)
|
|
1016
|
+
「拡張ポイント」参照)。
|
|
1017
|
+
|
|
1018
|
+
## ブロックツールバーの拡張
|
|
1019
|
+
|
|
1020
|
+
BlockMenu(統合ブロックツールバー。ブロック選択時に出る単一のツールバー。旧 TextMenu /
|
|
1021
|
+
ImageBlockMenu 等の並立構成を統合したもの)へ独自のボタン・UI を差し込む経路は、拡張内 `blockMenu`
|
|
1022
|
+
宣言と Slot(`block-editor.block-menu` 系)の 2 つがある。**新しいレジストリは増やさない**。
|
|
1023
|
+
|
|
1024
|
+
- **拡張内 `blockMenu` 宣言**(推奨): `ctx.blockEditor.registerExtension` で登録する Tiptap 拡張自身
|
|
1025
|
+
の `addOptions()` に `blockMenu` プロパティを書く。ノード・コマンドを伴う拡張(カスタムブロック等)を
|
|
1026
|
+
作るときはこちらを使う。「拡張の登録」と「ツールバーへの露出」が同じ場所に書けるコロケーション
|
|
1027
|
+
設計で、WordPress Gutenberg が `BlockControls` をブロックの `edit` 実装内に書くのと同じ思想。
|
|
1028
|
+
- **Slot**(`block-editor.block-menu` 系。「Slot 一覧」参照): 新しい拡張・ノードを伴わず、UI だけを
|
|
1029
|
+
差し込みたい場合に使う低レベル経路。既存の拡張には手を入れられない・触れたくない場合や、ブロック
|
|
1030
|
+
種別に依存しない汎用ツールを差し込みたい場合に向く。
|
|
1031
|
+
|
|
1032
|
+
どちらの経路で追加した項目も、見た目・並び(共通区画が先、種別別区画が後)は同じツールバーの中に
|
|
1033
|
+
混在して表示される。なお、a-blog cms 本体のブロック種別セクション(画像・ファイル・リンクボタン・
|
|
1034
|
+
テーブル・テキスト書式)も同じ「拡張内 `blockMenu` 宣言」で登録してある(`ImageBlock` /
|
|
1035
|
+
`FileBlock` / `LinkButton` / `Table` / `TextFormat` 拡張)。BlockMenu 側にブロック種別ごとの分岐は
|
|
1036
|
+
無く、コアもプラグインも同じ経路で区画を組み立てる。
|
|
1037
|
+
|
|
1038
|
+
### blockMenu 宣言の形
|
|
1039
|
+
|
|
1040
|
+
`blockMenu` は 2 種類の宣言を配列で混在できる(型は `@ablogcms/block-editor/types` の
|
|
1041
|
+
`BlockMenuToolbarItem` = `BlockMenuButtonItem | BlockMenuRenderItem`)。
|
|
1042
|
+
|
|
1043
|
+
- **データ型**(`BlockMenuButtonItem`): `icon`(Material Symbols 名)/ `tooltip` / `onSelect` を持つ
|
|
1044
|
+
プレーンなオブジェクト。React を持ち込まずに済み、既存の `Toolbar.Button` + `Icon` で描画される。
|
|
1045
|
+
`isActive` / `isDisabled` はトランザクションのたびに再評価される。
|
|
1046
|
+
- **コンポーネント型**(`BlockMenuRenderItem`): `render({ editor, variant })` が任意の `ReactNode` を
|
|
1047
|
+
返す。`Toolbar.Button` の枠に収まらない UI(複数ボタンの組み合わせ、ポップオーバー付きメニュー等)
|
|
1048
|
+
向け。`scope`/`order` は `render` を実行しなくても判定できるプレーンなデータとして持つため、
|
|
1049
|
+
対象外の区画では `render` は一度も呼ばれない。
|
|
1050
|
+
|
|
1051
|
+
両方とも共通のメタ情報を持つ。
|
|
1052
|
+
|
|
1053
|
+
- `scope?: BlockMenuItemScope | BlockMenuItemScope[]`(`'common' | 'text' | 'image' | 'file' |
|
|
1054
|
+
'linkButton' | 'table' | (任意のノード名)`): 表示する区画。固定の区画名に加えて、**スキーマに
|
|
1055
|
+
存在する任意のノード名**をそのまま指定できる(`BlockMenuItemScope` は LiteralUnion パターン。
|
|
1056
|
+
組み込み値の補完を保ちつつ任意の string も受け付ける)。ノード名を指定した場合、「現在選択中の
|
|
1057
|
+
ブロックがそのノードか」を `editor.isActive(nodeName)`(NodeSelection・カーソル位置どちらでも
|
|
1058
|
+
機能する)で直接判定して表示可否を決める。`image`/`file`/`linkButton`/`table`/`text` の 5 つだけは
|
|
1059
|
+
`resolveBlockMenuState` が解決する固定 variant とそのまま比較する(後方互換の判定経路)。
|
|
1060
|
+
**省略時は宣言元の拡張から自動判定**する。拡張が Node で
|
|
1061
|
+
`imageBlock`/`fileBlock`/`linkButton`/`table` のいずれかに対応するノード名ならその区画、テキスト
|
|
1062
|
+
ブロック相当のノード(`isTextblock`)なら `'text'`、**それ以外の Node(独自ノードを含む)は
|
|
1063
|
+
自ノード名**になる(=ノード名を明示したときと同じ「そのノード選択時のみ表示」になる)。
|
|
1064
|
+
Mark・Extension(Node ではない拡張)は `'common'` になる。`'common'` を他の値と併記した場合、
|
|
1065
|
+
他の値の有無に関わらず常に共通区画に表示される(variant 別区画に二重表示されることはない)。
|
|
1066
|
+
ノード名スコープの項目には専用の区画コンポーネントが無いため、描画位置は共通区画と同じ位置に
|
|
1067
|
+
なる(区画内の並び順は `order` がそのまま効く)。
|
|
1068
|
+
- `order?: number`: 同一区画内での並び順(既定 `0`。小さいほど先。同値は宣言順を維持)。コアの
|
|
1069
|
+
ブロック種別セクションは `-100`(`CORE_BLOCK_MENU_SECTION_ORDER`)で宣言されているため、既定値の
|
|
1070
|
+
ままならプラグインの項目はセクションの後ろに並ぶ。セクションより前に出したい場合は `-100` より
|
|
1071
|
+
小さい値を指定する。
|
|
1072
|
+
|
|
1073
|
+
不正な宣言(`blockMenu` が配列でない、データ型/コンポーネント型どちらの形にも一致しない、`scope` が
|
|
1074
|
+
既知の値でもスキーマ上のノード名でもない等)はその項目だけを無視し、`console.warn` で該当拡張名を
|
|
1075
|
+
出す。1 つのプラグインの記述ミスでエディタ全体が落ちないよう、例外は投げない。
|
|
1076
|
+
|
|
1077
|
+
### コード例(データ型)
|
|
1078
|
+
|
|
1079
|
+
「カスタムブロックの作り方」の `callout` 拡張に、太字化ボタンを追加する例。
|
|
1080
|
+
|
|
1081
|
+
```tsx
|
|
1082
|
+
ACMS.plugins.register('my-plugin', {
|
|
1083
|
+
setup(ctx) {
|
|
1084
|
+
ctx.blockEditor.registerExtension('callout', ({ core }) =>
|
|
1085
|
+
core.Node.create({
|
|
1086
|
+
name: 'callout',
|
|
1087
|
+
group: 'block',
|
|
1088
|
+
content: 'paragraph+',
|
|
1089
|
+
addOptions() {
|
|
1090
|
+
return {
|
|
1091
|
+
blockMenu: [
|
|
1092
|
+
{
|
|
1093
|
+
icon: 'format_bold',
|
|
1094
|
+
tooltip: '強調表示に切り替え',
|
|
1095
|
+
isActive: (editor) => editor.isActive('callout', { emphasis: true }),
|
|
1096
|
+
onSelect: (editor) => editor.chain().focus().updateAttributes('callout', { emphasis: true }).run(),
|
|
1097
|
+
// scope 省略時は Node 名 'callout' から自動判定される。callout は
|
|
1098
|
+
// imageBlock/fileBlock/linkButton/table のいずれでもなく isTextblock でもないため、
|
|
1099
|
+
// 自ノード名 'callout' にフォールバックする。つまりこのボタンは「callout ブロックを
|
|
1100
|
+
// 選択している間だけ」表示され、他のブロックを選んでいるときは出ない
|
|
1101
|
+
// (描画位置自体は専用区画が無いため共通区画と同じ位置になる)。
|
|
1102
|
+
},
|
|
1103
|
+
],
|
|
1104
|
+
};
|
|
1105
|
+
},
|
|
1106
|
+
})
|
|
1107
|
+
);
|
|
1108
|
+
},
|
|
1109
|
+
});
|
|
1110
|
+
```
|
|
1111
|
+
|
|
1112
|
+
### コード例(独自ノードへの明示的なスコープ指定)
|
|
1113
|
+
|
|
1114
|
+
上の例は `scope` 省略時のフォールバックで暗黙的に `callout` へスコープされているが、
|
|
1115
|
+
明示的にノード名を書いても同じ結果になる。複数のノードに同時対応させたい場合など、
|
|
1116
|
+
フォールバックだけでは表現できないケースで使う。
|
|
1117
|
+
|
|
1118
|
+
```tsx
|
|
1119
|
+
addOptions() {
|
|
1120
|
+
return {
|
|
1121
|
+
blockMenu: [
|
|
1122
|
+
{
|
|
1123
|
+
icon: 'format_bold',
|
|
1124
|
+
tooltip: '強調表示に切り替え',
|
|
1125
|
+
// 'callout' と 'anotherCustomNode' のどちらを選択しているときも表示する
|
|
1126
|
+
scope: ['callout', 'anotherCustomNode'],
|
|
1127
|
+
isActive: (editor) => editor.isActive('callout', { emphasis: true }),
|
|
1128
|
+
onSelect: (editor) => editor.chain().focus().updateAttributes('callout', { emphasis: true }).run(),
|
|
1129
|
+
},
|
|
1130
|
+
],
|
|
1131
|
+
};
|
|
1132
|
+
},
|
|
1133
|
+
```
|
|
1134
|
+
|
|
1135
|
+
### コード例(コンポーネント型)
|
|
1136
|
+
|
|
1137
|
+
```tsx
|
|
1138
|
+
import type { BlockMenuRenderItem } from '@ablogcms/block-editor/types';
|
|
1139
|
+
|
|
1140
|
+
ctx.blockEditor.registerExtension('callout', ({ core }) =>
|
|
1141
|
+
core.Node.create({
|
|
1142
|
+
name: 'callout',
|
|
1143
|
+
group: 'block',
|
|
1144
|
+
content: 'paragraph+',
|
|
1145
|
+
addOptions() {
|
|
1146
|
+
return {
|
|
1147
|
+
blockMenu: [
|
|
1148
|
+
{
|
|
1149
|
+
scope: 'common',
|
|
1150
|
+
render: ({ editor }) => (
|
|
1151
|
+
<select onChange={(e) => editor.chain().focus().updateAttributes('callout', { level: e.target.value }).run()}>
|
|
1152
|
+
<option value="info">情報</option>
|
|
1153
|
+
<option value="warning">警告</option>
|
|
1154
|
+
</select>
|
|
1155
|
+
),
|
|
1156
|
+
} satisfies BlockMenuRenderItem,
|
|
1157
|
+
],
|
|
1158
|
+
};
|
|
1159
|
+
},
|
|
1160
|
+
})
|
|
1161
|
+
);
|
|
1162
|
+
```
|
|
1163
|
+
|
|
1164
|
+
### コード例(Slot。UI だけを差し込みたい場合)
|
|
1165
|
+
|
|
1166
|
+
```tsx
|
|
1167
|
+
import { withFillProps } from '@ablogcms/plugins';
|
|
1168
|
+
import type { BlockMenuSlotFillProps } from '@ablogcms/block-editor/block-menu';
|
|
1169
|
+
|
|
1170
|
+
ACMS.plugins.register('my-plugin', {
|
|
1171
|
+
setup(ctx) {
|
|
1172
|
+
// 画像ブロックがアクティブなときだけ表示される独自ボタン(新しい拡張・ノードは伴わない)
|
|
1173
|
+
ctx.ui.fill(
|
|
1174
|
+
'block-editor.block-menu.image',
|
|
1175
|
+
withFillProps<BlockMenuSlotFillProps>(({ editor }) => (
|
|
1176
|
+
<button type="button" onClick={() => alert('画像を検査')}>
|
|
1177
|
+
画像を検査
|
|
1178
|
+
</button>
|
|
1179
|
+
))
|
|
1180
|
+
);
|
|
1181
|
+
},
|
|
1182
|
+
});
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
## カスタムユニットの作り方
|
|
1186
|
+
|
|
1187
|
+
ユニットエディター側も「編集設定(サーバー)でメニュー露出 + `ctx.unitEditor.registerUnitDefinition`
|
|
1188
|
+
(JS レジストリ)で描画」というブロックエディターと同じ分業を取る(レジストリ一覧参照)。ただし
|
|
1189
|
+
ユニットは「保存データ構造を持つ実体」(`column_type` ごとの PHP Unit Model)を必ず伴う点がブロック
|
|
1190
|
+
エディター(コンテンツは HTML/JSON としてそのまま保存され、PHP 側は基本サニタイズのみ)と異なる。
|
|
1191
|
+
そのため、プラグインが完全に新しい type を作るか、既存の仕組みに乗るかで手順が変わる。
|
|
1192
|
+
|
|
1193
|
+
### 方式A: 独自の保存データ構造を持つユニットを追加する(JS UI + PHP Model の両方が必要)
|
|
1194
|
+
|
|
1195
|
+
1. JS 側(描画・編集 UI)は `ctx.unitEditor.registerUnitDefinition('my-chart', unitDef)` で登録する。
|
|
1196
|
+
`unit-editor` / `inplace-editor` / `config-editor` の3経路すべてに配線済み
|
|
1197
|
+
(`unit-definitions.ts` の `getUnitDefinitions()`)ため、この登録だけで3画面に反映される。
|
|
1198
|
+
**type 名にアンダースコア(`_`)を含めないこと**。PHP `detectUnitTypeSpecifier()`
|
|
1199
|
+
(`ACMS/function.php`)と JS `UnitFactory#resolveName`(`core/factory.ts`)が共に
|
|
1200
|
+
`_` より前だけを実際の type として扱う仕様(同一 type の派生バリアントを
|
|
1201
|
+
`type_variant` のように区別する既存の仕組み)のため、`my_chart` は `my` として解決され
|
|
1202
|
+
意図せず衝突・分断する。ハイフン区切り(`my-chart`)を使う。
|
|
1203
|
+
2. PHP 側(保存・読み込み・公開側レンダリング)は、`Acms\Services\Unit\Registry`(`unit-registry`
|
|
1204
|
+
サービス)が `column_type` 文字列とモデルクラスを対応付けて解決する。この対応表に無い type は
|
|
1205
|
+
`Acms\Services\Unit\Repository::makeModel()` が `null` を返して**例外もログもなく黙って捨てる**
|
|
1206
|
+
(保存 POST・DB からの読み込み・公開側レンダリングで共通の契約。`extractUnits()` は
|
|
1207
|
+
`continue`、`loadModels()` は `array_filter` で除外)ため、PHP 側の bind を忘れると
|
|
1208
|
+
「保存したはずのユニットが消える」という気づきにくい形で失敗する。拡張アプリの
|
|
1209
|
+
ServiceProvider から次のように登録する(コア標準 type も `UnitServiceProvider::init()` が同じ
|
|
1210
|
+
仕組みで登録している)。
|
|
1211
|
+
|
|
1212
|
+
```php
|
|
1213
|
+
use Acms\Services\Facades\Application;
|
|
1214
|
+
use Acms\Services\Unit\Registry;
|
|
1215
|
+
|
|
1216
|
+
Application::bootstrap('unit-registry', function (Registry $registry) {
|
|
1217
|
+
$registry->bind('my-chart', \Acms\Plugins\MyPlugin\Unit\MyChart::class);
|
|
1218
|
+
});
|
|
1219
|
+
```
|
|
1220
|
+
|
|
1221
|
+
`MyChart` クラスは `Acms\Services\Unit\Contracts\Model` を実装する(`render` / `renderEdit` /
|
|
1222
|
+
`extract` / `canSave` など。既存モデル `ablogcms/php/Services/Unit/Models/*.php` が実装例)。
|
|
1223
|
+
Hook のような新しい仕組みは不要で、`Application::bootstrap()` という既存のコンテナ機構
|
|
1224
|
+
(`Services/Container.php`)だけで完結する(動作は
|
|
1225
|
+
`tests/phpunit/Unit/Services/Unit/RegistryTest.php` の特徴付けテストで固定済み)。
|
|
1226
|
+
このコールバックは `Application::make('unit-registry')` が呼ばれるたびに再実行される設計
|
|
1227
|
+
(キャッシュされるのはインスタンスのみ)のため、`bind()` のような冪等な呼び出しに留め、
|
|
1228
|
+
副作用を持つ処理(カウントアップ・外部通信等)をここに書かないこと。
|
|
1229
|
+
3. 編集設定への type 追加は PHP 側の追加実装が不要。ユニット追加メニューの構成
|
|
1230
|
+
(`ablogcms/themes/system/admin/config/edit.html` の `column_add_type[]`)はブロックエディターの
|
|
1231
|
+
`<select>` と異なり、自由テキスト入力(`<input list="column_add_type">` + `<datalist>` サジェスト)
|
|
1232
|
+
のため、管理者は `my-chart` をそのまま入力して保存できる。datalist のサジェスト候補
|
|
1233
|
+
(`ACMS_GET_Admin_Unit_ModelOptions` → `Registry::getOptions()`)は2.で bind した type から自動的に
|
|
1234
|
+
列挙されるため、ここでも追加の PHP 実装は不要。
|
|
1235
|
+
|
|
1236
|
+
### 方式B: 既存の `custom`(カスタムユニット)に乗る(PHP Model 実装が不要)
|
|
1237
|
+
|
|
1238
|
+
独自の React UI が不要で、フィールドの組み合わせで十分な場合は、コア標準の `custom` type
|
|
1239
|
+
(`Acms\Services\Unit\Models\Custom`、Field ベースの汎用データストア)をそのまま使う方が軽い。
|
|
1240
|
+
この場合 PHP Model の新規実装は不要(`custom` は既にコアで `unit-registry` に bind 済み)。
|
|
1241
|
+
JS 側の描画も `registerUnitDefinition('custom', unitDef)` で上書き可能(`unitDefs` は
|
|
1242
|
+
`{ ...coreUnitDefs, ...registryUnitDefs }` という後勝ちのマージのため、組み込みの `custom`
|
|
1243
|
+
UnitDef をプラグインの React コンポーネントに差し替えられる)が、**この上書きはそのブログの
|
|
1244
|
+
全 `custom` ユニットに一律で効く**(`custom_foo` のような specifier 付き type も
|
|
1245
|
+
`detectUnitTypeSpecifier()` で `custom` に丸められるため、特定プラグイン専用の `custom` だけを
|
|
1246
|
+
差し替えることはできない)。プラグイン専用の見た目・挙動が必要な場合は方式A を使う。
|
|
1247
|
+
|
|
1248
|
+
### ツールバー拡張
|
|
1249
|
+
|
|
1250
|
+
ユニットエディターのツールバーへの UI 差し込みは既存の Slot `unit-editor.unit-toolbar` を使う
|
|
1251
|
+
(Slot 一覧参照)。
|