feat: support variants of depth 1 (#1)

feat: support variants of depth 1
This commit is contained in:
Christoph Lindstädt 2023-05-25 16:54:35 +02:00 committed by GitHub
parent 26321747a4
commit 15b5a3de40
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 849 additions and 381 deletions

View file

@ -10,15 +10,19 @@ With this plugin, you can either import PGNs or simply start a fresh new game. I
## Table of contents
- [Motivation](#motivation)
- [Installation](#installation)
- [Usage](#usage)
- [Features](#features)
- [Settings](#settings)
- [Roadmap](#roadmap)
- [Tools Used](#tools-used)
- [Alternatives](#alternatives)
- [License](#license)
- [Obsidian Chess Study](#obsidian-chess-study)
- [Table of contents](#table-of-contents)
- [Motivation](#motivation)
- [Installation](#installation)
- [Usage](#usage)
- [Features](#features)
- [1.0.0](#100)
- [1.0.1](#101)
- [Settings](#settings)
- [Roadmap](#roadmap)
- [Tools Used](#tools-used)
- [Alternatives](#alternatives)
- [License](#license)
## Motivation
@ -40,12 +44,14 @@ Once you click `Submit`, Obsidian Chess Study will parse the PGN, generate a new
![chess-study-codeblock](imgs/chess-study-codeblock.png)
After that the PGN viewer/editor will render and you are good to go:
After that the PGN viewer/editor will render and you are good to go (styles are aligned with your theme and accent color):
![cchess-study-codeblock](imgs/chess-study-demo.gif)
![chess-study-codeblock](imgs/chess-study-demo.gif)
## Features
### 1.0.0
- [x] Import PGNs
- [x] Store game state in JSON
- [x] Add custom PGN viewer
@ -54,6 +60,12 @@ After that the PGN viewer/editor will render and you are good to go:
- [x] Draw and sync shapes
- [x] Add and sync comments with Markdown support
### 1.0.1
- [x] Add support for variants (depth 1)
![chess-study-variants](imgs/chess-study-variants.png)
## Settings
Here are the available settings for a `chessStudy` code block:
@ -68,7 +80,7 @@ You can permanently set some settings in the [Obsidian](https://obsidian.md/) pl
## Roadmap
- [ ] Add support for variants
- [ ] Add undo button
- [ ] Add view to manage stored games
- [ ] Add more styles
- [ ] Add more settings

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View file

@ -1,7 +1,7 @@
{
"id": "chess-study",
"name": "Chess Study",
"version": "1.0.0",
"version": "1.0.1",
"minAppVersion": "0.15.0",
"description": "A chess study helper and PGN viewer/editor.",
"author": "Christoph Lindstädt",

46
package-lock.json generated
View file

@ -14,13 +14,15 @@
"@tiptap/starter-kit": "2.0.3",
"chess.js": "1.0.0-beta.6",
"chessground": "8.3.7",
"lucide-react": "0.217.0",
"immer": "^10.0.2",
"lucide-react": "0.220.0",
"nanoid": "4.0.2",
"react": "18.2.0",
"react-dom": "18.2.0"
"react-dom": "18.2.0",
"use-immer": "^0.9.0"
},
"devDependencies": {
"@types/node": "20.1.7",
"@types/node": "20.2.1",
"@types/react": "18.2.6",
"@types/react-dom": "18.2.4",
"@typescript-eslint/eslint-plugin": "5.59.6",
@ -34,7 +36,7 @@
"prettier": "2.8.8",
"prettier-plugin-organize-imports": "3.2.2",
"pretty-quick": "3.1.3",
"tslib": "2.5.0",
"tslib": "2.5.2",
"typescript": "5.0.4"
}
},
@ -1528,9 +1530,9 @@
"dev": true
},
"node_modules/@types/node": {
"version": "20.1.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.7.tgz",
"integrity": "sha512-WCuw/o4GSwDGMoonES8rcvwsig77dGCMbZDrZr2x4ZZiNW4P/gcoZXe/0twgtobcTkmg9TuKflxYL/DuwDyJzg==",
"version": "20.2.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.2.1.tgz",
"integrity": "sha512-DqJociPbZP1lbZ5SQPk4oag6W7AyaGMO6gSfRwq3PWl4PXTwJpRQJhDq4W0kzrg3w6tJ1SwlvGZ5uKFHY13LIg==",
"dev": true
},
"node_modules/@types/object.omit": {
@ -3147,6 +3149,15 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.0.2.tgz",
"integrity": "sha512-Rx3CqeqQ19sxUtYV9CU911Vhy8/721wRFnJv3REVGWUmoAcIwzifTsdmJte/MV+0/XpM35LZdQMBGkRIoLPwQA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
@ -3638,9 +3649,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.217.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.217.0.tgz",
"integrity": "sha512-I4DHIW9EDYuAFNFpkYHZw7lh++BM9Vim86L+gLdSAnb7WwAB2JpGrOl9j8a6c8PSw67UXc3gxj3c26smyZ+7mg==",
"version": "0.220.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.220.0.tgz",
"integrity": "sha512-bYtGUsLAWBvZu+BzAU/ziP1gzE4LwMEXLnlgSr1yUKEPPalLG77JLd5GdYebOVkpm+GtqRqnp6tEKDX7Bm8ZlQ==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
}
@ -4863,9 +4874,9 @@
}
},
"node_modules/tslib": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz",
"integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==",
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.2.tgz",
"integrity": "sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==",
"dev": true
},
"node_modules/tsutils": {
@ -5001,6 +5012,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-immer": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/use-immer/-/use-immer-0.9.0.tgz",
"integrity": "sha512-/L+enLi0nvuZ6j4WlyK0US9/ECUtV5v9RUbtxnn5+WbtaXYUaOBoKHDNL9I5AETdurQ4rIFIj/s+Z5X80ATyKw==",
"peerDependencies": {
"immer": ">=2.0.0",
"react": "^16.8.0 || ^17.0.1 || ^18.0.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.6.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "chess-study",
"version": "1.0.0",
"version": "1.0.1",
"description": "A chess study helper and PGN viewer/editor.",
"keywords": [
"chess",
@ -22,13 +22,15 @@
"@tiptap/starter-kit": "2.0.3",
"chess.js": "1.0.0-beta.6",
"chessground": "8.3.7",
"lucide-react": "0.217.0",
"immer": "^10.0.2",
"lucide-react": "0.220.0",
"nanoid": "4.0.2",
"react": "18.2.0",
"react-dom": "18.2.0"
"react-dom": "18.2.0",
"use-immer": "^0.9.0"
},
"devDependencies": {
"@types/node": "20.1.7",
"@types/node": "20.2.1",
"@types/react": "18.2.6",
"@types/react-dom": "18.2.4",
"@typescript-eslint/eslint-plugin": "5.59.6",
@ -42,7 +44,7 @@
"prettier": "2.8.8",
"prettier-plugin-organize-imports": "3.2.2",
"pretty-quick": "3.1.3",
"tslib": "2.5.0",
"tslib": "2.5.2",
"typescript": "5.0.4"
}
}

View file

@ -1,7 +1,7 @@
import { App, MarkdownRenderChild } from 'obsidian';
import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
import { ChessStudyDataAdapter, ChessStudyFileData } from 'src/utils';
import { ChessStudyDataAdapter, ChessStudyFileData } from 'src/lib/storage';
import { ChessStudyPluginSettings } from './obsidian/SettingsTab';
import { ChessStudy } from './react/ChessStudy';

View file

@ -2,20 +2,29 @@ import { JSONContent } from '@tiptap/react';
import { Chess, Move } from 'chess.js';
import { Api } from 'chessground/api';
import { DrawShape } from 'chessground/draw';
import { nanoid } from 'nanoid';
import { App, Notice } from 'obsidian';
import * as React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { ChessStudyPluginSettings } from 'src/components/obsidian/SettingsTab';
import { parseUserConfig } from 'src/lib/obsidian';
import {
ChessStudyDataAdapter,
ChessStudyFileData,
parseUserConfig,
} from 'src/utils';
import { ChessGroundSettings, ChessgroundWrapper } from './ChessgroundWrapper';
ChessStudyMove,
VariantMove,
} from 'src/lib/storage';
import {
displayMoveInHistory,
findMoveIndex,
getCurrentMove,
} from 'src/lib/ui-state';
import { useImmerReducer } from 'use-immer';
import { ChessgroundProps, ChessgroundWrapper } from './ChessgroundWrapper';
import { CommentSection } from './CommentSection';
import { PgnViewer } from './PgnViewer';
export type ChessStudyConfig = ChessGroundSettings;
export type ChessStudyConfig = ChessgroundProps;
interface AppProps {
source: string;
@ -25,6 +34,20 @@ interface AppProps {
dataAdapter: ChessStudyDataAdapter;
}
export interface GameState {
currentMove: ChessStudyMove | VariantMove;
isViewOnly: boolean;
study: ChessStudyFileData;
}
export type GameActions =
| { type: 'ADD_MOVE_TO_HISTORY'; move: Move }
| { type: 'DISPLAY_NEXT_MOVE_IN_HISTORY' }
| { type: 'DISPLAY_PREVIOUS_MOVE_IN_HISTORY' }
| { type: 'DISPLAY_SELECTED_MOVE_IN_HISTORY'; moveId: string }
| { type: 'SYNC_SHAPES'; shapes: DrawShape[] }
| { type: 'SYNC_COMMENT'; comment: JSONContent | null };
export const ChessStudy = ({
source,
pluginSettings,
@ -37,11 +60,11 @@ export const ChessStudy = ({
source
);
const [chessStudyDataModified] = useState(chessStudyData);
// Setup Chessboard and Chess.js APIs
// Setup Chessground API
const [chessView, setChessView] = useState<Api | null>(null);
const chessLogic = useMemo(() => {
// Setup Chess.js API
const initialChessLogic = useMemo(() => {
const chess = new Chess();
chessStudyData.moves.forEach((move) => {
@ -54,131 +77,211 @@ export const ChessStudy = ({
return chess;
}, [chessStudyData.moves]);
// Track Application State
const [history, setHistory] = useState<Move[]>([]);
const [isViewOnly, setIsViewOnly] = useState<boolean>(false);
const [currentMove, setCurrentMove] = useState<number>(0);
const [shapes, setShapes] = useState<DrawShape[][]>(
chessStudyData.moves.map((data) => data.shapes)
);
const [comments, setComments] = useState<(JSONContent | null)[]>(
chessStudyData.moves.map((data) => data.comment)
);
const [chessLogic, setChessLogic] = useState(initialChessLogic);
//PgnViewer Functions
const onBackButtonClick = useCallback(() => {
if (currentMove >= 0) {
setCurrentMove((currentMove) => {
const move = history[currentMove];
const tempChess = new Chess(move.before);
chessView?.set({
fen: move.before,
check: tempChess.isCheck(),
});
setIsViewOnly(true);
return currentMove - 1;
});
}
}, [chessView, currentMove, history]);
const [gameState, dispatch] = useImmerReducer<GameState, GameActions>(
(draft, action) => {
switch (action.type) {
case 'DISPLAY_NEXT_MOVE_IN_HISTORY': {
if (!chessView) return draft;
const onForwardButtonClick = useCallback(() => {
if (currentMove < history.length - 1) {
setCurrentMove((currentMove) => {
const move = history[currentMove + 1];
const tempChess = new Chess(move.after);
chessView?.set({
fen: move.after,
check: tempChess.isCheck(),
});
if (currentMove + 1 === history.length - 1) setIsViewOnly(false);
return currentMove + 1;
});
}
}, [currentMove, chessView, history]);
const onMoveItemClick = useCallback(
(moveIndex: number) => {
if (moveIndex !== currentMove) {
setCurrentMove(() => {
const move = history[moveIndex];
const tempChess = new Chess(move.after);
chessView?.set({
fen: move.after,
check: tempChess.isCheck(),
displayMoveInHistory(draft, chessView, setChessLogic, {
offset: 1,
selectedMoveId: null,
});
if (moveIndex !== history.length - 1) {
setIsViewOnly(true);
return draft;
}
case 'DISPLAY_PREVIOUS_MOVE_IN_HISTORY': {
if (!chessView) return draft;
displayMoveInHistory(draft, chessView, setChessLogic, {
offset: -1,
selectedMoveId: null,
});
return draft;
}
case 'DISPLAY_SELECTED_MOVE_IN_HISTORY': {
if (!chessView) return draft;
const selectedMoveId = action.moveId;
displayMoveInHistory(draft, chessView, setChessLogic, {
offset: 0,
selectedMoveId: selectedMoveId,
});
return draft;
}
case 'SYNC_SHAPES': {
const move = getCurrentMove(draft);
move.shapes = action.shapes;
draft.currentMove = move;
return draft;
}
case 'SYNC_COMMENT': {
const move = getCurrentMove(draft);
move.comment = action.comment;
draft.currentMove = move;
return draft;
}
case 'ADD_MOVE_TO_HISTORY': {
const newMove = action.move;
const moves = draft.study.moves;
const currentMoveId = draft.currentMove?.moveId;
const currentMoveIndex = moves.findIndex(
(move) => move.moveId === currentMoveId
);
const { variant, moveIndex } = findMoveIndex(moves, currentMoveId);
const moveId = nanoid();
if (variant) {
//handle variant
const parent = moves[variant.parentMoveIndex];
const variantMoves = parent.variants[variant.variantIndex].moves;
const isLastMove = moveIndex === variantMoves.length - 1;
//Only push if its the last move in the variant because depth can only be 1
if (isLastMove) {
const move = {
...newMove,
moveId: moveId,
shapes: [],
comment: null,
};
variantMoves.push(move);
const tempChess = new Chess(newMove.after);
draft.currentMove = move;
chessView?.set({
fen: newMove.after,
check: tempChess.isCheck(),
});
}
} else {
setIsViewOnly(false);
//handle main line
const isLastMove = currentMoveIndex === moves.length - 1;
if (isLastMove) {
const move = {
...newMove,
moveId: moveId,
variants: [],
shapes: [],
comment: null,
};
moves.push(move);
draft.currentMove = move;
} else {
const currentMove = moves[moveIndex];
// check if the next move is the same move
const nextMove = moves[moveIndex + 1];
if (nextMove.san === newMove.san) {
draft.currentMove = nextMove;
return draft;
}
const move = {
...newMove,
moveId: moveId,
shapes: [],
comment: null,
};
currentMove.variants.push({
parentMoveId: currentMove.moveId,
variantId: nanoid(),
moves: [move],
});
draft.currentMove = move;
}
}
return moveIndex;
});
return draft;
}
default:
break;
}
},
[chessView, currentMove, history]
{
currentMove: chessStudyData.moves[chessStudyData.moves.length - 1],
isViewOnly: false,
study: chessStudyData,
}
);
const onSaveButtonClick = useCallback(async () => {
const chessStudyData: ChessStudyFileData = {
header: chessStudyDataModified.header,
moves: chessLogic.history({ verbose: true }).map((move, index) => ({
...move,
variants: [],
shapes: shapes[index],
comment: comments[index],
})),
};
await dataAdapter.saveFile(chessStudyData, chessStudyId);
new Notice('Save successfull!');
}, [
chessLogic,
chessStudyDataModified.header,
chessStudyId,
comments,
dataAdapter,
shapes,
]);
try {
await dataAdapter.saveFile(gameState.study, chessStudyId);
new Notice('Save successfull!');
} catch (e) {
new Notice('Something went wrong during saving:', e);
}
}, [chessStudyId, dataAdapter, gameState.study]);
return (
<div className="chess-study border">
<div className="chess-study">
<div className="chessground-pgn-container">
<div className="chessground-container">
<ChessgroundWrapper
api={chessView}
setApi={setChessView}
chessStudyId={chessStudyId}
config={{
orientation: boardOrientation,
}}
boardColor={boardColor}
chess={chessLogic}
setHistory={setHistory}
setMoveNumber={setCurrentMove}
isViewOnly={isViewOnly}
setShapes={setShapes}
currentMoveNumber={currentMove}
currentMoveShapes={shapes[currentMove]}
addMoveToHistory={(move: Move) =>
dispatch({ type: 'ADD_MOVE_TO_HISTORY', move })
}
isViewOnly={gameState.isViewOnly}
syncShapes={(shapes: DrawShape[]) =>
dispatch({ type: 'SYNC_SHAPES', shapes })
}
shapes={gameState.currentMove?.shapes}
/>
</div>
<div className="pgn-container">
<PgnViewer
history={history}
currentMove={currentMove}
onBackButtonClick={onBackButtonClick}
onForwardButtonClick={onForwardButtonClick}
onMoveItemClick={onMoveItemClick}
history={gameState.study.moves}
currentMoveId={gameState.currentMove?.moveId}
onBackButtonClick={() =>
dispatch({ type: 'DISPLAY_PREVIOUS_MOVE_IN_HISTORY' })
}
onForwardButtonClick={() =>
dispatch({ type: 'DISPLAY_NEXT_MOVE_IN_HISTORY' })
}
onMoveItemClick={(moveId: string) =>
dispatch({
type: 'DISPLAY_SELECTED_MOVE_IN_HISTORY',
moveId: moveId,
})
}
onSaveButtonClick={onSaveButtonClick}
/>
</div>
</div>
<div className="CommentSection border-top">
<div className="CommentSection">
<CommentSection
currentMove={currentMove}
currentComment={comments[currentMove]}
setComments={setComments}
currentComment={gameState.currentMove?.comment}
setComments={(comment: JSONContent) =>
dispatch({ type: 'SYNC_COMMENT', comment: comment })
}
/>
</div>
</div>

View file

@ -1,43 +1,36 @@
import { Chessground as ChessgroundApi } from 'chessground';
import * as React from 'react';
import { useEffect, useRef } from 'react';
import { Chess, Move } from 'chess.js';
import { Chessground as ChessgroundApi } from 'chessground';
import { Api } from 'chessground/api';
import { Config } from 'chessground/config';
import { DrawShape } from 'chessground/draw';
import { playOtherSide, toColor, toDests } from 'src/utils';
import * as React from 'react';
import { useEffect, useRef } from 'react';
import { playOtherSide, toColor, toDests } from 'src/lib/chess-logic';
export interface ChessGroundSettings {
export interface ChessgroundProps {
api: Api | null;
setApi: React.Dispatch<React.SetStateAction<Api>>;
chessStudyId: string;
chess: Chess;
addMoveToHistory: (move: Move) => void;
syncShapes: (shapes: DrawShape[]) => void;
isViewOnly: boolean;
shapes: DrawShape[];
config?: Config;
boardColor?: 'brown' | 'green';
chess: Chess;
setHistory: React.Dispatch<React.SetStateAction<Move[]>>;
setShapes: React.Dispatch<React.SetStateAction<DrawShape[][]>>;
setMoveNumber: React.Dispatch<React.SetStateAction<number>>;
currentMoveNumber: number;
isViewOnly: boolean;
currentMoveShapes: DrawShape[];
}
export const ChessgroundWrapper = React.memo(
({
api,
setApi,
chessStudyId,
chess,
addMoveToHistory,
syncShapes: setShapes,
isViewOnly,
shapes,
boardColor = 'green',
config = {},
chess,
setHistory,
setMoveNumber,
currentMoveNumber,
setShapes,
isViewOnly,
currentMoveShapes,
}: ChessGroundSettings) => {
}: ChessgroundProps) => {
const ref = useRef<HTMLDivElement>(null);
//Chessground Init
@ -51,67 +44,52 @@ export const ChessgroundWrapper = React.memo(
free: false,
color: toColor(chess),
dests: toDests(chess),
events: {
//Hook up the Chessground UI changes to our App State
after: (orig, dest, _metadata) => {
const handler = playOtherSide(chessgroundApi, chess);
setHistory(handler(orig, dest));
setMoveNumber((state) => state + 1);
},
},
},
highlight: {
check: true,
},
drawable: {
onChange: (shapes) => {
setShapes(shapes);
},
},
turnColor: toColor(chess),
...config,
});
setHistory(chess.history({ verbose: true }));
setApi(chessgroundApi);
setMoveNumber(chess.history().length - 1);
} else if (ref.current && api) {
api.set(config);
}
}, [
ref,
chess,
api,
chessStudyId,
config,
setApi,
setHistory,
setMoveNumber,
setShapes,
currentMoveNumber,
]);
}, [addMoveToHistory, api, chess, config, setApi, setShapes]);
//Sync Chess Logic
useEffect(() => {
api?.set({
movable: {
events: {
//Hook up the Chessground UI changes to our App State
after: (orig, dest, _metadata) => {
const handler = playOtherSide(api, chess);
addMoveToHistory(handler(orig, dest));
},
},
},
});
}, [addMoveToHistory, api, chess]);
//Sync View Only
useEffect(() => {
api?.set({ viewOnly: isViewOnly });
}, [isViewOnly, api]);
//Sync Shapes To State
// Load Shapes
useEffect(() => {
api?.set({
drawable: {
onChange(shapes) {
setShapes((currentShapes) => {
const shapesModified = [...currentShapes];
shapesModified[currentMoveNumber] = shapes;
return shapesModified;
});
},
},
});
}, [api, currentMoveNumber, setShapes]);
//Load Shapes
useEffect(() => {
if (currentMoveShapes) api?.setShapes(currentMoveShapes);
}, [api, currentMoveNumber, currentMoveShapes, setShapes]);
if (shapes) {
api?.setShapes([...shapes]);
}
}, [api, shapes]);
return (
<div className={`${boardColor}-board height-width-100 table`}>

View file

@ -4,21 +4,17 @@ import * as React from 'react';
import { useEffect } from 'react';
interface CommentSectionProps {
currentMove: number;
currentComment: JSONContent | null;
setComments: React.Dispatch<React.SetStateAction<(JSONContent | null)[]>>;
setComments: (comment: JSONContent) => void;
}
export const CommentSection = React.memo(
({ currentMove, currentComment, setComments }: CommentSectionProps) => {
({ currentComment, setComments }: CommentSectionProps) => {
const editor = useEditor({
extensions: [StarterKit],
onUpdate: (state) => {
setComments((currentComments) => {
const currentCommentModified = [...currentComments];
currentCommentModified[currentMove] = state.editor.getJSON();
return currentCommentModified;
});
const comment = state.editor.getJSON();
if (comment) setComments(comment);
},
});

View file

@ -1,7 +1,7 @@
import { Move } from 'chess.js';
import { ArrowLeft, ArrowRight, Save } from 'lucide-react';
import * as React from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { ReactNode, useEffect, useMemo, useRef } from 'react';
import { ChessStudyMove } from 'src/lib/storage';
const chunkArray = <T,>(array: T[], chunkSize: number) => {
return array.reduce((resultArray, item, index) => {
@ -54,20 +54,70 @@ const MoveItem = ({
);
};
const VariantMoveItem = ({
isCurrentMove,
san,
onMoveItemClick,
moveIndicator = null,
}: {
isCurrentMove: boolean;
san: string;
onMoveItemClick: () => void;
moveIndicator?: string | null;
}) => {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current && isCurrentMove) {
ref.current?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'end',
});
}
}, [isCurrentMove]);
return (
<div
className={`variant-move-item ${(isCurrentMove && 'active') || ''}`}
onClick={(e) => {
e.stopPropagation();
onMoveItemClick();
}}
ref={ref}
>
<span className={'variant-move-indicator'}>{moveIndicator}</span>
{san}
</div>
);
};
const VariantMoveItemContainer = ({ children }: { children: ReactNode }) => {
return <div className="variant-move-item-container">{children}</div>;
};
const VariantContainer = ({ children }: { children: ReactNode }) => {
return <div className="variant-container">{children}</div>;
};
const VariantsContainer = ({ children }: { children: ReactNode }) => {
return <div className="variants-container">{children}</div>;
};
export const PgnViewer = React.memo(
({
history,
currentMove,
currentMoveId,
onBackButtonClick,
onForwardButtonClick,
onMoveItemClick,
onSaveButtonClick,
}: {
history: Move[];
currentMove: number;
history: ChessStudyMove[];
currentMoveId: string;
onBackButtonClick: () => void;
onForwardButtonClick: () => void;
onMoveItemClick: (moveIndex: number) => void;
onMoveItemClick: (moveId: string) => void;
onSaveButtonClick: () => void;
}) => {
const movePairs = useMemo(() => chunkArray(history, 2), [history]);
@ -76,23 +126,142 @@ export const PgnViewer = React.memo(
<div className="height-width-100">
<div className="move-item-section">
<div className="move-item-container">
{movePairs.map((pair, i) => {
{movePairs.map((pair, currentMoveIndex) => {
const [wMove, bMove] = pair;
return (
<React.Fragment key={wMove.san + bMove?.san}>
<p className="move-indicator center">{i + 1}</p>
<React.Fragment key={wMove.san + bMove?.san + currentMoveIndex}>
<p className="move-indicator center">
{currentMoveIndex + 1}
</p>
<MoveItem
san={wMove.san}
isCurrentMove={i * 2 === currentMove}
onMoveItemClick={() => onMoveItemClick(i * 2)}
isCurrentMove={wMove.moveId === currentMoveId}
onMoveItemClick={() => onMoveItemClick(wMove.moveId)}
/>
{bMove && (
<MoveItem
san={bMove.san}
isCurrentMove={i * 2 + 1 === currentMove}
onMoveItemClick={() => onMoveItemClick(i * 2 + 1)}
isCurrentMove={bMove.moveId === currentMoveId}
onMoveItemClick={() => onMoveItemClick(bMove.moveId)}
/>
)}
{!!wMove.variants.concat(bMove?.variants || []).length && (
<VariantsContainer>
{!!wMove.variants.length && (
<VariantContainer>
{wMove.variants.map((variant) => {
return (
<VariantMoveItemContainer key={variant.variantId}>
{chunkArray(variant.moves, 2).map(
(pair, wMoveVarianti) => {
const [bMove, wMove] = pair;
return (
<React.Fragment
key={
bMove.san +
wMove?.san +
currentMoveIndex
}
>
<VariantMoveItem
isCurrentMove={
bMove.moveId === currentMoveId
}
san={bMove.san}
onMoveItemClick={() =>
onMoveItemClick(bMove.moveId)
}
moveIndicator={
(wMoveVarianti === 0 &&
`${
currentMoveIndex +
1 +
wMoveVarianti
}... `) ||
null
}
/>
{wMove && (
<VariantMoveItem
isCurrentMove={
wMove.moveId === currentMoveId
}
san={wMove.san}
onMoveItemClick={() =>
onMoveItemClick(wMove.moveId)
}
moveIndicator={
(wMoveVarianti % 2 === 0 &&
null) ||
`${
currentMoveIndex +
2 +
wMoveVarianti
}. `
}
/>
)}
</React.Fragment>
);
}
)}
</VariantMoveItemContainer>
);
})}
</VariantContainer>
)}
{!!bMove?.variants.length && (
<VariantContainer>
{bMove.variants.map((variant) => {
return (
<VariantMoveItemContainer key={variant.variantId}>
{chunkArray(variant.moves, 2).map(
(pair, bMoveVarianti) => {
const [wMove, bMove] = pair;
return (
<React.Fragment
key={
wMove.san +
bMove?.san +
currentMoveIndex
}
>
<VariantMoveItem
isCurrentMove={
wMove.moveId === currentMoveId
}
san={wMove.san}
onMoveItemClick={() =>
onMoveItemClick(wMove.moveId)
}
moveIndicator={`${
currentMoveIndex + 2 + bMoveVarianti
}. `}
/>
{bMove && (
<VariantMoveItem
isCurrentMove={
bMove.moveId === currentMoveId
}
san={bMove.san}
onMoveItemClick={() =>
onMoveItemClick(bMove.moveId)
}
/>
)}
</React.Fragment>
);
}
)}
</VariantMoveItemContainer>
);
})}
</VariantContainer>
)}
</VariantsContainer>
)}
</React.Fragment>
);
})}

View file

@ -0,0 +1,47 @@
import { Chess, QUEEN, SQUARES, Square } from 'chess.js';
import { Api } from 'chessground/api';
import { Config } from 'chessground/config';
export function toColor(chess: Chess) {
return chess.turn() === 'w' ? 'white' : 'black';
}
export function toDests(chess: Chess): Map<Square, Square[]> {
const dests = new Map();
SQUARES.forEach((s) => {
const ms = chess.moves({ square: s, verbose: true });
if (ms.length)
dests.set(
s,
ms.map((m) => m.to)
);
});
return dests;
}
export function playOtherSide(cg: Api, chess: Chess) {
return (orig: string, dest: string) => {
const move = chess.move({ from: orig, to: dest, promotion: QUEEN });
const commonTurnProperties: Partial<Config> = {
turnColor: toColor(chess),
movable: {
color: toColor(chess),
dests: toDests(chess),
},
check: chess.isCheck(),
};
if (move.flags === 'e' || move.promotion) {
//Handle En Passant && Promote to Queen by default
cg.set({
fen: chess.fen(),
...commonTurnProperties,
});
} else {
cg.set(commonTurnProperties);
}
return move;
};
}

25
src/lib/obsidian/index.ts Normal file
View file

@ -0,0 +1,25 @@
import { parseYaml } from 'obsidian';
import { ChessStudyPluginSettings } from 'src/components/obsidian/SettingsTab';
type ChessStudyAppConfig = ChessStudyPluginSettings & {
chessStudyId: string;
};
export const parseUserConfig = (
settings: ChessStudyPluginSettings,
content: string
): ChessStudyAppConfig => {
const chessStudyConfig: ChessStudyAppConfig = {
...settings,
chessStudyId: '',
};
try {
return {
...chessStudyConfig,
...parseYaml(content),
};
} catch (e) {
throw Error('Something went wrong during parsing. :(');
}
};

68
src/lib/storage/index.ts Normal file
View file

@ -0,0 +1,68 @@
import { JSONContent } from '@tiptap/react';
import { Move } from 'chess.js';
import { DrawShape } from 'chessground/draw';
import { nanoid } from 'nanoid';
import { DataAdapter, normalizePath } from 'obsidian';
export const CURRENT_STORAGE_VERSION = '0.0.1';
export interface VariantMove extends Move {
moveId: string;
shapes: DrawShape[];
comment: JSONContent | null;
}
export interface Variant {
variantId: string;
parentMoveId: string;
moves: VariantMove[];
}
export interface ChessStudyMove extends Move {
moveId: string;
variants: Variant[];
shapes: DrawShape[];
comment: JSONContent | null;
}
export interface ChessStudyFileData {
version: string;
header: { title: string | null };
moves: ChessStudyMove[];
}
export class ChessStudyDataAdapter {
adapter: DataAdapter;
storagePath: string;
constructor(adapter: DataAdapter, storagePath: string) {
this.adapter = adapter;
this.storagePath = storagePath;
}
async saveFile(data: ChessStudyFileData, id?: string) {
const chessStudyId = id || nanoid();
await this.adapter.write(
normalizePath(`${this.storagePath}/${chessStudyId}.json`),
JSON.stringify(data, null, 2),
{}
);
return chessStudyId;
}
async loadFile(id: string): Promise<ChessStudyFileData> {
const data = await this.adapter.read(
normalizePath(`${this.storagePath}/${id}.json`)
);
return JSON.parse(data);
}
async createStorageFolderIfNotExists() {
const folderExists = await this.adapter.exists(this.storagePath);
if (!folderExists) {
this.adapter.mkdir(this.storagePath);
}
}
}

111
src/lib/ui-state/index.ts Normal file
View file

@ -0,0 +1,111 @@
import { Chess } from 'chess.js';
import { Api as ChessgroundApi } from 'chessground/api';
import { Draft } from 'immer';
import { GameState } from 'src/components/react/ChessStudy';
import { toColor, toDests } from '../chess-logic';
import { ChessStudyMove, VariantMove } from '../storage';
interface MovePosition {
variant: { parentMoveIndex: number; variantIndex: number } | null;
moveIndex: number;
}
export const findMoveIndex = (
moves: ChessStudyMove[],
moveId: string
): MovePosition => {
for (const [iMainLine, move] of moves.entries()) {
if (move.moveId === moveId) return { variant: null, moveIndex: iMainLine };
for (const [iVariant, variant] of move.variants.entries()) {
const moveIndex = variant.moves.findIndex(
(move) => move.moveId === moveId
);
if (moveIndex >= 0)
return {
variant: { parentMoveIndex: iMainLine, variantIndex: iVariant },
moveIndex: moveIndex,
};
}
}
return { variant: null, moveIndex: -1 };
};
export const displayMoveInHistory = (
draft: Draft<GameState>,
chessView: ChessgroundApi,
setChessLogic: React.Dispatch<React.SetStateAction<Chess>>,
options: { offset: number; selectedMoveId: string | null } = {
offset: 0,
selectedMoveId: null,
}
): Draft<GameState> => {
let moveToDisplay: ChessStudyMove | VariantMove | null = null;
const { offset, selectedMoveId } = options;
//Figure out where we are
const currentMove = draft.currentMove;
const currentMoveId = currentMove.moveId;
const moves = draft.study.moves;
//If we pass a moveId, find out where that is and offset from there, otherwise take current moveId
const baseMoveId = selectedMoveId || currentMoveId;
const { variant, moveIndex } = findMoveIndex(moves, baseMoveId);
//Are we in a variant? Are we not? Decide which move to display
if (variant) {
const variantMoves =
moves[variant.parentMoveIndex].variants[variant.variantIndex].moves;
if (typeof variantMoves[moveIndex + offset] !== 'undefined') {
moveToDisplay = variantMoves[moveIndex + offset];
}
} else {
if (typeof moves[moveIndex + offset] !== 'undefined') {
moveToDisplay = moves[moveIndex + offset];
}
}
if (!moveToDisplay) return draft;
const chess = new Chess(moveToDisplay.after);
chessView.set({
fen: moveToDisplay.after,
check: chess.isCheck(),
movable: {
free: false,
color: toColor(chess),
dests: toDests(chess),
},
turnColor: toColor(chess),
});
draft.currentMove = moveToDisplay;
setChessLogic(chess);
return draft;
};
export const getCurrentMove = (
draft: Draft<GameState>
): Draft<ChessStudyMove> | Draft<VariantMove> => {
const currentMoveId = draft.currentMove?.moveId;
const moves = draft.study.moves;
const { variant, moveIndex } = findMoveIndex(moves, currentMoveId);
if (variant) {
return moves[variant.parentMoveIndex].variants[variant.variantIndex].moves[
moveIndex
];
} else {
return moves[moveIndex];
}
};

View file

@ -1,18 +1,8 @@
/* General */
.chess-study button:active {
transform: translateY(1px);
}
.chess-study .border {
border: 1px var(--color-base-10) solid;
border-radius: 5px;
}
.chess-study .border-top {
border: 1px var(--color-base-10) solid;
}
.chess-study .vertical-align {
display: flex;
align-items: center;
@ -34,12 +24,13 @@
}
.chess-study {
height: 100%;
max-width: 750px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.chess-study {
height: 100%;
background-color: var(--background-secondary);
border: 1px solid var(--background-secondary-alt);
border-radius: 5px;
}
.chess-study *,
@ -85,6 +76,7 @@
}
/* PGN Viewer */
.chess-study .move-item-section {
height: 415px;
}
@ -98,10 +90,12 @@
height: 35px;
}
/* Move Items */
.chess-study .move-item-container {
display: grid;
grid-template-columns: 0.15fr 0.425fr 0.425fr;
grid-auto-rows: 30px;
grid-auto-rows: minmax(30px, auto);
width: 100%;
max-height: 415px;
overflow-y: scroll;
@ -132,6 +126,21 @@
font-weight: bold;
}
/* Variant Move Items */
.chess-study .variants-container {
grid-column: span 3 / auto;
display: flex;
flex-direction: column;
border-bottom: 1px solid var(--color-base-50);
}
.chess-study .variant-container {
grid-column: span 3 / auto;
display: flex;
flex-direction: column;
}
.chess-study .move-indicator {
color: var(--color-base-60);
text-align: center;
@ -140,6 +149,28 @@
cursor: default;
}
.chess-study .variant-move-item {
padding-left: 6px;
user-select: none;
cursor: pointer;
}
.chess-study .variant-move-item-container {
display: flex;
flex-wrap: wrap;
padding: 4px 0;
border-top: 1px solid var(--color-base-50);
}
.chess-study .variant-move-item.active,
.chess-study .variant-move-item:hover {
color: hsl(var(--accent-h), var(--accent-s), calc(var(--accent-l) - 10%));
}
.chess-study .variant-move-indicator {
font-weight: bold;
}
/* Comment Section */
.chess-study .editor-input {
@ -158,10 +189,11 @@
height: 250px;
padding-top: 5px;
overflow: scroll;
border-top: 1px solid var(--background-secondary-alt);
}
.chess-study.ProseMirror,
.chess-study.CommentSection > div {
.chess-study .ProseMirror,
.chess-study .CommentSection > div {
width: 100%;
height: 100%;
}

View file

@ -1,5 +1,10 @@
import { Chess } from 'chess.js';
import { Editor, Notice, Plugin, normalizePath } from 'obsidian';
import {
CURRENT_STORAGE_VERSION,
ChessStudyDataAdapter,
ChessStudyFileData,
} from 'src/lib/storage';
import { ReactView } from './components/ReactView';
import { PgnModal } from './components/obsidian/PgnModal';
import {
@ -7,17 +12,14 @@ import {
DEFAULT_SETTINGS,
SettingsTab,
} from './components/obsidian/SettingsTab';
import {
ChessStudyDataAdapter,
ChessStudyFileData,
parseUserConfig,
} from './utils';
// these styles must be imported somewhere
import 'assets/board/green.css';
import 'chessground/assets/chessground.base.css';
import 'chessground/assets/chessground.brown.css';
import 'chessground/assets/chessground.cburnett.css';
import { nanoid } from 'nanoid';
import { parseUserConfig } from './lib/obsidian';
import './main.css';
export default class ChessStudyPlugin extends Plugin {
@ -61,11 +63,13 @@ export default class ChessStudyPlugin extends Plugin {
}
const chessStudyFileData: ChessStudyFileData = {
version: CURRENT_STORAGE_VERSION,
header: {
title: chess.header()['opening'] || null,
},
moves: chess.history({ verbose: true }).map((move) => ({
...move,
moveId: nanoid(),
variants: [],
shapes: [],
comment: null,
@ -75,7 +79,7 @@ export default class ChessStudyPlugin extends Plugin {
const id = await this.dataAdapter.saveFile(chessStudyFileData);
editor.replaceRange(
`\`\`\`chessStudy\nchessStudyId: ${id}\nboardOrientation: white\nboardColor: green\n\`\`\``,
`\`\`\`chessStudy\nchessStudyId: ${id}\n\`\`\``,
cursorPosition
);
} catch (e) {

View file

@ -1,124 +0,0 @@
import { JSONContent } from '@tiptap/react';
import { Chess, Move, QUEEN, SQUARES, Square } from 'chess.js';
import { Api } from 'chessground/api';
import { Config } from 'chessground/config';
import { DrawShape } from 'chessground/draw';
import { nanoid } from 'nanoid';
import { DataAdapter, normalizePath, parseYaml } from 'obsidian';
import { ChessStudyPluginSettings } from 'src/components/obsidian/SettingsTab';
//Chess Logic
type ChessStudyAppConfig = ChessStudyPluginSettings & {
chessStudyId: string;
};
export const parseUserConfig = (
settings: ChessStudyPluginSettings,
content: string
): ChessStudyAppConfig => {
const chessStudyConfig: ChessStudyAppConfig = {
...settings,
chessStudyId: '',
};
try {
return {
...chessStudyConfig,
...parseYaml(content),
};
} catch (e) {
throw Error('Something went wrong during parsing. :(');
}
};
export function toColor(chess: Chess) {
return chess.turn() === 'w' ? 'white' : 'black';
}
export function toDests(chess: Chess): Map<Square, Square[]> {
const dests = new Map();
SQUARES.forEach((s) => {
const ms = chess.moves({ square: s, verbose: true });
if (ms.length)
dests.set(
s,
ms.map((m) => m.to)
);
});
return dests;
}
export function playOtherSide(cg: Api, chess: Chess) {
return (orig: string, dest: string) => {
const move = chess.move({ from: orig, to: dest, promotion: QUEEN });
const commonTurnProperties: Partial<Config> = {
turnColor: toColor(chess),
movable: {
color: toColor(chess),
dests: toDests(chess),
},
check: chess.isCheck(),
};
if (move.flags === 'e' || move.promotion) {
//Handle En Passant && Promote to Queen by default
cg.set({
fen: chess.fen(),
...commonTurnProperties,
});
} else {
cg.set(commonTurnProperties);
}
return chess.history({ verbose: true });
};
}
//Storage Logic
interface ChessStudyMove extends Move {
variants: Move[][];
shapes: DrawShape[];
comment: JSONContent | null;
}
export interface ChessStudyFileData {
header: { title: string | null };
moves: ChessStudyMove[];
}
export class ChessStudyDataAdapter {
adapter: DataAdapter;
storagePath: string;
constructor(adapter: DataAdapter, storagePath: string) {
this.adapter = adapter;
this.storagePath = storagePath;
}
async saveFile(data: ChessStudyFileData, id?: string) {
const chessStudyId = id || nanoid();
await this.adapter.write(
normalizePath(`${this.storagePath}/${chessStudyId}.json`),
JSON.stringify(data, null, 2),
{}
);
return chessStudyId;
}
async loadFile(id: string): Promise<ChessStudyFileData> {
const data = await this.adapter.read(
normalizePath(`${this.storagePath}/${id}.json`)
);
return JSON.parse(data);
}
async createStorageFolderIfNotExists() {
const folderExists = await this.adapter.exists(this.storagePath);
if (!folderExists) {
this.adapter.mkdir(this.storagePath);
}
}
}

File diff suppressed because one or more lines are too long

View file

@ -1,3 +1,4 @@
{
"1.0.0": "0.15.0"
"1.0.0": "0.15.0",
"1.0.1": "0.15.0"
}