사용자: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;
});