# スキンUI API

> 基本スキン(basic-v2)にメニュー・ダイアログ・領域・装飾を追加するroom.skin APIの全一覧です。

`room.skin` を使うと、基本スキン(basic-v2)の画面にボタン・メニュー・ダイアログ・領域を追加したり、ニックネームやメッセージの表示を変えたりできます。このページは正式な一覧です。初めての方は[スキンUIを拡張する](/ja/doc/2.3)からご覧ください。

レガシースキン(`basic`)にはこのAPIはありません。レガシースキンについては[レガシースキンAPI](/ja/doc/3.8)をご覧ください。

## 共通ルール {#rules}

```js
U.chat('*').on('after.create#my-ui', function (room) {
	if (room.id !== 'my-room') return;
	var ui = room.skin;
	var remove = ui.menu.add({
		id: 'hello',
		label: 'あいさつ',
		onSelect: function (ctx) { ctx.room.action.send('こんにちは!'); },
	});
	// 不要になったら remove();
});
```

- **登録のタイミング:** `after.create` の中で登録します。チャットルームを設置し直すと(`room.reload()`、タグの再挿入)、スキンの登録はすべて消え、`after.create` が再度届きます。このとき、そのルームだけに登録したイベント(`U.chat('my-room').on`)も消えるため、上の例のように `U.chat('*')` に登録して `room.id` で絞り込むと、設置し直すたびに自動で再登録されます。
- **ID:** 登録ごとに `id` が必要で、同じ種類の中で重複するとエラーを投げます。
- **解除関数:** `add` 系の関数は解除関数を返します。最初に呼ぶと `true`、その後は `false` を返し、何度呼んでも安全です。
- **同期コールバックのみ:** `condition`、`describe`、`resolve`、`match` などのコールバックは同期関数にしてください。Promiseを返すとエラーとして扱われ、その項目は除外されます。コールバック内の例外はコンソールに記録され、その項目だけがスキップされます。`onSelect` は戻り値を見ないので非同期でも構いませんが、その中の例外は自分で処理してください。
- **表示だけが変わる:** メニューを隠したり装飾したりしても権限は変わりません。権限のチェックはサーバーが行います。
- チャットルームが破棄された後に `room.skin` を呼ぶとエラーを投げます。

## メニュー {#menus}

| API | 位置 |
| --- | --- |
| `ui.menu` | 入力欄ツールバーのボタン |
| `ui.userMenu` | ニックネームを押したときに出るメニュー |

| 関数 | 戻り値 | 説明 |
| --- | --- | --- |
| `add(item)` | 解除関数 | 項目を追加 |
| `remove(id)` | `true`/`false` | 項目を削除。存在しないIDなら `false` |
| `resolver.add({ id, resolve })` | 解除関数 | 項目一覧を調整。下の[メニューresolver](#menu-resolver)を参照 |
| `resolver.remove(id)` | `true`/`false` | resolverを削除 |
| `resolver.refresh()` | — | 条件が変わったときに一覧を再計算 |

### 項目のフィールド

| キー | 形式 | 説明 |
| --- | --- | --- |
| `id` | 文字列 | 必須。固有のID |
| `label` | 文字列 | 表示テキスト。なければ `title`、それもなければ `id` |
| `title` | 文字列 | マウスを重ねたときの説明 |
| `order` | 数値 | 小さいほど前。既定は0 |
| `group` | 文字列 | `userMenu`: 同じグループの項目を1行にまとめます |
| `column` | `0`/`1` | `userMenu`: 同じグループ内で左(0)・右(1)の2列に分けます |
| `separator` | 真偽値 | `userMenu`: 区切り線。選択できません |
| `disabled` | 真偽値 | 無効(表示されるが押せない) |
| `pressed` | 真偽値 | `menu`: 押された状態で表示(オン・オフボタン) |
| `padding` | `'none'`/`'normal'` | `menu`: ボタンの内側の余白。既定は `'normal'` |
| `condition(ctx)` | 関数 | `false` を返すと非表示 |
| `describe(ctx)` | 関数 | その都度変わるフィールド(`label`、`disabled`、`pressed` など)をオブジェクトで返します。`null` なら非表示 |
| `onSelect(ctx)` | 関数 | 押されたとき |

### コールバックのctx

| API | `ctx` |
| --- | --- |
| `ui.menu` | `{ room, skin }`、`onSelect` では `event` も |
| `ui.userMenu` | `{ room, skin, target, messageUid, message, event }` — `target` は押された人の[ユーザーオブジェクト](/ja/doc/3.2#user-object)。メッセージのニックネームを押した場合は `messageUid` と `message` が入ります |

```js
ui.userMenu.add({
	id: 'profile',
	label: 'プロフィールを見る',
	condition: function (ctx) { return !!ctx.target.id; },
	onSelect: function (ctx) {
		window.open('/profile/' + encodeURIComponent(ctx.target.id));
	},
});
```

### 既定の項目

| API | ID(`order`) |
| --- | --- |
| `ui.menu` | `skin.bold`(-60), `skin.italic`(-50), `skin.underline`(-40), `skin.color`(-30), `skin.emoticon`(-20), `skin.upload`(-10), `skin.brand`(100), `skin.scroll`(110) |
| `ui.userMenu` | `skin.whisper`、`skin.mute`、`skin.ban` のように `skin.` で始まります。`order` は -1000から10刻み |

## メニューresolver {#menu-resolver}

resolverはすでに登録された項目だけを扱います。非表示にする、順序を変える、表示値を変える、無効にすることができ、新しい項目を作ったり無効な項目を有効にしたりすることはできません。

```js
ui.userMenu.resolver.add({
	id: 'hide-ignore',
	resolve: function (ctx) {
		return ctx.current.filter(function (item) { return item.id !== 'skin.ignore'; });
	},
});
```

| 項目 | 説明 |
| --- | --- |
| `resolve(ctx)` | `ctx` はメニューのctxに `base`(resolver適用前の一覧)、`current`(前のresolverまで適用した一覧)を加えたもの。配列を返すとその一覧に置き換え、`null` ならそのままにします |
| 返した配列 | IDのない項目、登録されていないID、重複したIDは捨てられます。`order` で並べ直します |

## ダイアログ {#dialogs}

| 関数 | 戻り値(Promiseの結果) |
| --- | --- |
| `ui.dialog.alert(title, content)` | 確認 `true`、閉じる `null` |
| `ui.dialog.confirm(title, content)` | 確認 `true`、キャンセル `false` |
| `ui.dialog.prompt(title, content, value)` | 入力した文字列、キャンセル `null` |
| `ui.dialog.password(title, content, value)` | 入力した文字列(伏せ字で入力)、キャンセル `null` |
| `ui.dialog.select(title, content, options, value)` | 選んだ値(キー)、キャンセル `null`。`options` は `{ 値: '表示テキスト' }` のオブジェクト |
| `ui.dialog.open({ kind, title, content, options, value })` | `{ id, result, close }` |

```js
ui.dialog.confirm('挑戦', 'じゃんけんしますか?').then(function (ok) {
	if (ok) room.action.send('挑戦!');
});

var handle = ui.dialog.open({
	kind: 'confirm',
	title: '挑戦',
	content: '10秒以内に答えてください。',
	options: { backdropDismiss: false },
});
handle.result.then(function (ok) { /* true、false、または close に渡した値 */ });
setTimeout(function () { handle.close(null); }, 10000);
```

- `kind` は `alert`、`confirm`、`prompt`、`password`、`select` のいずれかです。それ以外の値はエラーを投げます。
- `title`、`content` はテキストとして表示します(HTMLではありません)。
- `options.backdropDismiss: false` にすると、外側を押しても閉じません。
- `close(value)` はその値で `result` を確定させ、`true` を返します。すでに閉じている場合は `false` です。
- ダイアログは一度に1つずつ表示され、残りは順番に待ちます。スキン自体のダイアログが開いている場合は、そちらが先に表示されます。チャットルームが破棄されると、待っていたダイアログはすべて `null` で終わります。

## 領域の追加 {#mount}

`ui.mount.*` に `mount(host, ctx)` を渡すと、スキンが空の要素(`host`)を作って渡します。hostの中だけを扱い、外側のDOMは変更しないでください。

```js
ui.mount.messageList({
	id: 'banner',
	position: 'before',
	mount: function (host, ctx) {
		host.textContent = '今日の20時にイベントがあります!';
		return function () { host.replaceChildren(); };
	},
});
```

| 関数 | `position` | 位置 |
| --- | --- | --- |
| `header` | `before`(既定)、`after` | ヘッダー領域の前後 |
| `headerTitle` | — | チャットルームのタイトル部分の中 |
| `middle` | `before`(既定)、`after` | 会話+ユーザー一覧領域の前後 |
| `messageList` | `before`(既定)、`after` | 会話領域の前後 |
| `userList` | `before`(既定)、`after` | ユーザー一覧の前後 |
| `composer` | `above-input`(既定、`before-input` も同じ)、`after-input` | 入力欄の上、入力欄の後 |
| `footer` | `before`(既定)、`after` | 入力領域の前後 |
| `wrap` | `before`(既定)、`after`、`left`、`right` | チャットルーム全体の上・下・左・右 |
| `toolbar` | — | 入力欄ツールバーの中(ボタンの後) |
| `overlay` | — | 画面の上に重ねる。下の[重ねる領域](#overlay)を参照 |

すべて解除関数を返します。`ui.mount.removeOverlay(id)`、`ui.mount.removeToolbar(id)` でIDを指定して削除することもできます。サポートしていない `position` はエラーを投げます。

### mountのctx

| キー | 説明 |
| --- | --- |
| `room`, `skin` | roomオブジェクト、`room.skin` |
| `signal` | `AbortSignal`。後片付けの直前にabortされます。`addEventListener(..., { signal })` に渡すと後片付けが自動になります |
| `bounds()` | hostの `getBoundingClientRect()` の値 |
| `close()` | この登録全体を削除します(解除関数と同じ) |
| `requestLayout()` | hostのサイズを変えた後、スキンに再配置を依頼 |
| `slot` | (領域)追加された位置の名前。例: `messageList.before` |
| `layer`, `scope` | (`overlay`)重なりのレイヤーと範囲 |
| `message`, `author` | (`message.extend`)そのメッセージと送信者 |

`mount` が関数または `{ destroy }` オブジェクトを返すと、hostがなくなるか登録を削除したときに一度呼ばれます。

## 重ねる領域 {#overlay}

`ui.mount.overlay({ id, layer, scope, exclusive, mount })` は、レイアウトを押し広げずに上に重ねます。

| キー | 値 | 説明 |
| --- | --- | --- |
| `layer` | `background-effect` | 背面の効果。クリックは通り抜けます |
|  | `content-overlay`(既定) | 内容の上に情報を表示 |
|  | `modal` | 最前面で覆う |
| `scope` | `skin`(既定) | チャットルーム全体に重ねる |
|  | `message-list` | 会話領域だけに重ねる |
| `exclusive` | 真偽値 | `layer: 'modal'` と一緒に使うと、この領域が表示されている間は `ui.dialog` のダイアログを表示せずに待たせます。`scope` が `skin` のときだけ使え、それ以外ではエラーを投げます |

```js
ui.mount.overlay({
	id: 'player',
	layer: 'content-overlay',
	scope: 'message-list',
	mount: function (host, ctx) {
		host.textContent = '再生中';
	},
});
```

## 固定お知らせ {#top-layer}

| 関数 | 戻り値 | 説明 |
| --- | --- | --- |
| `ui.topLayer.add({ id, content, backgroundColor, color, dismissLabel, weight })` | 解除関数 | 会話領域の上部にあるお知らせ行に項目を追加します |
| `ui.topLayer.remove(id)` | `true`/`false` | 項目を削除 |

- `content` は必須で、テキストとして表示します。
- `weight` が大きいほど上に置かれます(既定は0)。
- `dismissLabel` は、閲覧者がお知らせを閉じるボタンの文字です。

```js
ui.topLayer.add({ id: 'event', content: 'イベント開催中!', backgroundColor: '#fff4cc', color: '#7a4b00' });
```

## メッセージへの追加 {#message-extend}

`ui.message.extend({ id, position, match, mount })` は、通常のメッセージとウィスパーの行ごとに領域を追加します。過去のメッセージと新しいメッセージの両方に適用されます。

| キー | 説明 |
| --- | --- |
| `position` | `before`(行の先頭、ニックネームの前)、`inline`(内容の直後)、`after`(既定、`inline` の後) |
| `match(ctx)` | 追加するメッセージを選びます。`ctx` は `{ message, author, room, skin }`。省略するとすべてのメッセージ |
| `mount(host, ctx)` | [mountのctx](#mount) に `message`、`author` が加わります |

`message` の主な値は `kind`(`'message'` または `'whisper'`)、`author`(送信者)、`content`、`uid`、`created`、`mine`(自分が送った場合は `true`)です。`ctx.close()` はそのメッセージ1件ではなく、登録全体を削除します。

```js
ui.message.extend({
	id: 'admin-badge',
	position: 'inline',
	match: function (ctx) { return ctx.author && ctx.author.auth >= 2; },
	mount: function (host) { host.textContent = ' [管理者]'; },
});
```

## ニックネームの装飾 {#nickname-resolver}

`ui.nickname.resolver.add({ id, resolve })`、`remove(id)`、`refresh()`。

| 項目 | 説明 |
| --- | --- |
| `resolve(ctx)` の `ctx` | `{ user, location, message, backgroundColor, room, skin, base, current }`。`location` は `'message'`(会話)または `'user-list'`(ユーザー一覧)、`message` は会話のときだけ |
| 返す値 | `{ style, nickcon, iconsBefore, iconsAfter, addBefore, addAfter, tag, gradient, classes, vars }` のうち変えるものだけ。`null` ならスキップします |
| `style` | 下の[使えるstyle](#style)を参照 |
| `gradient` | ニックネームの文字を左から右に塗る2〜8色の配列(例: `['#e65c00', '#d4145a']`)。許可styleの色形式のみ受け付け、`null`で消します。称号・アイコンには塗りません |
| `classes` | ニックネーム全体(`[data-nickname-display]`)に付けるクラス名の配列。[プラグインCSS](#plugin-style)で登録したルールを当てるのに使います。追加だけで(前のresolverのクラスは消せません)、最大16個。英字・数字・`_`・`-`だけの名前を受け付けます |
| `vars` | ニックネーム全体に入れるCSS変数(`{ '--my-color': '#f00' }`)。人ごとに違う値をCSSに渡すときに使います。名前は `--` で始め、値に `;` `{` `}` があると捨てます。`null` でその変数を消します |
| `nickcon` | `{ src, alt }`(ニックネームの代わりに画像)または `null`(ニックコンを消す) |
| `iconsBefore`, `iconsAfter` | `[{ src, alt }]`。ニックネームの前・後のアイコン一覧を**丸ごと置き換えます**。追加するには `ctx.current.iconsAfter.concat(...)` |
| `addBefore`, `addAfter` | ニックネームの前・後ろに**追加する**パーツ `[{ text, image, color, background, border, title, classes }]`。片側最大6個、文字は16文字まで。`classes`(最大8個)で[プラグインCSS](#plugin-style)の形を当てられます |

画像の `src` には `https://`、`http://`、`//`、`/` で始まるアドレスを使います。空白を含むアドレスや形式が正しくないアドレスは捨てられます。

```js
ui.nickname.resolver.add({
	id: 'admin-nick',
	resolve: function (ctx) {
		if (!ctx.user || ctx.user.auth < 2) return null;
		return {
			style: { fontWeight: 'bold', color: '#c2410c' },
			iconsAfter: ctx.current.iconsAfter.concat({ src: 'https://example.com/star.png', alt: '管理者' }),
		};
	},
});
```

## メッセージの装飾 {#message-resolver}

`ui.message.resolver.add({ id, resolve })`、`remove(id)`、`refresh()`。通常のメッセージとウィスパーにだけ適用されます。

| 項目 | 説明 |
| --- | --- |
| `resolve(ctx)` の `ctx` | `{ message, author, backgroundColor, room, skin, base, current }`。`message` は読み取り専用 |
| 返す値 | `{ style, row, classes, vars }` のうち変えるものだけ。`null` ならスキップします |
| `style` | 内容の文字と行の背景。下の[使えるstyle](#style)を参照 |
| `row.spacing` | `'normal'` または `'relaxed'`(行の余白を広げる) |
| `row.hideNickname` | `true` ならニックネームを隠します。通常のメッセージにだけ適用され、ウィスパーでは隠しません |
| `classes` | メッセージ1行全体に付けるクラス名の配列。[プラグインCSS](#plugin-style)で登録したルールを当てます。追加だけで最大16個。本文の文字はその中の `.message-content` です |
| `vars` | メッセージ行に入れるCSS変数(`{ '--my-accent': '#f00' }`)。ルールはニックネームの `vars` と同じです |

```js
ui.message.resolver.add({
	id: 'notice-highlight',
	resolve: function (ctx) {
		if (ctx.message.content.indexOf('お知らせ') < 0) return null;
		return { style: { backgroundColor: '#fff4cc' }, row: { spacing: 'relaxed' } };
	},
});
```

resolverは `order`(既定は0)の順、同じなら登録順に適用され、前の結果に上書きします。条件が変わって再計算が必要なら `refresh()` を呼んでください。

## 使えるstyle {#style}

ニックネーム・メッセージのresolverの `style` は、下のキーだけを受け付けます。それ以外のキーや形式が正しくない値は、何も知らせずに捨てられます。それ以外のCSSは[プラグインCSS](#plugin-style)で登録し、ニックネームの `classes` で当ててください。

| キー | 使える値 |
| --- | --- |
| `color`, `backgroundColor` | `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()`, `hsl()`, `hsla()` |
| `fontWeight` | `normal`, `bold`, `100`~`900`(100刻み) |
| `fontStyle` | `normal`, `italic`, `oblique` |
| `textDecoration` | `none`, `underline`, `overline`, `line-through` |

## プラグインCSS {#plugin-style}

`ui.style.add({ id, css })` は解除関数を、`ui.style.remove(id)` は `true`/`false` を返します。

- `css` の文字列をそのままチャットルーム画面の `<style>` として入れます。内容はチェックしません。プラグインがオフになるかスキンが片付けられると一緒に外れます。
- このCSSはチャットルーム画面全体に効きます。ほかの部分に影響しないよう、クラス名の前にプラグイン名を付けてください。
- ニックネームの文字は必ず `[data-nickname-label]` の中にあります。グラデーションを使うと同じ要素に `[data-nickname-gradient]` も付きます。

```js
ui.style.add({
	id: 'myPlugin.flow',
	css: '.my-flow [data-nickname-gradient]{background-size:200% 100%;animation:my-flow 4s linear infinite}'
		+ '@keyframes my-flow{to{background-position:-200% 0}}',
});
ui.nickname.resolver.add({
	id: 'myPlugin.nick',
	resolve: function (ctx) {
		if (!ctx.user || ctx.user.auth < 2) return null;
		return { gradient: ['#538eff', '#f61ecf', '#538eff'], classes: ['my-flow'] };
	},
});
```

## その他の機能 {#other}

| 関数 | 戻り値 | 説明 |
| --- | --- | --- |
| `ui.composer.guard.add({ id, shouldBlock, onBlocked })` | 解除関数 | 入力欄の使用を止めます。`shouldBlock(ctx)` が真なら止めて `onBlocked(ctx)` を呼びます |
| `ui.composer.guard.remove(id)` | `true`/`false` | ガードを削除 |
| `ui.message.removeByUser(nick)` | 削除した件数 | そのニックネームのメッセージを自分の画面から削除します |
| `ui.scrollToBottom(force)` | `true`/`false` | 会話の一番下へ移動。`force` がない場合、ユーザーがメッセージのニックネームやリンクにマウスを重ねているとき、または自動スクロールをオフにして上を見ているときは動かずに `false` |

ガードの `ctx` は `{ action, draft, event, room, skin }` で、`action` は `'send'`(送信)または `'activate'`(入力欄を押す、またはフォーカス)です。

```js
ui.composer.guard.add({
	id: 'login-first',
	shouldBlock: function (ctx) { return ctx.room.my.auth < 1; },
	onBlocked: function (ctx) { ctx.skin.dialog.alert('ご案内', 'ログインした会員だけがチャットできます。'); },
});
```
