lila/ui/editor/src/view.ts

496 lines
13 KiB
TypeScript

import { h, VNode } from 'snabbdom';
import { MouchEvent, NumberPair } from 'chessground/types';
import { dragNewPiece } from 'chessground/drag';
import { eventPosition, opposite } from 'chessground/util';
import { Rules } from 'chessops/types';
import { parseFen, EMPTY_FEN } from 'chessops/fen';
import modal from 'common/modal';
import EditorCtrl from './ctrl';
import chessground from './chessground';
import { Selected, CastlingToggle, EditorState } from './interfaces';
function castleCheckBox(ctrl: EditorCtrl, id: CastlingToggle, label: string, reversed: boolean): VNode {
const input = h('input', {
attrs: {
type: 'checkbox',
},
props: {
checked: ctrl.castlingToggles[id],
},
on: {
change(e) {
ctrl.setCastlingToggle(id, (e.target as HTMLInputElement).checked);
},
},
});
return h('label', reversed ? [input, label] : [label, input]);
}
function optgroup(name: string, opts: VNode[]): VNode {
return h('optgroup', { attrs: { label: name } }, opts);
}
function studyButton(ctrl: EditorCtrl, state: EditorState): VNode {
return h(
'form',
{
attrs: {
method: 'post',
action: '/study/as',
},
},
[
h('input', { attrs: { type: 'hidden', name: 'orientation', value: ctrl.bottomColor() } }),
h('input', { attrs: { type: 'hidden', name: 'variant', value: ctrl.rules } }),
h('input', { attrs: { type: 'hidden', name: 'fen', value: state.legalFen || '' } }),
h(
'button',
{
attrs: {
type: 'submit',
'data-icon': '',
disabled: !state.legalFen,
},
class: {
button: true,
'button-empty': true,
text: true,
disabled: !state.legalFen,
},
},
ctrl.trans.noarg('toStudy')
),
]
);
}
function variant2option(key: Rules, name: string, ctrl: EditorCtrl): VNode {
return h(
'option',
{
attrs: {
value: key,
selected: key == ctrl.rules,
},
},
`${ctrl.trans.noarg('variant')} | ${name}`
);
}
const allVariants: Array<[Rules, string]> = [
['chess', 'Standard'],
['antichess', 'Antichess'],
['atomic', 'Atomic'],
['crazyhouse', 'Crazyhouse'],
['horde', 'Horde'],
['kingofthehill', 'King of the Hill'],
['racingkings', 'Racing Kings'],
['3check', 'Three-check'],
];
function controls(ctrl: EditorCtrl, state: EditorState): VNode {
const position2option = function (pos: Editor.OpeningPosition): VNode {
return h(
'option',
{
attrs: {
value: pos.epd || pos.fen,
'data-fen': pos.fen,
},
},
pos.eco ? `${pos.eco} ${pos.name}` : pos.name
);
};
const endgamePosition2option = function (pos: Editor.EndgamePosition): VNode {
return h(
'option',
{
attrs: {
value: pos.epd || pos.fen,
'data-fen': pos.fen,
},
},
pos.name
);
};
return h('div.board-editor__tools', [
...(ctrl.cfg.embed || !ctrl.cfg.positions || !ctrl.cfg.endgamePositions
? []
: [
h('div', [
h(
'select.positions',
{
props: {
value: state.fen.split(' ').slice(0, 4).join(' '),
},
on: {
change(e) {
const el = e.target as HTMLSelectElement;
let value = el.selectedOptions[0].getAttribute('data-fen');
if (value == 'prompt') value = (prompt('Paste FEN') || '').trim();
if (!value || !ctrl.setFen(value)) el.value = '';
},
},
},
[
optgroup(ctrl.trans.noarg('setTheBoard'), [
h(
'option',
{
attrs: {
selected: true,
},
},
`- ${ctrl.trans.noarg('boardEditor')} -`
),
...ctrl.extraPositions.map(position2option),
]),
optgroup(ctrl.trans.noarg('popularOpenings'), ctrl.cfg.positions.map(position2option)),
optgroup(ctrl.trans.noarg('endgamePositions'), ctrl.cfg.endgamePositions.map(endgamePosition2option)),
]
),
]),
]),
h('div.metadata', [
h(
'div.color',
h(
'select',
{
on: {
change(e) {
ctrl.setTurn((e.target as HTMLSelectElement).value as Color);
},
},
props: {
value: ctrl.turn,
},
},
['whitePlays', 'blackPlays'].map(function (key) {
return h(
'option',
{
attrs: {
value: key[0] === 'w' ? 'white' : 'black',
selected: key[0] === ctrl.turn[0],
},
},
ctrl.trans(key)
);
})
)
),
h('div.castling', [
h('strong', ctrl.trans.noarg('castling')),
h('div', [
castleCheckBox(ctrl, 'K', ctrl.trans.noarg('whiteCastlingKingside'), !!ctrl.options.inlineCastling),
castleCheckBox(ctrl, 'Q', 'O-O-O', true),
]),
h('div', [
castleCheckBox(ctrl, 'k', ctrl.trans.noarg('blackCastlingKingside'), !!ctrl.options.inlineCastling),
castleCheckBox(ctrl, 'q', 'O-O-O', true),
]),
]),
]),
...(ctrl.cfg.embed
? [
h('div.actions', [
h(
'a.button.button-empty',
{
on: {
click() {
ctrl.startPosition();
},
},
},
ctrl.trans.noarg('startPosition')
),
h(
'a.button.button-empty',
{
on: {
click() {
ctrl.clearBoard();
},
},
},
ctrl.trans.noarg('clearBoard')
),
]),
]
: [
h('div', [
h(
'select',
{
attrs: { id: 'variants' },
on: {
change(e) {
ctrl.setRules((e.target as HTMLSelectElement).value as Rules);
},
},
},
allVariants.map(x => variant2option(x[0], x[1], ctrl))
),
]),
h('div.actions', [
h(
'button.button.button-empty.text',
{
attrs: { 'data-icon': '' },
on: {
click() {
ctrl.setFen(EMPTY_FEN);
},
},
},
ctrl.trans.noarg('clearBoard')
),
h(
'button.button.button-empty.text',
{
attrs: { 'data-icon': '' },
on: {
click() {
ctrl.chessground!.toggleOrientation();
ctrl.redraw();
},
},
},
ctrl.trans.noarg('flipBoard')
),
h(
'a',
{
attrs: {
'data-icon': '',
rel: 'nofollow',
...(state.legalFen ? { href: ctrl.makeAnalysisUrl(state.legalFen) } : {}),
},
class: {
button: true,
'button-empty': true,
text: true,
disabled: !state.legalFen,
},
},
ctrl.trans.noarg('analysis')
),
h(
'button',
{
class: {
button: true,
'button-empty': true,
disabled: !state.playable,
},
on: {
click: () => {
if (state.playable)
modal({
content: $('.continue-with'),
});
},
},
},
[h('span.text', { attrs: { 'data-icon': '' } }, ctrl.trans.noarg('continueFromHere'))]
),
studyButton(ctrl, state),
]),
h('div.continue-with.none', [
h(
'a.button',
{
attrs: {
href: '/?fen=' + state.legalFen + '#ai',
rel: 'nofollow',
},
},
ctrl.trans.noarg('playWithTheMachine')
),
h(
'a.button',
{
attrs: {
href: '/?fen=' + state.legalFen + '#friend',
rel: 'nofollow',
},
},
ctrl.trans.noarg('playWithAFriend')
),
]),
]),
]);
}
function inputs(ctrl: EditorCtrl, fen: string): VNode | undefined {
if (ctrl.cfg.embed) return;
return h('div.copyables', [
h('p', [
h('strong', 'FEN'),
h('input.copyable', {
attrs: {
spellcheck: false,
},
props: {
value: fen,
},
on: {
change(e) {
const el = e.target as HTMLInputElement;
ctrl.setFen(el.value.trim());
el.reportValidity();
},
input(e) {
const el = e.target as HTMLInputElement;
const valid = parseFen(el.value.trim()).isOk;
el.setCustomValidity(valid ? '' : 'Invalid FEN');
},
blur(e) {
const el = e.target as HTMLInputElement;
el.value = ctrl.getFen();
el.setCustomValidity('');
},
},
}),
]),
h('p', [
h('strong.name', 'URL'),
h('input.copyable.autoselect', {
attrs: {
readonly: true,
spellcheck: false,
value: ctrl.makeUrl(ctrl.cfg.baseUrl, fen),
},
}),
]),
]);
}
// can be 'pointer', 'trash', or [color, role]
function selectedToClass(s: Selected): string {
return s === 'pointer' || s === 'trash' ? s : s.join(' ');
}
let lastTouchMovePos: NumberPair | undefined;
function sparePieces(ctrl: EditorCtrl, color: Color, _orientation: Color, position: 'top' | 'bottom'): VNode {
const selectedClass = selectedToClass(ctrl.selected());
const pieces = ['king', 'queen', 'rook', 'bishop', 'knight', 'pawn'].map(function (role) {
return [color, role];
});
return h(
'div',
{
attrs: {
class: ['spare', 'spare-' + position, 'spare-' + color].join(' '),
},
},
['pointer', ...pieces, 'trash'].map((s: Selected) => {
const className = selectedToClass(s);
const attrs = {
class: className,
...(s !== 'pointer' && s !== 'trash'
? {
'data-color': s[0],
'data-role': s[1],
}
: {}),
};
const selectedSquare =
selectedClass === className &&
(!ctrl.chessground ||
!ctrl.chessground.state.draggable.current ||
!ctrl.chessground.state.draggable.current.newPiece);
return h(
'div',
{
class: {
'no-square': true,
pointer: s === 'pointer',
trash: s === 'trash',
'selected-square': selectedSquare,
},
on: {
mousedown: onSelectSparePiece(ctrl, s, 'mouseup'),
touchstart: onSelectSparePiece(ctrl, s, 'touchend'),
touchmove: e => {
lastTouchMovePos = eventPosition(e as any);
},
},
},
[h('div', [h('piece', { attrs })])]
);
})
);
}
function onSelectSparePiece(ctrl: EditorCtrl, s: Selected, upEvent: string): (e: MouchEvent) => void {
return function (e: MouchEvent): void {
e.preventDefault();
if (s === 'pointer' || s === 'trash') {
ctrl.selected(s);
ctrl.redraw();
} else {
ctrl.selected('pointer');
dragNewPiece(
ctrl.chessground!.state,
{
color: s[0],
role: s[1],
},
e,
true
);
document.addEventListener(
upEvent,
(e: MouchEvent) => {
const eventPos = eventPosition(e) || lastTouchMovePos;
if (eventPos && ctrl.chessground!.getKeyAtDomPos(eventPos)) ctrl.selected('pointer');
else ctrl.selected(s);
ctrl.redraw();
},
{ once: true }
);
}
};
}
function makeCursor(selected: Selected): string {
if (selected === 'pointer') return 'pointer';
const name = selected === 'trash' ? 'trash' : selected.join('-');
const url = lichess.assetUrl('cursors/' + name + '.cur');
return `url('${url}'), default !important`;
}
export default function (ctrl: EditorCtrl): VNode {
const state = ctrl.getState();
const color = ctrl.bottomColor();
return h(
'div.board-editor',
{
attrs: {
style: `cursor: ${makeCursor(ctrl.selected())}`,
},
},
[
sparePieces(ctrl, opposite(color), color, 'top'),
h('div.main-board', [chessground(ctrl)]),
sparePieces(ctrl, color, color, 'bottom'),
controls(ctrl, state),
inputs(ctrl, state.fen),
]
);
}