# 스킨 UI API

> 기본 스킨(basic-v2)에 메뉴·대화상자·영역·꾸미기를 더하는 room.skin API의 전체 목록이에요.

`room.skin`으로 기본 스킨(basic-v2) 화면에 버튼·메뉴·대화상자·영역을 더하고 닉네임·메시지 표시를 바꿔요. 이 문서는 정본 목록이에요. 처음이라면 [스킨 UI 확장하기](/doc/2.3)부터 보세요.

레거시 스킨(`basic`)에는 이 API가 없어요. 레거시 스킨은 [레거시 스킨 API](/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`가 필요하고, 같은 종류 안에서 겹치면 오류를 던져요.
- **해제 함수:** `add`류 함수는 해제 함수를 돌려줘요. 처음 부르면 `true`, 그 뒤로는 `false`를 돌려주며 여러 번 불러도 안전해요.
- **동기 콜백만:** `condition`, `describe`, `resolve`, `match` 등 콜백은 동기 함수여야 해요. Promise를 돌려주면 오류로 처리되고 그 항목은 빠져요. 콜백 안의 예외는 콘솔에 기록되고 그 항목만 건너뛰어요. `onSelect`는 반환값을 보지 않으므로 비동기로 써도 되지만, 그 안의 예외는 직접 처리해요.
- **표시만 바꿈:** 메뉴를 숨기거나 꾸며도 권한은 바뀌지 않아요. 권한 검사는 서버가 해요.
- 채팅방이 파괴된 뒤 `room.skin`을 부르면 오류를 던져요.

## 메뉴 {#menus}

| API | 위치 |
| --- | --- |
| `ui.menu` | 입력창 도구 모음의 버튼 |
| `ui.userMenu` | 닉네임을 눌렀을 때 나오는 메뉴 |

| 함수 | 반환 | 설명 |
| --- | --- | --- |
| `add(item)` | 해제 함수 | 항목 추가 |
| `remove(id)` | `true`/`false` | 항목 제거. 없던 아이디면 `false` |
| `resolver.add({ id, resolve })` | 해제 함수 | 항목 목록 고치기. 아래 [메뉴 resolver](#menu-resolver) |
| `resolver.remove(id)` | `true`/`false` | resolver 제거 |
| `resolver.refresh()` | — | 조건이 바뀌었을 때 목록을 다시 계산 |

### 항목 필드

| 키 | 형식 | 설명 |
| --- | --- | --- |
| `id` | 문자열 | 필수. 고유 아이디 |
| `label` | 문자열 | 표시 글자(텍스트). 없으면 `title`, 그것도 없으면 `id` |
| `title` | 문자열 | 마우스를 올렸을 때 설명 |
| `order` | 숫자 | 작을수록 앞. 기본 0 |
| `group` | 문자열 | `userMenu`: 같은 그룹 항목을 한 줄에 모아요 |
| `column` | `0`/`1` | `userMenu`: 같은 그룹 안에서 왼쪽(0)·오른쪽(1) 두 칸으로 나눠요 |
| `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`은 누른 사람의 [사용자 객체](/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 | 아이디(`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`이면 그대로 둬요 |
| 돌려준 배열 | 아이디가 없는 항목, 등록되지 않은 아이디, 겹치는 아이디는 버려요. `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`예요.
- 대화상자는 한 번에 하나씩 보이고, 나머지는 순서대로 기다려요. 스킨 자체 대화상자가 떠 있으면 그것이 먼저 보여요. 채팅방이 파괴되면 기다리던 대화상자는 모두 `null`로 끝나요.

## 영역 붙이기 {#mount}

`ui.mount.*`에 `mount(host, ctx)`를 주면 스킨이 빈 요소(`host`)를 만들어 건네줘요. host 안만 다루고 바깥 DOM은 고치지 마세요.

```js
ui.mount.messageList({
	id: 'banner',
	position: 'before',
	mount: function (host, ctx) {
		host.textContent = '오늘 8시에 이벤트가 있습니다!';
		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)`로 아이디로 지울 수도 있어요. 지원하지 않는 `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()`는 그 메시지 하나가 아니라 등록 전체를 지워요.

```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` | 메시지 한 줄 전체에 붙일 클래스 이름 배열. [플러그인 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('안내', '로그인한 회원만 채팅할 수 있습니다.'); },
});
```
