사용자:Hsl0/연구소/숫자야구 live/ui.js
참고: 설정을 저장한 후에 바뀐 점을 확인하기 위해서는 브라우저의 캐시를 새로 고쳐야 합니다.
- 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
- 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
- 인터넷 익스플로러 / 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
- 오페라: Ctrl-F5를 입력.
mw.loader.load('/w/index.php?title=사용자:hsl0/연구소/숫자야구 live/ui.css&action=raw&ctype=text/css', 'text/css');
await mw.loader.using('oojs-ui-widgets');
const INTERVAL = Symbol('interval_id');
/* export */class Lobby {
constructor(config = {}) {
const lobby = this;
this.rooms = {};
this.element = document.createElement('div');
this.element.className = 'lui-lobby';
this.header = document.createElement('div');
this.header.className = 'lui-lobby-header';
this.element.append(this.header);
this.searchElement = new OO.ui.SearchInputWidget({
classes: ['lui-lobby-search'],
icon: 'search',
placeholder: '이름 검색 및 ID 입력'
});
this.searchElement.on('change', (timeout => (value => {
if(timeout) clearTimeout(timeout);
timeout = setTimeout(this.search.bind(this), 500, value);
}))());
this.searchElement.on('enter', () => {
this.go(this.searchElement.getValue());
});
this.searchElement.$element.appendTo(this.header);
this.listElement = document.createElement('ul');
this.listElement.className = 'lui-lobby-rooms lui-search-result-id lui-search-result-and';
this.element.append(this.listElement);
this.filter = new FilterGroup({
name: 'lobby-filter',
type: null,
uiConfig: {
initElement: $('<div />', {
class: 'lui-lobby-filter-body'
}),
prefix: 'lui-filter'
}
});
const idResultFilter = new Filter({
name: 'id',
label: 'ID',
value: true,
get() {
return lobby.listElement.classList.contains('lui-search-result-id');
},
set(value) {
if(value) lobby.listElement.classList.add('lui-search-result-id');
else lobby.listElement.classList.remove('lui-search-result-id');
this.dispatchEvent(new CustomEvent('change', {
detail: {value}
}));
}
});
const keywordResultFilter = new Filter({
name: 'and',
label: '키워드',
value: true,
get() {
return lobby.listElement.classList.contains('lui-search-result-and');
},
set(value) {
if(value) lobby.listElement.classList.add('lui-search-result-and');
else {
lobby.listElement.classList.remove('lui-search-result-and');
keywordOrFilter.value = false;
}
this.dispatchEvent(new CustomEvent('change', {
detail: {value}
}));
}
});
const keywordOrFilter = new Filter({
name: 'or',
label: 'or',
value: false,
get() {
return lobby.listElement.classList.contains('lui-search-result-keyword');
},
set(value) {
if(value) {
lobby.listElement.classList.add('lui-search-result-keyword');
keywordResultFilter.value = true;
}
else lobby.listElement.classList.remove('lui-search-result-keyword');
this.dispatchEvent(new CustomEvent('change', {
detail: {value}
}));
}
})
this.searchFilter = new FilterGroup({
type: 'ToggleButtonBundleFieldset',
name: 'search',
label: '검색 결과',
members: [
idResultFilter,
new FilterGroup({
type: 'ToggleButtonGroup',
name: 'keyword',
members: [keywordResultFilter, keywordOrFilter],
uiConfig: {
prefix: 'lui-filter-keyword'
}
})
],
uiConfig: {
prefix: 'search'
}
});
this.addFilter(this.searchFilter);
function applyFilter() {
$('.lui-room[hidden]').not($('.lui-room[hidden=0]').show()).hide();
}
const openFilter = new Filter({
name: 'open',
label: '개방',
value: true,
get() {
return lobby.listElement.classList.contains('lui-list-state-open');
},
set(value) {
if(value) lobby.listElement.classList.add('lui-list-state-open');
else lobby.listElement.classList.remove('lui-list-state-open');
this.dispatchEvent(new CustomEvent('change', {
detail: {value}
}));
}
});
const lockedFilter = new Filter({
name: 'locked',
label: '잠김',
value: true,
get() {
return lobby.listElement.classList.contains('lui-list-state-locked');
},
set(value) {
if(value) {
lobby.listElement.classList.add('lui-list-state-locked');
} else {
lobby.listElement.classList.remove('lui-list-state-locked');
}
applyFilter();
}
})
const fullFilter = new Filter({
name: 'full',
value: false,
label: '초과',
get() {
return lobby.listElement.classList.contains('lui-list-state-full');
},
set(value) {
if(value) {
lobby.listElement.classList.add('lui-list-state-full');
} else {
lobby.listElement.classList.remove('lui-list-state-full');
}
applyFilter();
}
})
const startedFilter = new Filter({
name: 'started',
label: '진행',
value: false,
get() {
return lobby.listElement.classList.contains('lui-list-state-started');
},
set(value) {
if(value) {
lobby.listElement.classList.add('lui-list-state-started');
} else {
lobby.listElement.classList.remove('lui-list-state-started');
}
applyFilter();
}
});
const kickedFilter = new Filter({
name: 'kicked',
label: '추방',
value: false,
get() {
return lobby.listElement.classList.contains('lui-list-state-kicked');
},
set(value) {
if(value) {
lobby.listElement.classList.add('lui-list-state-kicked');
} else {
lobby.listElement.classList.remove('lui-list-state-kicked');
}
applyFilter();
}
});
this.stateFilters = new FilterGroup({
name: 'state',
type: 'ToggleButtonBundleFieldset',
label: '상태',
members: [
new FilterGroup({
name: 'state',
type: 'ToggleButtonGroup',
members: [openFilter, lockedFilter, fullFilter, startedFilter, kickedFilter],
uiConfig: {
prefix: 'lui-filter-state'
}
})
],
uiConfig: {
prefix: 'lui-filter-state'
}
});
this.addFilter(this.stateFilters);
if(config.filter) this.addFilter(config.filter);
this.filterElement = document.createElement('div');
this.filterElement.className = 'lui-lobby-filter';
this.header.append(this.filterElement);
const filterHead = document.createElement('div');
filterHead.className = 'lui-lobby-filter-head';
if(config.collapseFilter) filterHead.classList.add('lui-collapsed');
filterHead.innerText = '필터';
filterHead.addEventListener('click', () => this.filter.classList.toggle('lui-collapsed'));
this.filterElement.append(filterHead);
this.filterBody = this.filter.createUI();
$(this.filterElement).append(this.filterBody);
}
addRoom(...rooms) {
if(Array.isArray(rooms[0])) rooms = rooms[0];
const element = document.createElement('li');
for(let room of rooms) {
if(!(room instanceof Room)) throw new TypeError('올바른 Room 객체가 아닙니다');
if(this.rooms[room.id]) throw new TypeError('id가 중복되는 방이 있습니다');
this.rooms[room.id] = room;
room.parent = this;
element.append(room.element);
}
this.listElement.append(element);
return this;
}
refresh() {
throw new TypeError('추상 메소드 refresh가 정의되지 않은 채로 실행되었습니다');
}
startRefresh(interval) {
if(this[INTERVAL]) this.stopRefresh();
this[INTERVAL] = setInterval(this.refresh.call(this), interval);
return this;
}
stopRefresh() {
if(!this[INTERVAL]) return false;
clearInterval(this[INTERVAL]);
this[INTERVAL] = null;
return true;
}
loadNext() {
throw new TypeError('추상 메소드 loadNext가 정의되지 않은 채로 실행되었습니다');
}
clearList() {
this.rooms = {};
this.listElement.innerHTML = '';
return this;
}
addFilter(...members) {
for(let member of members) member.target = this;
this.filter.add(...members);
return this;
}
search(value) {
const ids = Object.keys(this.rooms);
const rooms = Object.values(this.rooms);
value = value.toLowerCase();
const keywords = value.split(' ');
$(this.listElement).find('.lui-match').removeClass('lui-match lui-match-exact lui-match-id lui-match-keyword lui-match-and');
if(value.length) {
this.listElement.classList.add('lui-search-result');
for(const id of ids) {
if(id.startsWith(value.toLowerCase())) {
const classList = this.rooms[id].element.classList;
classList.add('lui-match', 'lui-match-id');
if(id === value) classList.add('lui-match-exact');
}
}
for(const room of rooms) {
const name = room.name.toLowerCase();
const match = keywords.filter(keyword => name.includes(keyword)).length;
const classList = room.element.classList;
if(match) {
classList.add('lui-match', 'lui-match-keyword');
if(match === keywords.length) classList.add('lui-match-and');
}
}
} else {
this.listElement.classList.remove('lui-search-result');
}
return this;
}
go(id) {
const room = this.rooms[id.toLowerCase()];
if(room) room.go();
return this;
}
}
/*export*/ class Room {
constructor(id, name, config) {
if(typeof id !== 'number' && typeof id !== 'string') throw new TypeError('id가 지정되지 않았습니다');
this.id = (typeof id === 'string')? id.toLowerCase() : id;
this.parent = null;
this.element = document.createElement('a');
this.element.className = 'lui-room';
this.element.addEventListener('click', event => {
event.preventDefault();
this.go();
});
this.idElement = document.createElement('span');
this.idElement.className = 'lui-room-id';
this.idElement.innerText = (typeof id === 'string')? id.toUpperCase() : id;
this.element.append(this.idElement);
this.nameElement = document.createElement('span');
this.nameElement.className = 'lui-room-name';
this.element.append(this.nameElement);
this.usersElement = document.createElement('span');
this.usersElement.className = 'lui-room-users';
this.element.append(this.usersElement);
this.curUsersElement = document.createElement('span');
this.curUsersElement.className = 'lui-room-users-now';
this.usersElement.append(this.curUsersElement);
this.maxUsersElement = document.createElement('span');
this.maxUsersElement.className = 'lui-room-users-max';
this.usersElement.append(this.maxUsersElement);
this.connectionElement = document.createElement('span');
this.connectionElement.className = 'lui-room-connection';
this.element.append(this.connectionElement);
this.optionsElement = document.createElement('span');
this.optionsElement.className = 'lui-room-options';
this.element.append(this.optionsElement);
if(config.options) this.addOptions(config.options);
this.indicator = document.createElement('span');
this.indicator.className = 'lui-room-indicator';
this.optionsElement.append(this.indicator);
this.name = name;
this.locked = !!config.locked;
this.kicked = !!config.kicked;
this.maxUsers = config.maxUsers;
this.curUsers = config.curUsers;
this.connection = config.connection;
if(typeof config.go === 'function') this.go = config.go;
if(typeof config.join === 'function') this.join = config.join;
if(typeof config.refresh === 'function') this.refresh = config.refresh;
if(config.parent) config.parent.addRoom(this);
}
go() {
location.href = this.element.href;
}
join() {
throw new TypeError('추상 메소드 join이 정의되지 않은 채로 실행되었습니다');
}
get full() {
return this.curUsers >= this.maxUsers;
}
get started() {
return this.element.classList.contains('lui-room-started');
}
set started(val) {
if(typeof val !== 'boolean') throw new TypeError('started 속성의 새 값이 boolean이 아닙니다');
if(val) this.element.classList.add('lui-room-started');
else this.element.classList.remove('lui-room-started');
}
get locked() {
return this.element.classList.contains('lui-room-locked');
}
set locked(val) {
if(typeof val !== 'boolean') throw new TypeError('locked 속성의 새 값이 boolean이 아닙니다');
if(val) this.element.classList.add('lui-room-locked');
else this.element.classList.remove('lui-room-locked');
}
get kicked() {
return this.element.classList.contains('lui-room-kicked');
}
set kicked(val) {
if(typeof val !== 'boolean') throw new TypeError('kicked 속성의 새 값이 boolean이 아닙니다');
if(val) this.element.classList.add('lui-room-kicked');
else this.element.classList.remove('lui-room-kicked');
}
get name() {
return this.nameElement.innerText;
}
set name(val) {
this.nameElement.innerText = val;
}
get maxUsers() {
return +this.maxUsersElement.innerText;
}
set maxUsers(val) {
if(this.curUsers > val) throw new RangeError('최대 수용 인원이 현재 인원보다 적습니다');
else if(this.curUsers === val) this.element.classList.add('lui-room-full');
else if(this.element.classList.contains('lui-room-full')) this.element.classList.remove('lui-room-full');
this.maxUsersElement.innerText = val;
}
get curUsers() {
return +this.curUsersElement.innerText;
}
set curUsers(val) {
if(val > this.maxUsers) throw new RangeError('최대 수용 인원을 초과하였습니다');
else if(val === this.maxUsers) this.element.classList.add('lui-room-full');
else if(val <= 0) this.terminate();
else if(this.element.classList.contains('lui-room-full')) this.element.classList.remove('lui-room-full');
this.curUsersElement.innerText = val;
}
get connection() {
return +this.connectionElement.innerText;
}
set connection(val) {
this.connectionElement.classList.remove('lui-connection-good', 'lui-connection-normal', 'lui-connection-bad');
if(val <= 1000) this.connectionElement.classList.add('lui-connection-good');
else if(1000 < val && val <= 3000) this.connectionElement.classList.add('lui-connection-normal');
else this.connectionElement.classList.add('lui-connection-bad');
this.connectionElement.innerText = val;
}
refresh() {
throw new TypeError('추상 메소드 refresh가 정의되지 않은 채로 실행되었습니다');
}
addOptions(...options) {
if((options[0]))
$(options).map(option => {
return typeof option === 'function'? option.call(this) : option;
}).appendTo(this.optionsElement);
}
terminate() {
this.element.parentElement.remove();
delete this.parent.rooms[this.id];
}
}
const UIMODEL_OBJ = Symbol('UIModels object');
const UIMODEL_MAP = Symbol('UIModels map');
/*export*/ class Filter extends EventTarget {
constructor(config = {}) {
if(typeof config.name !== 'string') throw new TypeError('name 속성이 올바르게 지정되지 않았습니다');
super();
this.name = config.name;
this.label = config.label || config.name;
this.type = config.type || ('value' in config)? typeof config.value : null;
this.target = null;
this.uiConfig = config.uiConfig;
this.connectedUI = new Set();
Object.defineProperty(this, 'value', {
get: config.get,
set: config.set
});
if('value' in config) this.value = config.value;
}
createUI(model, config = this.uiConfig) {
const ui = this.constructor.getUIModel(model).call(this, config);
this.connectedUI.add(ui);
return ui;
}
static registerUIModel(model, builder) {
if(typeof builder !== 'function') throw new TypeError('UI 모델 생성자가 함수가 아닙니다');
if(typeof model === 'string') this[UIMODEL_OBJ][model] = builder;
else if(model instanceof Object) this[UIMODEL_MAP].set(model, builder);
else throw new TypeError('등록할 UI 모델이 정상적으로 지정되지 않았습니다');
}
static getUIModel(model) {
if(typeof model === 'string') return this[UIMODEL_OBJ][model];
else if(model instanceof Object) return this[UIMODEL_MAP].get(model);
else throw new TypeError('UI 모델이 정상적으로 지정되지 않았습니다');
}
}
Filter[UIMODEL_OBJ] = {};
Filter[UIMODEL_MAP] = new WeakMap();
Filter.registerUIModel(OO.ui.ToggleButtonWidget, function(config) {
const widget = new OO.ui.ToggleButtonWidget({
classes: [(config.prefix? config.prefix + '-' : '') + this.name],
label: this.label,
value: this.value
});
widget.on('change', value => {
this.value = value;
});
this.addEventListener('change', () => {
widget.setValue(this.value);
});
return widget;
});
Filter.registerUIModel(OO.ui.NumberInputWidget, function(config) {
const widget = new OO.ui.NumberInputWidget({
classes: [(config.prefix? config.prefix + '-' : '') + this.name],
input: {value: config.default},
min: config.min,
max: config.max
});
widget.on('change', value => {
this.value = value;
});
this.addEventListener('change', () => {
widget.setValue(this.value);
});
return widget;
});
/*export*/ class FilterGroup extends EventTarget {
constructor(config = {}) {
if(typeof config.name !== 'string') throw new TypeError('name 속성이 올바르게 지정되지 않았습니다');
super();
this.name = config.name;
this.type = config.type || null;
this.label = config.label || config.name;
this.members = new Map();
this.uiConfig = config.uiConfig;
this.connectedUI = new Set();
if(config.members) this.add(config.members);
}
findFilter(name) {
if(typeof name !== 'string') throw new TypeError('이름이 정상적으로 지정되지 않았습니다');
if(this.members.get(name) instanceof Filter) return this.members.get(name);
else for(const member of this.members.values()) {
if(member instanceof FilterGroup) member.findFilter(name);
}
}
findGroup(name) {
if(typeof name !== 'string') throw new TypeError('이름이 정상적으로 지정되지 않았습니다');
if(this.members.get(name) instanceof FilterGroup) return this.members.get(name);
else for(const member of this.members.values()) {
if(member instanceof FilterGroup) member.findGroup(name);
}
}
add(...members) {
if(Array.isArray(members[0])) members = members[0];
for(const member of members) {
if(!(member instanceof Filter || member instanceof FilterGroup)) {
throw new TypeError(`Filter 또는 FilterGroup이 아닌 항목을 추가하러 했습니다`);
}
if(this.members.has(member.name)) console.warn(`이름이 중복되는 "${member.name}" 항목을 추가했습니다`);
this.members.set(member.name, member);
this.dispatchEvent(new CustomEvent('add', {
detail: {
addedItem: member
}
}));
}
return this;
}
merge(...groups) {
if(Array.isArray(groups[0])) groups = groups[0];
for(const group of groups) {
if(!(group instanceof FilterGroup)) throw new TypeError('FilterGroup이 아닌 항목을 병합하려 했습니다');
this.add(group.values());
}
return this;
}
flat(depth = 1) {
if(Number.isNaN(depth)) throw new TypeError('깊이는 숫자여야 합니다');
if(depth < 1) throw new RangeError('깊이는 1 미만일 수 없습니다');
for(; depth >= 1; depth--) {
let hasGroup = false;
for(const group in this.members.values()) {
if(group instanceof FilterGroup) {
hasGroup = true;
this.members.delete(name);
for(const [name, member] of group.members) {
if(!this.members.has(name)) this.members.set(name, member);
}
}
}
if(!hasGroup) break;
}
return this;
}
clone() {
return new FilterGroup({
name: this.name,
type: this.type,
members: this.members.values(),
uiConfig: this.uiConfig
});
}
createUI(model = this.type, config = this.uiConfig) {
let ui;
if(model === null) {
const $element = config.initElement? $(config.initElement).clone() : $(new DocumentFragment());
for(const member of this.members.values()) {
const ui = member.createUI();
if(ui.element || ui.$element) $element.append(ui.element || ui.$element);
else if(ui instanceof HTMLElement) $element.append(ui);
}
ui = $element;
} else ui = this.constructor.getUIModel(model).call(this, config);
this.connectedUI.add(ui);
return ui;
}
exportState() {
const state = {};
for(const [name, member] of this.members) {
if(member instanceof FilterGroup) state[name] = member.exportState();
else if(member instanceof Filter) state[name] = member.value;
}
return state;
}
importState(state) {
if(typeof state !== 'object') throw new TypeError('객체만 불러올 수 있습니디');
for(const name in state) {
const value = state[name];
if(typeof value === 'object') this.members.get(name).importState(value);
else this.members.get(name).value = value;
}
}
static registerUIModel(model, builder) {
if(typeof builder !== 'function') throw new TypeError('UI 모델 생성자가 함수가 아닙니다');
if(typeof model === 'string') this[UIMODEL_OBJ][model] = builder;
else if(model instanceof Object) this[UIMODEL_MAP].set(model, builder);
else throw new TypeError('등록할 UI 모델이 정상적으로 지정되지 않았습니다');
}
static getUIModel(model) {
if(typeof model === 'string') return this[UIMODEL_OBJ][model];
else if(model instanceof Object) return this[UIMODEL_MAP].get(model);
else throw new TypeError('UI 모델이 정상적으로 지정되지 않았습니다');
}
}
FilterGroup[UIMODEL_OBJ] = {};
FilterGroup[UIMODEL_MAP] = new WeakMap();
FilterGroup.registerUIModel('ToggleButtonGroup', function(config) {
const className = (config.prefix? config.prefix + '-' : '') + this.name;
const group = new OO.ui.ButtonGroupWidget({
classes: [className]
});
const newConfig = {
prefix: config.prefix || className
};
for(const member of this.members.values()) {
if(member instanceof Filter && member.type === 'boolean') {
group.addItems(member.createUI(OO.ui.ToggleButtonWidget, newConfig));
}
}
return group;
});
FilterGroup.registerUIModel('ToggleButtonBundleFieldset', function(config) {
const className = (config.prefix? config.prefix + '-' : '') + this.name;
const layout = new OO.ui.FieldsetLayout({
classes: [className],
label: this.label
});
let useGroup = false;
const group = new OO.ui.ButtonGroupWidget({
classes: [className + '-items']
});
const newConfig = {
prefix: className
};
for(const member of this.members.values()) {
if(member instanceof Filter && member.type === 'boolean') {
useGroup = true;
group.addItems(member.createUI(OO.ui.ToggleButtonWidget, newConfig));
} else if(member instanceof FilterGroup) {
layout.addItems(member.createUI(undefined, newConfig));
}
}
this.addEventListener('add', event => {
const item = event.detail.addedItem;
if(item instanceof Filter && member.type === 'boolean') {
if(!useGroup) {
iuseGroup = true;
layout.addItems(group, layout.items.length - 2);
}
group.addItems(item.createUI(OO.ui.ToggleButtonWidget, newConfig));
} else if(item instanceof FilterGroup) {
layout.addItems(item.createUI(undefined, newConfig));
}
});
if(useGroup) layout.addItems(group);
const controller = new OO.ui.ButtonGroupWidget({
classes: [className + '-options']
});
if(config.useSelectAll !== false) {
const selectAll = new OO.ui.ButtonWidget({
classes: [className + '-all'],
label: '모두',
flags: 'progressive'
});
selectAll.on('click', () => {
for(const member of this.members.values()) {
if(member instanceof Filter && member.type === 'boolean') member.value = true;
}
});
controller.addItems(selectAll);
}
if(config.useReverse !== false) {
const reverse = new OO.ui.ButtonWidget({
classes: [className + '-reverse'],
label: '반전',
});
reverse.on('click', () => {
for(const member of this.members.values()) {
if(member instanceof Filter && member.type === 'boolean') member.value = !member.value;
}
});
controller.addItems(reverse);
}
if(config.useReset !== false) {
const reset = new OO.ui.ButtonWidget({
classes: [className + '-reset'],
label: '초기화',
flags: 'destructive'
});
reset.on('click', config.default? () => {
this.importState(config.default);
} : () => {
for(const member of this.members.values()) {
if(member instanceof Filter && member.type === 'boolean') member.value = false;
}
});
controller.addItems(reset);
}
layout.addItems(controller);
return layout;
});