Compare commits

...

13 commits
1.0.0 ... trunk

Author SHA1 Message Date
latenitecoding
d15b71985e feat: navigation can cycle through board 2024-10-07 14:16:15 +02:00
David Gilhooley
edf436f198 feat: Add titles to buttons 2024-10-07 14:15:57 +02:00
Christoph Lindstädt
93694d1ff9
Update README.md 2024-02-11 15:39:28 +01:00
chrislicodes
05e17ca3b5 chore: bump versions 2024-02-11 15:24:08 +01:00
chrislicodes
137821c4da chore: minor improvements 2024-02-11 14:58:23 +01:00
latenitecode
e5381f1cf5
feat: FEN support 2024-02-04 18:06:51 +01:00
latenitecode
87e4b522dc
feat: adds undo button
feat: adds undo button
2024-02-04 18:02:26 +01:00
latenitecode
0a8746532f
feat: adds option to disable comments (#10) 2024-02-04 18:00:14 +01:00
chrislicodes
f61649c0f1 chore: update settings 2023-06-08 21:02:07 +02:00
chrislicodes
1fd08ea85d chore: update README.md 2023-05-27 15:01:29 +02:00
chrislicodes
f190528f5a chore: update README.md 2023-05-25 17:11:01 +02:00
chrislicodes
e47df69218 chore: bump versions 2023-05-25 17:06:00 +02:00
Christoph Lindstädt
15b5a3de40
feat: support variants of depth 1 (#1)
feat: support variants of depth 1
2023-05-25 16:54:35 +02:00
26 changed files with 1215 additions and 535 deletions

View file

@ -1,34 +1,34 @@
name: Release Obsidian plugin
on:
push:
tags:
- '*'
push:
tags:
- '*'
jobs:
build:
runs-on: ubuntu-latest
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '18.x'
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '18.x'
- name: Build plugin
run: |
npm install
npm run build
- name: Build plugin
run: |
npm install
npm run build
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
gh release create "$tag" \
--title="$tag" \
--draft \
main.js manifest.json styles.css
gh release create "$tag" \
--title="$tag" \
--draft \
main.js manifest.json styles.css

View file

@ -1,3 +1,4 @@
node_modules
main.js
data.json
README.md

View file

@ -1,6 +1,6 @@
{
"singleQuote": true,
"tabWidth": 2,
"tabWidth": 1,
"proseWrap": "preserve",
"semi": true
}

View file

@ -1,5 +1,4 @@
<!-- omit in toc -->
# Obsidian Chess Study
> A chess study helper and PGN viewer/editor for [Obsidian](https://obsidian.md/).
@ -7,13 +6,15 @@
With this plugin, you can either import PGNs or simply start a fresh new game. It allows you to add comments and arrows for each move, which will be persisted within a JSON File in your vault. Although it is not a full analysis board, it serves as a valuable tool to support your chess notetaking in [Obsidian](https://obsidian.md/).
<!-- omit in toc -->
## Table of contents
- [Motivation](#motivation)
- [Installation](#installation)
- [Usage](#usage)
- [Features](#features)
- [1.0.0](#100)
- [1.1.0](#110)
- [1.2.0](#120)
- [Settings](#settings)
- [Roadmap](#roadmap)
- [Tools Used](#tools-used)
@ -40,12 +41,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 +57,20 @@ 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.1.0
- [x] Add support for variants (depth 1)
![chess-study-variants](imgs/chess-study-variants.png)
### 1.2.0
- [x] Add FEN start position support
- [x] Add undo button
- [x] Copy current FEN string to clipboard
Thanks to [@latenitecoding](https://github.com/latenitecoding) for the contributions
## Settings
Here are the available settings for a `chessStudy` code block:
@ -63,15 +80,17 @@ Here are the available settings for a `chessStudy` code block:
| `chessStudyId` | Valid nanoid | Valid ID for a file stored in the plugin storage |
| `boardOrientation` | `white` \| `black` | Orientation of the board |
| `boardColor` | `green` \| `brown` | Color of the board |
| `viewComments` | `true` \| `false` | Whether to display the comments section |
You can permanently set some settings in the [Obsidian](https://obsidian.md/) plugin settings for Obsidian Chess Study.
## Roadmap
- [ ] Add support for variants
- [ ] Add view to manage stored games
- [ ] Add more styles
- [ ] Add more settings
- [ ] Support canvas view
- [ ] Mobile support
## Tools Used

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View file

@ -1,10 +1,10 @@
{
"id": "chess-study",
"name": "Chess Study",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"version": "1.2.0",
"minAppVersion": "1.4.0",
"description": "A chess study helper and PGN viewer/editor.",
"author": "Christoph Lindstädt",
"authorUrl": "https://github.com/chrislicodes",
"isDesktopOnly": false
"isDesktopOnly": true
}

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.1.0",
"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

@ -1,7 +1,8 @@
import { App, Modal, Setting } from 'obsidian';
import { ChessString } from 'src/main';
export class PgnModal extends Modal {
pgn: string;
export class ChessStringModal extends Modal {
chessString: ChessString;
onSubmit: (pgn: string) => void;
constructor(app: App, onSubmit: (pgn: string) => void) {
@ -13,13 +14,13 @@ export class PgnModal extends Modal {
const { contentEl } = this;
contentEl.createEl('h1', {
text: 'Paste the full PGN (leave empty for a new game):',
text: 'Paste the full PGN/FEN (leave empty for a new game):',
});
new Setting(contentEl).setName('PGN').addTextArea((text) =>
new Setting(contentEl).setName('PGN/FEN').addTextArea((text) =>
text
.onChange((value) => {
this.pgn = value;
this.chessString = value;
})
.inputEl.setCssStyles({ width: '100%', height: '250px' })
);
@ -30,7 +31,7 @@ export class PgnModal extends Modal {
.setCta()
.onClick(() => {
this.close();
this.onSubmit(this.pgn);
this.onSubmit(this.chessString);
})
);
}

View file

@ -4,11 +4,13 @@ import ChessStudyPlugin from 'src/main';
export interface ChessStudyPluginSettings {
boardOrientation: 'white' | 'black';
boardColor: 'green' | 'brown';
viewComments: true | false;
}
export const DEFAULT_SETTINGS: ChessStudyPluginSettings = {
boardOrientation: 'white',
boardColor: 'green',
viewComments: true,
};
export class SettingsTab extends PluginSettingTab {
@ -24,10 +26,8 @@ export class SettingsTab extends PluginSettingTab {
containerEl.empty();
containerEl.createEl('h2', { text: 'Obsidian Chess Study Settings' });
new Setting(containerEl)
.setName('Board Orientation')
.setName('Board orientation')
.setDesc('Sets the default orientation of the board')
.addDropdown((dropdown) => {
dropdown.addOption('white', 'White');
@ -36,15 +36,13 @@ export class SettingsTab extends PluginSettingTab {
dropdown
.setValue(this.plugin.settings.boardOrientation)
.onChange((orientation) => {
this.plugin.settings.boardOrientation = orientation as
| 'white'
| 'black';
this.plugin.settings.boardOrientation = orientation as 'white' | 'black';
this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName('Board Color')
.setName('Board color')
.setDesc('Sets the default color of the board')
.addDropdown((dropdown) => {
dropdown.addOption('green', 'Green');
@ -57,5 +55,19 @@ export class SettingsTab extends PluginSettingTab {
this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName('View Comments')
.setDesc('Sets the default view of the comments')
.addDropdown((dropdown) => {
dropdown.addOption('true', 'True');
dropdown.addOption('false', 'False');
dropdown
.setValue(this.plugin.settings.viewComments.toString())
.onChange((viewComments) => {
this.plugin.settings.viewComments = viewComments === 'true';
this.plugin.saveSettings();
});
});
}
}

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,21 @@ interface AppProps {
dataAdapter: ChessStudyDataAdapter;
}
export interface GameState {
currentMove: ChessStudyMove | VariantMove | null;
isViewOnly: boolean;
study: ChessStudyFileData;
}
export type GameActions =
| { type: 'ADD_MOVE_TO_HISTORY'; move: Move }
| { type: 'REMOVE_LAST_MOVE_FROM_HISTORY' }
| { 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,
@ -32,17 +56,18 @@ export const ChessStudy = ({
dataAdapter,
}: AppProps) => {
// Parse Obsidian / Code Block Settings
const { boardColor, boardOrientation, chessStudyId } = parseUserConfig(
pluginSettings,
source
);
const { boardColor, boardOrientation, viewComments, chessStudyId } =
parseUserConfig(pluginSettings, source);
const [chessStudyDataModified] = useState(chessStudyData);
// Setup Chessboard and Chess.js APIs
// Setup Chessground API
const [chessView, setChessView] = useState<Api | null>(null);
const chessLogic = useMemo(() => {
const chess = new Chess();
// Setup Chess.js API
const [initialChessLogic, firstPlayer, initialMoveNumber] = useMemo(() => {
const chess = new Chess(chessStudyData.rootFEN);
const firstPlayer = chess.turn();
const initialMoveNumber = chess.moveNumber();
chessStudyData.moves.forEach((move) => {
chess.move({
@ -51,136 +76,310 @@ export const ChessStudy = ({
promotion: move.promotion,
});
});
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)
);
return [chess, firstPlayer, initialMoveNumber];
}, [chessStudyData.moves, chessStudyData.rootFEN]);
//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 [chessLogic, setChessLogic] = useState(initialChessLogic);
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 [gameState, dispatch] = useImmerReducer<GameState, GameActions>(
(draft, action) => {
const hasNoMoves = draft.study.moves.length === 0;
switch (action.type) {
case 'DISPLAY_NEXT_MOVE_IN_HISTORY': {
if (!chessView || hasNoMoves) return draft;
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);
} else {
setIsViewOnly(false);
return draft;
}
case 'DISPLAY_PREVIOUS_MOVE_IN_HISTORY': {
if (!chessView || hasNoMoves) return draft;
displayMoveInHistory(draft, chessView, setChessLogic, {
offset: -1,
selectedMoveId: null,
});
return draft;
}
case 'REMOVE_LAST_MOVE_FROM_HISTORY': {
if (!chessView || hasNoMoves) return draft;
const moves = draft.study.moves;
const currentMoveId = draft.currentMove?.moveId;
if (currentMoveId) {
const { variant, moveIndex } = findMoveIndex(moves, currentMoveId);
if (variant) {
const parent = moves[variant.parentMoveIndex];
const variantMoves = parent.variants[variant.variantIndex].moves;
const isLastMove = moveIndex === variantMoves.length - 1;
if (isLastMove) {
displayMoveInHistory(draft, chessView, setChessLogic, {
offset: -1,
selectedMoveId: currentMoveId,
});
}
variantMoves.pop();
if (variantMoves.length === 0) {
parent.variants.splice(variant.variantIndex, 1);
}
if (isLastMove) {
draft.currentMove =
variantMoves.length > 0
? variantMoves[variantMoves.length - 1]
: moves[variant.parentMoveIndex];
}
} else {
const isLastMove = moveIndex === moves.length - 1;
if (isLastMove) {
displayMoveInHistory(draft, chessView, setChessLogic, {
offset: -1,
selectedMoveId: currentMoveId,
});
}
moves.pop();
if (isLastMove) {
draft.currentMove = moves.length > 0 ? moves[moves.length - 1] : null;
}
}
}
return moveIndex;
});
return draft;
}
case 'DISPLAY_SELECTED_MOVE_IN_HISTORY': {
if (!chessView || hasNoMoves) return draft;
const selectedMoveId = action.moveId;
displayMoveInHistory(draft, chessView, setChessLogic, {
offset: 0,
selectedMoveId: selectedMoveId,
});
return draft;
}
case 'SYNC_SHAPES': {
if (!chessView || hasNoMoves) return draft;
const move = getCurrentMove(draft);
if (move) {
move.shapes = action.shapes;
draft.currentMove = move;
}
return draft;
}
case 'SYNC_COMMENT': {
if (!chessView || hasNoMoves) return draft;
const move = getCurrentMove(draft);
if (move) {
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 moveId = nanoid();
if (currentMoveId) {
const currentMoveIndex = moves.findIndex(
(move) => move.moveId === currentMoveId
);
const { variant, moveIndex } = findMoveIndex(moves, currentMoveId);
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 {
//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;
}
}
} else {
const move = {
...newMove,
moveId: moveId,
variants: [],
shapes: [],
comment: null,
};
moves.push(move);
draft.currentMove = move;
}
return draft;
}
default:
break;
}
},
[chessView, currentMove, history]
{
currentMove: chessStudyData.moves[chessStudyData.moves.length - 1] ?? null,
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 ?? null}
firstPlayer={firstPlayer}
initialMoveNumber={initialMoveNumber}
onUndoButtonClick={() =>
dispatch({ type: 'REMOVE_LAST_MOVE_FROM_HISTORY' })
}
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}
onCopyButtonClick={() => {
try {
navigator.clipboard.writeText(chessLogic.fen());
new Notice('Copied to clipboard!');
} catch (e) {
new Notice('Could not copy to clipboard:', e);
}
}}
/>
</div>
</div>
<div className="CommentSection border-top">
<CommentSection
currentMove={currentMove}
currentComment={comments[currentMove]}
setComments={setComments}
/>
</div>
{viewComments && (
<div className="CommentSection">
<CommentSection
currentComment={gameState.currentMove?.comment ?? null}
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

@ -0,0 +1,36 @@
import { ArrowLeft, ArrowRight, Copy, Save, Undo2 } from 'lucide-react';
import * as React from 'react';
export interface ControlActions {
onUndoButtonClick: () => void;
onBackButtonClick: () => void;
onForwardButtonClick: () => void;
onSaveButtonClick: () => void;
onCopyButtonClick: () => void;
}
export const Controls = (props: ControlActions) => {
return (
<div>
<div className="button-section">
<button title="Back" onClick={() => props.onBackButtonClick()}>
<ArrowLeft />
</button>
<button title="Forward" onClick={() => props.onForwardButtonClick()}>
<ArrowRight />
</button>
<button title="Save" onClick={() => props.onSaveButtonClick()}>
<Save strokeWidth={'1px'} />
</button>
</div>
<div className="button-section">
<button title="Copy FEN" onClick={() => props.onCopyButtonClick()}>
<Copy strokeWidth={'1px'} />
</button>
<button title="Undo" onClick={() => props.onUndoButtonClick()}>
<Undo2 />
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,74 @@
import * as React from 'react';
export const MoveItem = ({
isCurrentMove,
san,
onMoveItemClick,
}: {
isCurrentMove: boolean;
san: string;
onMoveItemClick: () => void;
}) => {
const ref = React.useRef<HTMLParagraphElement>(null);
React.useEffect(() => {
if (ref.current && isCurrentMove) {
ref.current?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'end',
});
}
}, [isCurrentMove]);
return (
<p
className={`move-item ${(isCurrentMove && 'active') || ''} vertical-align`}
ref={ref}
onClick={(e) => {
e.stopPropagation();
onMoveItemClick();
}}
>
{san}
</p>
);
};
export const VariantMoveItem = ({
isCurrentMove,
san,
onMoveItemClick,
moveIndicator = null,
}: {
isCurrentMove: boolean;
san: string;
onMoveItemClick: () => void;
moveIndicator?: string | null;
}) => {
const ref = React.useRef<HTMLDivElement>(null);
React.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>
);
};

View file

@ -1,11 +1,12 @@
import { Move } from 'chess.js';
import { ArrowLeft, ArrowRight, Save } from 'lucide-react';
import * as React from 'react';
import { useEffect, useMemo, useRef } from 'react';
import { useMemo } from 'react';
import { ChessStudyMove } from 'src/lib/storage';
import { Controls } from './Controls';
import { MoveItem, VariantMoveItem } from './MoveItems';
const chunkArray = <T,>(array: T[], chunkSize: number) => {
const chunkArray = <T,>(array: T[], chunkSize: number, offsetByOne = false) => {
return array.reduce((resultArray, item, index) => {
const chunkIndex = Math.floor(index / chunkSize);
const chunkIndex = Math.floor((index + (offsetByOne ? 1 : 0)) / chunkSize);
if (!resultArray[chunkIndex]) {
resultArray[chunkIndex] = [];
@ -17,101 +18,187 @@ const chunkArray = <T,>(array: T[], chunkSize: number) => {
}, [] as T[][]);
};
const MoveItem = ({
isCurrentMove,
san,
onMoveItemClick,
export const VariantMoveItemContainer = ({
children,
}: {
isCurrentMove: boolean;
san: string;
onMoveItemClick: () => void;
children: React.ReactNode;
}) => {
const ref = useRef<HTMLParagraphElement>(null);
useEffect(() => {
if (ref.current && isCurrentMove) {
ref.current?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'end',
});
}
}, [isCurrentMove]);
return (
<p
className={`move-item ${
(isCurrentMove && 'active') || ''
} vertical-align`}
ref={ref}
onClick={(e) => {
e.stopPropagation();
onMoveItemClick();
}}
>
{san}
</p>
);
return <div className="variant-move-item-container">{children}</div>;
};
export const PgnViewer = React.memo(
({
history,
currentMove,
onBackButtonClick,
onForwardButtonClick,
onMoveItemClick,
onSaveButtonClick,
}: {
history: Move[];
currentMove: number;
onBackButtonClick: () => void;
onForwardButtonClick: () => void;
onMoveItemClick: (moveIndex: number) => void;
onSaveButtonClick: () => void;
}) => {
const movePairs = useMemo(() => chunkArray(history, 2), [history]);
export const VariantContainer = ({
children,
}: {
children: React.ReactNode;
}) => {
return <div className="variant-container">{children}</div>;
};
return (
<div className="height-width-100">
<div className="move-item-section">
<div className="move-item-container">
{movePairs.map((pair, i) => {
const [wMove, bMove] = pair;
return (
<React.Fragment key={wMove.san + bMove?.san}>
<p className="move-indicator center">{i + 1}</p>
export const VariantsContainer = ({
children,
}: {
children: React.ReactNode;
}) => {
return <div className="variants-container">{children}</div>;
};
interface PgnViewerProps {
history: ChessStudyMove[];
currentMoveId: string | null;
firstPlayer: string;
initialMoveNumber: number;
onMoveItemClick: (moveId: string) => void;
onUndoButtonClick: () => void;
onBackButtonClick: () => void;
onForwardButtonClick: () => void;
onSaveButtonClick: () => void;
onCopyButtonClick: () => void;
}
export const PgnViewer = React.memo((props: PgnViewerProps) => {
const {
history,
currentMoveId,
firstPlayer,
initialMoveNumber,
onMoveItemClick,
...controlActions
} = props;
const movePairs = useMemo(
() => chunkArray(history, 2, firstPlayer === 'b'),
[firstPlayer, history]
);
return (
<div className="height-width-100">
<div className="move-item-section">
<div className="move-item-container">
{movePairs.map((pair, currentMoveIndex) => {
const [wMove, bMove] = pair;
return (
<React.Fragment key={wMove.san + bMove?.san + currentMoveIndex}>
<p className="move-indicator center">
{currentMoveIndex + initialMoveNumber}
</p>
{firstPlayer === 'b' && !bMove && currentMoveIndex === 0 && (
<MoveItem
san={wMove.san}
isCurrentMove={i * 2 === currentMove}
onMoveItemClick={() => onMoveItemClick(i * 2)}
san={'...'}
isCurrentMove={false}
onMoveItemClick={() => {}}
/>
{bMove && (
<MoveItem
san={bMove.san}
isCurrentMove={i * 2 + 1 === currentMove}
onMoveItemClick={() => onMoveItemClick(i * 2 + 1)}
/>
)}
</React.Fragment>
);
})}
</div>
</div>
<div className="button-section">
<button onClick={() => onBackButtonClick()}>
<ArrowLeft />
</button>
<button onClick={() => onForwardButtonClick()}>
<ArrowRight />
</button>
<button onClick={() => onSaveButtonClick()}>
<Save strokeWidth={'1px'} />
</button>
)}
<MoveItem
san={wMove.san}
isCurrentMove={wMove.moveId === currentMoveId}
onMoveItemClick={() => onMoveItemClick(wMove.moveId)}
/>
{bMove && (
<MoveItem
san={bMove.san}
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 &&
(firstPlayer === 'w' || currentMoveIndex > 0) &&
`${
currentMoveIndex + initialMoveNumber + wMoveVarianti
}... `) ||
(firstPlayer === 'b' &&
currentMoveIndex === 0 &&
`${
currentMoveIndex + initialMoveNumber + wMoveVarianti
}. `) ||
null
}
/>
{wMove && (
<VariantMoveItem
isCurrentMove={wMove.moveId === currentMoveId}
san={wMove.san}
onMoveItemClick={() => onMoveItemClick(wMove.moveId)}
moveIndicator={
((firstPlayer === 'w' || currentMoveIndex > 0) &&
`${
currentMoveIndex + initialMoveNumber + 1 + wMoveVarianti
}. `) ||
null
}
/>
)}
</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 + initialMoveNumber + 1 + bMoveVarianti
}. `}
/>
{bMove && (
<VariantMoveItem
isCurrentMove={bMove.moveId === currentMoveId}
san={bMove.san}
onMoveItemClick={() => onMoveItemClick(bMove.moveId)}
/>
)}
</React.Fragment>
);
})}
</VariantMoveItemContainer>
);
})}
</VariantContainer>
)}
</VariantsContainer>
)}
</React.Fragment>
);
})}
</div>
</div>
);
}
);
<Controls {...controlActions} />
</div>
);
});
PgnViewer.displayName = 'PgnViewer';

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. :(');
}
};

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

@ -0,0 +1,90 @@
import { JSONContent } from '@tiptap/react';
import { Move } from 'chess.js';
import { DrawShape } from 'chessground/draw';
import { nanoid } from 'nanoid';
import { DataAdapter, normalizePath } from 'obsidian';
import { ROOT_FEN } from 'src/main';
export const CURRENT_STORAGE_VERSION = '0.0.2';
export interface Variant {
variantId: string;
parentMoveId: string;
moves: VariantMove[];
}
export interface VariantMove extends Move {
moveId: string;
shapes: DrawShape[];
comment: JSONContent | null;
}
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[];
rootFEN: string;
}
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();
console.log(
`Writing file to ${normalizePath(
`${this.storagePath}/${chessStudyId}.json`
)}`
);
await this.adapter.write(
normalizePath(`${this.storagePath}/${chessStudyId}.json`),
JSON.stringify(data, null, 2),
{}
);
return chessStudyId;
}
async loadFile(id: string): Promise<ChessStudyFileData> {
console.log(
`Reading file from ${normalizePath(`${this.storagePath}/${id}.json`)}`
);
const data = await this.adapter.read(
normalizePath(`${this.storagePath}/${id}.json`)
);
const jsonData = JSON.parse(data);
//Make sure data is compatible with storage version 0.0.1.
if (!jsonData.rootFEN) {
return { ...jsonData, rootFEN: ROOT_FEN };
}
return jsonData;
}
async createStorageFolderIfNotExists() {
const folderExists = await this.adapter.exists(this.storagePath);
if (!folderExists) {
console.log(`Creating storage folder at: ${this.storagePath}`);
this.adapter.mkdir(this.storagePath);
}
}
}

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

@ -0,0 +1,144 @@
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;
if (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];
}
if (typeof moveToDisplay === 'undefined') {
moveToDisplay = moves[variant.parentMoveIndex + offset];
}
} else {
if (typeof moves[moveIndex + offset] !== 'undefined') {
moveToDisplay = moves[moveIndex + offset];
}
}
} else if (offset < 0) {
moveToDisplay = draft.study.moves[draft.study.moves.length - 1];
} else if (offset > 0) {
moveToDisplay = draft.study.moves[0];
}
if (moveToDisplay) {
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);
} else if (offset !== 0){
const chess = draft.study.root.fen ? new Chess(draft.study.root.fen) : new Chess();
chessView.set({
fen: chess.fen(),
check: chess.isCheck(),
movable: {
free: false,
color: toColor(chess),
dests: toDests(chess),
},
turnColor: toColor(chess),
});
draft.currentMove = null;
setChessLogic(chess);
} else {
console.log(`No move to display found`);
return draft;
}
return draft;
};
export const getCurrentMove = (
draft: Draft<GameState>
): Draft<ChessStudyMove> | Draft<VariantMove> | null => {
const currentMoveId = draft.currentMove?.moveId;
const moves = draft.study.moves;
if (currentMoveId) {
const { variant, moveIndex } = findMoveIndex(moves, currentMoveId);
if (variant) {
return moves[variant.parentMoveIndex].variants[variant.variantIndex].moves[
moveIndex
];
} else {
return moves[moveIndex];
}
}
return null;
};

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,8 +76,9 @@
}
/* PGN Viewer */
.chess-study .move-item-section {
height: 415px;
height: 380px;
}
.chess-study .button-section {
@ -95,29 +87,31 @@
align-items: center;
width: 100%;
gap: 4px;
height: 35px;
height: 32px;
}
/* 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(35px, auto);
width: 100%;
max-height: 415px;
max-height: 380px;
overflow-y: scroll;
}
.chess-study .move-item-container > * {
margin: 0;
}
.chess-study .move-item {
padding-left: 8px;
user-select: none;
}
.chess-study .move-item:hover {
background-color: hsl(
var(--accent-h),
var(--accent-s),
calc(var(--accent-l))
);
background-color: hsl(var(--accent-h), var(--accent-s), calc(var(--accent-l)));
color: var(--text-on-accent);
cursor: pointer;
}
@ -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,25 +1,38 @@
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 { ChessStringModal } from './components/obsidian/ChessStringModal';
import {
ChessStudyPluginSettings,
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';
type FEN = string;
type PGN = string;
export type ChessString = FEN | PGN;
export const ROOT_FEN =
'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
// TODO:
// 1) Allow to show the root position
// 2) Display correct move after removing the last move
export default class ChessStudyPlugin extends Plugin {
settings: ChessStudyPluginSettings;
dataAdapter: ChessStudyDataAdapter;
@ -37,7 +50,7 @@ export default class ChessStudyPlugin extends Plugin {
this.storagePath
);
this.dataAdapter.createStorageFolderIfNotExists();
await this.dataAdapter.createStorageFolderIfNotExists();
// Add settings tab
this.addSettingTab(new SettingsTab(this.app, this));
@ -45,37 +58,46 @@ export default class ChessStudyPlugin extends Plugin {
// Add command
this.addCommand({
id: 'insert-chess-study',
name: 'Insert PGN-Editor at cursor position',
name: 'Insert FEN/PGN-Editor at cursor position',
editorCallback: (editor: Editor) => {
const cursorPosition = editor.getCursor();
const onSubmit = async (pgn: string) => {
const onSubmit = async (chessString: ChessString | undefined) => {
try {
const chess = new Chess();
const chessStringTrimmed = chessString?.trim() ?? '';
if (pgn) {
const isFen = chessStringTrimmed.includes('/');
const chess = isFen ? new Chess(chessStringTrimmed) : new Chess();
if (!isFen) {
//Try to parse the PGN
chess.loadPgn(pgn, {
chess.loadPgn(chessStringTrimmed, {
strict: false,
});
}
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,
})),
rootFEN: isFen ? chessStringTrimmed : ROOT_FEN,
};
this.dataAdapter.createStorageFolderIfNotExists();
const id = await this.dataAdapter.saveFile(chessStudyFileData);
editor.replaceRange(
`\`\`\`chessStudy\nchessStudyId: ${id}\nboardOrientation: white\nboardColor: green\n\`\`\``,
`\`\`\`chessStudy\nchessStudyId: ${id}\n\`\`\``,
cursorPosition
);
} catch (e) {
@ -84,7 +106,7 @@ export default class ChessStudyPlugin extends Plugin {
}
};
new PgnModal(this.app, onSubmit).open();
new ChessStringModal(this.app, onSubmit).open();
},
});
@ -104,14 +126,7 @@ export default class ChessStudyPlugin extends Plugin {
const data = await this.dataAdapter.loadFile(chessStudyId);
ctx.addChild(
new ReactView(
el,
source,
this.app,
this.settings,
data,
this.dataAdapter
)
new ReactView(el, source, this.app, this.settings, data, this.dataAdapter)
);
} catch (e) {
new Notice(

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,5 @@
{
"1.0.0": "0.15.0"
"1.0.0": "0.15.0",
"1.1.0": "0.15.0",
"1.2.0": "1.4.0"
}