feat: basic setup with pgn viewer, obsidian import and interactive chess board

This commit is contained in:
chrislicodes 2023-05-09 22:05:55 +02:00
parent 7fd6afbf8f
commit b9bf576025
18 changed files with 1796 additions and 121 deletions

View file

@ -4,6 +4,8 @@
"env": { "node": true },
"plugins": ["@typescript-eslint"],
"extends": [
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"

47
.gitignore vendored
View file

@ -1,22 +1,25 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
storage/*
!storage/.keep

View file

@ -1,6 +1,6 @@
import builtins from "builtin-modules";
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import { renameStyles } from "./rename-styles.mjs";
const banner = `/*

1041
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -19,21 +19,26 @@
"dependencies": {
"chess.js": "1.0.0-beta.5",
"chessground": "8.3.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"lucide-react": "0.216.0",
"nanoid": "4.0.2",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@types/node": "20.1.0",
"@types/react": "^18.2.6",
"@types/react": "18.2.6",
"@types/react-dom": "^18.2.4",
"@typescript-eslint/eslint-plugin": "5.59.2",
"@typescript-eslint/parser": "5.59.2",
"builtin-modules": "3.3.0",
"esbuild": "0.17.18",
"husky": "^8.0.3",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0",
"husky": "8.0.3",
"obsidian": "latest",
"prettier": "^2.8.8",
"pretty-quick": "^3.1.3",
"prettier": "2.8.8",
"prettier-plugin-organize-imports": "3.2.2",
"pretty-quick": "3.1.3",
"tslib": "2.5.0",
"typescript": "5.0.4"
}

47
reset.css Normal file
View file

@ -0,0 +1,47 @@
/*
Josh's Custom CSS Reset
https://www.joshwcomeau.com/css/custom-css-reset/
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html,
body {
height: 100%;
}
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
}
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
#root,
#__next {
isolation: isolate;
}

View file

@ -0,0 +1,46 @@
import { App, MarkdownRenderChild } from "obsidian";
import * as React from "react";
import * as ReactDOM from "react-dom/client";
import { ChessifyFileData } from "src/utils";
import { Chessify } from "../components/react/Chessify";
import { ChessifyPluginSettings } from "./obsidian/SettingsTab";
export class ReactView extends MarkdownRenderChild {
root: ReactDOM.Root;
source: string;
app: App;
settings: ChessifyPluginSettings;
data: ChessifyFileData;
constructor(
containerEL: HTMLElement,
source: string,
app: App,
settings: ChessifyPluginSettings,
data: ChessifyFileData
) {
super(containerEL);
this.source = source;
this.app = app;
this.settings = settings;
this.data = data;
}
onload() {
this.root = ReactDOM.createRoot(this.containerEl);
this.root.render(
<React.StrictMode>
<Chessify
source={this.source}
app={this.app}
pluginSettings={this.settings}
chessifyData={this.data}
/>
</React.StrictMode>
);
}
onunload() {
this.root.unmount();
}
}

View file

@ -0,0 +1,42 @@
import { App, Modal, Setting } from "obsidian";
export class PgnModal extends Modal {
pgn: string;
onSubmit: (pgn: string) => void;
constructor(app: App, onSubmit: (pgn: string) => void) {
super(app);
this.onSubmit = onSubmit;
}
onOpen() {
const { contentEl } = this;
contentEl.createEl("h1", {
text: "Paste the full PGN (leave empty for a new game):",
});
new Setting(contentEl).setName("PGN").addTextArea((text) =>
text
.onChange((value) => {
this.pgn = value;
})
.inputEl.setCssStyles({ width: "100%", height: "250px" })
);
new Setting(contentEl).addButton((btn) =>
btn
.setButtonText("Submit")
.setCta()
.onClick(() => {
this.close();
this.onSubmit(this.pgn);
})
);
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -1,4 +1,4 @@
import { PluginSettingTab, App, Setting } from "obsidian";
import { App, PluginSettingTab, Setting } from "obsidian";
import ChessifyPlugin from "src/main";
export interface ChessifyPluginSettings {

View file

@ -1,70 +1,98 @@
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { Chessground as ChessgroundApi } from "chessground";
import * as React from "react";
import { useEffect, useRef } from "react";
import { Chess, Move } from "chess.js";
import { Api } from "chessground/api";
import { Config } from "chessground/config";
import { Chess } from "chess.js";
import { playOtherSide, toColor, toDests } from "src/utils";
export interface ChessGroundSettings {
api: Api | null;
setApi: React.Dispatch<React.SetStateAction<Api>>;
chessifyId: string;
config?: Config;
boardColor?: "brown" | "green";
chess: Chess;
setHistory: React.Dispatch<React.SetStateAction<Move[]>>;
setMoveNumber: React.Dispatch<React.SetStateAction<number>>;
isViewOnly: boolean;
}
export function ChessgroundWrapper({
api,
setApi,
chessifyId,
boardColor = "green",
config = {},
chess,
setHistory,
setMoveNumber,
isViewOnly,
}: ChessGroundSettings) {
const [api, setApi] = useState<Api | null>(null);
const [chess, setChess] = useState<Chess | null>(null);
const ref = useRef<HTMLDivElement>(null);
//Chessground Init
useEffect(() => {
if (ref.current && !api) {
const chess = config.fen?.length
? new Chess(config.fen)
: new Chess();
const chessgroundApi = ChessgroundApi(ref.current, {
fen: chess.fen(),
animation: { enabled: true, duration: 100 },
check: chess.isCheck(),
movable: {
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,
},
turnColor: toColor(chess),
drawable: {
onChange(shapes) {},
},
...config,
});
chessgroundApi.set({
movable: {
events: { after: playOtherSide(chessgroundApi, chess) },
},
});
setHistory(chess.history({ verbose: true }));
setApi(chessgroundApi);
setChess(chess);
setMoveNumber(chess.history().length - 1);
} else if (ref.current && api) {
api.set(config);
}
}, [ref, chess]);
}, [
ref,
chess,
api,
chessifyId,
config,
setApi,
setHistory,
setMoveNumber,
]);
//Sync Is View Only
useEffect(() => {
api?.set(config);
if (config.fen?.length) {
chess?.load(config.fen);
}
}, [api, config]);
api?.set({ viewOnly: isViewOnly });
}, [isViewOnly, api]);
return (
<div
style={{
height: "450px",
width: "450px",
height: "100%",
width: "100%",
}}
className={`${boardColor}-board`}
>

View file

@ -1,8 +1,12 @@
import { Chess, Move } from "chess.js";
import { Api } from "chessground/api";
import { App } from "obsidian";
import * as React from "react";
import { useCallback, useMemo, useState } from "react";
import { ChessifyPluginSettings } from "src/components/obsidian/SettingsTab";
import { ChessgroundWrapper, ChessGroundSettings } from "./ChessgroundWrapper";
import { parseUserConfig } from "src/utils";
import { ChessifyFileData, parseUserConfig } from "src/utils";
import { ChessGroundSettings, ChessgroundWrapper } from "./ChessgroundWrapper";
import { PgnViewer } from "./PgnViewer";
export type ChessifyConfig = ChessGroundSettings;
@ -10,21 +14,129 @@ interface AppProps {
source: string;
app: App;
pluginSettings: ChessifyPluginSettings;
chessifyData: ChessifyFileData;
}
export const Chessify = ({ source, pluginSettings }: AppProps) => {
const { fen, boardColor, boardOrientation } = parseUserConfig(
export const Chessify = ({
source,
pluginSettings,
chessifyData,
}: AppProps) => {
// Parse Obsidian / Code Block Settings
const { boardColor, boardOrientation, chessifyId } = parseUserConfig(
pluginSettings,
source
);
// Setup Chessboard and Chess.js APIs
const [chessView, setChessView] = useState<Api | null>(null);
const chessLogic = useMemo(() => {
const chess = new Chess();
chessifyData.moves.forEach((move) => {
chess.move({
from: move.from,
to: move.to,
promotion: move.promotion,
});
});
return chess;
}, [chessifyData.moves]);
// Track Application State
const [history, setHistory] = useState<Move[]>([]);
const [isViewOnly, setIsViewOnly] = useState<boolean>(false);
const [currentMove, setCurrentMove] = useState<number>(0);
//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 onForwardButtonClick = useCallback(() => {
if (currentMove < history.length - 1) {
setCurrentMove((currentMove) => {
const move = history[currentMove + 1];
const tempChess = new Chess(move.after);
chessView?.set({
fen: move.after,
check: tempChess.isCheck(),
});
if (currentMove + 1 === history.length - 1)
setIsViewOnly(false);
return currentMove + 1;
});
}
}, [currentMove, chessView, history]);
const onMoveItemClick = useCallback(
(moveIndex: number) => {
setCurrentMove(() => {
const move = history[moveIndex];
const tempChess = new Chess(move.after);
chessView?.set({
fen: move.after,
check: tempChess.isCheck(),
});
if (moveIndex + 1 !== history.length - 1) {
setIsViewOnly(false);
} else {
setIsViewOnly(true);
}
return moveIndex;
});
},
[chessView, history]
);
return (
<ChessgroundWrapper
boardColor={boardColor}
config={{
fen: fen,
orientation: boardOrientation,
<div
style={{
display: "flex",
height: "450px",
}}
/>
>
<div
style={{
flex: "0 0 450px",
height: "100%",
}}
>
<ChessgroundWrapper
api={chessView}
setApi={setChessView}
chessifyId={chessifyId}
config={{
orientation: boardOrientation,
}}
boardColor={boardColor}
chess={chessLogic}
setHistory={setHistory}
setMoveNumber={setCurrentMove}
isViewOnly={isViewOnly}
/>
</div>
<div style={{ flex: 1, height: "100%" }}>
<PgnViewer
history={history}
currentMove={currentMove}
onBackButtonClick={onBackButtonClick}
onForwardButtonClick={onForwardButtonClick}
onMoveItemClick={onMoveItemClick}
/>
</div>
</div>
);
};

View file

@ -0,0 +1,129 @@
import { Move } from "chess.js";
import { ArrowLeft, ArrowRight } from "lucide-react";
import * as React from "react";
import { useEffect, useMemo, useRef } from "react";
const chunkArray = <T,>(array: T[], chunkSize: number) => {
return array.reduce((resultArray, item, index) => {
const chunkIndex = Math.floor(index / chunkSize);
if (!resultArray[chunkIndex]) {
resultArray[chunkIndex] = [];
}
resultArray[chunkIndex].push(item);
return resultArray;
}, [] as T[][]);
};
const MoveItem = ({
isCurrentMove,
san,
onMoveItemClick,
}: {
isCurrentMove: boolean;
san: string;
onMoveItemClick: () => void;
}) => {
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={() => onMoveItemClick()}
>
{san}
</p>
);
};
export const PgnViewer = ({
history,
currentMove,
onBackButtonClick,
onForwardButtonClick,
onMoveItemClick,
}: {
history: Move[];
currentMove: number;
onBackButtonClick: () => void;
onForwardButtonClick: () => void;
onMoveItemClick: (moveIndex: number) => void;
}) => {
const movePairs = useMemo(() => chunkArray(history, 2), [history]);
return (
<div style={{ width: "100%", height: "100%" }}>
<div style={{ height: "415px" }}>
<div
style={{
display: "grid",
gridTemplateColumns: "0.15fr 0.425fr 0.425fr",
gridAutoRows: "30px",
width: "100%",
maxHeight: "415px",
overflowY: "scroll",
}}
>
{movePairs.map((pair, i) => {
const [wMove, bMove] = pair;
return (
<React.Fragment key={wMove.san + bMove?.san}>
<p className="move-indicator center">{i + 1}</p>
<MoveItem
san={wMove.san}
isCurrentMove={i * 2 === currentMove}
onMoveItemClick={() =>
onMoveItemClick(i * 2)
}
/>
{bMove && (
<MoveItem
san={bMove.san}
isCurrentMove={
i * 2 + 1 === currentMove
}
onMoveItemClick={() =>
onMoveItemClick(i * 2 + 1)
}
/>
)}
</React.Fragment>
);
})}
</div>
</div>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "100%",
gap: "4px",
height: "35px",
}}
>
<button onClick={() => onBackButtonClick()}>
<ArrowLeft />
</button>
<button onClick={() => onForwardButtonClick()}>
<ArrowRight />
</button>
</div>
</div>
);
};

47
src/main.css Normal file
View file

@ -0,0 +1,47 @@
button:active {
transform: translateY(1px);
}
.vertical-align {
display: flex;
align-items: center;
}
.center {
display: flex;
align-items: center;
justify-content: center;
}
.move-item {
padding-left: 8px;
user-select: none;
}
.move-item:hover {
background-color: hsl(
var(--accent-h),
var(--accent-s),
calc(var(--accent-l))
);
color: var(--text-on-accent);
cursor: pointer;
}
.move-item.active {
background-color: hsl(
var(--accent-h),
var(--accent-s),
calc(var(--accent-l) - 10%)
);
color: var(--text-on-accent);
font-weight: bold;
}
.move-indicator {
color: var(--color-base-60);
text-align: center;
background-color: var(--color-base-10);
user-select: none;
cursor: default;
}

View file

@ -1,36 +1,116 @@
import { App, MarkdownRenderChild, Plugin } from "obsidian";
import { Chess } from "chess.js";
import { Editor, Notice, Plugin } from "obsidian";
import { ReactView } from "./components/ReactView";
import { PgnModal } from "./components/obsidian/PgnModal";
import {
ChessifyPluginSettings,
DEFAULT_SETTINGS,
SettingsTab,
} from "./components/obsidian/SettingsTab";
import * as ReactDOM from "react-dom/client";
import * as React from "react";
import { Chessify } from "./components/react/Chessify";
import {
ChessifyDataAdapter,
ChessifyFileData,
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 "assets/board/green.css";
import "../reset.css";
import "./main.css";
export default class ChessifyPlugin extends Plugin {
settings: ChessifyPluginSettings;
dataAdapter: ChessifyDataAdapter;
storagePath = `${this.app.vault.configDir}/plugins/${this.manifest.id}/storage/`;
async onload() {
// Load the settings
// Load Settings
await this.loadSettings();
// Register Data Adapter
this.dataAdapter = new ChessifyDataAdapter(
this.app.vault.adapter,
this.storagePath
);
// Add settings tab
this.addSettingTab(new SettingsTab(this.app, this));
// Add command
this.addCommand({
id: "insert-chesser",
name: "Insert PGN-Editor at cursor position",
editorCallback: (editor: Editor) => {
const cursorPosition = editor.getCursor();
const onSubmit = async (pgn: string) => {
try {
const chess = new Chess();
if (pgn) {
//Try to parse the PGN
chess.loadPgn(pgn, {
strict: false,
});
}
const chessifyFileData: ChessifyFileData = {
header: {
title: chess.header()["opening"] || null,
},
moves: chess
.history({ verbose: true })
.map((move) => ({
...move,
subMoves: [],
shapes: [],
})),
};
const id = await this.dataAdapter.saveFile(
chessifyFileData
);
editor.replaceRange(
`\`\`\`chessify\nchessifyId: ${id}\n\`\`\``,
cursorPosition
);
} catch (e) {
new Notice("There was an error during PGN parsing.", 0);
}
};
new PgnModal(this.app, onSubmit).open();
},
});
// Add chessify code block processor
this.registerMarkdownCodeBlockProcessor(
"chessify",
(source, el, ctx) => {
ctx.addChild(
new ReactView(el, source, this.app, this.settings)
);
async (source, el, ctx) => {
const { chessifyId } = parseUserConfig(this.settings, source);
if (!chessifyId.trim().length)
return new Notice(
"No chessifyId parameter found, please add one manually if the file already exists or add it via the 'Insert PGN-Editor at cursor position' command.",
0
);
try {
const data = await this.dataAdapter.loadFile(chessifyId);
ctx.addChild(
new ReactView(el, source, this.app, this.settings, data)
);
} catch (e) {
new Notice(
`There was an error while trying to load ${chessifyId}.json. You can check the plugin folder if the file exist and if not add one via the 'Insert PGN-Editor at cursor position' command.`,
0
);
}
}
);
@ -53,39 +133,3 @@ export default class ChessifyPlugin extends Plugin {
await this.saveData(this.settings);
}
}
class ReactView extends MarkdownRenderChild {
root: ReactDOM.Root;
source: string;
app: App;
settings: ChessifyPluginSettings;
constructor(
containerEL: HTMLElement,
source: string,
app: App,
settings: ChessifyPluginSettings
) {
super(containerEL);
this.source = source;
this.app = app;
this.settings = settings;
}
onload() {
this.root = ReactDOM.createRoot(this.containerEl);
this.root.render(
<React.StrictMode>
<Chessify
source={this.source}
app={this.app}
pluginSettings={this.settings}
/>
</React.StrictMode>
);
}
onunload() {
this.root.unmount();
}
}

View file

@ -1,11 +1,14 @@
import { parseYaml } from "obsidian";
import { ChessifyPluginSettings } from "src/components/obsidian/SettingsTab";
import { Chess, QUEEN, SQUARES, Square } from "chess.js";
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, parseYaml } from "obsidian";
import { ChessifyPluginSettings } from "src/components/obsidian/SettingsTab";
//Chess Logic
type ChessifyAppConfig = ChessifyPluginSettings & {
fen: string;
chessifyId: string;
};
export const parseUserConfig = (
@ -14,7 +17,7 @@ export const parseUserConfig = (
): ChessifyAppConfig => {
const chessifyConfig: ChessifyAppConfig = {
...settings,
fen: "",
chessifyId: "",
};
try {
@ -66,5 +69,44 @@ export function playOtherSide(cg: Api, chess: Chess) {
} else {
cg.set(commonTurnProperties);
}
return chess.history({ verbose: true });
};
}
//Storage Logic
interface ChessifyMove extends Move {
subMoves: Move[];
shapes: DrawShape[];
}
export interface ChessifyFileData {
header: { title: string | null };
moves: ChessifyMove[];
}
export class ChessifyDataAdapter {
adapter: DataAdapter;
storagePath: string;
constructor(adapter: DataAdapter, storagePath: string) {
this.adapter = adapter;
this.storagePath = storagePath;
}
async saveFile(data: ChessifyFileData) {
const id = nanoid();
await this.adapter.write(
`${this.storagePath}/${id}.json`,
JSON.stringify(data, null, 2),
{}
);
return id;
}
async loadFile(id: string): Promise<ChessifyFileData> {
const data = await this.adapter.read(`${this.storagePath}/${id}.json`);
return JSON.parse(data);
}
}

0
storage/.keep Normal file
View file

File diff suppressed because one or more lines are too long

View file

@ -14,5 +14,6 @@
"jsx": "react",
"lib": ["DOM", "ES5", "ES6", "ES7"]
},
"include": ["**/*.ts", "src/main.tsx"]
"include": ["**/*.ts", "src/**/*.tsx"],
"exclude": ["node-modules"]
}