mirror of
https://github.com/chrislicodes/obsidian-chess-study.git
synced 2026-07-22 07:50:30 +00:00
Compare commits
13 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d15b71985e | ||
|
|
edf436f198 | ||
|
|
93694d1ff9 | ||
|
|
05e17ca3b5 | ||
|
|
137821c4da | ||
|
|
e5381f1cf5 | ||
|
|
87e4b522dc | ||
|
|
0a8746532f | ||
|
|
f61649c0f1 | ||
|
|
1fd08ea85d | ||
|
|
f190528f5a | ||
|
|
e47df69218 | ||
|
|
15b5a3de40 |
26 changed files with 1215 additions and 535 deletions
48
.github/workflows/release.yml
vendored
48
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
node_modules
|
||||
main.js
|
||||
data.json
|
||||
README.md
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"tabWidth": 1,
|
||||
"proseWrap": "preserve",
|
||||
"semi": true
|
||||
}
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -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
|
|||
|
||||

|
||||
|
||||
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):
|
||||
|
||||

|
||||

|
||||
|
||||
## 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)
|
||||
|
||||

|
||||
|
||||
### 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
|
||||
|
||||
|
|
|
|||
BIN
imgs/chess-study-variants.png
Normal file
BIN
imgs/chess-study-variants.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
|
|
@ -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
46
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
36
src/components/react/PgnViewer/Controls/index.tsx
Normal file
36
src/components/react/PgnViewer/Controls/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
74
src/components/react/PgnViewer/MoveItems/index.tsx
Normal file
74
src/components/react/PgnViewer/MoveItems/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
47
src/lib/chess-logic/index.ts
Normal file
47
src/lib/chess-logic/index.ts
Normal 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
25
src/lib/obsidian/index.ts
Normal 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
90
src/lib/storage/index.ts
Normal 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
144
src/lib/ui-state/index.ts
Normal 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;
|
||||
};
|
||||
82
src/main.css
82
src/main.css
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
|||
59
src/main.tsx
59
src/main.tsx
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
68
styles.css
68
styles.css
File diff suppressed because one or more lines are too long
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue