embed board editor in study

pull/1885/head
Thibault Duplessis 2016-05-14 00:01:44 +02:00
parent 7e99e6cc1c
commit 8de43f2624
17 changed files with 245 additions and 173 deletions

View File

@ -24,13 +24,31 @@ object Editor extends LilaController {
def load(urlFen: String) = Open { implicit ctx =>
val fenStr = Some(urlFen.trim.replace("_", " ")).filter(_.nonEmpty) orElse get("fen")
fuccess {
val decodedFen = fenStr.map { java.net.URLDecoder.decode(_, "UTF-8").trim }.filter(_.nonEmpty)
val situation = (decodedFen flatMap Forsyth.<<< map (_.situation)) | Situation(chess.variant.Standard)
val fen = Forsyth >> situation
Ok(html.board.editor(situation, fen, positionsJson, animationDuration = Env.api.EditorAnimationDuration))
val situation = readFen(fenStr)
Ok(html.board.editor(
sit = situation,
fen = Forsyth >> situation,
positionsJson,
animationDuration = Env.api.EditorAnimationDuration))
}
}
def data = Open { implicit ctx =>
fuccess {
val situation = readFen(get("fen"))
Ok(html.board.JsData(
sit = situation,
fen = Forsyth >> situation,
animationDuration = Env.api.EditorAnimationDuration)) as JSON
}
}
private def readFen(fen: Option[String]): Situation =
fen.map {
java.net.URLDecoder.decode(_, "UTF-8").trim
}.filter(_.nonEmpty)
.flatMap(Forsyth.<<<).map(_.situation) | Situation(chess.variant.Standard)
def game(id: String) = Open { implicit ctx =>
OptionResult(GameRepo game id) { game =>
Redirect {

View File

@ -0,0 +1,45 @@
package views.html.board
import controllers.routes
import play.api.libs.json.{ JsArray, Json }
import scala.concurrent.duration.Duration
import lila.api.Context
import lila.app.templating.Environment._
object JsData extends lila.Steroids {
def apply(
sit: chess.Situation,
fen: String,
animationDuration: Duration)(implicit ctx: Context) = Json.obj(
"fen" -> fen.split(" ").headOption,
"baseUrl" -> s"$netBaseUrl${routes.Editor.load("")}",
"color" -> sit.color.letter.toString,
"castles" -> Json.obj(
"K" -> (sit canCastle chess.White on chess.KingSide),
"Q" -> (sit canCastle chess.White on chess.QueenSide),
"k" -> (sit canCastle chess.Black on chess.KingSide),
"q" -> (sit canCastle chess.Black on chess.QueenSide)
),
"animation" -> Json.obj(
"duration" -> ctx.pref.animationFactor * animationDuration.toMillis
),
"i18n" -> i18nJsObject(
trans.startPosition,
trans.clearBoard,
trans.flipBoard,
trans.loadPosition,
trans.castling,
trans.whiteCastlingKingside,
trans.whiteCastlingQueenside,
trans.blackCastlingKingside,
trans.blackCastlingQueenside,
trans.whitePlays,
trans.blackPlays,
trans.continueFromHere,
trans.playWithTheMachine,
trans.playWithAFriend,
trans.analysis)
)
}

View File

@ -1,46 +1,14 @@
@(sit: chess.Situation, fen: String, positionsJson: String, animationDuration: scala.concurrent.duration.Duration)(implicit ctx: Context)
@import chess.Color.{ White, Black }
@import chess.{ KingSide, QueenSide }
@moreCss = {
@cssTag("boardEditor.css")
}
@moreJs = {
@jsAt(s"compiled/lichess.editor${isProd??(".min")}.js")
@embedJs {
LichessEditor(document.getElementById('board_editor'), {
fen: "@fen.split(" ").headOption",
baseUrl: "@netBaseUrl@routes.Editor.load("")",
color: "@sit.color.letter",
castles: {
K: @sit.canCastle(White).on(KingSide),
Q: @sit.canCastle(White).on(QueenSide),
k: @sit.canCastle(Black).on(KingSide),
q: @sit.canCastle(Black).on(QueenSide)
},
animation: {
duration: @{ctx.pref.animationFactor * animationDuration.toMillis}
},
positions: @Html(positionsJson),
i18n: @Html(J.stringify(i18nJsObject(
trans.startPosition,
trans.clearBoard,
trans.flipBoard,
trans.loadPosition,
trans.castling,
trans.whiteCastlingKingside,
trans.whiteCastlingQueenside,
trans.blackCastlingKingside,
trans.blackCastlingQueenside,
trans.whitePlays,
trans.blackPlays,
trans.continueFromHere,
trans.playWithTheMachine,
trans.playWithAFriend,
trans.analysis
)))
});
var data = @J.stringify(JsData(sit, fen, animationDuration));
data.positions = @positionsJson;
LichessEditor(document.getElementById('board_editor'), data);
}
}
@ -53,5 +21,5 @@ openGraph = lila.app.ui.OpenGraph(
title = "Chess board editor",
url = s"$netBaseUrl${routes.Editor.index.url}",
description = "Load opening positions or create your own chess position on a chess board editor").some) {
<div id="board_editor" class="cg-512"></div>
<div id="board_editor" class="board_editor cg-512"></div>
}

View File

@ -349,6 +349,7 @@ POST /import controllers.Importer.sendGame
GET /import/master/$id<\w{8}>/:color controllers.Importer.masterGame(id: String, color: String)
# Edit
GET /editor.json controllers.Editor.data
GET /editor/*urlFen controllers.Editor.load(urlFen: String)
GET /editor controllers.Editor.index

View File

@ -32,6 +32,8 @@ case class Study(
object Study {
def toName(str: String) = str.trim take 100
case class Data(
name: String,
visibility: String,

View File

@ -252,7 +252,7 @@ final class StudyApi(
def editStudy(byUserId: User.ID, studyId: Study.ID, data: Study.Data) = sequenceStudy(studyId) { study =>
data.settings ?? { settings =>
val newStudy = study.copy(name = data.name, settings = settings)
val newStudy = study.copy(name = Study toName data.name, settings = settings)
(newStudy != study) ?? {
studyRepo.update(newStudy) >>- sendTo(study, Socket.ReloadAll)
}

View File

@ -54,13 +54,13 @@ body.dark #board_editor .spare.white {
left: 0;
top: -36%;
}
#editor-side {
#board_editor .editor-side {
position: absolute;
left: 552px;
top: 150px;
width: 228px;
}
#editor-side > div {
#board_editor .editor-side > div {
margin-bottom: 3em;
text-align: center;
}
@ -72,10 +72,10 @@ body.dark #board_editor .spare.white {
#board_editor .button.disabled {
opacity: 0.5;
}
#editor-side select.positions {
#board_editor .editor-side select.positions {
width: 100%;
}
#editor-side select.positions option:checked {
#board_editor .editor-side select.positions option:checked {
font-style: italic;
}
#board_editor table td, #board_editor table th {
@ -97,14 +97,14 @@ body.dark #board_editor .spare.white {
#board_editor .copyable {
width: 462px;
}
#editor-side > div.metadata {
#board_editor .editor-side > div.metadata {
white-space: nowrap;
}
#editor-side > div.metadata div.color {
#board_editor .editor-side > div.metadata div.color {
margin-bottom: 1em;
}
#editor-side .castling label,
#editor-side .castling input {
#board_editor .editor-side .castling label,
#board_editor .editor-side .castling input {
display:inline-block;
margin: 3px;
vertical-align: middle;

View File

@ -267,6 +267,12 @@ body:not(.dark) .material.form .study_tabs a {
.study_overboard .form {
padding: 20px;
}
.study_overboard .form .editor {
margin: -30px 0 30px 0;
}
.study_overboard .form .editor .spinner {
padding-top: 80px;
}
.study_buttons {
display: flex;
justify-content: space-between;
@ -361,3 +367,54 @@ form.delete_study button {
form.delete_study button:hover {
opacity: 1;
}
form .editor {
position: relative;
}
form .editor .spare {
height: 26px;
width: 224px;
margin: 3px 0;
}
form .editor .spare .no-square {
position: relative;
display:inline-block;
width: 26px;
height: 26px;
}
form .editor .spare piece {
cursor: pointer;
}
form .editor .cg-board-wrap {
width: 224px;
height: 224px;
}
form .editor .editor-side {
position: absolute;
left: 233px;
top: 50px;
text-align: left;
}
form .editor .editor-side .content_box {
background: none;
border: 0;
padding: 0;
box-shadow: none;
}
form .editor .castling {
margin-top: 10px;
}
form .editor .castling strong {
display: none;
}
form .editor .castling label {
display: block;
cursor: pointer;
}
form .editor .castling input {
cursor: pointer;
margin-right: 5px;
}
form .editor a.button {
display: block;
}

View File

@ -1,86 +0,0 @@
function copy(obj, newValues) {
var k, c = {};
for (k in obj) {
c[k] = obj[k];
}
for (k in newValues) {
c[k] = newValues[k];
}
return c;
}
module.exports = {
default: function(ply) {
return [{
ply: ply || 0,
variation: null
}];
},
read: function(str) {
return str.split(',').map(function(step) {
var s = step.split(':');
return {
ply: parseInt(s[0]),
variation: s[1] ? parseInt(s[1]) : null
};
})
},
write: function(path) {
return path.map(function(step) {
return step.variation ? step.ply + ':' + step.variation : step.ply;
}).join(',');
},
isRoot: function(path) {
return path.length === 1;
},
contains: function(p1, p2) {
if (p2.length < p1.length) return;
for (var i = 0; i < p2.length; i++) {
if (!p1[i].variation) return true;
if (p1[i].ply !== p2[i].ply || p1[i].variation !== p2[i].variation) return false;
}
return false;
},
currentPly: function(path) {
return path[path.length - 1].ply;
},
withPly: function(path, ply) {
var p2 = path.slice(0);
var last = p2.length - 1;
p2[last] = copy(p2[last], {
ply: ply
});
return p2;
},
withVariation: function(path, index) {
var p2 = path.slice(0);
var last = p2.length - 1;
var ply = p2[last].ply;
p2[last] = copy(p2[last], {
ply: ply,
variation: index
});
p2.push({
ply: ply,
variation: null
});
return p2;
},
withoutVariation: function(path) {
var p2 = path.slice(0, path.length - 1);
var last = p2.length - 1;
p2[last] = copy(p2[last], {
variation: null
});
return p2;
}
};

View File

@ -9,13 +9,14 @@ function implicitVariant(tab) {
}
module.exports = {
ctrl: function(send, chapters, setTab) {
ctrl: function(send, chapters, setTab, root) {
var vm = {
variants: [],
open: false,
initial: m.prop(false),
tab: storedProp('study.form.tab', 'blank'),
tab: storedProp('study.form.tab', 'init'),
editorFen: m.prop(null)
};
var loadVariants = function() {
@ -37,6 +38,7 @@ module.exports = {
return {
vm: vm,
open: open,
root: root,
openInitial: function() {
open();
vm.initial(true);
@ -74,14 +76,14 @@ module.exports = {
return dialog.form({
onClose: ctrl.close,
content: [
m('h2', 'New chapter'),
activeTab === 'edit' ? null : m('h2', 'New chapter'),
m('form.material.form', {
onsubmit: function(e) {
ctrl.submit({
name: fieldValue(e, 'name'),
game: fieldValue(e, 'game'),
variant: fieldValue(e, 'variant'),
fen: fieldValue(e, 'fen'),
fen: fieldValue(e, 'fen') || (activeTab === 'edit' ? ctrl.vm.editorFen() : null),
pgn: fieldValue(e, 'pgn'),
orientation: fieldValue(e, 'orientation')
});
@ -89,8 +91,11 @@ module.exports = {
return false;
}
}, [
m('div.game.form-group', [
m('div.form-group', [
m('input#chapter-name', {
required: true,
minlength: 2,
maxlength: 80,
config: function(el, isUpdate) {
if (!isUpdate && !el.value) {
el.value = 'Chapter ' + (ctrl.initial() ? 1 : (ctrl.chapters().length + 1));
@ -103,26 +108,50 @@ module.exports = {
m('i.bar')
]),
m('div.study_tabs', [
makeTab('blank', 'Blank', 'Start from initial position'),
makeTab('init', 'Init', 'Start from initial position'),
makeTab('edit', 'Edit', 'Start from custom position'),
makeTab('game', 'game', 'Load a lichess game'),
makeTab('fen', 'FEN', 'Load a FEN position'),
makeTab('pgn', 'PGN', 'Load a PGN game')
]),
activeTab === 'game' ? m('div.game.form-group', [
activeTab === 'edit' ? m('div', {
config: function(el, isUpdate, ctx) {
if (ctx.editor) return;
$.when(
lichess.loadScript('/assets/compiled/lichess.editor.js'),
$.get('/editor.json', {
fen: ctrl.root.vm.node.fen
})
).then(function(a, b) {
var data = b[0];
data.embed = true;
data.options = {
inlineCastling: true,
onChange: function(fen) {
ctrl.vm.editorFen(fen);
m.redraw();
}
};
ctx.editor = LichessEditor(el, data);
ctrl.vm.editorFen(ctx.editor.getFen());
});
}
}, m.trust(lichess.spinnerHtml)) : null,
activeTab === 'game' ? m('div.form-group', [
m('input#chapter-game', {
placeholder: 'Game ID or URL'
}),
m('label.control-label[for=chapter-game]', 'From played or imported game'),
m('i.bar')
]) : null,
activeTab === 'fen' ? m('div.game.form-group', [
activeTab === 'fen' ? m('div.form-group', [
m('input#chapter-fen', {
placeholder: 'Initial position'
}),
m('label.control-label[for=chapter-fen]', 'From FEN position'),
m('i.bar')
]) : null,
activeTab === 'pgn' ? m('div.game.form-group', [
activeTab === 'pgn' ? m('div.form-group', [
m('textarea#chapter-pgn', {
placeholder: 'PGN tags and moves'
}),
@ -130,7 +159,7 @@ module.exports = {
m('i.bar')
]) : null,
m('div', [
m('div.game.form-group.half', [
m('div.form-group.half', [
m('select#chapter-variant', {
disabled: implicitVariant(activeTab)
}, implicitVariant(activeTab) ? [
@ -144,7 +173,7 @@ module.exports = {
m('label.control-label[for=chapter-variant]', 'Variant'),
m('i.bar')
]),
m('div.game.form-group.half', [
m('div.form-group.half', [
m('select#chapter-orientation', ['White', 'Black'].map(function(color) {
return m('option', {
value: color.toLowerCase()

View File

@ -4,12 +4,12 @@ var partial = require('chessground').util.partial;
var chapterForm = require('./chapterForm');
module.exports = {
ctrl: function(initChapters, send, setTab) {
ctrl: function(initChapters, send, setTab, root) {
var confing = m.prop(null); // which chapter is being configured by us
var list = m.prop(initChapters);
var form = chapterForm.ctrl(send, list, setTab);
var form = chapterForm.ctrl(send, list, setTab, root);
return {
confing: confing,

View File

@ -34,7 +34,7 @@ module.exports = {
return data;
});
var members = memberCtrl(data.members, ctrl.userId, data.ownerId, send, partial(vm.tab, 'members'));
var chapters = chapterCtrl(data.chapters, send, partial(vm.tab, 'chapters'));
var chapters = chapterCtrl(data.chapters, send, partial(vm.tab, 'chapters'), ctrl);
var currentChapterId = function() {
return vm.chapterId || data.position.chapterId;

View File

@ -71,10 +71,13 @@ module.exports = {
}, [
m('div.game.form-group', [
m('input#study-name', {
required: true,
minlength: 3,
maxlength: 100,
config: function(el, isUpdate) {
if (!isUpdate && !el.value) {
el.value = data.name;
el.select();
if (isNew) el.select();
el.focus();
}
}

View File

@ -92,7 +92,7 @@ function inputs(ctrl) {
m('input.copyable.autoselect[spellCheck=false]', {
value: ctrl.vm.node.fen,
onchange: function(e) {
if (e.target.value !== ctrl.vm.step.fen) ctrl.changeFen(e.target.value);
if (e.target.value !== ctrl.vm.node.fen) ctrl.changeFen(e.target.value);
}
}),
m('div.pgn', [

View File

@ -7,6 +7,8 @@ var keyboard = require('./keyboard');
module.exports = function(cfg) {
this.data = editor.init(cfg);
this.options = cfg.options;
this.embed = cfg.embed;
this.trans = partial(editor.trans, this.data.i18n);
@ -26,13 +28,14 @@ module.exports = function(cfg) {
}];
this.positionIndex = {};
cfg.positions.forEach(function(p, i) {
cfg.positions && cfg.positions.forEach(function(p, i) {
this.positionIndex[p.fen.split(' ')[0]] = i;
}.bind(this));
this.chessground = new chessground.controller({
fen: cfg.fen,
orientation: 'white',
orientation: cfg.options.orientation || 'white',
coordinates: !this.embed,
movable: {
free: true,
color: 'both',
@ -56,19 +59,37 @@ module.exports = function(cfg) {
enabled: false
},
events: {
change: m.redraw
change: function() {
onChange();
m.redraw();
}.bind(this)
},
disableContextMenu: true
});
var onChange = function() {
this.options.onChange && this.options.onChange(this.computeFen());
}.bind(this);
this.computeFen = partial(editor.computeFen, this.data, this.chessground.getFen);
this.setColor = function(letter) {
this.data.color(letter);
onChange();
}.bind(this);
this.setCastle = function(id, value) {
this.data.castles[id](value);
onChange();
}.bind(this);
this.startPosition = function() {
this.chessground.set({
fen: 'start'
});
this.data.castles = editor.castlesAt(true);
this.data.color('w');
onChange();
}.bind(this);
this.clearBoard = function() {
@ -76,6 +97,7 @@ module.exports = function(cfg) {
fen: '8/8/8/8/8/8/8/8'
});
this.data.castles = editor.castlesAt(false);
onChange();
}.bind(this);
this.loadNewFen = function(fen) {

View File

@ -8,4 +8,8 @@ module.exports = function(element, config) {
controller: function () { return controller; },
view: view
});
return {
getFen: controller.computeFen
};
};

View File

@ -7,8 +7,8 @@ var m = require('mithril');
function castleCheckBox(ctrl, id, label, reversed) {
var input = m('input[type=checkbox]', {
checked: ctrl.data.castles[id](),
onchange: function() {
ctrl.data.castles[id](this.checked);
onchange: function(e) {
ctrl.setCastle(id, e.target.checked);
}
});
return m('label', reversed ? [input, label] : [label, input]);
@ -22,7 +22,7 @@ function optgroup(name, opts) {
function controls(ctrl, fen) {
var positionIndex = ctrl.positionIndex[fen.split(' ')[0]];
var currentPosition = positionIndex !== -1 ? ctrl.data.positions[positionIndex] : null;
var currentPosition = ctrl.data.positions && positionIndex !== -1 ? ctrl.data.positions[positionIndex] : null;
var encodedFen = fen.replace(/\s/g, '_');
var position2option = function(pos) {
return {
@ -34,9 +34,9 @@ function controls(ctrl, fen) {
children: [pos.name]
};
}
return m('div#editor-side', [
m('div', [
m('select.positions', {
return m('div.editor-side', [
ctrl.embed ? null : m('div', [
ctrl.data.positions ? m('select.positions', {
onchange: function(e) {
ctrl.loadNewFen(e.target.value);
}
@ -51,31 +51,39 @@ function controls(ctrl, fen) {
optgroup('Popular openings',
ctrl.data.positions.map(position2option)
)
])
]) : null
]),
m('div.metadata.content_box', [
m('div.color', [
m('div.color',
m('select', {
value: ctrl.data.color(),
onchange: m.withAttr('value', ctrl.data.color)
}, [
m('option[value=w]', ctrl.trans('whitePlays')),
m('option[value=b]', ctrl.trans('blackPlays'))
])
]),
onchange: m.withAttr('value', ctrl.setColor)
}, ['whitePlays', 'blackPlays'].map(function(key) {
return m('option', {
value: key[0],
selected: ctrl.data.color() === key[0]
}, ctrl.trans(key));
}))
),
m('div.castling', [
m('strong', ctrl.trans('castling')),
m('div', [
castleCheckBox(ctrl, 'K', ctrl.trans('whiteCastlingKingside'), false),
castleCheckBox(ctrl, 'K', ctrl.trans('whiteCastlingKingside'), ctrl.options.inlineCastling),
castleCheckBox(ctrl, 'Q', ctrl.trans('whiteCastlingQueenside'), true)
]),
m('div', [
castleCheckBox(ctrl, 'k', ctrl.trans('blackCastlingKingside'), false),
castleCheckBox(ctrl, 'k', ctrl.trans('blackCastlingKingside'), ctrl.options.inlineCastling),
castleCheckBox(ctrl, 'q', ctrl.trans('blackCastlingQueenside'), true)
])
])
]),
m('div', [
ctrl.embed ? m('div', [
m('a.button.frameless', {
onclick: ctrl.startPosition
}, 'Initial position'),
m('a.button.frameless', {
onclick: ctrl.clearBoard
}, 'Empty board')
]) : m('div', [
m('a.button.text[data-icon=B]', {
onclick: ctrl.chessground.toggleOrientation
}, ctrl.trans('flipBoard')),
@ -92,7 +100,7 @@ function controls(ctrl, fen) {
},
m('span.text[data-icon=U]', ctrl.trans('continueFromHere')))
]),
m('div.continue_with', [
ctrl.embed ? null : m('div.continue_with', [
m('a.button', {
href: '/?fen=' + encodedFen + '#ai',
rel: 'nofollow'
@ -107,6 +115,7 @@ function controls(ctrl, fen) {
}
function inputs(ctrl, fen) {
if (ctrl.embed) return;
if (ctrl.vm.redirecting) return m.trust(lichess.spinnerHtml);
return m('div.copyables', [
m('p', [