First Commitment

This commit is contained in:
StinsonZ 2024-01-19 19:03:28 +08:00
commit 228a2f3a7c
67 changed files with 12560 additions and 0 deletions

34
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: Release Obsidian plugin
on:
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18.x"
- name: Build plugin
run: |
npm install
npm run build
- 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

28
.gitignore vendored Normal file
View file

@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
main.js
*.map
data.json

20
LICENSE.md Normal file
View file

@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2015-2020 Yichuan Shen
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

64
README.md Normal file
View file

@ -0,0 +1,64 @@
# Goban SGF for Obsidian
[简体中文](./README_CN.md)
**Goban SGF for Obsidian** is a plugin for managing Go game records in Obsidian, backed by the [SGF (Smart Game Format)](https://en.wikipedia.org/wiki/Smart_Game_Format). This plugin has ported and referenced most functionalities and the UI from [Sabaki](https://github.com/SabakiHQ/Sabaki) (excluding all engine and analysis-related features).
![Plugin-Img](./assets/goban-sgf.jpg)
The purpose of this plugin is to assist friends learning Go. Now, they can use this plugin to study games and life-and-death problems in Obsidian, manage their game records as they would with regular notes, and make notes as they would when studying any other subject.
## Features
- Basic functionalities from [Sabaki](https://github.com/SabakiHQ/Sabaki), which can be referred to as **Sabaki-Lite**
- Play Go: Supports multiple branches and variation trees
- Edit: Allows adding various common markers
- Comment
- Free navigation
- Import and export of SGF game record files
- Flexible settings: the plugin has settings, and these can also be finely tuned to the file level (achieved through the frontmatter field)
- Support for displaying partial boards (useful for studying life-and-death problems)
- Support for rendering `sgf` format code blocks, meaning that `sgf` code blocks can be inserted into regular MD documents to render embedded read-only Go boards
- Supports UI in both light and dark modes
## How to
- Create a new Go game record file:
- You can find an icon resembling a "Go stone" on the leftmost part of Obsidian; click it to create a new file
- You can also find the option to create a new Go game record file through Obsidian's global command
- Right-click on a folder in the sidebar and find "Create Go Game Record File" in the menu
- After creating a new Go game record:
- If you have existing SGF format game records to import, you can immediately import them by clicking "Import SGF File" in the menu at the bottom left
- You can play Go step by step, placing stones one by one
- You can switch to "Edit Mode" at any time to mark the board and stones
- You can open "Comment" editing at any time to add comments and good/bad move markers
- Navigation:
- Navigate using the progress bar below the board
- Navigate using the arrow keys on the keyboard
- Navigate by scrolling the middle mouse button over the board
- Drag and click on a variation point in the variation tree to navigate
- Use the menu in the upper right corner of the variation tree for quick navigation to some marked points
- Save as a template:
- After creating a new Go game record file, open the settings from the menu in the bottom left
- After setting your default configurations, place the Go game record file into your Obsidian template folder
- Next time you need the same configuration, you can use a plugin like **Templater** to automatically generate a new file based on that template, or you can manually copy it to create a new file
## Development
- Clone the code repository
- Run `pnpm install` to install dependencies
- Run `pnpm run dev` to watch for changes in the source code
- Make your Obsidian accessible to this plugin, **strongly recommended to use a development-specific empty Obsidian Vault** instead of your daily note-taking regular vault. This can be achieved by establishing a symbolic link.
- `ln -s /path/to/your/<plugin-repo> /path/to/your/obsidian/vault/.obsidian/plugins/<plugin-name>`
## Thanks
Special thanks to the [Sabaki](https://github.com/SabakiHQ/Sabaki) project and all the developers involved. Without you, I might have needed to do a tremendous amount of work to implement this plugin. Thank you for developing such an excellent Go tool and for being willing to open-source it.
## License
This project is licensed under the [MIT License](./LICENSE.md).
## Lang
The English, Korean, and Japanese in the project were all translated using translation tools. If there are any issues, feel free to point them out.

63
README_CN.md Normal file
View file

@ -0,0 +1,63 @@
# Goban SGF for Obsidian
**Goban SGF for Obsidian** 是一个用于在 Obsidian 上管理棋谱的插件,背后支持的棋谱格式是
[SGF](https://en.wikipedia.org/wiki/Smart_Game_Format),该插件移植了和参考了大部分 [Sabaki](https://github.com/SabakiHQ/Sabaki) 的功能和 UI去掉了所有引擎、分析相关的功能
![Plugin-Img](./assets/goban-sgf.jpg)
该插件的目的是为了帮助学习围棋的朋友,现在他们可以使用该插件在 Obsidian 上研究对局、死活题,像管理普通笔记那样管理棋谱,像学习其他知识一样记笔记。
## Feature
- [Sabaki](https://github.com/SabakiHQ/Sabaki) 的基础功能,可以称之为 **Sabaki-Lite**
- 下棋:支持多分支,支持变化树
- 编辑:可添加各种常用标记
- 评论
- 自由导航
- SGF 棋谱文件的导入和导出
- 设置的灵活性,插件有设置,还可以将设置细粒度到文件级别(通过 frontmatter 字段实现)
- 支持显示局部棋盘(对于研究死活题比较有用)
- 支持 `sgf` 格式的代码块渲染,即可以在在普通的 MD 文档中插入 `sgf` 的代码块,即可渲染内嵌的只读棋盘
- 支持亮/暗两种模式的 UI
## How to
- 新建棋谱文件:
- 你可以通过 Obsidian 最左边找到一个像“铜板”的图标,点击即可新建
- 还可以通过 Obsidian 的全局命令找到新建棋谱文件
- 可以通过在侧边栏的文件夹上右键,在菜单中找到“新建棋谱文件”
- 新建棋谱后:
- 如果你有已有的 SGF 格式棋谱要导入,可以通过左下角的菜单项中点击”导入 SGF 文件“来立马导入棋谱
- 可以像下围棋一样,一步一步落子
- 可以在任意时候切换到“编辑模式”,从而能对棋盘、棋子做标记
- 可以在任意时候打开“评论”编辑,从而添加评论、好坏手标记
- 导航:
- 可以通过棋盘下方的进度条导航
- 可以通过键盘方向键导航
- 可以把鼠标放到棋盘上滚动鼠标中键导航
- 可以在变化树中拖动然后点击某个变化点导航
- 可以在变化树的右上角通过菜单快速导航到一些标志点
- 存为模版:
- 可以新建一个棋谱文件后,在左下角菜单项中点开显示设置
- 设置好你需要的默认配置后,将该棋谱文件放到你的 Obsidian 模版文件夹
- 下次你需要相同的配置时,可以借由 **Templater** 这样的插件来自动基于该模版生成新文件,也可以手动 Copy 一份以建立新文件
## Development
- 克隆该代码库
- 运行 `pnpm install` 以安装依赖
- 运行 `pnpm run dev` 以 watch 源码改动
- 使你的 obsidian 能访问到该插件,**强烈建议使用一个开发用的空 Obsidian Vault**,而不是用你日常使用的用来记笔记的正式 vault具体可以通过建立软链接的方式。
- `ln -s /path/to/your/<plugin-repo> /path/to/your/obsidian/vault/.obsidian/plugins/<plugin-name>`
## Thanks
感谢 [Sabaki](https://github.com/SabakiHQ/Sabaki) 项目以及所有相关开发者,要是没有你们,我可能需要做非常非常多的工作来实现这个插件,感谢你们开发了如此好的围棋工具并且愿意开源。
## License
这个项目是基于 [MIT 许可](./LICENSE.md)
## Lang
项目中英文、韩文和日文都是借由翻译工具自动翻译的,如有问题,欢迎指正。

BIN
assets/goban-sgf.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

9
manifest.json Normal file
View file

@ -0,0 +1,9 @@
{
"id": "goban-sgf",
"name": "Goban SGF",
"version": "1.0.0",
"minAppVersion": "0.7.0",
"description": "Obsidian plugin for recording Go games (SGF format goban).",
"author": "Stinson",
"isDesktopOnly": true
}

41
package.json Normal file
View file

@ -0,0 +1,41 @@
{
"name": "goban-sgf",
"version": "0.7.0",
"description": "Obsidian plugin for recording Go games (SGF format goban).",
"main": "main.js",
"type": "module",
"scripts": {
"dev": "vite build --watch --mode development",
"build": "vite build"
},
"license": "MIT",
"dependencies": {
"@preact/signals": "^1.2.2",
"@sabaki/boardmatcher": "^1.3.0",
"@sabaki/go-board": "^1.4.3",
"@sabaki/immutable-gametree": "^1.9.4",
"@sabaki/sgf": "^3.4.7",
"@sabaki/shudan": "^1.7.1",
"builtin-modules": "^3.3.0",
"hexo-front-matter": "^4.2.1",
"json-stable-stringify": "^1.1.1",
"monkey-around": "^2.3.0",
"obsidian": "^1.4.11",
"preact": "^10.13.1"
},
"devDependencies": {
"@preact/preset-vite": "^2.5.0",
"@types/json-stable-stringify": "^1.0.36",
"@types/node": "^20.10.6",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-config-preact": "^1.3.0",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^4.3.2"
},
"eslintConfig": {
"extends": "preact"
}
}

3242
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
}
}

56
src/GobanCodeBlock.ts Normal file
View file

@ -0,0 +1,56 @@
import { MarkdownRenderChild, MarkdownPostProcessorContext } from 'obsidian'
import {
GobanSGFPluginSettings,
DEFAULT_SETTINGS,
GobanSGFPluginFrontmatterSettings,
mergeSettings,
FinalSettings,
stringifyFrontmatterData,
} from './settings'
import {
FRONTMATTER,
CONTAINER_HTML_TOKEN,
APP_HTML_TOKEN,
POPUP_LAYER_HTML_TOKEN,
FRONTMATTER_KEY,
} from '@/consts/consts'
import { createStore, Store } from '@/store/store'
import * as matter from 'hexo-front-matter'
import { h, render } from 'preact'
import AppOnlyGobanReadonly from '@/components/AppOnlyGobanReadonly'
class GobanCodeBlock extends MarkdownRenderChild {
store?: Store
constructor(
private readonly el: HTMLElement,
private readonly markdownSource: string,
private readonly ctx: MarkdownPostProcessorContext,
private readonly pluginSettings: GobanSGFPluginSettings
) {
super(el)
this.el.classList.add('goban-sgf-plugin-container')
}
onload() {
const selectInfo = this.ctx.getSectionInfo(this.el)?.text || ''
const { _content, ...frontmatterData } = selectInfo ? matter.parse(selectInfo.trim()) || { _content: '' } : { _content: '' }
this.store = createStore(
this.markdownSource,
frontmatterData || {},
this.pluginSettings,
{},
{}
)
render(
h(AppOnlyGobanReadonly, {
store: this.store
}),
this.el
)
}
}
export default GobanCodeBlock

156
src/GobanController.ts Normal file
View file

@ -0,0 +1,156 @@
import {
GobanSGFPluginSettings,
DEFAULT_SETTINGS,
GobanSGFPluginFrontmatterSettings,
mergeSettings,
FinalSettings,
stringifyFrontmatterData,
} from './settings'
import GobanSGFView from './GobanSGFView'
import { h, render } from 'preact'
import App from '@/components/App'
import {
CONTAINER_HTML_TOKEN,
APP_HTML_TOKEN,
POPUP_LAYER_HTML_TOKEN,
FRONTMATTER_KEY,
} from '@/consts/consts'
import { genMarkdownSGFSection } from './utils/goban'
import { getAutoIncrID } from './utils/utils'
import { createStore, Store } from '@/store/store'
interface SGFEntry {
sgf: string
timestamp: number
}
export default class GobanController {
sgfEntry: SGFEntry
containerEL: HTMLElement
settings: FinalSettings
path?: string
view: GobanSGFView
appEl: HTMLElement
saveTimer: NodeJS.Timeout | null = null
store?: Store
popLayer: HTMLElement
ctrlID: string
frontmatterData: GobanSGFPluginFrontmatterSettings
pluginSettings: GobanSGFPluginSettings
needRerender: boolean = false
constructor(
sgfStr: string,
frontmatterData: GobanSGFPluginFrontmatterSettings,
view: GobanSGFView
) {
this.sgfEntry = {
sgf: sgfStr,
timestamp: Date.now(),
}
this.ctrlID = getAutoIncrID().toString()
this.view = view
this.containerEL = view.contentEl
this.frontmatterData = frontmatterData
this.pluginSettings = Object.assign({}, DEFAULT_SETTINGS, view.plugin.settings)
this.settings = mergeSettings(
this.frontmatterData,
this.pluginSettings
)
this.containerEL.style.paddingTop = '0px'
this.containerEL.style.paddingBottom = '24px'
this.containerEL.style.position = 'relative'
this.containerEL.classList.add(CONTAINER_HTML_TOKEN, `${CONTAINER_HTML_TOKEN}-${this.ctrlID}`)
this.containerEL.id = `${CONTAINER_HTML_TOKEN}-${this.ctrlID}`
this.popLayer = document.createElement('div')
this.popLayer.classList.add(POPUP_LAYER_HTML_TOKEN, `${POPUP_LAYER_HTML_TOKEN}-${this.ctrlID}`)
this.popLayer.id = `${POPUP_LAYER_HTML_TOKEN}-${this.ctrlID}`
this.appEl = document.createElement('div')
this.appEl.classList.add(APP_HTML_TOKEN, `${APP_HTML_TOKEN}-${this.ctrlID}`)
this.appEl.id = `${APP_HTML_TOKEN}-${this.ctrlID}`
this.containerEL.appendChild(this.appEl)
this.containerEL.appendChild(this.popLayer)
}
renderApp() {
this.store = createStore(
this.sgfEntry.sgf,
this.frontmatterData,
this.pluginSettings,
{
obViewID: this.ctrlID,
obView: this.view,
obPlugin: this.view.plugin,
obApp: this.view.app,
},
{
onFrontmatterChange: this.setFrontmatterData.bind(this),
}
)
render(
h(App, {
store: this.store,
onChange: this.setSGF.bind(this),
}),
this.appEl
)
}
setPath(path: string) {
this.path = path
}
setFrontmatterData(st: GobanSGFPluginFrontmatterSettings) {
this.frontmatterData = st
this.settings = mergeSettings(
this.frontmatterData,
Object.assign({}, DEFAULT_SETTINGS, this.view.plugin.settings)
)
this.debouncedSaveFile()
}
async setSGF(sgfEntry: { sgf: string; timestamp: number }, { immediately = false, needRerender = false }) {
if (this.sgfEntry.timestamp > sgfEntry.timestamp) {
return
}
this.sgfEntry = sgfEntry
this.needRerender = needRerender
if (immediately) {
if (this.saveTimer) {
clearTimeout(this.saveTimer)
}
await this.saveFile()
return
}
this.debouncedSaveFile()
}
async debouncedSaveFile() {
if (this.saveTimer) {
clearTimeout(this.saveTimer)
}
this.saveTimer = setTimeout(async () => {
await this.saveFile()
this.saveTimer = null
}, 2000)
}
async saveFile() {
const fm = this.frontmatterData
if (!fm[FRONTMATTER_KEY]) {
fm[FRONTMATTER_KEY] = process.env.PLUGIN_VERSION
}
if (!fm.tags || !fm.tags.includes('goban')) {
fm.tags = fm.tags ? [...fm.tags, 'goban'] : ['goban']
}
const frontmatterPart = stringifyFrontmatterData(fm)
const fileCont = `${frontmatterPart}\n${genMarkdownSGFSection(this.sgfEntry.sgf)}`
await this.view.app.vault.modify(this.view.file, fileCont)
}
clear() {}
}

117
src/GobanSGFSettingsTab.ts Normal file
View file

@ -0,0 +1,117 @@
import { App, PluginSettingTab, Setting } from 'obsidian'
import GobanSGFPlugin from './main'
import { t } from './lang/helper'
import { DEFAULT_SETTINGS } from './settings'
import { convertKomi, convertHandicap, convertSize } from './utils/utils'
export default class GobanSGFSettingsTab extends PluginSettingTab {
plugin: GobanSGFPlugin
constructor(app: App, plugin: GobanSGFPlugin) {
super(app, plugin)
this.plugin = plugin
}
display(): void {
const { containerEl } = this
containerEl.empty()
new Setting(containerEl)
.setName(`${t('FOLDER')}`)
.setDesc(`${t('FOLDER_INFO')}`)
.addText((text) =>
text
.setValue(this.plugin.settings.folder || DEFAULT_SETTINGS.folder)
.onChange((value: string) => {
this.plugin.settings.folder = value || DEFAULT_SETTINGS.folder
this.plugin.saveData(this.plugin.settings)
})
)
new Setting(containerEl).setName(`${t('GOTO_END_AT_BEGINNING')}`).addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.gotoEndAtBeginning || DEFAULT_SETTINGS.gotoEndAtBeginning)
.onChange((value: boolean) => {
this.plugin.settings.gotoEndAtBeginning = value
this.plugin.saveData(this.plugin.settings)
})
)
new Setting(containerEl).setName(`${t('FUZZY_STONE_PLACEMENT')}`).addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.fuzzyStonePlacement || DEFAULT_SETTINGS.fuzzyStonePlacement)
.onChange((value: boolean) => {
this.plugin.settings.fuzzyStonePlacement = value
this.plugin.saveData(this.plugin.settings)
})
)
new Setting(containerEl).setName(`${t('SHOW_NEXT_MOVE')}`).addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.dftShowNextMoves || DEFAULT_SETTINGS.dftShowNextMoves)
.onChange((value: boolean) => {
this.plugin.settings.dftShowNextMoves = value
this.plugin.saveData(this.plugin.settings)
})
)
new Setting(containerEl)
.setName(`${t('SHOW_SIBLINGS')}`)
.setDesc(`${t('SHOW_SIBLINGS_INFO')}`)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.dftShowSiblings || DEFAULT_SETTINGS.dftShowSiblings)
.onChange((value: boolean) => {
this.plugin.settings.dftShowSiblings = value
this.plugin.saveData(this.plugin.settings)
})
)
new Setting(containerEl)
.setName(`${t('SHOW_MOVE_NUMBER')}`)
.setDesc(`${t('SHOW_MOVE_NUMBER_INFO')}`)
.addToggle((toggle) =>
toggle
.setValue(this.plugin.settings.dftShowMoveNumbers || DEFAULT_SETTINGS.dftShowMoveNumbers)
.onChange((value: boolean) => {
this.plugin.settings.dftShowMoveNumbers = value
this.plugin.saveData(this.plugin.settings)
})
)
new Setting(containerEl)
.setName(`${t('INIT_KOMI')}`)
.setDesc(`${t('INIT_KOMI_INFO')}`)
.addText((text) =>
text
.setValue(this.plugin.settings.dftKomi?.toString() || DEFAULT_SETTINGS.dftKomi.toString())
.onChange((value: string) => {
this.plugin.settings.dftKomi = convertKomi(value) || DEFAULT_SETTINGS.dftKomi
this.plugin.saveData(this.plugin.settings)
})
)
new Setting(containerEl)
.setName(`${t('INIT_HANDICAP')}`)
.setDesc(`${t('INIT_HANDICAP_INFO')}`)
.addText((text) =>
text
.setValue(
this.plugin.settings.dftHandicap?.toString() || DEFAULT_SETTINGS.dftHandicap.toString()
)
.onChange((value: string) => {
this.plugin.settings.dftHandicap =
Number(value) < 2 ? 0 : convertHandicap(value) || DEFAULT_SETTINGS.dftHandicap
this.plugin.saveData(this.plugin.settings)
})
)
new Setting(containerEl).setName(`${t('INIT_SIZE')}`).addText((text) =>
text
.setValue(this.plugin.settings.dftSize?.toString() || DEFAULT_SETTINGS.dftSize.toString())
.onChange((value: string) => {
this.plugin.settings.dftSize = convertSize(value) || DEFAULT_SETTINGS.dftSize
this.plugin.saveData(this.plugin.settings)
})
)
}
}

165
src/GobanSGFView.ts Normal file
View file

@ -0,0 +1,165 @@
import {
HoverPopover,
HoverParent,
TextFileView,
WorkspaceLeaf,
TFile,
Notice,
Platform,
Menu,
} from 'obsidian'
import * as matter from 'hexo-front-matter'
import GobanSGFPlugin from './main'
import { t } from './lang/helper'
import {
VIEW_TYPE_GOBAN_SGF,
DEFAULT_DATA,
FRONT_MATTER_REGEX,
SGF_CONTENT_REGEX,
DEFAULT_SGF,
} from './consts/consts'
import GobanController from './GobanController'
import { shallowObjEquals } from './utils/utils'
export default class GobanSGFView extends TextFileView implements HoverParent {
plugin: GobanSGFPlugin
hoverPopover: HoverPopover | null
saveTimer: NodeJS.Timeout | null
container: Element
fileCache: any
gobanCtrl: GobanController
firstInit: boolean = true
constructor(leaf: WorkspaceLeaf, plugin: GobanSGFPlugin) {
super(leaf)
this.plugin = plugin
this.fileCache = {
frontmatter: {
FRONTMATTER_KEY: `${process.env.PLUGIN_VERSION}`,
},
}
}
getViewType() {
return VIEW_TYPE_GOBAN_SGF
}
getDisplayText() {
if (this.file) {
return this.file.basename
}
return t('NOFILE')
}
clear() {
this.saveTimer && clearTimeout(this.saveTimer)
this.saveTimer = null
}
getViewData() {
return this.data
}
onload() {
super.onload()
this.registerEvent(this.app.workspace.on('quick-preview', () => this.onQuickPreview, this))
}
onQuickPreview(file: TFile, data: string) {
if (file === this.file && data !== this.data) {
this.setViewData(data)
this.fileCache = this.app.metadataCache.getFileCache(file)
}
}
async onFileMetadataChange(file: TFile) {
var path = file.path
let md = await this.app.vault.adapter.read(path)
this.onQuickPreview(file, md)
}
onunload() {
this.app.workspace.offref('quick-preview')
this.app.workspace.offref('resize')
if (this.gobanCtrl) {
this.gobanCtrl.clear()
this.contentEl.innerHTML = ''
this.gobanCtrl = null
}
this.plugin.setMarkdownView(this.leaf)
}
setViewData(data: string = DEFAULT_DATA, clear: boolean = false) {
data = this.data = data.replaceAll('\r\n', '\n').replaceAll('\r', '\n')
const { mdContent, frontmatterData } = this.parseMd(this.data)
const sgfStr = this.extractSGFFromMD(mdContent)
if (this.gobanCtrl && !this.gobanCtrl.needRerender && this.gobanCtrl.sgfEntry.sgf === sgfStr && shallowObjEquals(frontmatterData, this.gobanCtrl.frontmatterData)) {
return
}
if (this.gobanCtrl) {
this.gobanCtrl.clear()
this.contentEl.innerHTML = ''
}
this.gobanCtrl = new GobanController(sgfStr, frontmatterData, this)
if (this.firstInit) {
setTimeout(() => {
if (this.leaf) {
const view = this.leaf.view as GobanSGFView
this.gobanCtrl.setPath(view?.file.path)
if (view.file) {
this.fileCache = this.app.metadataCache.getFileCache(view.file)
}
}
this.gobanCtrl.renderApp()
this.firstInit = false
}, 100)
} else {
const view = this.leaf.view as GobanSGFView
this.fileCache = this.app.metadataCache.getFileCache(view.file)
this.gobanCtrl.setPath(view?.file.path)
this.gobanCtrl.renderApp()
}
}
parseMd(data: string) {
const { _content: content, ...frontmatterData } = matter.parse(data.trim())
return {
mdContent: content.trim(),
frontmatterData,
}
}
extractSGFFromMD(mdText: string) {
const sgfCont = mdText.trim().match(SGF_CONTENT_REGEX)?.[3]?.trim()
let sgfData = DEFAULT_SGF
if (sgfCont) {
sgfData = sgfCont
} else {
new Notice(t('CONTENT_INVALID'))
}
return sgfData
}
onPaneMenu(menu: Menu, source: string): void {
const self = this
menu.addItem((item) => {
item
.setTitle(`${t('OPEN_AS_MD')}`)
.setIcon('document')
.onClick(() => {
self.plugin.sgfFileModes[(self.leaf as any).id || self.file.path] = 'markdown'
self.plugin.setMarkdownView(self.leaf)
})
.setSection('pane')
})
super.onPaneMenu(menu, source)
}
}

146
src/components/App.tsx Normal file
View file

@ -0,0 +1,146 @@
import '@sabaki/shudan/css/goban.css'
import { useRef, useEffect } from 'preact/hooks'
import GameGoban from './GameGoban'
import FuncBar from './FuncBar'
import Comment from './Comment'
import GameGraph from './GameGraph'
import { Store, GobanContext } from '@/store/store'
import { goStep, startAutoscrolling, stopAutoscrolling, undo, redo, getSGF } from '@/store/actions'
import { isTextLikeElement } from '@/utils/utils'
import { BODY_DATA_HTML_TOKEN } from '@/consts/consts'
export interface GobanProps {
store: Store
onChange: (
sgfEnrty: { sgf: string; timestamp: number },
options: { immediately?: boolean; needRerender?: boolean }
) => Promise<void>
}
const App = ({ store, onChange }: GobanProps) => {
const storeRef = useRef(store)
storeRef.current = store
const residueDeltaYRef = useRef(0)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (document.body.dataset[BODY_DATA_HTML_TOKEN] !== storeRef.current.id.value) {
return
}
if (
!e.ctrlKey &&
!e.metaKey &&
['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)
) {
if (isTextLikeElement(document.activeElement)) {
return
}
e.preventDefault()
let sign = e.key === 'ArrowUp' || e.key === 'ArrowLeft' ? -1 : 1
startAutoscrolling(storeRef.current, sign)
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
if (isTextLikeElement(document.activeElement)) {
return
}
e.preventDefault()
let step = -1
if (e.shiftKey) {
step = -step
}
if (step < 0) {
undo(storeRef.current)
} else {
redo(storeRef.current)
}
}
}
const handleKeyUp = (e: KeyboardEvent) => {
if (document.body.dataset[BODY_DATA_HTML_TOKEN] !== storeRef.current.id.value) {
return
}
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
stopAutoscrolling()
}
}
const handleWheel = (e: WheelEvent) => {
e.preventDefault()
if (!residueDeltaYRef.current) {
residueDeltaYRef.current = 0
}
residueDeltaYRef.current += e.deltaY
if (Math.abs(residueDeltaYRef.current) >= 40) {
goStep(storeRef.current, Math.sign(residueDeltaYRef.current))
residueDeltaYRef.current = 0
}
}
document
.querySelectorAll(`.goban-sgf-plugin-app-${storeRef.current.id.value} .wheel-nav`)
.forEach((el) => {
el.addEventListener('wheel', handleWheel)
})
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keyup', handleKeyUp)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
document
.querySelectorAll(`.goban-sgf-plugin-app-${storeRef.current.id.value} .wheel-nav`)
.forEach((el) => {
el.removeEventListener('wheel', handleWheel)
})
}
}, [store])
const { gameTreesHashs } = store
const onChangeRef = useRef(onChange)
useEffect(() => {
const cont = getSGF(storeRef.current)
onChangeRef.current &&
onChangeRef.current(
{
sgf: cont,
timestamp: Date.now(),
},
{
immediately: false,
}
)
}, [gameTreesHashs.value])
return (
<GobanContext.Provider value={store}>
<div className="flex w-full h-full">
<div className="flex flex-col h-full mr-2 grow">
<div className="w-full grow mb-2 rounded bg-[var(--background-primary-alt)] game-goban wheel-nav">
<GameGoban />
</div>
<div className="w-full shrink-0 rounded bg-[var(--background-primary-alt)] relative">
<div className="absolute inset-0 z-0 opacity-20 goban-sgf-app_bg"></div>
<FuncBar onSGFChange={onChange} />
</div>
</div>
<div className="flex flex-col h-full w-80 shrink-0">
<div className="relative w-full rounded bg-[var(--background-primary-alt)] grow mb-2 game-graph">
<div className="absolute inset-0 z-0 opacity-20 goban-sgf-app_bg"></div>
<GameGraph />
</div>
<div className="relative w-full h-96 shrink-0 rounded bg-[var(--background-primary-alt)]">
<div className="absolute inset-0 z-0 opacity-20 goban-sgf-app_bg"></div>
<Comment />
</div>
</div>
</div>
</GobanContext.Provider>
)
}
export default App

View file

@ -0,0 +1,33 @@
import '@sabaki/shudan/css/goban.css'
import GameGobanReadonly from './GameGobanReadonly'
import { Store, GobanContext } from '@/store/store'
import { useMeasure } from '@/hooks/useMeasure'
import { useMemo, useState } from 'preact/hooks'
export interface GobanProps {
store: Store
}
const AppOnlyGobanReadonly = ({ store }: GobanProps) => {
const [mref, { width }] = useMeasure()
const calcH = useMemo(() => {
return width ? width + 48 : 0
}, [width])
return (
<GobanContext.Provider value={store}>
<div ref={mref} className="w-full">
{width && calcH ? (
<div
className="w-full rounded bg-[var(--background-primary-alt)] game-goban-readonly"
style={{ height: calcH }}
>
<GameGobanReadonly />
</div>
) : null}
</div>
</GobanContext.Provider>
)
}
export default AppOnlyGobanReadonly

387
src/components/Comment.tsx Normal file
View file

@ -0,0 +1,387 @@
import { useContext, useState, useRef, useEffect } from 'preact/hooks'
import { GobanContext } from '@/store/store'
import { t } from '@/lang/helper'
import { setComment, setCommentMode, setSelectedVertices } from '@/store/actions'
import { typographer } from '@/utils/utils'
import AnnotaionIcon from '@/icons/annotation'
import { positionAnnotationOptions, moveAnnotationOptions, hotspotOption } from '@/store/common'
import {
EvenPosition,
GoodForBlack,
UnclearPosition,
GoodForWhite,
GoodMove,
BadMove,
IntMove,
DouMove,
Focus,
} from '@/icons/annos'
import EditIcon from '@/icons/edit'
import QuesIcon from '@/icons/ques'
export interface CommentProps {}
const Icons = {
GB: GoodForBlack,
GW: GoodForWhite,
UC: UnclearPosition,
DM: EvenPosition,
TE: GoodMove,
IT: IntMove,
DO: DouMove,
BM: BadMove,
HO: Focus,
}
const CommentAnno = ({ label, value, name, id, isChecked, onChange }) => {
const Icon = Icons[value]
return (
<div className="whitespace-pre">
<input
onChange={onChange}
hidden
id={id}
type="checkbox"
name={name}
value={value}
checked={isChecked}
/>
<label for={id} className="flex items-center cursor-pointer">
<div
className={`flex items-center w-full h-8 whitespace-pre rounded pl-0.5 pr-2 ${
isChecked
? 'bg-[hsl(var(--accent-h),var(--accent-s),96%)] dark:bg-[hsl(var(--accent-h),var(--accent-s),12%)] font-semibold'
: 'hover:bg-[rgba(0,0,0,0.08)] dark:hover:bg-[rgba(255,255,255,0.08)]'
}`}
>
<div className="flex items-center w-3 h-3 m-2.5">
<Icon />
</div>
{label}
</div>
</label>
</div>
)
}
const CommentTitle = () => {
const store = useContext(GobanContext)
const { curNodeCommentTitle, curAnnos, moveInterpretation } = store
const hasMoveInterpretation = !!moveInterpretation.value
const isPlainInterpretation =
hasMoveInterpretation && typeof moveInterpretation.value === 'string'
const handleEditButtonClick = () => {
setCommentMode(store, 'edit')
}
const handleMouseEnter = () => {
if (hasMoveInterpretation && !isPlainInterpretation) {
setSelectedVertices(store, moveInterpretation.value.matchedVertices)
}
}
const handleMouseLeave = () => {
if (hasMoveInterpretation && !isPlainInterpretation) {
setSelectedVertices(store, [])
}
}
const EditPAnnoIcon =
curAnnos.value.positionAnnotation[0] !== null
? Icons[curAnnos.value.positionAnnotation[0]]
: null
const EditMAnnoIcon =
curAnnos.value.moveAnnotation[0] !== null ? Icons[curAnnos.value.moveAnnotation[0]] : null
return (
<div className="flex items-start justify-between w-full h-full pl-2">
<div className="flex items-start">
{EditPAnnoIcon || EditMAnnoIcon ? (
<div className="flex items-center h-8 shrink-0">
{EditPAnnoIcon ? (
<div className={`w-3.5 h-3.5 relative ${EditMAnnoIcon !== null ? 'mr-4' : 'mr-2'}`}>
<EditPAnnoIcon />
<div className="absolute bottom-0 w-4 h-4 origin-bottom-right transform scale-50 -right-2.5">
{EditMAnnoIcon !== null ? <EditMAnnoIcon /> : null}
</div>
</div>
) : null}
{!EditPAnnoIcon && EditMAnnoIcon ? (
<div className={`w-3.5 h-3.5 mr-2 relative`}>
<EditMAnnoIcon />
</div>
) : null}
</div>
) : null}
<div
className="grow text-sm font-semibold pt-1.5"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{curNodeCommentTitle.value ? (
typographer(curNodeCommentTitle.value)
) : hasMoveInterpretation ? (
isPlainInterpretation ? (
moveInterpretation.value
) : (
<>
{moveInterpretation.value.patternName}
{moveInterpretation.value.url ? (
<a
href={moveInterpretation.value.url}
className="inline-block w-3 ml-1 text-[var(--text-faint)] hover:text-[var(--text-accent)] transition"
target="_blank"
title={t('VIEW_IN_SENSEI')}
>
<QuesIcon />
</a>
) : null}
</>
)
) : null}
</div>
</div>
<div className="ml-2 shrink-0">
<button
className="relative z-20 rounded shrink-0 h-8 w-8 p-0 flex items-center justify-center text-[var(--text-muted)] bg-transparent transition shadow-none outline-none hover:bg-[var(--interactive-accent-hover)] hover:text-[var(--text-on-accent)] active:scale-90 border-none cursor-pointer"
onClick={handleEditButtonClick}
>
<div className="w-3 h-3">
<EditIcon />
</div>
</button>
</div>
</div>
)
}
const CommentContent = () => {
const store = useContext(GobanContext)
const { curNodeCommentContent } = store
return <div className="w-full h-full px-2 text-sm">{curNodeCommentContent.value || ''}</div>
}
const Comment = () => {
const store = useContext(GobanContext)
const {
curTreePositionNodeID,
curNodeCommentTitle,
curNodeCommentContent,
commentMode,
curNode,
curAnnos,
} = store
const handleAnnotation = (e, data) => {
const checked = e.target.checked
const d = data
if (!checked) {
const [key] = Object.keys(d)
d[key] = null
}
setComment(store, curTreePositionNodeID.value, data)
}
const [titleComment, setTitleComment] = useState({
title: curNodeCommentTitle.value,
comment: curNodeCommentContent.value,
})
const titleRef = useRef<HTMLInputElement>()
const commentRef = useRef<HTMLTextAreaElement>()
const timerRef = useRef<any>()
const cRef = useRef<HTMLDivElement>()
const handleCommentInput = () => {
const data = {
title: titleRef.current?.value,
comment: commentRef.current?.value,
}
setTitleComment(data)
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
setComment(store, curTreePositionNodeID.value, data)
}, 500)
}
const handleCommentBlur = () => {
clearTimeout(timerRef.current)
setComment(store, curTreePositionNodeID.value, {
title: titleRef.current?.value,
comment: commentRef.current?.value,
})
}
const uptimerRef = useRef<any>()
const lastTreePositionNodeIDRef = useRef(curTreePositionNodeID.value)
useEffect(() => {
const treePositionChanged = lastTreePositionNodeIDRef.current !== curTreePositionNodeID.value
if (treePositionChanged) {
lastTreePositionNodeIDRef.current = curTreePositionNodeID.value
}
if (commentMode.value === 'edit') {
if (cRef.current) {
cRef.current.scrollTop = 0
}
if (treePositionChanged) {
if (commentRef.current) {
commentRef.current.scrollTop = 0
}
setTitleComment({ title: curNodeCommentTitle.value, comment: curNodeCommentContent.value })
}
} else {
clearTimeout(uptimerRef.current)
uptimerRef.current = setTimeout(() => {
if (cRef.current && treePositionChanged) {
cRef.current.scrollTop = 0
}
setTitleComment({ title: curNodeCommentTitle.value, comment: curNodeCommentContent.value })
}, 200)
}
}, [
curNodeCommentTitle.value,
curNodeCommentContent.value,
curTreePositionNodeID.value,
commentMode.value,
])
const handleConfirm = () => {
setCommentMode(store, 'view')
}
const handleClearAnnotions = () => {
setComment(store, curTreePositionNodeID.value, {
positionAnnotation: null,
moveAnnotation: null,
})
}
const EditPAnnoIcon =
curAnnos.value.positionAnnotation[0] !== null
? Icons[curAnnos.value.positionAnnotation[0]]
: AnnotaionIcon
const EditMAnnoIcon =
curAnnos.value.moveAnnotation[0] !== null ? Icons[curAnnos.value.moveAnnotation[0]] : null
return (
<div ref={cRef} className="absolute inset-0 z-10 w-full h-full overflow-y-auto">
{commentMode.value === 'edit' ? (
<div className="flex flex-col w-full h-full p-2">
<div className="flex items-center justify-between mb-2 shrink-0">
<div className="h-[30px] shrink-0"></div>
<input
ref={titleRef}
type="text"
className="grow h-[30px]"
value={titleComment.title}
placeholder={t('COMMENT_TITLE')}
onInput={handleCommentInput}
onBlur={handleCommentBlur}
/>
</div>
<div className="flex items-center justify-center w-full grow">
<textarea
ref={commentRef}
className="h-full resize-none grow"
placeholder={t('COMMENT')}
value={titleComment.comment}
onInput={handleCommentInput}
onBlur={handleCommentBlur}
/>
</div>
<div className="h-0 w-full bg-[rgba(0,0,0,0.05)] rounded my-2" />
<div className="flex items-center justify-between">
<div className="relative group">
<div className="absolute -top-1 left-0 whitespace-pre translate-y-[-100%] py-2 px-3 bg-[var(--background-secondary-alt)] text-[var(--text-normal)] transition-all origin-bottom rounded shadow-md text-xs opacity-50 scale-0 border-[var(--background-modifier-border-hover)] border border-solid group-hover:opacity-100 group-hover:scale-100 delay-100 hover:opacity-100 hover:scale-100">
<div
className="flex items-center w-full h-8 cursor-pointer hover:bg-[rgba(0,0,0,0.08)] rounded px-2 dark:hover:bg-[rgba(255,255,255,0.08)]"
onClick={handleClearAnnotions}
>
{t('CLEAR_ANNOTATION')}
</div>
<div className="h-[1px] w-full border-t border-dashed border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.1)] my-2" />
<div class="">
{positionAnnotationOptions.map((pa) => (
<CommentAnno
label={pa.label}
value={pa.value}
name="positionAnnotation"
id={pa.value}
isChecked={
curNode.value?.data?.[pa.value] !== null &&
curNode.value?.data?.[pa.value] !== undefined
}
onChange={(e) => handleAnnotation(e, { positionAnnotation: pa.value })}
/>
))}
{curNode.value?.data.B || curNode.value?.data.W ? (
<>
<div className="h-[1px] w-full border-t border-dashed border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.1)] my-2" />
{moveAnnotationOptions.map((ma) => (
<CommentAnno
label={ma.label}
value={ma.value}
name="moveAnnotation"
id={ma.value}
isChecked={
curNode.value?.data?.[ma.value] !== null &&
curNode.value?.data?.[ma.value] !== undefined
}
onChange={(e) => handleAnnotation(e, { moveAnnotation: ma.value })}
/>
))}
</>
) : null}
<div className="h-[1px] w-full border-t border-dashed border-[rgba(0,0,0,0.1)] my-2 dark:border-[rgba(255,255,255,0.1)]" />
<CommentAnno
label={hotspotOption.label}
value={hotspotOption.value}
name="hotspot"
id={hotspotOption.value}
isChecked={
curNode.value?.data?.[hotspotOption.value] !== null &&
curNode.value?.data?.[hotspotOption.value] !== undefined
}
onChange={(e) => handleAnnotation(e, { hotspot: true })}
/>
</div>
</div>
<button className="relative z-20 rounded shrink-0 h-8 py-0 pl-2.5 pr-3 flex items-center justify-center text-[var(--text-muted)] bg-[var(--interactive-normal)]transition shadow-none outline-none hover:bg-[var(--interactive-hover)] hover:text-[var(--text-normal)] border border-solid border-[var(--background-modifier-border)] cursor-default">
{curAnnos.value.isHotspot ? (
<div className="w-3.5 h-3.5 mr-2">
<Focus />
</div>
) : null}
<div className={`w-3.5 h-3.5 relative ${EditMAnnoIcon !== null ? 'mr-4' : 'mr-2'}`}>
<EditPAnnoIcon />
<div className="absolute bottom-0 w-4 h-4 origin-bottom-right transform scale-50 -right-2.5">
{EditMAnnoIcon !== null ? <EditMAnnoIcon /> : null}
</div>
</div>
{t('ANNOTATION')}
</button>
</div>
<button
className="rounded shrink-0 h-8 py-0 px-3 flex items-center justify-center text-[var(--text-on-accent)] bg-[var(--interactive-accent)] transition shadow-none outline-none border-none cursor-pointer hover:bg-[var(--interactive-accent-hover)] hover:text-[var(--text-on-accent)] active:scale-90"
onClick={handleConfirm}
>
{t('FINISH')}
</button>
</div>
</div>
) : (
<div className="flex flex-col w-full h-full p-2">
<div className="w-full mb-2 shrink-0">
<CommentTitle />
</div>
<div className="w-full grow">
<CommentContent />
</div>
</div>
)}
</div>
)
}
export default Comment

149
src/components/EditBar.tsx Normal file
View file

@ -0,0 +1,149 @@
import { useContext, useState } from 'preact/hooks'
import { GobanContext } from '@/store/store'
import { t } from '@/lang/helper'
import { setTool, setMode, toggleCurStoneTool } from '@/store/actions'
import ExitIcon from '@/icons/exit'
import Tooltip from '@/components/Tooltip'
import { Cross, Triangle, Square, Circle, Alpha, Number } from '@/icons/tools'
export interface EditBarProps {}
const StoneTool = ({ currentPlayer }) => {
return (
<div className="relative w-5 h-5">
<div
className={`absolute left-0 top-0 w-4 h-4 rounded-[24px] border-2 border-transparent dark:border-white bg-black ${
currentPlayer < 0 ? 'z-10' : 'z-30'
}`}
></div>
<div
className={`absolute z-20 right-0 bottom-0 w-4 h-4 rounded-[24px] border-2 border-black dark:border-transparent bg-white`}
></div>
</div>
)
}
const ToolIcons = {
cross: Cross,
triangle: Triangle,
square: Square,
circle: Circle,
label: Alpha,
number: Number,
}
const ToolNameKey = {
cross: 'CROSS',
triangle: 'TRIANGLE',
square: 'SQUARE',
circle: 'CIRCLE',
label: 'ALPHA',
number: 'NUMBER',
stone: 'STONE',
}
const ToolBtn = ({ tool, onClick, curTool }) => {
const Icon = ToolIcons[tool]
return (
<Tooltip message={t(ToolNameKey[tool])}>
<div
className={`w-8 h-8 rounded-lg flex items-center justify-center border border-solid cursor-pointer transition ${
curTool === tool
? 'text-[var(--text-on-accent)] bg-[var(--interactive-accent)] border-[var(--interactive-accent)]'
: 'text-[var(--text-normal)] bg-[var(--interactive-normal)] border-[var(--background-modifier-border)] hover:text-[var(--text-on-accent)] hover:bg-[var(--interactive-accent-hover)] hover:border-[var(--interactive-accent-hover)]'
}`}
style={{
boxShadow:
curTool === tool
? `0px 2px 16px -2px hsla(calc(var(--accent-h) - 3), calc(var(--accent-s) * 1.02), calc(var(--accent-l) * 1.15),1)`
: '',
}}
onClick={onClick}
>
<div className="w-5 h-5">
<Icon />
</div>
</div>
</Tooltip>
)
}
const EditBar = () => {
const store = useContext(GobanContext)
const { curTool, curStoneTool } = store
const handleToolSelect = (tool) => {
if (tool !== 'stone' && tool === curTool.value) {
return
}
if (tool === 'stone' && tool === curTool.value) {
toggleCurStoneTool(store)
return
}
setTool(store, tool)
}
const handleExit = () => {
setMode(store, 'play')
}
return (
<div className="relative h-8 shrink-0 flex items-center justify-between w-full grow flex-nowrap text-[var(--text-normal)]">
<div></div>
<div className="flex items-center justify-center gap-2 shrink-0 grow flex-nowrap">
<Tooltip
message={
t('CURRENT_SELECT_IS') +
(curStoneTool.value < 0 ? t('WHITE_STONE') : t('BLACK_STONE')) +
'(' +
t('CLICK_TO_TOGGLE') +
')'
}
>
<div
className={`w-8 h-8 rounded-lg flex items-center justify-center border border-solid cursor-pointer transition ${
curTool.value === 'stone'
? 'text-[var(--text-on-accent)] bg-[var(--interactive-accent)] border-[var(--interactive-accent)]'
: 'text-[var(--text-normal)] bg-[var(--interactive-normal)] border-[var(--background-modifier-border)] hover:text-[var(--text-on-accent)] hover:bg-[var(--interactive-accent)] hover:border-[var(--interactive-accent)]'
}`}
style={{
boxShadow:
curTool.value === 'stone'
? `0px 2px 16px -2px hsla(calc(var(--accent-h) - 3), calc(var(--accent-s) * 1.02), calc(var(--accent-l) * 1.15),1)`
: '',
}}
onClick={() => handleToolSelect('stone')}
>
<StoneTool currentPlayer={curStoneTool.value} />
</div>
</Tooltip>
{['cross', 'triangle', 'square', 'circle', 'label', 'number'].map((tool) => (
<ToolBtn
key={tool}
curTool={curTool.value}
tool={tool}
onClick={() => handleToolSelect(tool)}
/>
))}
</div>
<div className="flex items-center justify-end">
<Tooltip message={t('EXIT_EDIT')} placement="top-end">
<button
class="rounded shrink-0 h-8 w-8 p-0 flex items-center justify-center text-[var(--text-muted)] bg-transparent transition shadow-none outline-none border-none cursor-pointer hover:bg-[var(--interactive-accent)] hover:text-[var(--text-on-accent)] active:scale-90 ml-8"
onClick={handleExit}
>
<div class="w-4 h-4">
<ExitIcon />
</div>
</button>
</Tooltip>
</div>
</div>
)
}
export default EditBar

171
src/components/FuncBar.tsx Normal file
View file

@ -0,0 +1,171 @@
import { useCallback, useContext, useRef, useState } from 'preact/hooks'
import { GobanContext } from '@/store/store'
import { t } from '@/lang/helper'
import PlayBar from './PlayBar'
import EditBar from './EditBar'
import GameInfo from './GameInfo'
import MenuIcon from '@/icons/menu'
import modals from '@/modals/modals'
import { setFrontmatterSetting, getSGF } from '@/store/actions'
import { errorlog, newLinkClick } from '@/utils/utils'
import { Notice } from 'obsidian'
export interface FuncBarProps {
onSGFChange: (
sgfEnrty: { sgf: string; timestamp: number },
options: { immediately: boolean; needRerender?: boolean }
) => Promise<void>
}
const FuncBar = ({ onSGFChange }: FuncBarProps) => {
const store = useContext(GobanContext)
const { mode, id, frontmatterData, gameInfo } = store
const handling = useRef(false)
const handleOpenDisplaySettings = useCallback(async () => {
if (handling.current) {
return
}
try {
const res = await modals.DisplaySetting({
props: {
initData: frontmatterData.value,
},
id: id.value,
})
if (res) {
setFrontmatterSetting(store, res)
}
} catch {
} finally {
handling.current = false
}
}, [store, id.value, frontmatterData.value])
const importRef = useRef<HTMLInputElement>()
const handlingImport = useRef(false)
const handleImport = useCallback(
async (e) => {
const f = e.target.files?.[0]
if (!f || handlingImport.current) {
importRef.current.value = ''
return
}
handlingImport.current = true
const reader = new FileReader()
if (f.size > 10 * 1000 * 1000) {
new Notice(t('TOO_LARGE'))
importRef.current.value = ''
return
}
try {
const fileCont = await new Promise((resolve, reject) => {
reader.onload = function (event) {
const fileContent = event.target.result
resolve(fileContent)
}
reader.onerror = function (event) {
reject(event?.target?.error?.message || 'Error when reading file')
}
reader.readAsText(f)
})
const confirmRes = await modals.Confirm({
props: {
title: t('IMPORT_CONFIRM_TITLE'),
msg: t('IMPORT_CONFIRM_MSG'),
},
id: id.value,
})
if (confirmRes === 'confirm') {
await onSGFChange(
{
sgf: fileCont as string,
timestamp: Date.now(),
},
{
immediately: true,
needRerender: true,
}
)
}
} catch (e) {
errorlog({
where: 'import SGF',
error: e,
})
} finally {
handlingImport.current = false
importRef.current.value = ''
}
},
[id.value]
)
const handleExport = useCallback(async () => {
const cont = getSGF(store)
const sgfStr = 'data:text/plain;charset=utf-8,\uFEFF' + encodeURIComponent(cont)
newLinkClick(sgfStr, {
download: `${gameInfo.value.gameName || gameInfo.value.eventName || 'goban'}.sgf`,
})
}, [store])
return (
<div className="relative z-10 flex items-center justify-between w-full p-2 transition-all grow">
{mode.value === 'gameInfo' ? (
<GameInfo />
) : (
<>
<div className="relative group">
<div className="absolute -top-1 left-0 whitespace-pre translate-y-[-100%] py-2 px-3 bg-[var(--background-secondary-alt)] text-[var(--text-normal)] transition-all origin-bottom rounded shadow-md text-xs opacity-50 scale-0 border-[var(--background-modifier-border-hover)] border border-solid group-hover:opacity-100 group-hover:scale-100 delay-100 hover:opacity-100 hover:scale-100">
<div
className="flex items-center w-full h-8 cursor-pointer hover:bg-[rgba(0,0,0,0.08)] rounded px-2 dark:hover:bg-[rgba(255,255,255,0.08)]"
onClick={handleOpenDisplaySettings}
>
{t('DISPLAY_SETTINGS')}
</div>
<div className="h-[1px] w-full border-t border-dashed border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.1)] my-2" />
<label
for="import-sgf"
className="flex items-center w-full h-8 cursor-pointer hover:bg-[rgba(0,0,0,0.08)] rounded px-2 dark:hover:bg-[rgba(255,255,255,0.08)]"
>
{t('IMPORT_SGF')}
</label>
<input
ref={importRef}
hidden
type="file"
id="import-sgf"
name="import-sgf"
accept=".sgf"
onChange={handleImport}
/>
<div
className="flex items-center w-full h-8 cursor-pointer hover:bg-[rgba(0,0,0,0.08)] rounded px-2 dark:hover:bg-[rgba(255,255,255,0.08)]"
onClick={handleExport}
>
{t('EXPORT_SGF')}
</div>
</div>
<button className="rounded shrink-0 h-8 w-8 p-0 flex items-center justify-center text-[var(--text-muted)] bg-transparent transition shadow-none outline-none border-none cursor-pointer hover:bg-[var(--interactive-accent)] hover:text-[var(--text-on-accent)] mr-8 active:scale-90">
<div className="w-4 h-4">
<MenuIcon />
</div>
</button>
</div>
{mode.value === 'edit' ? (
<div className="flex grow">
<EditBar />
</div>
) : (
<div className="flex grow">
<PlayBar />
</div>
)}
</>
)}
</div>
)
}
export default FuncBar

View file

@ -0,0 +1,177 @@
import { Goban as SabakiGoban, Vertex } from '@sabaki/shudan'
import { useMeasure } from '@/hooks/useMeasure'
import { useContext, useState, useEffect, useRef, useMemo } from 'preact/hooks'
import { GobanContext } from '@/store/store'
import { Focus } from '@/icons/annos'
import { t } from '@/lang/helper'
import { Prev, Next } from '@/icons/navs'
import { debounce } from 'obsidian'
import { goStep, clickVertex } from '@/store/actions'
export interface GameGobanProps {}
const debounceSetStep = debounce((v: number, store: any, curStep: any) => {
const st = Number(v) - curStep.value
if (st) {
goStep(store, st)
}
}, 600)
const GameGoban = () => {
const [mref, { width, height }] = useMeasure()
const store = useContext(GobanContext)
const {
signMap,
markerMap,
ghostStoneMap,
fuzzyStonePlacement,
curAnnos,
selectedVertices,
curStep,
totalSteps,
gobanRange,
gameInfo,
} = store
const [step, setStep] = useState(curStep.value || 0)
const stepRef = useRef(step)
useEffect(() => {
if (Number(stepRef.current || 0) !== Number(curStep.value || 0)) {
stepRef.current = curStep.value || 0
setStep(stepRef.current)
}
}, [curStep.value])
const handleProcessChange = (e) => {
const v = e.target.value
setStep((pre) => (isNaN(Number(v)) ? pre : Number(v)))
stepRef.current = isNaN(Number(v)) ? step : Number(v)
debounceSetStep(Number(v), store, curStep)
}
const stepPercent =
Math.floor((totalSteps.value ? (step || 0) / totalSteps.value : 0) * 10000) / 100
const [clicked, setClicked] = useState(false)
const handling = useRef(false)
const handleVertexClick = async (e, vertex) => {
if (handling.current) {
return
}
try {
handling.current = false
const { button = 0, ctrlKey = false, metaKey = false, clientX = 0, clientY = 0 } = e
setClicked(true)
await clickVertex(store, vertex, button, ctrlKey || metaKey, clientX, clientY)
setTimeout(() => {
setClicked(false)
}, 200)
} catch {
//
} finally {
handling.current = false
}
}
const vSize = useMemo(() => {
let s = 24
const gobanSize = isNaN(Number(gameInfo.value?.size?.[0]))
? 19
: Number(gameInfo.value?.size?.[0])
const { x = [0, gobanSize - 1], y = [0, gobanSize - 1] } = gobanRange.value || {}
const vBasedW = Math.floor(width / (x[1] - x[0] + 1 + 2 * 0.25 + 2 * 0.15))
const vBasedH = Math.floor(height / (y[1] - y[0] + 1 + 2 * 0.25 + 2 * 0.15))
s = Math.min(vBasedW, vBasedH)
return s
}, [width, height, gobanRange, gameInfo])
return (
<div className="relative flex flex-col items-center w-full h-full grow">
<div
className={`absolute inset-0 z-0 rounded ${
curAnnos.value.isHotspot ? 'border-[3px] border-solid border-[var(--text-accent)]' : ''
}`}
>
<div className={`relative h-full w-full rounded opacity-50 goban-sgf-app_bg`}></div>
{curAnnos.value.isHotspot ? (
<div className="absolute left-[50%] bottom-0 transform -translate-x-[50%] h-6 text-xs text-white font-semibold flex items-center px-2 pt-[3px] rounded-t-md whitespace-nowrap flex-nowrap bg-[var(--text-accent)]">
<div className="w-3 h-3">
<Focus opposition />
</div>
<span className="ml-1">{t('AN_HOTSPOT')}</span>
</div>
) : null}
</div>
<div className="relative z-10 flex flex-col w-full grow shrink-0">
<div class="shrink-0 grow h-full w-full flex justify-center items-center relative rounded">
<div ref={mref} className="absolute inset-4">
{width && height && (
<div class="h-full w-full relative z-10 flex justify-center items-center">
<SabakiGoban
vertexSize={vSize}
signMap={signMap.value}
markerMap={markerMap.value}
ghostStoneMap={ghostStoneMap.value}
animateStonePlacement={clicked}
rangeX={gobanRange.value?.x || undefined}
rangeY={gobanRange.value?.y || undefined}
fuzzyStonePlacement={fuzzyStonePlacement.value}
selectedVertices={selectedVertices.value}
onVertexMouseDown={handleVertexClick}
/>
</div>
)}
</div>
</div>
</div>
<div className={`relative z-20 mb-3 flex items-center shrink-0 w-[60%] group transition ${totalSteps.value ? 'opacity-100' : 'opacity-0'}`}>
<div
className={`shrink-0 p-4 flex justify-center items-center text-[var(--text-muted)] transition hover:text-[var(--text-accent)] ${
step > 0 ? 'cursor-pointer' : 'cursor-not-allowed opacity-30'
}`}
onClick={step > 0 ? () => goStep(store, -1) : undefined}
>
<div className="w-4 h-4 transform scale-75">
<Prev />
</div>
</div>
<div className="relative z-10 flex items-center grow">
<input
className="relative z-10 w-full m-0 rounded-lg appearance-none cursor-pointer goban-sgf-plugin-process"
style={{
backgroundImage: `linear-gradient(to right, hsl(var(--accent-h), var(--accent-s), var(--accent-l)) ${stepPercent}%, var(--background-modifier-border) ${stepPercent}%)`,
}}
type="range"
min={0}
max={totalSteps.value}
value={step}
onInput={handleProcessChange}
/>
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-center h-8 transition opacity-0 group-hover:opacity-100">
<div className="flex justify-center items-center text-xs text-[var(--text-faint)]">
<div className="text-[var(--text-accent)]">{step}</div>
<div className="transform scale-75 opacity-50 font-semibold origin-center mx-0.5">
/
</div>
<div>{totalSteps.value}</div>
</div>
</div>
</div>
<div
className={`shrink-0 p-4 flex justify-center items-center text-[var(--text-muted)] transition hover:text-[var(--text-accent)] ${
step < totalSteps.value ? 'cursor-pointer' : 'cursor-not-allowed opacity-30'
}`}
onClick={step === totalSteps.value ? undefined : () => goStep(store, 1)}
>
<div className="w-4 h-4 transform scale-75">
<Next />
</div>
</div>
</div>
</div>
)
}
export default GameGoban

View file

@ -0,0 +1,156 @@
import { Goban as SabakiGoban } from '@sabaki/shudan'
import { useMeasure } from '@/hooks/useMeasure'
import { useContext, useState, useEffect, useRef, useMemo } from 'preact/hooks'
import { GobanContext } from '@/store/store'
import { Focus } from '@/icons/annos'
import { t } from '@/lang/helper'
import { Prev, Next } from '@/icons/navs'
import { debounce } from 'obsidian'
import { goStep } from '@/store/actions'
export interface GameGobanReadonlyProps {}
const debounceSetStep = debounce((v: number, store: any, curStep: any) => {
const st = Number(v) - curStep.value
if (st) {
goStep(store, st)
}
}, 600)
const GameGobanReadonly = () => {
const [mref, { width, height }] = useMeasure()
const store = useContext(GobanContext)
const {
signMap,
markerMap,
ghostStoneMap,
fuzzyStonePlacement,
curAnnos,
selectedVertices,
curStep,
totalSteps,
gobanRange,
gameInfo,
} = store
const [step, setStep] = useState(curStep.value || 0)
const stepRef = useRef(step)
useEffect(() => {
if (Number(stepRef.current || 0) !== Number(curStep.value || 0)) {
stepRef.current = curStep.value || 0
setStep(stepRef.current)
}
}, [curStep.value])
const handleProcessChange = (e) => {
const v = e.target.value
setStep((pre) => (isNaN(Number(v)) ? pre : Number(v)))
stepRef.current = isNaN(Number(v)) ? step : Number(v)
debounceSetStep(Number(v), store, curStep)
}
const stepPercent =
Math.floor((totalSteps.value ? (step || 0) / totalSteps.value : 0) * 10000) / 100
const vSize = useMemo(() => {
let s = 24
const gobanSize = isNaN(Number(gameInfo.value?.size?.[0]))
? 19
: Number(gameInfo.value?.size?.[0])
const { x = [0, gobanSize - 1], y = [0, gobanSize - 1] } = gobanRange.value || {}
const vBasedW = Math.floor(width / (x[1] - x[0] + 1 + 2 * 0.25 + 2 * 0.15))
const vBasedH = Math.floor(height / (y[1] - y[0] + 1 + 2 * 0.25 + 2 * 0.15))
s = Math.min(vBasedW, vBasedH)
return s
}, [width, height, gobanRange, gameInfo])
return (
<div className="relative z-0 flex flex-col items-center w-full h-full grow">
<div
className={`absolute inset-0 z-0 rounded ${
curAnnos.value.isHotspot ? 'border-[3px] border-solid border-[var(--text-accent)]' : ''
}`}
>
<div className={`relative h-full w-full rounded opacity-50 goban-sgf-app_bg`}></div>
{curAnnos.value.isHotspot ? (
<div className="absolute left-[50%] bottom-0 transform -translate-x-[50%] h-6 text-xs text-white font-semibold flex items-center px-2 pt-[3px] rounded-t-md whitespace-nowrap flex-nowrap bg-[var(--text-accent)]">
<div className="w-3 h-3">
<Focus opposition />
</div>
<span className="ml-1">{t('AN_HOTSPOT')}</span>
</div>
) : null}
</div>
<div className="relative z-10 flex flex-col w-full grow shrink-0">
<div class="shrink-0 grow h-full w-full flex justify-center items-center relative rounded">
<div ref={mref} className="absolute inset-2">
{width && height && (
<div class="h-full w-full relative z-10 flex justify-center items-center">
<SabakiGoban
vertexSize={vSize}
signMap={signMap.value}
markerMap={markerMap.value}
ghostStoneMap={ghostStoneMap.value}
rangeX={gobanRange.value?.x || undefined}
rangeY={gobanRange.value?.y || undefined}
fuzzyStonePlacement={fuzzyStonePlacement.value}
selectedVertices={selectedVertices.value}
/>
</div>
)}
</div>
</div>
</div>
{totalSteps.value ? (
<div className="relative z-20 mb-2 flex items-center shrink-0 w-[80%] group h-10">
<div
className={`shrink-0 p-3 flex justify-center items-center text-[var(--text-muted)] transition hover:text-[var(--text-accent)] ${
step > 0 ? 'cursor-pointer' : 'cursor-not-allowed opacity-30'
}`}
onClick={step > 0 ? () => goStep(store, -1) : undefined}
>
<div className="w-4 h-4 origin-center transform scale-75 relative -top-0.5">
<Prev />
</div>
</div>
<div className="relative z-10 flex items-center grow">
<input
className="relative z-10 w-full m-0 rounded-lg appearance-none cursor-pointer goban-sgf-plugin-process"
style={{
backgroundImage: `linear-gradient(to right, hsl(var(--accent-h), var(--accent-s), var(--accent-l)) ${stepPercent}%, var(--background-modifier-border) ${stepPercent}%)`,
}}
type="range"
min={0}
max={totalSteps.value}
value={step}
onInput={handleProcessChange}
/>
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-center h-6 transition opacity-0 group-hover:opacity-100">
<div className="flex justify-center items-center text-xs text-[var(--text-faint)]">
<div className="text-[var(--text-accent)]">{step}</div>
<div className="transform scale-75 opacity-50 font-semibold origin-center mx-0.5">
/
</div>
<div>{totalSteps.value}</div>
</div>
</div>
</div>
<div
className={`shrink-0 p-3 flex justify-center items-center text-[var(--text-muted)] transition hover:text-[var(--text-accent)] ${
step < totalSteps.value ? 'cursor-pointer' : 'cursor-not-allowed opacity-30'
}`}
onClick={step === totalSteps.value ? undefined : () => goStep(store, 1)}
>
<div className="w-4 h-4 origin-center transform scale-75 relative -top-0.5">
<Next />
</div>
</div>
</div>
) : null}
</div>
)
}
export default GameGobanReadonly

View file

@ -0,0 +1,748 @@
import { useContext, useState, useRef, useEffect, useCallback, useLayoutEffect } from 'preact/hooks'
import { GobanContext } from '@/store/store'
import {
setCurrentTreePosition,
makeMainVariation,
shiftVariation,
removeNode,
goToComment,
goToBE,
goToPrevBranch,
goToNextBranch,
goToFocus,
} from '@/store/actions'
import { BODY_DATA_HTML_TOKEN } from '@/consts/consts'
import { getMatrixWidth } from '@/store/common'
import { useMeasure } from '@/hooks/useMeasure'
import { h } from 'preact'
import { t } from '@/lang/helper'
import modals from '@/modals/modals'
import Goto from '@/icons/goto'
const GRID_SIZE = 24
const NODE_SIZE = 5
const PADDING = 8
const GameGraphNode = ({
position,
type,
current,
fill,
nodeSize,
onCurrentTrack,
node,
onNodeClick,
onNodeMenu,
onToggleHover,
}) => {
const store = useContext(GobanContext)
const [left, top] = position
const ref = useRef<any>()
const [hover, setHover] = useState(false)
const handleMouseEnter = (evt) => {
if (hover) {
return
}
setHover(true)
const { left, top } = ref.current.getBoundingClientRect()
onToggleHover(true, node, {
x: left,
y: top,
})
}
const handleMouseLeave = () => {
if (!hover) {
return
}
setHover(false)
onToggleHover(false, node)
}
const handleClick = (e) => {
e.stopPropagation()
e.preventDefault()
onNodeClick()
if (e.button === 0) {
setCurrentTreePosition(store, node.id)
}
}
const handleContextMenuClick = (e) => {
const { clientX, clientY } = e
onNodeMenu(node, {
x: clientX,
y: clientY,
})
}
return (
<path
ref={ref}
onClick={handleClick}
onContextMenu={handleContextMenuClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={`${
current
? 'stroke-[3px] stroke-[var(--text-accent)]'
: hover
? 'stroke-[3px] stroke-[var(--text-accent-hover)]'
: onCurrentTrack
? 'stroke-1 stroke-gray-600 dark:stroke-gray-300'
: 'stroke-1 stroke-gray-400 dark:stroke-gray-500'
} ${
fill
? ''
: onCurrentTrack
? 'fill-gray-600 dark:fill-gray-300'
: 'fill-gray-400 dark:fill-gray-500'
} cursor-pointer`}
fill={fill || 'none'}
d={(() => {
let nodeSize2 = nodeSize * 2
if (type === 'square') {
return `M ${left - nodeSize} ${top - nodeSize}
h ${nodeSize2} v ${nodeSize2} h ${-nodeSize2} Z`
} else if (type === 'circle') {
return `M ${left} ${top} m ${-nodeSize} 0
a ${nodeSize} ${nodeSize} 0 1 0 ${nodeSize2} 0
a ${nodeSize} ${nodeSize} 0 1 0 ${-nodeSize2} 0`
} else if (type === 'diamond') {
let diamondSide = Math.round(Math.sqrt(2) * nodeSize)
return `M ${left} ${top - diamondSide}
L ${left - diamondSide} ${top} L ${left} ${top + diamondSide}
L ${left + diamondSide} ${top} Z`
} else if (type === 'bookmark') {
return `M ${left - nodeSize} ${top - nodeSize * 1.3}
h ${nodeSize2} v ${nodeSize2 * 1.3}
l ${-nodeSize} ${-nodeSize} l ${-nodeSize} ${nodeSize} Z`
}
return ''
})()}
></path>
)
}
const GameGraphEdge = ({
positionAbove: [left1, top1],
positionBelow: [left2, top2],
length,
gridSize,
current,
}) => {
let points
if (left1 === left2) {
points = `${left1},${top1} ${left1},${top2 + length}`
} else {
points = `${left1},${top1} ${left2 - gridSize},${top2 - gridSize}
${left2},${top2} ${left2},${top2 + length}`
}
return (
<polyline
points={points}
fill="none"
className={`${
current
? 'stroke-[2px] stroke-gray-600 dark:stroke-gray-300'
: 'stroke-1 stroke-gray-400 dark:stroke-gray-500'
}`}
/>
)
}
const renderNodes = ({
gridSize,
cameraPosition: [cx, cy],
width,
height,
gameTree,
gameCurrents,
matrixDict,
commentProperties,
treePosition,
nodeSize,
onNodeClick,
onNodeMenu,
onToggleHover,
}: any) => {
const [matrix, dict] = matrixDict
let nodeColumns = []
let edges = []
let [minX, minY] = [cx, cy].map((z) => Math.max(Math.ceil(z / gridSize) - 2, 0))
let [maxX, maxY] = [cx, cy].map((z, i) => (z + [width, height][i]) / gridSize + 2)
minY -= 3
maxY += 3
let doneTreeBones = []
let currentTrack = [...gameTree.listCurrentNodes(gameCurrents)]
// Render only nodes that are visible
for (let x = minX; x <= maxX; x++) {
let column = []
for (let y = minY; y <= maxY; y++) {
if (matrix[y] == null || matrix[y][x] == null) continue
let id = matrix[y][x]
let node = gameTree.get(id)
let parent = gameTree.get(node.parentId)
let onCurrentTrack = currentTrack.includes(node)
// Render node
let isCurrentNode = treePosition === id
let opacity = onCurrentTrack ? 1 : 0.5
let fillRGB =
node.data.BM != null
? [241, 59, 59]
: node.data.DO != null
? [207, 26, 183]
: node.data.IT != null
? [26, 177, 124]
: node.data.TE != null
? [10, 228, 58]
: commentProperties.some((x) => node.data[x] != null)
? [255, 174, 61]
: ''
let left = x * gridSize
let top = y * gridSize
column.push(
h(GameGraphNode, {
key: y,
position: [left, top],
type:
node.data.HO != null
? 'bookmark' // Bookmark node
: (node.data.B != null && node.data.B[0] === '') ||
(node.data.W != null && node.data.W[0] === '')
? 'square' // Pass node
: node.data.B == null && node.data.W == null
? 'diamond' // Non-move node
: 'circle', // Normal node
current: isCurrentNode,
onCurrentTrack,
fill: fillRGB ? `rgb(${(fillRGB as any).map((x) => x * opacity).join(',')})` : '',
nodeSize: nodeSize + 1,
node,
onNodeClick,
onNodeMenu,
onToggleHover,
})
)
if (!doneTreeBones.includes(id)) {
// A *tree bone* denotes a straight edge through the tree
let positionAbove, positionBelow
if (parent != null) {
// Render parent edge with tree bone
let [px, py] = dict[parent.id]
positionAbove = [px * gridSize, py * gridSize]
positionBelow = [left, top]
} else {
// Render tree bone only
positionAbove = [left, top]
positionBelow = positionAbove
}
let sequence = [...gameTree.getSequence(id)]
if (positionAbove != null && positionBelow != null) {
edges[!onCurrentTrack ? 'unshift' : 'push'](
h(GameGraphEdge, {
key: id,
positionAbove,
positionBelow,
length: (sequence.length - 1) * gridSize,
current: onCurrentTrack,
gridSize,
})
)
doneTreeBones.push(...sequence.map((node) => node.id))
}
}
if (node.children.length > 1) {
// Render successor edges with subtree bones
for (let child of node.children) {
let current = onCurrentTrack && currentTrack.includes(child)
let [nx, ny] = dict[child.id]
let subsequence = [...gameTree.getSequence(child.id)]
edges[!current ? 'unshift' : 'push'](
h(GameGraphEdge, {
key: child.id,
positionAbove: [left, top],
positionBelow: [nx * gridSize, ny * gridSize],
length: (subsequence.length - 1) * gridSize,
current,
gridSize,
})
)
doneTreeBones.push(...subsequence.map((node) => node.id))
}
}
}
if (column.length > 0) nodeColumns.push(h('g', { key: x }, column))
}
return [
h(
'g',
{
class: `transition-transform duration-300 group-active:transition-none`,
style: {
transform: `translate(${-cx}px, ${-cy}px)`,
},
},
edges
),
h(
'g',
{
class: `transition-transform duration-300 group-active:transition-none`,
style: {
transform: `translate(${-cx}px, ${-cy}px)`,
},
},
nodeColumns
),
]
}
const GameGraph = () => {
const store = useContext(GobanContext)
const { curGame, curTreePositionNodeID, currents, matrixDict, id } = store
const [graphRef, { width, height, left, top }] = useMeasure()
const vWidth = width !== null ? width - 2 * PADDING : null
const vHeight = height !== null ? height - 2 * PADDING : null
const [cameraPosition, setCameraPosition] = useState([-GRID_SIZE, -GRID_SIZE])
const svgRef = useRef<SVGSVGElement>()
const mouseDownRef = useRef(null)
const dragRef = useRef(false)
const menuRef = useRef(null)
const [nodeMenu, setNodeMenu] = useState(null)
const handleNodeMenu = useCallback(
(node, { x, y }) => {
setNodeMenu({
node,
position: {
x,
y,
},
})
},
[left, top]
)
const updateCameraPosition = useCallback(() => {
const matrix = matrixDict.value[0]
const dict = matrixDict.value[1]
let [x, y] = dict[curTreePositionNodeID.value]
let [width, padding] = getMatrixWidth(y, matrix)
let relX = width === 1 ? 0 : 1 - (2 * (x - padding)) / (width - 1)
let diff = ((width - 1) * GRID_SIZE) / 2
diff = Math.min(diff, vWidth / 2 - GRID_SIZE)
setCameraPosition(
[x * GRID_SIZE + relX * diff - vWidth / 2, y * GRID_SIZE - vHeight / 2].map((z) =>
Math.round(z)
)
)
}, [matrixDict.value, curTreePositionNodeID.value, vWidth, vHeight])
useEffect(() => {
updateCameraPosition()
setNodeMenu(null)
}, [curTreePositionNodeID.value, matrixDict.value, vWidth, vHeight])
const nodeMenuRef = useRef(nodeMenu)
nodeMenuRef.current = nodeMenu
const accMovementRef = useRef([0, 0])
useEffect(() => {
const handleMouseMove = (evt) => {
if (document.body.dataset[BODY_DATA_HTML_TOKEN] !== id.value) {
return
}
if (!svgRef.current) {
return
}
let { movementX, movementY } = evt
if (mouseDownRef.current === 0) {
dragRef.current = true
accMovementRef.current = [
accMovementRef.current[0] + movementX,
accMovementRef.current[1] + movementY,
]
} else {
movementX = movementY = 0
accMovementRef.current = [0, 0]
dragRef.current = false
}
if (dragRef.current) {
evt.preventDefault()
window.requestAnimationFrame(() => {
const [mx, my] = accMovementRef.current
accMovementRef.current = [0, 0]
setCameraPosition((pre) => [pre[0] - mx, pre[1] - my])
})
}
}
const handleMouseUp = (evt) => {
if (document.body.dataset[BODY_DATA_HTML_TOKEN] !== id.value) {
return
}
mouseDownRef.current = null
}
const hideNodeMenu = (evt) => {
if (document.body.dataset[BODY_DATA_HTML_TOKEN] !== id.value) {
return
}
const menuEl = menuRef.current
if (
nodeMenuRef.current !== null &&
menuEl &&
(!menuEl.contains(evt.target) || menuEl === evt.target)
) {
setNodeMenu(null)
}
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.addEventListener('click', hideNodeMenu)
document.addEventListener('contextmenu', hideNodeMenu)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.removeEventListener('click', hideNodeMenu)
document.removeEventListener('contextmenu', hideNodeMenu)
}
}, [])
const handleGraphMouseDown = (e) => {
mouseDownRef.current = e.button
}
const handleNodeClick = useCallback(() => {
if (dragRef.current) {
dragRef.current = false
}
}, [])
const [hoverNode, setHoverNode] = useState(null)
const handleToggleNodeHover = useCallback(
(isHover, node, position) => {
if (isHover) {
const hoverStep = curGame.value.getLevel(node.id)
setHoverNode({
node,
position,
hoverStep,
})
} else {
setHoverNode((pre) => {
if (pre.node.id === node.id) {
return null
}
return pre
})
}
},
[curGame.value]
)
const handleSetMain = useCallback(
(node) => {
setNodeMenu(null)
makeMainVariation(store, node.id)
},
[store]
)
const handleMove = useCallback(
(node, sign = -1) => {
setNodeMenu(null)
shiftVariation(store, node.id, sign)
},
[store]
)
const delHandlingRef = useRef(false)
const handleDelNode = useCallback(
async (node) => {
if (delHandlingRef.current) {
return
}
let isConfirm = false
try {
delHandlingRef.current = true
const res = await modals.Confirm({
props: {
title: t('DELETE_NODE'),
msg: t('DELETE_NODE_CONFIRM'),
},
id: id.value,
})
isConfirm = res === 'confirm'
} catch (e) {
//
} finally {
delHandlingRef.current = false
}
setNodeMenu(null)
if (!isConfirm) {
return
}
removeNode(store, node)
},
[store]
)
const handleNav = useCallback(
(cmd: string) => {
switch (cmd) {
case 'start':
goToBE(store, -1)
break
case 'end':
goToBE(store, 1)
break
case 'prevBranch':
goToPrevBranch(store)
break
case 'nextBranch':
goToNextBranch(store)
break
case 'prevComment':
goToComment(store, -1)
break
case 'nextComment':
goToComment(store, 1)
break
case 'prevFocus':
goToFocus(store, -1)
break
case 'nextFocus':
goToFocus(store, 1)
break
default:
}
},
[store]
)
const [hide, setHide] = useState(true)
const hideRef = useRef(hide)
hideRef.current = hide
useEffect(() => {
if (vWidth < 0 && vHeight < 0 && !hideRef.current) {
setHide(true)
} else if (hideRef.current) {
setTimeout(() => {
setHide(false)
}, 200)
}
}, [vWidth, vHeight])
return (
<div className="absolute inset-0 z-10 flex flex-col">
<div className="relative z-30 flex items-center justify-between w-full h-10 px-4 mt-1 text-sm shrink-0">
<div className="font-semibold grow">{t('VARIATION_BRANCH')}</div>
<div className="relative ml-2 shrink-0 group">
<div className="absolute -bottom-1 right-0 whitespace-pre mt-1 translate-y-[100%] py-2 px-3 bg-[var(--background-secondary-alt)] text-[var(--text-normal)] transition-all origin-bottom rounded shadow-md text-xs opacity-50 scale-0 border-[var(--background-modifier-border-hover)] border border-solid group-hover:opacity-100 group-hover:scale-100 hover:opacity-100 hover:scale-100 delay-100">
<div
className="flex items-center w-full h-8 cursor-pointer hover:bg-[rgba(0,0,0,0.08)] rounded px-2 dark:hover:bg-[rgba(255,255,255,0.08)]"
onClick={() => handleNav('start')}
>
{t('START')}
</div>
<div
className="flex items-center w-full h-8 cursor-pointer hover:bg-[rgba(0,0,0,0.08)] rounded px-2 dark:hover:bg-[rgba(255,255,255,0.08)]"
onClick={() => handleNav('end')}
>
{t('ENDING')}
</div>
<div className="h-[1px] w-full border-t border-dashed border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.1)] my-2" />
<div
className="flex items-center w-full h-8 cursor-pointer hover:bg-[rgba(0,0,0,0.08)] rounded px-2 dark:hover:bg-[rgba(255,255,255,0.08)]"
onClick={() => handleNav('prevBranch')}
>
{t('PREV_BRANCH')}
</div>
<div
className="flex items-center w-full h-8 cursor-pointer hover:bg-[rgba(0,0,0,0.08)] rounded px-2 dark:hover:bg-[rgba(255,255,255,0.08)]"
onClick={() => handleNav('nextBranch')}
>
{t('NEXT_BRANCH')}
</div>
<div className="h-[1px] w-full border-t border-dashed border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.1)] my-2" />
<div
className="flex items-center w-full h-8 cursor-pointer hover:bg-[rgba(0,0,0,0.08)] rounded px-2 dark:hover:bg-[rgba(255,255,255,0.08)]"
onClick={() => handleNav('prevComment')}
>
{t('PREV_COMMENT')}
</div>
<div
className="flex items-center w-full h-8 cursor-pointer hover:bg-[rgba(0,0,0,0.08)] rounded px-2 dark:hover:bg-[rgba(255,255,255,0.08)]"
onClick={() => handleNav('nextComment')}
>
{t('NEXT_COMMENT')}
</div>
<div className="h-[1px] w-full border-t border-dashed border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.1)] my-2" />
<div
className="flex items-center w-full h-8 cursor-pointer hover:bg-[rgba(0,0,0,0.08)] rounded px-2 dark:hover:bg-[rgba(255,255,255,0.08)]"
onClick={() => handleNav('prevFocus')}
>
{t('PREV_FOCUS')}
</div>
<div
className="flex items-center w-full h-8 cursor-pointer hover:bg-[rgba(0,0,0,0.08)] rounded px-2 dark:hover:bg-[rgba(255,255,255,0.08)]"
onClick={() => handleNav('nextFocus')}
>
{t('NEXT_FOCUS')}
</div>
</div>
<button className="relative z-20 rounded shrink-0 h-8 py-0 pr-1 pl-2 flex items-center justify-center text-[var(--text-muted)] bg-transparent transition shadow-none outline-none hover:bg-[var(--interactive-accent-hover)] hover:text-[var(--text-on-accent)] active:scale-90 border-none cursor-default">
{t('NAV')}
<div className="w-3 h-3 ml-1">
<Goto />
</div>
</button>
</div>
</div>
<div className="w-full px-4 mb-3 shrink-0">
<div className="h-0.5 w-full bg-[rgba(0,0,0,0.05)] rounded" />
</div>
<div className="relative z-20 w-full grow">
<div
ref={graphRef}
id="game-graph"
className="absolute inset-0 z-10 flex flex-col w-full h-full wheel-nav"
style={{ padding: PADDING }}
>
{height && width ? (
<svg
ref={svgRef}
width={vWidth}
height={vHeight}
onMouseDown={handleGraphMouseDown}
className={`relative z-30 active:cursor-grabbing ${
hide ? 'opacity-0' : 'opacity-100'
} group`}
>
{renderNodes({
gridSize: GRID_SIZE,
cameraPosition,
width: vWidth,
height: vHeight,
gameTree: curGame.value,
gameCurrents: currents.value,
matrixDict: matrixDict.value,
commentProperties: ['C', 'N', 'UC', 'GW', 'DM', 'GB', 'BM', 'TE', 'DO', 'IT'],
treePosition: curTreePositionNodeID.value,
nodeSize: NODE_SIZE,
onNodeClick: handleNodeClick,
onNodeMenu: handleNodeMenu,
onToggleHover: handleToggleNodeHover,
})}
</svg>
) : null}
{nodeMenu !== null ? (
<div ref={menuRef} class="absolute inset-0 z-50">
<div
class="absolute whitespace-pre py-2 px-3 bg-[var(--background-secondary-alt)] text-[var(--text-normal)] transition-all rounded-l rounded-br shadow-md border-[var(--background-modifier-border-hover)] border border-solid text-xs origin-top-right transform -translate-x-full"
style={{
left: nodeMenu.position.x - left - 20,
top: nodeMenu.position.y - top,
}}
>
<span className="block absolute right-[-4px] top-[-1px] h-[6px] w-[6px] border-t border-r border-solid border-[var(--background-modifier-border-hover)] bg-[var(--background-secondary-alt)] transform skew-x-[-45deg]"></span>
<div>
<div
className="flex items-center cursor-pointer h-8 hover:bg-[rgba(0,0,0,0.08)] rounded px-2 dark:hover:bg-[rgba(255,255,255,0.08)] text-[var(--text-normal)]"
onClick={() => handleSetMain(nodeMenu.node)}
>
{t('SET_TO_MAIN')}
</div>
<div
className="flex items-center cursor-pointer h-8 hover:bg-[rgba(0,0,0,0.08)] rounded px-2 dark:hover:bg-[rgba(255,255,255,0.08)] text-[var(--text-normal)]"
onClick={() => handleMove(nodeMenu.node, -1)}
>
{t('MOVE_LEFT')}
</div>
<div
className="flex items-center cursor-pointer h-8 hover:bg-[rgba(0,0,0,0.08)] rounded px-2 dark:hover:bg-[rgba(255,255,255,0.08)] text-[var(--text-normal)]"
onClick={() => handleMove(nodeMenu.node, 1)}
>
{t('MOVE_RIGHT')}
</div>
<div className="h-[1px] w-full border-t border-dashed border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.1)] my-2" />
<div
className="flex items-center cursor-pointer h-8 hover:bg-[var(--background-modifier-error)] rounded px-2 dark:hover:bg-[var(--background-modifier-error)] text-[var(--text-normal)] hover:text-white"
onClick={() => handleDelNode(nodeMenu.node)}
>
{t('DELETE_NODE')}
</div>
</div>
</div>
</div>
) : null}
<div
className={`absolute whitespace-pre z-40 flex items-center h-4 text-xs transform p-1 -translate-x-full transition opacity-0 ${
hoverNode ? 'opacity-100' : ''
}`}
style={
hoverNode
? {
left: hoverNode.position.x - left - NODE_SIZE - 4,
top: hoverNode.position.y - top - 1,
}
: undefined
}
>
{hoverNode !== null ? (
<div className="p-1 text-right text-gray-200 bg-gray-800 rounded dark:text-gray-400 dark:bg-gray-700">
{hoverNode.hoverStep}
</div>
) : null}
</div>
</div>
</div>
</div>
)
}
export default GameGraph

286
src/components/GameInfo.tsx Normal file
View file

@ -0,0 +1,286 @@
import { useContext, useCallback, useState } from 'preact/hooks'
import { GobanContext } from '@/store/store'
import { t } from '@/lang/helper'
import { setMode, setGameInfo, switchGame } from '@/store/actions'
export interface GameInfoProps {}
const GameInfo = () => {
const store = useContext(GobanContext)
const { gameTrees, gameInfo, curPlayer, rootNode, gameInfos, curGameIndex, settings } = store
const [info, setInfo] = useState({
bPlayerRank: gameInfo.value?.playerRanks?.[0],
bPlayerName: gameInfo.value?.playerNames?.[0],
wPlayerRank: gameInfo.value?.playerRanks?.[1],
wPlayerName: gameInfo.value?.playerNames?.[1],
gameName: gameInfo.value?.gameName,
eventName: gameInfo.value?.eventName,
date: gameInfo.value?.date,
result: gameInfo.value?.result,
komi: gameInfo.value?.komi || settings.value?.komi || '0',
handicap: gameInfo.value?.handicap || settings.value?.handicap || '0',
size: gameInfo.value?.size?.[0] || settings.value?.size || '19',
gameComment: gameInfo.value?.gameComment,
})
const handleInfoChange = useCallback((e, field) => {
const v = e.target.value
setInfo((pre) => ({
...pre,
[field]: v,
}))
}, [])
const [selectGameIdx, setSelectGameIdx] = useState(curGameIndex.value)
const handleSwitchGame = useCallback(
(e) => {
const v = e.target.value
if (Number(v) === Number(selectGameIdx) || isNaN(Number(v))) {
return
}
setSelectGameIdx(v)
const gInfo = gameInfos.value?.[Number(v)]
setInfo({
bPlayerRank: gInfo?.playerRanks?.[0],
bPlayerName: gInfo?.playerNames?.[0],
wPlayerRank: gInfo?.playerRanks?.[1],
wPlayerName: gInfo?.playerNames?.[1],
gameName: gInfo?.gameName,
eventName: gInfo?.eventName,
date: gInfo?.date,
result: gInfo?.result,
komi: gInfo?.komi || '0',
handicap: gInfo?.handicap || '0',
size: gInfo?.size?.[0] || '19',
gameComment: gInfo?.gameComment,
})
},
[selectGameIdx, gameInfos.value]
)
const handleConfirm = useCallback(() => {
if (selectGameIdx !== curGameIndex.value) {
switchGame(store, selectGameIdx)
}
setTimeout(() => {
const isEmptyTree = rootNode.value?.children?.length === 0
const data: any = {
blackName: info.bPlayerName,
blackRank: info.bPlayerRank,
whiteName: info.wPlayerName,
whiteRank: info.wPlayerRank,
gameName: info.gameName,
eventName: info.eventName,
gameComment: info.gameComment,
date: info.date,
result: info.result,
komi: info.komi,
}
if (isEmptyTree) {
data.handicap = info.handicap
data.size = [info.size, info.size]
}
setGameInfo(store, data)
setMode(store, 'play')
}, 0)
}, [store, rootNode.value, info, selectGameIdx, curGameIndex.value])
const handleCancel = useCallback(() => {
setMode(store, 'play')
}, [store])
return (
<div className="relative shrink-0 w-full grow flex-nowrap text-[var(--text-normal)] px-4">
<div className="">
<div className="flex items-center justify-center pt-4 pb-2">
<div className="flex items-center justify-center">
<div className="flex items-center shrink-0">
<input
type="text"
className="text-right rounded-r-none max-w-16 relative z-10 left-[1px] focus:z-30"
value={info.bPlayerRank}
placeholder={t('RANK')}
onInput={(e) => handleInfoChange(e, 'bPlayerRank')}
/>
<input
type="text"
className="relative z-20 text-right rounded-l-none max-w-24"
value={info.bPlayerName}
placeholder={t('BLACK')}
onInput={(e) => handleInfoChange(e, 'bPlayerName')}
/>
</div>
<div className="flex items-center mx-3 shrink-0">
<div className="relative w-[34px] h-[22px]">
<div
className={`absolute left-0 top-0 bottom-0 w-[22px] rounded-[24px] border-2 border-transparent dark:border-white bg-black ${
curPlayer.value < 0 ? 'z-10' : 'z-30'
}`}
></div>
<div
className={`absolute z-20 right-0 top-0 bottom-0 w-[22px] rounded-[24px] border-2 border-black dark:border-transparent bg-white`}
></div>
</div>
</div>
<div className="flex items-center shrink-0">
<input
type="text"
className="relative z-20 rounded-r-none max-w-24"
value={info.wPlayerName}
placeholder={t('WHITE')}
onInput={(e) => handleInfoChange(e, 'wPlayerName')}
/>
<input
type="text"
className="rounded-l-none max-w-16 relative z-10 right-[1px] focus:z-30"
value={info.wPlayerRank}
placeholder={t('RANK')}
onInput={(e) => handleInfoChange(e, 'wPlayerRank')}
/>
</div>
</div>
</div>
<div className="flex mt-4">
<div className="min-w-80 grow w-[calc(50%-16px)] mr-8">
<div className="flex items-center mb-2">
<div className="w-24 mr-2 text-xs shrink-0">{t('GAME_NAME')}:</div>
<input
type="text"
className="grow"
value={info.gameName}
placeholder={t('GAME_NAME')}
onInput={(e) => handleInfoChange(e, 'gameName')}
/>
</div>
<div className="flex items-center mb-2">
<div className="w-24 mr-2 text-xs shrink-0">{t('TOURNAMENT_NAME')}:</div>
<input
type="text"
className="grow"
value={info.eventName}
placeholder={t('TOURNAMENT_NAME')}
onInput={(e) => handleInfoChange(e, 'eventName')}
/>
</div>
<div className="flex items-center mb-2">
<div className="w-24 mr-2 text-xs shrink-0">{t('DATE')}:</div>
<input
type="text"
className="grow"
value={info.date}
placeholder={t('DATE')}
onInput={(e) => handleInfoChange(e, 'date')}
/>
</div>
<div className="flex items-center mb-2">
<div className="w-24 mr-2 text-xs shrink-0">{t('NOTES')}:</div>
<input
type="text"
className="grow"
value={info.gameComment}
placeholder={'-'}
onInput={(e) => handleInfoChange(e, 'gameComment')}
/>
</div>
</div>
<div className="min-w-80 grow w-[calc(50%-16px)]">
<div className="flex items-center mb-2">
<div className="w-24 mr-2 text-xs shrink-0">{t('GAME_RESULT')}:</div>
<input
type="text"
className="grow"
value={info.result}
placeholder={'-'}
onInput={(e) => handleInfoChange(e, 'result')}
/>
</div>
<div className="flex items-center mb-2">
<div className="w-24 mr-2 text-xs shrink-0">{t('KOMI')}:</div>
<input
type="number"
className="grow"
step={0.5}
value={info.komi}
placeholder="0"
onInput={(e) => handleInfoChange(e, 'komi')}
/>
</div>
<div className="flex items-center mb-2">
<div className="w-24 mr-2 text-xs shrink-0">{t('HANDICAP')}:</div>
<select
disabled={rootNode.value.children.length !== 0}
value={info.handicap}
onChange={(e) => handleInfoChange(e, 'handicap')}
class="grow"
>
<option value="0">No stones</option>
{[...Array(8)].map((_, i) => (
<option value={String(i + 2)}>{i + 2} stones</option>
))}
</select>
</div>
<div className="flex items-center mb-2">
<div className="w-24 mr-2 text-xs shrink-0">{t('GOBAN_SIZE')}:</div>
<select
disabled={rootNode.value.children.length !== 0}
value={info.size}
onChange={(e) => handleInfoChange(e, 'size')}
class="grow"
>
{[...Array(24)].map((_, i) => (
<option value={String(i + 2)}>
{i + 2} × {i + 2}
</option>
))}
</select>
</div>
</div>
</div>
</div>
<div className="h-0.5 w-full bg-[rgba(0,0,0,0.05)] rounded my-4" />
<div className="flex items-center justify-between">
<div>
{gameTrees.value?.length > 1 ? (
<div className="flex items-center">
<div className="w-24 mr-2 text-xs shrink-0 font-semibold text-[var(--text-muted)]">
{t('SWITCH_GAME')}:
</div>
<select value={selectGameIdx} onChange={handleSwitchGame} class="w-80">
{gameInfos.value.map((g, i) => (
<option value={String(i)}>
Game-{i + 1}
{g.gameName || g.eventName ? `: ${g.gameName || g.eventName}` : ''}
</option>
))}
</select>
</div>
) : null}
</div>
<div className="flex items-center justify-end">
<button
className="rounded shrink-0 h-8 py-0 px-3 flex items-center justify-center text-[var(--text-on-accent)] bg-[var(--interactive-accent)] transition shadow-none outline-none border-none cursor-pointer hover:bg-[var(--interactive-accent-hover)] hover:text-[var(--text-on-accent)] mr-4 active:scale-90"
onClick={handleConfirm}
>
{t('CONFIRM')}
</button>
<button
className="rounded shrink-0 h-8 py-0 px-3 flex items-center justify-center text-[var(--text-muted)] bg-[var(--interactive-normal)]transition shadow-none outline-none cursor-pointer hover:bg-[var(--interactive-hover)] hover:text-[var(--text-normal)] active:scale-90 border border-solid border-[var(--background-modifier-border)]"
onClick={handleCancel}
>
{t('CANCEL')}
</button>
</div>
</div>
</div>
)
}
export default GameInfo

177
src/components/PlayBar.tsx Normal file
View file

@ -0,0 +1,177 @@
import { useContext } from 'preact/hooks'
import { GobanContext } from '@/store/store'
import { t } from '@/lang/helper'
import { setPlayer, setMode, makeResign, makeMove } from '@/store/actions'
import InfoIcon from '@/icons/info'
import MarkIcon from '@/icons/mark'
import ResignIcon from '@/icons/resign'
import SkipIcon from '@/icons/skip'
import Tooltip from '@/components/Tooltip'
export interface PlayBarProps {}
const PlayBar = () => {
const store = useContext(GobanContext)
const { gameInfo, curPlayer, playerCaptures, curTreePositionNodeID } = store
const handleClickCurrentPlayer = () => {
setPlayer(store, curTreePositionNodeID.value, -curPlayer)
}
const handleGameInfo = () => {
setMode(store, 'gameInfo')
}
const handleEdit = () => {
setMode(store, 'edit')
}
const handleReisgn = (p) => {
makeResign(store, { player: p })
}
const handleSkip = (p) => {
makeMove(store, [-1, -1], { player: p })
}
return (
<div className="relative h-8 shrink-0 flex items-center justify-between w-full grow flex-nowrap text-[var(--text-normal)]">
<div className="w-18"></div>
<div className="flex items-center justify-center">
<div
className={`mr-4 flex items-center justify-end transition ${
curPlayer.value < 0 ? 'opacity-20' : 'opacity-100'
}`}
>
<Tooltip message={t('RESIGN')} placement="top-begin">
<button
class="rounded shrink-0 h-8 w-8 p-0 flex items-center justify-center text-[var(--text-muted)] bg-transparent transition shadow-none outline-none border-none cursor-pointer hover:bg-[var(--interactive-accent)] hover:text-[var(--text-on-accent)] active:scale-90 mr-0.5"
onClick={() => handleReisgn(curPlayer.value)}
>
<div class="w-4 h-4 scale-75 transform origin-center">
<ResignIcon />
</div>
</button>
</Tooltip>
<Tooltip message={t('SKIP')} placement="top-begin">
<button
class="rounded shrink-0 h-8 w-8 p-0 flex items-center justify-center text-[var(--text-muted)] bg-transparent transition shadow-none outline-none border-none cursor-pointer hover:bg-[var(--interactive-accent)] hover:text-[var(--text-on-accent)] active:scale-90"
onClick={() => handleSkip(curPlayer.value)}
>
<div class="w-4 h-4 scale-75 transform origin-center">
<SkipIcon />
</div>
</button>
</Tooltip>
<div className="h-4 w-[1px] bg-[var(--background-modifier-border)] ml-2"></div>
</div>
<div className="flex items-center shrink-0">
<div
className={`rounded-[24px] opacity-0 h-4 w-4 bg-[rgba(0,0,0,0.3)] dark:bg-[rgba(255,255,255,0.3)] flex items-center justify-center text-xs font-medium ${
playerCaptures.value?.[0] ? 'opacity-70' : ''
}`}
>
{playerCaptures.value?.[0]}
</div>
{gameInfo.value?.playerRanks?.[0] ? (
<div className="text-xs ml-2 text-[var(--text-muted)]">
{gameInfo.value?.playerRanks?.[0]}
</div>
) : null}
<div className="ml-2 text-sm font-semibold">
{gameInfo.value?.playerNames?.[0] || t('BLACK')}
</div>
</div>
<div
className="flex items-center mx-3 cursor-pointer shrink-0"
onClick={handleClickCurrentPlayer}
>
<div className="relative w-[34px] h-[22px]">
<div
className={`absolute left-0 top-0 bottom-0 w-[22px] rounded-[24px] border-2 border-transparent dark:border-white bg-black ${
curPlayer.value < 0 ? 'z-10' : 'z-30'
}`}
></div>
<div
className={`absolute z-20 right-0 top-0 bottom-0 w-[22px] rounded-[24px] border-2 border-black dark:border-transparent bg-white`}
></div>
</div>
</div>
<div className="flex items-center shrink-0">
<div className="text-sm font-semibold">
{gameInfo.value?.playerNames?.[1] || t('WHITE')}
</div>
{gameInfo.value?.playerRanks?.[1] ? (
<div className="text-xs ml-2 text-[var(--text-muted)]">
{gameInfo.value?.playerRanks?.[1]}
</div>
) : null}
<div
className={`ml-2 rounded-[24px] opacity-0 h-4 w-4 bg-[rgba(0,0,0,0.3)] dark:bg-[rgba(255,255,255,0.3)] text-[var(--text-normal)] flex items-center justify-center text-xs font-medium ${
playerCaptures.value?.[1] ? 'opacity-70' : ''
}`}
>
{playerCaptures.value?.[1]}
</div>
</div>
<div
className={`ml-4 flex items-center transition ${
curPlayer.value < 0 ? 'opacity-100' : 'opacity-20'
}`}
>
<div className="h-4 w-[1px] bg-[var(--background-modifier-border)] mr-2"></div>
<Tooltip message={t('SKIP')} placement="top-end">
<button
class="rounded shrink-0 h-8 w-8 p-0 flex items-center justify-center text-[var(--text-muted)] bg-transparent transition shadow-none outline-none border-none cursor-pointer hover:bg-[var(--interactive-accent)] hover:text-[var(--text-on-accent)] active:scale-90 mr-0.5"
onClick={() => handleSkip(curPlayer.value)}
>
<div class="w-4 h-4 scale-75 transform origin-center">
<SkipIcon />
</div>
</button>
</Tooltip>
<Tooltip message={t('RESIGN')} placement="top-end">
<button
class="rounded shrink-0 h-8 w-8 p-0 flex items-center justify-center text-[var(--text-muted)] bg-transparent transition shadow-none outline-none border-none cursor-pointer hover:bg-[var(--interactive-accent)] hover:text-[var(--text-on-accent)] active:scale-90"
onClick={() => handleReisgn(curPlayer.value)}
>
<div class="w-4 h-4 scale-75 transform origin-center">
<ResignIcon />
</div>
</button>
</Tooltip>
</div>
</div>
<div className="flex items-center justify-end">
<Tooltip message={t('GAME_INFO')} placement="top-end">
<button
class="rounded shrink-0 h-8 w-8 p-0 flex items-center justify-center text-[var(--text-muted)] bg-transparent transition shadow-none outline-none border-none cursor-pointer hover:bg-[var(--interactive-accent)] hover:text-[var(--text-on-accent)] active:scale-90"
onClick={handleGameInfo}
>
<div class="w-4 h-4">
<InfoIcon />
</div>
</button>
</Tooltip>
<Tooltip message={t('EDIT_MODE')} placement="top-end">
<button
class="rounded shrink-0 h-8 w-8 p-0 flex items-center justify-center text-[var(--text-muted)] bg-transparent transition shadow-none outline-none border-none cursor-pointer hover:bg-[var(--interactive-accent)] hover:text-[var(--text-on-accent)] ml-2 active:scale-90"
onClick={handleEdit}
>
<div class="w-4 h-4">
<MarkIcon />
</div>
</button>
</Tooltip>
</div>
</div>
)
}
export default PlayBar

View file

@ -0,0 +1,36 @@
const Tooltip = ({ message, children, placement = '' }) => {
const cls = (() => {
switch (placement) {
case 'top-end':
return '-top-1 right-0 translate-y-[-100%]'
case 'top-begin':
return '-top-1 left-0 translate-y-[-100%]'
case 'bottom-end':
return '-bottom-1 right-0 translate-y-[100%]'
case 'bottom-begin':
return '-bottom-1 left-0 translate-y-[100%]'
case 'bottom':
return '-bottom-1 left-[50%] translate-y-[100%] -translate-x-[50%]'
case 'left':
return 'top-[50%] -left-1 translate-x-[-100%] -translate-y-[50%]'
case 'right':
return 'top-[50%] -right-1 translate-x-[100%] -translate-y-[50%]'
case 'top':
default:
return '-top-1 left-[50%] translate-y-[-100%] -translate-x-[50%]'
}
})()
return (
<div className="relative flex group">
{children}
<div
className={`absolute whitespace-pre p-2 text-xs text-white transition-all scale-0 bg-gray-800 rounded group-hover:scale-100 ${cls}`}
>
{message}
</div>
</div>
)
}
export default Tooltip

24
src/consts/consts.ts Normal file
View file

@ -0,0 +1,24 @@
import { moment } from "obsidian"
import { genMarkdownSGFSection } from '../utils/goban'
export const ICON_NAME = 'stop-circle'
export const VIEW_TYPE_GOBAN_SGF = 'goban-sgf'
export const LOCALE = moment.locale()
export const FRONTMATTER_KEY = "goban-sgf-plugin"
export const DEFAULT_SGF = `(;GM[1]FF[4]CA[UTF-8]AP[Obsidian Goban SGF Plugin:${process.env.PLUGIN_VERSION}]KM[6.5]SZ[19]DT[${moment().format('YYYY-MM-DD')}])`
export const FRONTMATTER = [
"---",
"",
`${FRONTMATTER_KEY}: ${process.env.PLUGIN_VERSION}`,
"tags: [goban]",
"",
"---"
].join("\n")
export const DEFAULT_DATA = genMarkdownSGFSection(DEFAULT_SGF)
export const FRONT_MATTER_REGEX = /^(---)$.+?^(---)$.+?/ims
export const SGF_CONTENT_REGEX = /^(```\s*(goban-)?sgf)$(.*)^(```)$/ims
export const CONTAINER_HTML_TOKEN = 'goban-sgf-plugin-container'
export const APP_HTML_TOKEN = 'goban-sgf-plugin-app'
export const POPUP_LAYER_HTML_TOKEN = 'goban-sgf-plugin-pop-layer'
export const HIDE_STATUS_BAR_HTML_TOKEN = 'hide-status-bar'
export const BODY_DATA_HTML_TOKEN = 'gobanSgfPluginViewActive'

37
src/hooks/useMeasure.ts Normal file
View file

@ -0,0 +1,37 @@
import { useState, useRef, useCallback } from 'preact/hooks'
export const useMeasure = () => {
const [dimensions, setDimensions] = useState({
width: null,
height: null,
left: null,
top: null,
})
const previousObserver = useRef(null)
const customRef = useCallback((node: any) => {
if (previousObserver.current) {
previousObserver.current.disconnect()
previousObserver.current = null
}
if (node?.nodeType === Node.ELEMENT_NODE) {
const observer = new ResizeObserver(([entry]) => {
if (entry && entry.borderBoxSize) {
const { inlineSize: width, blockSize: height } = entry.borderBoxSize[0]
const { left, top } = node.getBoundingClientRect()
setDimensions({ width, height, left, top })
}
})
observer.observe(node)
previousObserver.current = observer
}
}, [])
return [customRef, dimensions] as [
(node: any) => void,
{ width: number | null; height: number | null; left: number | null; top: number | null }
]
}

114
src/icons/annos.tsx Normal file
View file

@ -0,0 +1,114 @@
export const EvenPosition = (props) => {
const width = props.width || '100%'
const height = props.height || '100%'
return (
<div className="w-full h-full text-black dark:text-white">
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 16 16">
<g fill="currentColor">
<path d="M8,0C3.6,0,0,3.6,0,8s3.6,8,8,8s8-3.6,8-8S12.4,0,8,0z M2,8c0-3.3,2.7-6,6-6v12C4.7,14,2,11.3,2,8z"></path>
</g>
</svg>
</div>
)
}
export const GoodForBlack = (props) => {
return (
<div className="rounded-[100px] h-full w-full border border-solid border-black bg-black dark:border-white"></div>
)
}
export const GoodForWhite = (props) => {
return (
<div className="rounded-[100px] h-full w-full border border-solid border-black bg-white dark:border-white"></div>
)
}
export const UnclearPosition = (props) => {
const width = props.width || '100%'
const height = props.height || '100%'
return (
<div className="w-full h-full text-[rgba(0,0,0,0.4)] dark:text-[rgba(255,255,255,0.5)]">
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 16 16">
<g fill="currentColor">
<path d="M8,0C3.6,0,0,3.6,0,8s3.6,8,8,8,8-3.6,8-8S12.4,0,8,0Zm0,13c-.552,0-1-.448-1-1s.448-1,1-1,1,.448,1,1-.448,1-1,1Zm1.623-4.908c-.497,.427-.623,.571-.623,.908,0,.553-.448,1-1,1s-1-.447-1-1c0-1.294,.795-1.976,1.322-2.427,.497-.425,.623-.57,.623-.906,0-.183,0-.667-.944-.667-.436,.024-.901,.224-1.258,.561-.401,.378-1.035,.359-1.414-.041-.379-.402-.361-1.035,.041-1.414,.702-.661,1.617-1.054,2.579-1.104h.003c1.812,0,2.993,1.071,2.993,2.666,0,1.293-.795,1.975-1.321,2.425Z"></path>
</g>
</svg>
</div>
)
}
export const GoodMove = (props) => {
const width = props.width || '100%'
const height = props.height || '100%'
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 16 16">
<g fill="#0AE43A">
<path d="M2,7H1A.945.945,0,0,0,0,8v7a.945.945,0,0,0,1,1H2Z"></path>
<path d="M14,6H9V2A1.89,1.89,0,0,0,7,0L4,7v9h8a2.908,2.908,0,0,0,2.9-2.4l1-5.2A1.937,1.937,0,0,0,14,6Z"></path>
</g>
</svg>
)
}
export const BadMove = (props) => {
const width = props.width || '100%'
const height = props.height || '100%'
return (
<div className="w-full h-full origin-center rotate-180">
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 16 16">
<g fill="#F13B3B">
<path d="M2,7H1A.945.945,0,0,0,0,8v7a.945.945,0,0,0,1,1H2Z"></path>
<path d="M14,6H9V2A1.89,1.89,0,0,0,7,0L4,7v9h8a2.908,2.908,0,0,0,2.9-2.4l1-5.2A1.937,1.937,0,0,0,14,6Z"></path>
</g>
</svg>
</div>
)
}
export const IntMove = (props) => {
const width = props.width || '100%'
const height = props.height || '100%'
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 16 16">
<g fill="#1AB17C">
<path d="M14.866,7.16l-6.5-7a.5.5,0,0,0-.732,0l-6.5,7A.5.5,0,0,0,1.5,8H5v7a1,1,0,0,0,1,1h4a1,1,0,0,0,1-1V8h3.5a.5.5,0,0,0,.366-.84Z"></path>
</g>
</svg>
)
}
export const DouMove = (props) => {
const width = props.width || '100%'
const height = props.height || '100%'
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 16 16">
<g fill="#CF1AB7">
<path d="M14.958,8.3A.5.5,0,0,0,14.5,8H11V1a1,1,0,0,0-1-1H6A1,1,0,0,0,5,1V8H1.5a.5.5,0,0,0-.366.84l6.5,7a.5.5,0,0,0,.732,0l6.5-7A.5.5,0,0,0,14.958,8.3Z"></path>
</g>
</svg>
)
}
export const Focus = (props) => {
const width = props.width || '100%'
const height = props.height || '100%'
const opp = props.opposition || false
return (
<div className={`w-full h-full ${opp ? 'text-white' : 'text-[var(--text-accent)]'}`}>
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 16 16">
<g fill="currentColor">
<circle cx="8" cy="5" r="5"></circle>{' '}
<path d="M8,12c-1.1,0-2.1-0.3-3-0.7V16l3-2l3,2v-4.7C10.1,11.7,9.1,12,8,12z"></path>
</g>
</svg>
</div>
)
}

16
src/icons/annotation.tsx Normal file
View file

@ -0,0 +1,16 @@
const Annotaion = (props) => {
const fill = props.fill || 'currentColor'
const stroke = props.stroke || fill
const width = props.width || '100%'
const height = props.height || '100%'
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 16 16">
<g fill={fill}>
<path fill={fill} d="M4,0C2.9,0,2,0.9,2,2v14l6-3l6,3V2c0-1.1-0.9-2-2-2H4z"></path>
</g>
</svg>
)
}
export default Annotaion

11
src/icons/edit.tsx Normal file
View file

@ -0,0 +1,11 @@
const Edit = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" viewBox="0 0 16 16">
<g fill="currentColor">
<path d="M11.707.293a1,1,0,0,0-1.414,0l-9,9A1,1,0,0,0,1.02,9.8l-1,5A1,1,0,0,0,1.2,15.98l5-1a1,1,0,0,0,.511-.273l9-9a1,1,0,0,0,0-1.414Zm-6.2,12.786-3.233.647.647-3.233L8,5.414,10.586,8ZM12,6.586,9.414,4,11,2.414,13.586,5Z"></path>
</g>
</svg>
)
}
export default Edit

24
src/icons/exit.tsx Normal file
View file

@ -0,0 +1,24 @@
const Exit = (props) => {
const fill = props.fill || 'currentColor'
const stroke = props.stroke || fill
const width = props.width || '100%'
const height = props.height || '100%'
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 16 16">
<g
stroke-width="1"
fill="none"
stroke={stroke}
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="5.5" y1="8" x2="15.5" y2="8"></line>
<polyline points="11.5 4 15.5 8 11.5 12"></polyline>
<path d="M8.5,15.5H2A1.5,1.5,0,0,1,.5,14V2A1.5,1.5,0,0,1,2,.5H8.5"></path>
</g>
</svg>
)
}
export default Exit

11
src/icons/goto.tsx Normal file
View file

@ -0,0 +1,11 @@
const Goto = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" viewBox="0 0 16 16">
<g fill="currentColor">
<path d="M8,0C4.1,0,1,3.1,1,7c0,1.9,0.7,3.7,2.1,5c0.1,0.1,4.1,3.7,4.2,3.8c0.4,0.3,1,0.3,1.3,0 c0.1-0.1,4.2-3.7,4.2-3.8c1.4-1.3,2.1-3.1,2.1-5C15,3.1,11.9,0,8,0z M8,9C6.9,9,6,8.1,6,7s0.9-2,2-2c1.1,0,2,0.9,2,2S9.1,9,8,9z"></path>
</g>
</svg>
)
}
export default Goto

23
src/icons/info.tsx Normal file
View file

@ -0,0 +1,23 @@
const GameInfo = (props) => {
const fill = props.fill || 'currentColor'
const stroke = props.stroke || fill
const width = props.width || '100%'
const height = props.height || '100%'
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 16 16">
<g
stroke-width="1"
fill="none"
stroke={stroke}
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="12,4.5 8,0.5 4,4.5 "></polyline>{' '}
<path d="M14.5,15.5h-13 c-0.552,0-1-0.448-1-1v-9c0-0.552,0.448-1,1-1h13c0.552,0,1,0.448,1,1v9C15.5,15.052,15.052,15.5,14.5,15.5z"></path>
</g>
</svg>
)
}
export default GameInfo

26
src/icons/mark.tsx Normal file
View file

@ -0,0 +1,26 @@
const Mark = (props) => {
const fill = props.fill || 'currentColor'
const stroke = props.stroke || fill
const width = props.width || '100%'
const height = props.height || '100%'
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 16 16">
<g
stroke-width="1"
fill="none"
stroke={stroke}
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points=".5 13.5 2.5 15.5 11.487 6.487 9.513 4.487 .5 13.5"></polygon>
<line x1="7.513" y1="6.487" x2="9.513" y2="8.487"></line>
<path d="M3.5,2.5c1.105,0,2-.895,2-2,0,1.105,.895,2,2,2-1.105,0-2,.895-2,2,0-1.105-.895-2-2-2"></path>
<path d="M11.5,2.5c1.105,0,2-.895,2-2,0,1.105,.895,2,2,2-1.105,0-2,.895-2,2,0-1.105-.895-2-2-2"></path>
<path d="M11.5,10.493c1.105,0,2-.895,2-2,0,1.105,.895,2,2,2-1.105,0-2,.895-2,2,0-1.105-.895-2-2-2"></path>
</g>
</svg>
)
}
export default Mark

26
src/icons/menu.tsx Normal file
View file

@ -0,0 +1,26 @@
const Menu = (props) => {
const fill = props.fill || 'currentColor'
const stroke = props.stroke || fill
const width = props.width || '100%'
const height = props.height || '100%'
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 16 16">
<g
stroke-width="1"
fill="none"
stroke={stroke}
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="3.5" cy="3.5" r="2"></circle> <line x1="8.5" y1="2.5" x2="15.5" y2="2.5"></line>{' '}
<line x1="8.5" y1="6.5" x2="15.5" y2="6.5"></line>{' '}
<circle cx="3.5" cy="12.5" r="2"></circle>{' '}
<line x1="8.5" y1="11.5" x2="15.5" y2="11.5"></line>{' '}
<line x1="8.5" y1="15.5" x2="15.5" y2="15.5"></line>
</g>
</svg>
)
}
export default Menu

19
src/icons/navs.tsx Normal file
View file

@ -0,0 +1,19 @@
export const Prev = (props) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" viewBox="0 0 16 16">
<g fill="currentColor">
<path d="M2,8.001C2,8.327,2.159,8.633,2.427,8.82l10,7c0.305,0.214,0.704,0.238,1.035,0.068 C13.793,15.715,14,15.374,14,15.001V0.999c0-0.373-0.207-0.715-0.538-0.887c-0.331-0.17-0.73-0.146-1.035,0.068l-10,7 C2.159,7.367,2,7.673,2,7.999C2,8,2,8,2,8.001C2,8,2,8,2,8.001z"></path>
</g>
</svg>
)
}
export const Next = (props) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" viewBox="0 0 16 16">
<g fill="currentColor">
<path d="M14,7.999c0-0.326-0.159-0.632-0.427-0.819l-10-7C3.269-0.034,2.869-0.058,2.538,0.112 C2.207,0.285,2,0.626,2,0.999v14.001c0,0.373,0.207,0.715,0.538,0.887c0.331,0.17,0.73,0.146,1.035-0.068l10-7 C13.841,8.633,14,8.327,14,8.001C14,8,14,8,14,7.999C14,8,14,8,14,7.999z"></path>
</g>
</svg>
)
}

11
src/icons/ques.tsx Normal file
View file

@ -0,0 +1,11 @@
const Ques = (props) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" viewBox="0 0 16 16">
<g fill="currentColor">
<path d="M8,0C3.6,0,0,3.6,0,8s3.6,8,8,8,8-3.6,8-8S12.4,0,8,0Zm0,13c-.552,0-1-.448-1-1s.448-1,1-1,1,.448,1,1-.448,1-1,1Zm1.623-4.908c-.497,.427-.623,.571-.623,.908,0,.553-.448,1-1,1s-1-.447-1-1c0-1.294,.795-1.976,1.322-2.427,.497-.425,.623-.57,.623-.906,0-.183,0-.667-.944-.667-.436,.024-.901,.224-1.258,.561-.401,.378-1.035,.359-1.414-.041-.379-.402-.361-1.035,.041-1.414,.702-.661,1.617-1.054,2.579-1.104h.003c1.812,0,2.993,1.071,2.993,2.666,0,1.293-.795,1.975-1.321,2.425Z"></path>
</g>
</svg>
)
}
export default Ques

20
src/icons/resign.tsx Normal file
View file

@ -0,0 +1,20 @@
const Resign = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" viewBox="0 0 16 16">
<g
stroke-width="1"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="3" cy="3" r="1.5"></circle>{' '}
<path d="M4.5,10.5v4H.5v-6a1.945,1.945,0,0,1,2-2C4.326,6.5,4.7,8.163,7,10"></path>{' '}
<path d="M11.5,10.5v4h4v-6a1.945,1.945,0,0,0-2-2C11.674,6.5,11.3,8.163,9,10"></path>{' '}
<circle cx="13" cy="3" r="1.5"></circle>
</g>
</svg>
)
}
export default Resign

18
src/icons/skip.tsx Normal file
View file

@ -0,0 +1,18 @@
const Skip = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" viewBox="0 0 16 16">
<g
stroke-width="1"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="8" cy="8" r="7.5"></circle>{' '}
<line x1="2.697" y1="13.303" x2="13.303" y2="2.697"></line>
</g>
</svg>
)
}
export default Skip

128
src/icons/tools.tsx Normal file
View file

@ -0,0 +1,128 @@
export const Cross = (props) => {
const fill = props.fill || 'currentColor'
const stroke = props.stroke || fill
const width = props.width || '100%'
const height = props.height || '100%'
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 24 24">
<g
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
fill="none"
stroke={stroke}
>
<line x1="19" y1="5" x2="5" y2="19"></line> <line x1="19" y1="19" x2="5" y2="5"></line>
</g>
</svg>
)
}
export const Square = (props) => {
const fill = props.fill || 'currentColor'
const stroke = props.stroke || fill
const width = props.width || '100%'
const height = props.height || '100%'
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 24 24">
<g
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
fill="none"
stroke={stroke}
>
<rect x="2" y="2" width="20" height="20"></rect>
</g>
</svg>
)
}
export const Triangle = (props) => {
const fill = props.fill || 'currentColor'
const stroke = props.stroke || fill
const width = props.width || '100%'
const height = props.height || '100%'
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 24 24">
<g
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
fill="none"
stroke={stroke}
>
<polygon points="12 3 2 21 22 21 12 3"></polygon>
</g>
</svg>
)
}
export const Circle = (props) => {
const fill = props.fill || 'currentColor'
const stroke = props.stroke || fill
const width = props.width || '100%'
const height = props.height || '100%'
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 24 24">
<g
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
fill="none"
stroke={stroke}
>
<circle cx="12" cy="12" r="10"></circle>
</g>
</svg>
)
}
export const Alpha = (props) => {
const fill = props.fill || 'currentColor'
const stroke = props.stroke || fill
const width = props.width || '100%'
const height = props.height || '100%'
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 24 24">
<g
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
fill="none"
stroke={stroke}
>
<polyline points="3 22 11.499 2 12.499 2 21 22"></polyline>
<line x1="5.974" y1="15" x2="18.024" y2="15"></line>
</g>
</svg>
)
}
export const Number = (props) => {
const fill = props.fill || 'currentColor'
const stroke = props.stroke || fill
const width = props.width || '100%'
const height = props.height || '100%'
return (
<svg xmlns="http://www.w3.org/2000/svg" width={width} height={height} viewBox="0 0 24 24">
<g
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
fill="none"
stroke={stroke}
>
<polyline points="7 6 12 2 13 2 13 22"></polyline>
</g>
</svg>
)
}

14
src/lang/helper.ts Normal file
View file

@ -0,0 +1,14 @@
import en from './locales/en'
import zhCN from './locales/zh-cn'
import { LOCALE } from '@/consts/consts'
const localeMap: { [k: string]: Partial<typeof en> } = {
en,
'zh-cn': zhCN,
}
const locale = localeMap[LOCALE]
export const t = (str: keyof typeof en): string => {
return (locale && locale[str]) || en[str]
}

162
src/lang/locales/en.ts Normal file
View file

@ -0,0 +1,162 @@
export default {
CREATE_NEW: "New Goban File",
UNTITLED: "Untitled",
NOFILE: "No file",
FOLDER: "Goban Folder",
FOLDER_INFO: "Default storage path for the new Goban, and will be automatically created if it does not exist.",
ERROR_CREATING_GOBAN: "Error when creating Goban file",
ERROR_OPEN_GOBAN: "Error when opening Goban file",
OPEN_AS_GOBAN: "Open as Goban file",
TOGGLE_MD_GOBAN: "Toggle Markdown / Goban",
CONTENT_INVALID: "SGF content is invalid",
OPEN_AS_MD: "Open as Markdown file",
WHITE: "White",
BLACK: "Black",
GAME_INFO: "Game Info",
PLAY_MODE: 'Play',
EDIT_MODE: "Edit",
VIEW_MODE: 'View',
CONFIRM: "Confirm",
CANCEL: "Cancel",
GAME_NAME: "Game Name",
TOURNAMENT_NAME: "Event Name",
DATE: "Date",
NOTES: "Notes",
GAME_RESULT: "Result",
KOMI: "Komi",
HANDICAP: "Handicap",
GOBAN_SIZE: "Board Size",
RANK: "Rank",
SWITCH_GAME: "Switch Game",
EXIT_EDIT: "Exit Edit Mode",
CROSS: "Cross",
TRIANGLE: "Triangle",
SQUARE: "Square",
CIRCLE: "Circle",
NUMBER: "Number",
ALPHA: "Alpha",
STONE: "Stone",
BLACK_STONE: "Black Stone",
WHITE_STONE: "White Stone",
CURRENT_SELECT_IS: "Current: ",
CLICK_TO_TOGGLE: "Click to Toggle",
EDIT_LABEL: 'Edit Label',
RESIGN: 'Resign',
SKIP: 'Skip',
NAV: 'Navigation',
START: 'Beginning',
ENDING: 'Ending',
PREV_BRANCH: 'Previous Branch',
NEXT_BRANCH: 'Next Branch',
PREV_COMMENT: 'Previous Comment',
NEXT_COMMENT: 'Next Comment',
PREV_FOCUS: 'Previous Hotspot',
NEXT_FOCUS: 'Next Hotspo',
COMMENT: "Comment",
COMMENT_TITLE: "Comment Title",
FINISH: 'Finish',
VARIATION_BRANCH: 'Variation Branch',
SET_TO_MAIN: 'Set to Main Branch',
MOVE_LEFT: 'Move Left',
MOVE_RIGHT: 'Move Right',
DELETE_NODE: 'Delete Node & Followings',
DELETE_NODE_CONFIRM: 'This will delete this node and all following nodes, continue?',
ANNOTATION: 'Annotation',
CLEAR_ANNOTATION: 'Clear All Annotations',
DISPLAY_SETTINGS: 'Display Settings',
SELECT_YES: 'Yes',
SELECT_NO: 'No',
SELECT_INHERIT: 'Inherit',
IMPORT_SGF: 'Import SGF File',
TOO_LARGE: 'File too large',
IMPORT_CONFIRM_TITLE: 'Import the SGF file into the current goban',
IMPORT_CONFIRM_MSG: 'This will overwrite the existing content of the current goban, are you sure?',
EXPORT_SGF: 'Export SGF File',
GOTO_END_AT_BEGINNING: 'Default to end when opening',
FUZZY_STONE_PLACEMENT: 'Stones don\'t need to align precisely',
SHOW_NEXT_MOVE: 'Show next move position',
SHOW_SIBLINGS: 'Show other potential point positions for this move',
SHOW_SIBLINGS_INFO: 'As the previous move is a branching point, there are multiple branches from the previous move to the next move. Enabling this option will display the options for multiple branches.',
SHOW_MOVE_NUMBER: 'Display move count',
SHOW_MOVE_NUMBER_INFO: 'Displaying move numbers will cover the markers on the board, making the editing function unavailable. It is recommended not to enable this by default. If there are multiple game files that need to display move numbers only, you can modify the display settings for specific game files. It is not recommended to set this globally here.',
SHOW_LATS_MOVES: 'Display last X moves',
INIT_MODE: 'Default mode on open',
INIT_COMMENT_MODE: 'Default commentary mode on open',
INIT_KOMI: 'Default komi points',
INIT_KOMI_INFO: 'Fill in multiples of 0.5.',
INIT_HANDICAP: 'Default handicap stones',
INIT_HANDICAP_INFO: 'Fill in the integer between 0-9, 0 and 1 both indicate No-Handicap.',
INIT_SIZE: 'Default board size',
SHOW_RANGE: 'Limit to part of the board',
AXIOS_X: 'Horizontal Range',
AXIOS_Y: 'Vertical Range',
PAGE_SETTINGS_INFO: 'All settings here only apply to this game record and will override the settings with the same name in the plugin. If you want to share this configuration in multiple game record files, it is recommended to create a new game record file with all the settings here and use it as a template file to create new files using the Templater plugin, or manually create a copy to create a new game record.',
AN_GOOD_FOR_BLACK: 'Good for Black',
AN_UNCLEAR_POSITION: 'Unclear Position',
AN_EVEN_POSITION: 'Even Position',
AN_GOOD_FOR_WHITE: 'Good for White',
AN_GOOD_MOVE: 'Good Move',
AN_INTERESTING_MOVE: 'Interesting Move',
AN_DOUBTFUL_MOVE: 'Doubtful Move',
AN_BAD_MOVE: 'Bad Move',
AN_HOTSPOT: 'Hotspot',
BM_LOW_CHINESE_OPENING: 'Low Chinese Opening',
BM_HIGH_CHINESE_OPENING: 'High Chinese Opening',
BM_ORTHODOX_OPENING: 'Orthodox Opening',
BM_ENCLOSURE_OPENING: 'Enclosure Opening',
BM_KOBAYASHI_OPENING: 'Kobayashi Opening',
BM_SMALL_CHINESE_OPENING: 'Small Chinese Opening',
BM_MICRO_CHINESE_OPENING: 'Micro Chinese Opening',
BM_SANRENSEI_OPENING: 'Sanrensei Opening',
BM_NIRENSEI_OPENING: 'Nirensei Opening',
BM_SHŪSAKU_OPENING: 'Shūsaku Opening',
BM_LOW_APPROACH: 'Low Approach',
BM_HIGH_APPROACH: 'High Approach',
BM_LOW_ENCLOSURE: 'Low Enclosure',
BM_HIGH_ENCLOSURE: 'High Enclosure',
BM_MOUTH_SHAPE: 'Mouth Shape',
BM_TABLE_SHAPE: 'Table Shape',
BM_TIPPY_TABLE: 'Tippy Table',
BM_BAMBOO_JOINT: 'Bamboo Joint',
BM_TRAPEZIUM: 'Trapezium',
BM_DIAMOND: 'Diamond',
'BM_TIGERS_MOUTH': 'Tigers Mouth',
BM_EMPTY_TRIANGLE: 'Empty Triangle',
BM_TURN: 'Turn',
BM_STRETCH: 'Stretch',
BM_DIAGONAL: 'Diagonal',
BM_WEDGE: 'Wedge',
BM_HANE: 'Hane',
BM_CUT: 'Cut',
BM_SQUARE: 'Square',
BM_THROWING_STAR: 'Throwing Star',
BM_PARALLELOGRAM: 'Parallelogram',
'BM_DOGS_HEAD': 'Dogs Head',
'BM_HORSES_HEAD': 'Horses Head',
BM_ATTACHMENT: 'Attachment',
'BM_ONE-POINT_JUMP': 'One-Point Jump',
BM_BIG_BULGE: 'Big Bulge',
BM_SMALL_KNIGHT: 'Small Knight',
'BM_Two-Point_Jump': 'Two-Point Jump',
BM_LARGE_KNIGHT: 'Large Knight',
'BM_3-3_POINT_INVASION': '3-3 Point Invasion',
BM_SHOULDER_HIT: 'Shoulder Hit',
BM_DIAGONAL_JUMP: 'Diagonal Jump',
'BM_3-4_POINT': '3-4 Point',
'BM_4-4_POINT': '4-4 Point',
'BM_3-3_POINT': '3-3 Point',
'BM_3-5_POINT': '3-5 Point',
'BM_4-5_POINT': '4-5 Point',
'BM_6-3_POINT': '6-3 Point',
'BM_6-4_POINT': '6-4 Point',
'BM_5-5_POINT': '5-5 Point',
BM_PASS: 'Pass',
BM_TAKE: 'Take',
BM_ATARI: 'Atari',
BM_SUICIDE: 'Suicide',
BM_FILL: 'Fill',
BM_CONNECT: 'Connect',
BM_TENGEN: 'Tengen',
BM_HOSHI: 'Hoshi',
VIEW_IN_SENSEI: 'View article on Senseis Library'
}

162
src/lang/locales/ja.ts Normal file
View file

@ -0,0 +1,162 @@
export default {
CREATE_NEW: '新しい碁盤のファイルを作成',
UNTITLED: '無題',
NOFILE: 'ファイルなし',
FOLDER: '碁盤ファイルフォルダ',
FOLDER_INFO: '新しい碁盤のデフォルト保存パス。存在しない場合は自動的に作成されます。',
ERROR_CREATING_GOBAN: '碁盤ファイル作成時にエラー',
ERROR_OPEN_GOBAN: '碁盤ファイルを開く際のエラー',
OPEN_AS_GOBAN: '碁盤ファイルとして開く',
TOGGLE_MD_GOBAN: 'Markdown / 碁盤に切り替え',
CONTENT_INVALID: '碁盤の内容が無効',
OPEN_AS_MD: 'Markdownファイルとして開く',
BLACK: '黒',
WHITE: '白',
GAME_INFO: '対局情報',
PLAY_MODE: '対局',
EDIT_MODE: '編集',
VIEW_MODE: '閲覧',
CONFIRM: '確認',
CANCEL: 'キャンセル',
GAME_NAME: '対局名',
TOURNAMENT_NAME: '大会名',
DATE: '日付',
NOTES: 'ノート',
GAME_RESULT: '対局結果',
KOMI: 'コミ',
HANDICAP: '置き石',
GOBAN_SIZE: '碁盤のサイズ',
RANK: '段級位',
SWITCH_GAME: '対局を切り替え',
EXIT_EDIT: '編集モードを終了',
CROSS: '十字',
TRIANGLE: '三角',
SQUARE: '四角',
CIRCLE: '円',
NUMBER: '数字',
ALPHA: 'アルファベット',
STONE: '石',
BLACK_STONE: '黒石',
WHITE_STONE: '白石',
CURRENT_SELECT_IS: '現在: ',
CLICK_TO_TOGGLE: 'クリックして切り替え',
EDIT_LABEL: 'ラベルの編集',
RESIGN: '投了',
SKIP: 'パス',
NAV: 'ナビゲーション',
START: '開始',
ENDING: '終局',
PREV_BRANCH: '前の分岐',
NEXT_BRANCH: '次の分岐',
PREV_COMMENT: '前のコメント',
NEXT_COMMENT: '次のコメント',
PREV_FOCUS: '前の焦点',
NEXT_FOCUS: '次の焦点',
VARIATION_BRANCH: '変化図',
SET_TO_MAIN: '主変化図に設定',
MOVE_LEFT: '変化図を左に移動',
MOVE_RIGHT: '変化図を右に移動',
DELETE_NODE: 'このノードとそれ以降を削除',
DELETE_NODE_CONFIRM: 'このノードとそれ以降を削除しますか?',
COMMENT: 'コメント',
COMMENT_TITLE: 'コメントのタイトル',
FINISH: '完了',
ANNOTATION: '注釈',
CLEAR_ANNOTATION: '全ての注釈をクリア',
DISPLAY_SETTINGS: '表示設定',
IMPORT_SGF: 'SGFファイルをインポート',
TOO_LARGE: 'ファイルが大きすぎます',
IMPORT_CONFIRM_TITLE: '現在の碁盤にSGFファイルをインポート',
IMPORT_CONFIRM_MSG: '現在の碁盤の内容を上書きします。よろしいですか?',
EXPORT_SGF: 'SGFファイルをエクスポート',
SELECT_YES: 'はい',
SELECT_NO: 'いいえ',
SELECT_INHERIT: 'デフォルト',
GOTO_END_AT_BEGINNING: '碁盤を開いた時、デフォルトで対局の終わりに移動',
FUZZY_STONE_PLACEMENT: '石の表示位置が厳密に碁盤の交点上になる必要はありません',
SHOW_NEXT_MOVE: '次の一手を表示',
SHOW_SIBLINGS: 'この一手の他の可能な手の位置を表示',
SHOW_SIBLINGS_INFO: '前の一手が分岐点である場合、前の一手から次の一手に複数の分岐がある場合、このオプションを有効にすると複数の分岐の手の位置を表示できます',
SHOW_MOVE_NUMBER: '手数を表示',
SHOW_MOVE_NUMBER_INFO: '手数を表示すると、碁上のマークが覆われ、編集機能が使用できなくなる可能性があります。デフォルトで有効にしないことをお勧めします。手数のみを表示する必要がある複数の碁盤ファイルがある場合、特定の碁盤の表示設定を変更することができますが、ここでグローバル設定を行うことはお勧めしません。',
SHOW_LAST_MOVES: '最後のX手の手数を表示',
INIT_MODE: '碁盤を開いた時のデフォルトモード',
INIT_COMMENT_MODE: '碁盤を開いた時のデフォルトコメントモード',
INIT_KOMI: 'デフォルトのコミ',
INIT_KOMI_INFO: '0.5の倍数を入力',
INIT_HANDICAP: 'デフォルトの置き石',
INIT_HANDICAP_INFO: '0から9の間の整数を入力。0と1は置き石なしを意味します。',
INIT_SIZE: 'デフォルトの碁盤サイズ',
SHOW_RANGE: '局部的な碁盤のみを使用',
AXIS_X: '左から右への範囲',
AXIS_Y: '上から下への範囲',
PAGE_SETTINGS_INFO: 'ここでの全ての設定は、この碁盤にのみ適用され、プラグイン設定にある同名の設定項目を上書きします。もし複数の碁盤ファイルでこの設定を共有したい場合、新しい碁盤ファイルを作成し、ここの全ての設定項目を設定した後、この碁盤ファイルをテンプレートファイルとして利用し、Templaterプラグインで新しいファイルを作成するか、手動でコピーを作成することをお勧めします。',
AN_GOOD_FOR_BLACK: '黒有利',
AN_UNCLEAR_POSITION: '形勢不明',
AN_EVEN_POSITION: '均衡',
AN_GOOD_FOR_WHITE: '白有利',
AN_GOOD_MOVE: '好手',
AN_INTERESTING_MOVE: '興味深い一手',
AN_DOUBTFUL_MOVE: '疑問手',
AN_BAD_MOVE: '悪手',
AN_HOTSPOT: '焦点',
BM_LOW_CHINESE_OPENING: '低中国流',
BM_HIGH_CHINESE_OPENING: '高中国流',
BM_ORTHODOX_OPENING: '星+無憂角',
BM_ENCLOSURE_OPENING: '守角',
BM_KOBAYASHI_OPENING: '小林流',
BM_SMALL_CHINESE_OPENING: '変相中国流',
BM_MICRO_CHINESE_OPENING: 'ミニ中国流',
BM_SANRENSEI_OPENING: '三連星',
BM_NIRENSEI_OPENING: '二連星',
BM_SHŪSAKU_OPENING: '秀策流',
BM_LOW_APPROACH: '低掛け',
BM_HIGH_APPROACH: '高掛け',
BM_LOW_ENCLOSURE: '低位守角',
BM_HIGH_ENCLOSURE: '高位守角',
BM_MOUTH_SHAPE: '飛び方',
BM_TABLE_SHAPE: '変形飛び方',
BM_TIPPY_TABLE: '斜め飛び',
BM_BAMBOO_JOINT: '並び',
BM_TRAPEZIUM: '逆尖',
BM_DIAMOND: '抜花型',
'BM_TIGERS_MOUTH': '虎の口',
BM_EMPTY_TRIANGLE: '空三角',
BM_TURN: '曲がり',
BM_STRETCH: '伸び',
BM_DIAGONAL: '尖り',
BM_WEDGE: '挿入',
BM_HANE: '扳き',
BM_CUT: '切断',
BM_SQUARE: '正方形',
BM_THROWING_STAR: '小飛び方形',
BM_PARALLELOGRAM: '平行四辺形',
'BM_DOGS_HEAD': '犬の顔',
'BM_HORSES_HEAD': '馬の顔',
BM_ATTACHMENT: '付け',
'BM_ONE-POINT_JUMP': '一点跳び',
BM_BIG_BULGE: '小飛び方形一子抜き',
BM_SMALL_KNIGHT: '飛び',
'BM_Two-Point_Jump': '大跳び',
BM_LARGE_KNIGHT: '大飛び',
'BM_3-3_POINT_INVASION': '点三三',
BM_SHOULDER_HIT: '肩突き',
BM_DIAGONAL_JUMP: '象跳び',
'BM_3-4_POINT': '小目',
'BM_4-4_POINT': '星',
'BM_3-3_POINT': '三三',
'BM_3-5_POINT': '目外し',
'BM_4-5_POINT': '高目',
'BM_6-3_POINT': '6-3点',
'BM_6-4_POINT': '6-4点',
'BM_5-5_POINT': '五五',
BM_PASS: 'パス',
BM_TAKE: '取る',
BM_ATARI: 'アタリ',
BM_SUICIDE: '自殺',
BM_FILL: '詰める',
BM_CONNECT: '繋ぐ',
BM_TENGEN: '天元',
BM_HOSHI: '星',
VIEW_IN_SENSEI: 'Senseis Libraryで記事を見る',
}

162
src/lang/locales/ko.ts Normal file
View file

@ -0,0 +1,162 @@
export default {
CREATE_NEW: '새 바둑 기보 파일 생성',
UNTITLED: '제목 없음',
NOFILE: '파일 없음',
FOLDER: '기보 파일 폴더',
FOLDER_INFO: '새 기보의 기본 저장 경로입니다. 존재하지 않으면 자동으로 생성됩니다.',
ERROR_CREATING_GOBAN: '기보 파일 생성 중 오류 발생',
ERROR_OPEN_GOBAN: '기보 파일 열기 오류',
OPEN_AS_GOBAN: '기보 파일로 열기',
TOGGLE_MD_GOBAN: 'Markdown / 기보로 전환',
CONTENT_INVALID: '기보 내용이 유효하지 않음',
OPEN_AS_MD: 'Markdown 파일로 열기',
BLACK: '흑',
WHITE: '백',
GAME_INFO: '대국 정보',
PLAY_MODE: '대국 모드',
EDIT_MODE: '편집 모드',
VIEW_MODE: '조회 모드',
CONFIRM: '확인',
CANCEL: '취소',
GAME_NAME: '대국명',
TOURNAMENT_NAME: '대회명',
DATE: '날짜',
NOTES: '주석',
GAME_RESULT: '대국 결과',
KOMI: '덤',
HANDICAP: '덤받기',
GOBAN_SIZE: '바둑판 크기',
RANK: '단급',
SWITCH_GAME: '대국 전환',
EXIT_EDIT: '편집 모드 종료',
CROSS: '십자가',
TRIANGLE: '삼각형',
SQUARE: '사각형',
CIRCLE: '원',
NUMBER: '숫자',
ALPHA: '알파벳',
STONE: '돌',
BLACK_STONE: '흑돌',
WHITE_STONE: '백돌',
CURRENT_SELECT_IS: '현재 선택:',
CLICK_TO_TOGGLE: '전환하려면 클릭',
EDIT_LABEL: '마크 텍스트 편집',
RESIGN: '기권',
SKIP: '한수 건너뛰기',
NAV: '네비게이션',
START: '시작',
ENDING: '종료',
PREV_BRANCH: '이전 분기',
NEXT_BRANCH: '다음 분기',
PREV_COMMENT: '이전 코멘트',
NEXT_COMMENT: '다음 코멘트',
PREV_FOCUS: '이전 핵심 포인트',
NEXT_FOCUS: '다음 핵심 포인트',
VARIATION_BRANCH: '변화수 나무',
SET_TO_MAIN: '주요 변화수로 설정',
MOVE_LEFT: '변화수를 왼쪽으로 이동',
MOVE_RIGHT: '변화수를 오른쪽으로 이동',
DELETE_NODE: '해당 노드와 이후 노드 삭제',
DELETE_NODE_CONFIRM: '해당 노드 및 이후 모든 노드가 삭제됩니다. 계속하시겠습니까?',
COMMENT: '코멘트',
COMMENT_TITLE: '코멘트 제목',
FINISH: '완료',
ANNOTATION: '주석',
CLEAR_ANNOTATION: '모든 주석 지우기',
DISPLAY_SETTINGS: '디스플레이 설정',
IMPORT_SGF: 'SGF 파일 가져오기',
TOO_LARGE: '파일이 너무 큼',
IMPORT_CONFIRM_TITLE: '현재 기보에 SGF 파일 가져오기',
IMPORT_CONFIRM_MSG: '현재 기보의 내용을 덮어쓰게 됩니다. 계속하시겠습니까?',
EXPORT_SGF: 'SGF 파일 내보내기',
SELECT_YES: '예',
SELECT_NO: '아니오',
SELECT_INHERIT: '기본값',
GOTO_END_AT_BEGINNING: '기보를 열 때 기본적으로 대국 끝으로 이동',
FUZZY_STONE_PLACEMENT: '돌의 위치가 정확히 바둑판의 교차점에 있을 필요는 없음',
SHOW_NEXT_MOVE: '다음 수의 위치 표시',
SHOW_SIBLINGS: '이 수의 다른 가능한 수 위치 표시',
SHOW_SIBLINGS_INFO: '이전 수가 분기점인 경우, 이전 수로부터 다음 수까지 여러 개의 분기가 있는 경우, 이 옵션을 켜면 여러 분기의 수 위치를 표시할 수 있음',
SHOW_MOVE_NUMBER: '수순 표시',
SHOW_MOVE_NUMBER_INFO: '수순을 표시하면 바둑판 위의 마크가 가려질 수 있으며, 편집 기능이 사용 불가능해질 수 있습니다. 기본 설정으로는 권장하지 않습니다. 여러 기보 파일에만 수순을 표시하려면, 특정 기보의 디스플레이 설정에서 변경하는 것을 권장합니다. 전체 설정에서는 변경하지 않는 것이 좋습니다.',
SHOW_LATS_MOVES: '마지막 X수의 수순 표시',
INIT_MODE: '기보를 열 때 기본 모드',
INIT_COMMENT_MODE: '기보를 열 때 기본 코멘트 모드',
INIT_KOMI: '기본 덤',
INIT_KOMI_INFO: '0.5의 배수로 작성',
INIT_HANDICAP: '기본 덤받기',
INIT_HANDICAP_INFO: '0-9 사이의 정수를 작성, 0과 1 모두 덤받기 없음을 의미',
INIT_SIZE: '기본 바둑판 크기',
SHOW_RANGE: '부분 바둑판만 사용',
AXIOS_X: '왼쪽에서 오른쪽 범위',
AXIOS_Y: '위에서 아래 범위',
PAGE_SETTINGS_INFO: '이 설정은 해당 기보에만 적용되며, 플러그인 설정에서 같은 이름의 설정 항목을 덮어씁니다. 여러 기보 파일에 이 설정을 공유하려면, 새 기보 파일을 만들고 여기에 모든 설정 항목을 설정한 후, 해당 기보 파일을 템플릿 파일로 사용하여 Templater 플러그인으로 새 파일을 생성하거나 수동으로 복사하여 새 기보 파일을 만드는 것이 좋습니다.',
AN_GOOD_FOR_BLACK: '흑이 유리',
AN_UNCLEAR_POSITION: '형세 불명',
AN_EVEN_POSITION: '균형 잡힌 형세',
AN_GOOD_FOR_WHITE: '백이 유리',
AN_GOOD_MOVE: '좋은 수',
AN_INTERESTING_MOVE: '흥미로운 수',
AN_DOUBTFUL_MOVE: '의심스러운 수',
AN_BAD_MOVE: '나쁜 수',
AN_HOTSPOT: '핵심 지점',
BM_LOW_CHINESE_OPENING: '저반중국식 포석',
BM_HIGH_CHINESE_OPENING: '고반중국식 포석',
BM_ORTHODOX_OPENING: '별+무우각',
BM_ENCLOSURE_OPENING: '각守',
BM_KOBAYASHI_OPENING: '코바야시식',
BM_SMALL_CHINESE_OPENING: '변형중국식 포석',
BM_MICRO_CHINESE_OPENING: '미니중국식 포석',
BM_SANRENSEI_OPENING: '삼련성',
BM_NIRENSEI_OPENING: '이련성',
BM_SHŪSAKU_OPENING: '슈사쿠식',
BM_LOW_APPROACH: '저공',
BM_HIGH_APPROACH: '고공',
BM_LOW_ENCLOSURE: '저자리 각守',
BM_HIGH_ENCLOSURE: '고자리 각守',
BM_MOUTH_SHAPE: '입 모양',
BM_TABLE_SHAPE: '탁자 모양',
BM_TIPPY_TABLE: '기울어진 탁자',
BM_BAMBOO_JOINT: '쌍',
BM_TRAPEZIUM: '거꾸로된 뾰족한 모양',
BM_DIAMOND: '다이아몬드 모양',
'BM_TIGERS_MOUTH': '호',
BM_EMPTY_TRIANGLE: '빈 삼각형',
BM_TURN: '꺾기',
BM_STRETCH: '길게 뻗기',
BM_DIAGONAL: '뾰족하게',
BM_WEDGE: '쐐기',
BM_HANE: '번',
BM_CUT: '끊기',
BM_SQUARE: '정사각형',
BM_THROWING_STAR: '작은 비행 정사각형',
BM_PARALLELOGRAM: '평행사변형',
'BM_DOGS_HEAD': '개 머리',
'BM_HORSES_HEAD': '말 머리',
BM_ATTACHMENT: '붙이기',
'BM_ONE-POINT_JUMP': '점프',
BM_BIG_BULGE: '작은 비행 정사각형에 하나 빠진 모양',
BM_SMALL_KNIGHT: '비행',
'BM_Two-Point_Jump': '큰 점프',
BM_LARGE_KNIGHT: '큰 비행',
'BM_3-3_POINT_INVASION': '점삼삼',
BM_SHOULDER_HIT: '뾰족하게 찌르기',
BM_DIAGONAL_JUMP: '코끼리 점프',
'BM_3-4_POINT': '소목',
'BM_4-4_POINT': '별 자리',
'BM_3-3_POINT': '삼삼',
'BM_3-5_POINT': '목밖',
'BM_4-5_POINT': '고목',
'BM_6-3_POINT': '6-3점',
'BM_6-4_POINT': '6-4점',
'BM_5-5_POINT': '오오',
BM_PASS: '한 수 건너뛰기',
BM_TAKE: '제거',
BM_ATARI: '아타리',
BM_SUICIDE: '자살',
BM_FILL: '채우기',
BM_CONNECT: '접착',
BM_TENGEN: '천원',
BM_HOSHI: '별',
VIEW_IN_SENSEI: 'Sensei\'s Library에서 기사를 확인하십시오',
}

162
src/lang/locales/zh-cn.ts Normal file
View file

@ -0,0 +1,162 @@
export default {
CREATE_NEW: '新建棋谱文件',
UNTITLED: '未命名',
NOFILE: '无文件',
FOLDER: '棋谱文件夹',
FOLDER_INFO: '新棋谱的默认存储路径,如果不存在则会自动创建。',
ERROR_CREATING_GOBAN: '创建棋谱文件时出错',
ERROR_OPEN_GOBAN: '打开棋谱文件时出错',
OPEN_AS_GOBAN: '打开为棋谱文件',
TOGGLE_MD_GOBAN: '切换为 Markdown / 棋谱',
CONTENT_INVALID: '棋谱内容无效',
OPEN_AS_MD: '打开为 Markdown 文件',
BLACK: '黑方',
WHITE: '白方',
GAME_INFO: '对局信息',
PLAY_MODE: '下棋',
EDIT_MODE: '编辑',
VIEW_MODE: '查看',
CONFIRM: '确定',
CANCEL: '取消',
GAME_NAME: '对局名称',
TOURNAMENT_NAME: '赛事名称',
DATE: '日期',
NOTES: '注释',
GAME_RESULT: '对局结果',
KOMI: '贴目',
HANDICAP: '让子',
GOBAN_SIZE: '棋盘尺寸',
RANK: '段位',
SWITCH_GAME: '切换对局',
EXIT_EDIT: '退出编辑模式',
CROSS: '叉叉',
TRIANGLE: '三角',
SQUARE: '方块',
CIRCLE: '圆圈',
NUMBER: '数字',
ALPHA: '字母',
STONE: '棋子',
BLACK_STONE: '黑子',
WHITE_STONE: '白子',
CURRENT_SELECT_IS: '当前: ',
CLICK_TO_TOGGLE: '点击切换',
EDIT_LABEL: '编辑标记文本',
RESIGN: '认输',
SKIP: '停一手',
NAV: '导航',
START: '开局',
ENDING: '终局',
PREV_BRANCH: '上一分叉',
NEXT_BRANCH: '下一分叉',
PREV_COMMENT: '上一评论',
NEXT_COMMENT: '下一评论',
PREV_FOCUS: '上一关键点',
NEXT_FOCUS: '下一关键点',
VARIATION_BRANCH: '变化树',
SET_TO_MAIN: '设置为主变化分支',
MOVE_LEFT: '左移变化分支',
MOVE_RIGHT: '右移变化分支',
DELETE_NODE: '删除该节点及之后节点',
DELETE_NODE_CONFIRM: '这将删除该节点以及之后的所有节点,是否继续?',
COMMENT: '评论',
COMMENT_TITLE: '评论标题',
FINISH: '完成',
ANNOTATION: '标记',
CLEAR_ANNOTATION: '清除所有标记',
DISPLAY_SETTINGS: '显示设置',
IMPORT_SGF: '导入 SGF 文件',
TOO_LARGE: '文件过大',
IMPORT_CONFIRM_TITLE: '导入 SGF 文件到当前棋谱',
IMPORT_CONFIRM_MSG: '这将覆盖当前棋谱的已有内容,确定吗?',
EXPORT_SGF: '导出 SGF 文件',
SELECT_YES: '是',
SELECT_NO: '否',
SELECT_INHERIT: '默认',
GOTO_END_AT_BEGINNING: '打开棋谱时默认跳到对局末尾',
FUZZY_STONE_PLACEMENT: '棋子显示位置不必严格落在棋盘交叉线上',
SHOW_NEXT_MOVE: '显示下一手位置',
SHOW_SIBLINGS: '显示这一手其他可能的选点位置',
SHOW_SIBLINGS_INFO: '如上一手为分叉点,从上一手到下一手有多个分支,开启这个选项可以显示多个分支的选点',
SHOW_MOVE_NUMBER: '显示手数',
SHOW_MOVE_NUMBER_INFO: '显示手数将会覆盖棋上的标记,将导致编辑功能不可用,建议不要默认开启,如果有多个棋谱文件需要仅显示手数,可以在特定棋谱的显示设置中修改,不建议在此做全局设置',
SHOW_LATS_MOVES: '显示最后 X 手的手数',
INIT_MODE: '打开棋谱时默认进入的模式',
INIT_COMMENT_MODE: '打开棋谱时默认进入的评论模式',
INIT_KOMI: '默认贴目',
INIT_KOMI_INFO: '填写 0.5 的倍数',
INIT_HANDICAP: '默认让子',
INIT_HANDICAP_INFO: '填写 0-9 之间的整数, 0 和 1 都表示不让子',
INIT_SIZE: '默认棋盘尺寸',
SHOW_RANGE: '仅使用局部棋盘',
AXIOS_X: '左到右范围',
AXIOS_Y: '上到下范围',
PAGE_SETTINGS_INFO: '该处所有设置仅针对该棋谱生效,会覆盖插件设置中的同名设置项,如果你想在多个棋谱文件中共享该配置,建议新建棋谱文件设置好这里的所有配置项,最后将该棋谱文件作为模版文件,利用 Templater 插件以此模版创建新的文件或手动创建副本以新建棋谱文件',
AN_GOOD_FOR_BLACK: '黑好',
AN_UNCLEAR_POSITION: '形势不明',
AN_EVEN_POSITION: '均势',
AN_GOOD_FOR_WHITE: '白好',
AN_GOOD_MOVE: '好棋',
AN_INTERESTING_MOVE: '有趣的一手',
AN_DOUBTFUL_MOVE: '问题手',
AN_BAD_MOVE: '臭棋',
AN_HOTSPOT: '关键',
BM_LOW_CHINESE_OPENING: '低中国流',
BM_HIGH_CHINESE_OPENING: '高中国流',
BM_ORTHODOX_OPENING: '星+无忧角',
BM_ENCLOSURE_OPENING: '守角',
BM_KOBAYASHI_OPENING: '小林流',
BM_SMALL_CHINESE_OPENING: '变相中国流',
BM_MICRO_CHINESE_OPENING: '迷你中国流',
BM_SANRENSEI_OPENING: '三连星',
BM_NIRENSEI_OPENING: '二连星',
BM_SHŪSAKU_OPENING: '秀策流',
BM_LOW_APPROACH: '低挂',
BM_HIGH_APPROACH: '高挂',
BM_LOW_ENCLOSURE: '低位守角',
BM_HIGH_ENCLOSURE: '高位守角',
BM_MOUTH_SHAPE: '跳方',
BM_TABLE_SHAPE: '变形跳方',
BM_TIPPY_TABLE: '斜飞',
BM_BAMBOO_JOINT: '双',
BM_TRAPEZIUM: '倒尖',
BM_DIAMOND: '拔花型',
'BM_TIGERS_MOUTH': '虎',
BM_EMPTY_TRIANGLE: '空三角',
BM_TURN: '拐',
BM_STRETCH: '长',
BM_DIAGONAL: '尖',
BM_WEDGE: '挖',
BM_HANE: '扳',
BM_CUT: '断',
BM_SQUARE: '正方方阵',
BM_THROWING_STAR: '小飞方阵',
BM_PARALLELOGRAM: '错位方阵',
'BM_DOGS_HEAD': '猴脸',
'BM_HORSES_HEAD': '马脸',
BM_ATTACHMENT: '贴',
'BM_ONE-POINT_JUMP': '跳',
BM_BIG_BULGE: '小飞方阵少一子',
BM_SMALL_KNIGHT: '飞',
'BM_Two-Point_Jump': '大跳',
BM_LARGE_KNIGHT: '大飞',
'BM_3-3_POINT_INVASION': '点三三',
BM_SHOULDER_HIT: '尖冲',
BM_DIAGONAL_JUMP: '象飞',
'BM_3-4_POINT': '小目',
'BM_4-4_POINT': '星位',
'BM_3-3_POINT': '三三',
'BM_3-5_POINT': '目外',
'BM_4-5_POINT': '高目',
'BM_6-3_POINT': '6-3_点',
'BM_6-4_POINT': '6-4_点',
'BM_5-5_POINT': '五五',
BM_PASS: '停一手',
BM_TAKE: '提子',
BM_ATARI: '打',
BM_SUICIDE: '自杀',
BM_FILL: '填',
BM_CONNECT: '粘',
BM_TENGEN: '天元',
BM_HOSHI: '星',
VIEW_IN_SENSEI: '在 Senseis Library 上查看文章',
}

331
src/main.ts Normal file
View file

@ -0,0 +1,331 @@
import {
MarkdownView,
Notice,
Plugin,
WorkspaceLeaf,
normalizePath,
TFile,
ViewState,
TFolder,
MarkdownPostProcessorContext,
} from 'obsidian'
import {
ICON_NAME,
VIEW_TYPE_GOBAN_SGF,
FRONTMATTER_KEY,
FRONTMATTER,
HIDE_STATUS_BAR_HTML_TOKEN,
BODY_DATA_HTML_TOKEN,
} from './consts/consts'
import { around } from 'monkey-around'
import { t } from './lang/helper'
import { checkAndCreateFolder, getNewUniqueFilepath, getGobanFilename } from './utils/filesUtils'
import { errorlog } from './utils/utils'
import { genMarkdownSGFSection, genInitSGF } from './utils/goban'
import GobanSGFView from './GobanSGFView'
import { GobanSGFPluginSettings, DEFAULT_SETTINGS } from './settings'
import GobanSGFSettingsTab from './GobanSGFSettingsTab'
import GobanCodeBlock from './GobanCodeBlock'
import './style.css'
export default class GobanSGFPlugin extends Plugin {
settings: GobanSGFPluginSettings
sgfFileModes: { [file: string]: string } = {}
private _loaded: boolean = false
async onload() {
await this.loadSettings()
this.addCommands()
this.registerEvents()
this.registerMonkeyAround()
this.registerView(VIEW_TYPE_GOBAN_SGF, (leaf: WorkspaceLeaf) => new GobanSGFView(leaf, this))
this.addRibbonIcon(ICON_NAME, t('CREATE_NEW'), () => {
this.createAndOpenGoban()
})
this.registerMarkdownCodeBlockProcessor('sgf', this.gobanProcessor.bind(this))
this.registerMarkdownCodeBlockProcessor('goban-sgf', this.gobanProcessor.bind(this))
this.addSettingTab(new GobanSGFSettingsTab(this.app, this))
}
onunload() {
this.app.workspace.detachLeavesOfType(VIEW_TYPE_GOBAN_SGF)
}
async gobanProcessor(source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) {
ctx.addChild(new GobanCodeBlock(el, source, ctx, this.settings))
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData())
}
async saveSettings() {
await this.saveData(this.settings)
}
private async createAndOpenGoban(filename?: string, foldername?: string): Promise<string> {
try {
const file = await this.createGobanFile(filename, foldername)
this.openGoban(file)
return file.path
} catch (e) {
errorlog({
where: 'createAndOpenGoban(main.ts)',
error: e,
})
new Notice(t('ERROR_CREATING_GOBAN'))
return null
}
}
private async createGobanFile(filename?: string, foldername?: string) {
const folderpath = normalizePath(foldername ? foldername : this.settings.folder)
await checkAndCreateFolder(this, folderpath) //create folder if it does not exist
const fname = getNewUniqueFilepath(
this.app.vault,
getGobanFilename(this.settings, filename || t('UNTITLED')),
folderpath
)
const file = await this.app.vault.create(fname, await this.genBlankGobanFile())
//wait for metadata cache
let counter = 0
while (file instanceof TFile && !this.isGobanFile(file) && counter++ < 10) {
await sleep(50)
}
if (counter > 10) {
errorlog({
file,
error: 'new goban not recognized as an goban file',
fn: this.createGobanFile,
})
}
return file
}
private async genBlankGobanFile() {
return `${FRONTMATTER}\n${genMarkdownSGFSection(
genInitSGF(
this.settings.dftSize || 19,
this.settings.dftHandicap || 0,
this.settings.dftKomi || 5.5
)
)}`
}
private isGobanFile(f: TFile) {
if (!f) {
return false
}
const fileCache = f ? this.app.metadataCache.getFileCache(f) : null
return !!fileCache?.frontmatter && !!fileCache.frontmatter[FRONTMATTER_KEY]
}
private async openGoban(gobanFile: TFile) {
let leaf: WorkspaceLeaf = this.app.workspace.getLeaf('tab')
if (!leaf) {
leaf = this.app.workspace.getLeaf(false)
}
try {
await leaf.setViewState({
type: VIEW_TYPE_GOBAN_SGF,
state: {
file: gobanFile.path,
},
active: true,
})
} catch (e) {
errorlog({
where: 'openGoban(main.ts): leaf.openFile',
error: e,
})
new Notice(t('ERROR_OPEN_GOBAN'))
}
}
private registerEvents() {
this.registerEvent(
this.app.workspace.on(
'file-menu',
(menu, file: TFile, source: string, leaf?: WorkspaceLeaf & { id?: string }) => {
// Add a menu item to the folder context menu to create a goban
if (file instanceof TFolder) {
menu.addItem((item) => {
item
.setTitle(`${t('CREATE_NEW')}`)
.setIcon('document')
.onClick(() => this.createAndOpenGoban(undefined, file.path))
})
}
//add markdown view menu open as goban view
if (leaf && this.sgfFileModes[leaf.id || file.path] == 'markdown') {
const cache = this.app.metadataCache.getFileCache(file)
if (cache?.frontmatter && cache.frontmatter[FRONTMATTER_KEY]) {
menu
.addItem((item) => {
item
.setTitle(`${t('OPEN_AS_GOBAN')}`)
.setIcon('document')
.onClick(() => {
this.sgfFileModes[leaf.id || file.path] = VIEW_TYPE_GOBAN_SGF
this.setGobanView(leaf)
})
})
.addSeparator()
}
}
}
)
)
this.registerEvent(
this.app.metadataCache.on('changed', (file) => {
this.app.workspace.getLeavesOfType(VIEW_TYPE_GOBAN_SGF).forEach((leaf) => {
const view = leaf.view as GobanSGFView
view.onFileMetadataChange(file)
})
})
)
this.registerEvent(
this.app.workspace.on('active-leaf-change', (leaf) => {
if (!leaf) {
return
}
const gobanSGFLeaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_GOBAN_SGF) || []
// const gobanView = this.app.workspace.getActiveViewOfType(GobanSGFView) not working: click on sidebar whill emit this event, and `this.app.workspace.getActiveViewOfType(GobanSGFView)` will return null
// So use this trick to know whether any goban view is can be seen
const gobanLeaf = gobanSGFLeaves.find((leaf: any) => leaf?.height > 0 || leaf?.width > 0)
const gobanView = gobanLeaf?.view as GobanSGFView
if (gobanView) {
if (!document.body.classList.contains(HIDE_STATUS_BAR_HTML_TOKEN)) {
document.body.classList.add(HIDE_STATUS_BAR_HTML_TOKEN)
}
if (
gobanView?.gobanCtrl?.ctrlID &&
document.body.dataset[BODY_DATA_HTML_TOKEN] !== gobanView?.gobanCtrl?.ctrlID
) {
document.body.dataset[BODY_DATA_HTML_TOKEN] = gobanView?.gobanCtrl?.ctrlID
}
} else {
document.body.classList.remove(HIDE_STATUS_BAR_HTML_TOKEN)
if (document.body.dataset[BODY_DATA_HTML_TOKEN]) {
delete document.body.dataset[BODY_DATA_HTML_TOKEN]
}
}
})
)
}
async setMarkdownView(leaf: WorkspaceLeaf) {
const state = leaf.view.getState()
await leaf.setViewState({
type: VIEW_TYPE_GOBAN_SGF,
state: { file: null },
})
await leaf.setViewState(
{
type: 'markdown',
state,
popstate: true,
} as ViewState,
{ focus: true }
)
}
async setGobanView(leaf: WorkspaceLeaf) {
await leaf.setViewState({
type: VIEW_TYPE_GOBAN_SGF,
state: leaf.view.getState(),
popstate: true,
} as ViewState)
}
private addCommands() {
this.addCommand({
id: 'Create New Goban',
name: `${t('CREATE_NEW')}`,
callback: () => {
this.createAndOpenGoban()
},
})
this.addCommand({
id: 'Toggle to Markdown or Goban',
name: `${t('TOGGLE_MD_GOBAN')}`,
mobileOnly: false,
callback: () => {
const gobanView = this.app.workspace.getActiveViewOfType(GobanSGFView)
const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView)
if (gobanView != null) {
this.sgfFileModes[(gobanView.leaf as any).id || gobanView.file.path] = 'markdown'
this.setMarkdownView(gobanView.leaf)
} else if (markdownView != null) {
this.sgfFileModes[(markdownView.leaf as any).id || markdownView.file.path] =
VIEW_TYPE_GOBAN_SGF
this.setMarkdownView(markdownView.leaf)
}
},
})
}
private registerMonkeyAround() {
const self = this
this.register(
around(WorkspaceLeaf.prototype, {
detach(next) {
return function () {
const state = this.view?.getState()
if (state?.file && self.sgfFileModes[this.id || state.file]) {
delete self.sgfFileModes[this.id || state.file]
}
return next.apply(this)
}
},
setViewState(next) {
return function (state: ViewState, ...rest: any[]) {
if (
// Don't force goban mode during shutdown
self._loaded &&
state.type === 'markdown' &&
state.state?.file &&
// And the current mode of the file is not set to markdown
self.sgfFileModes[this.id || state.state.file] !== 'markdown'
) {
// Then check for the goban frontMatterKey
const cache = self.app.metadataCache.getCache(state.state.file)
if (cache?.frontmatter && cache.frontmatter[FRONTMATTER_KEY]) {
// If we have it, force the view type to goban
const newState = {
...state,
type: VIEW_TYPE_GOBAN_SGF,
}
self.sgfFileModes[state.state.file] = VIEW_TYPE_GOBAN_SGF
return next.apply(this, [newState, ...rest])
}
}
return next.apply(this, [state, ...rest])
}
},
})
)
}
}

5
src/modals/common.ts Normal file
View file

@ -0,0 +1,5 @@
import { FunctionalComponent } from 'preact'
export type WithFinishFC<T> = FunctionalComponent<T & { onFinish: (unMountReady?: Promise<any>) => Promise<any> }>
export const noopAsync = async () => {}

View file

@ -0,0 +1,52 @@
import { WithFinishFC } from '../common'
interface BaseModalProps {
title: any
children: any
width?: number
}
const BaseModal: WithFinishFC<BaseModalProps> = ({ title, onFinish, children, width = 640 }) => {
return (
<div className="absoulte inset-0 z-[2000]">
<div className="transition-all absolute inset-0 z-1 bg-[rgba(0,2,10,0.3)] dark:bg-[rgba(0,2,10,0.6)] backdrop-import backdrop-blur-sm"></div>
<div
className="absolute inset-0 flex items-center justify-center px-32 pointer-events-auto z-2 pb-28"
onClick={() => onFinish()}
>
<div
className="box-border border rounded shadow-overlay bg-[var(--background-secondary-alt)] text-[var(--text-normal)] border-[var(--background-modifier-border-hover)]"
style={{ width }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between w-full px-6 pt-4 mb-4">
<div className="flex items-center font-semibold">{title}</div>
<div
className="flex items-center justify-center h-8 transition cursor-pointer text-[var(--text-muted)] hover:text-[var(--text-accent)]"
onClick={() => onFinish()}
>
<div class="w-3 h-3">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<g fill="currentColor">
<path d="M14.7,1.3c-0.4-0.4-1-0.4-1.4,0L8,6.6L2.7,1.3c-0.4-0.4-1-0.4-1.4,0s-0.4,1,0,1.4L6.6,8l-5.3,5.3 c-0.4,0.4-0.4,1,0,1.4C1.5,14.9,1.7,15,2,15s0.5-0.1,0.7-0.3L8,9.4l5.3,5.3c0.2,0.2,0.5,0.3,0.7,0.3s0.5-0.1,0.7-0.3 c0.4-0.4,0.4-1,0-1.4L9.4,8l5.3-5.3C15.1,2.3,15.1,1.7,14.7,1.3z"></path>
</g>
</svg>
</div>
</div>
</div>
<div className="w-full px-6 my-3">
<div className="h-[1px] w-full border-t border-dashed border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.1)]" />
</div>
<div
className="relative z-10 overflow-y-auto"
style={{ maxHeight: Math.min(640, window.innerHeight - 256) }}
>
{children}
</div>
</div>
</div>
</div>
)
}
export default BaseModal

View file

@ -0,0 +1,43 @@
import { t } from '@/lang/helper'
import { noopAsync, WithFinishFC } from '../common'
import BaseModal from './BaseModal'
export interface ConfirmProps {
title: any
msg: any
onConfirm?: () => void
onCancel?: () => void
}
export const Confirm: WithFinishFC<ConfirmProps> = ({ onFinish, title, msg }) => {
const handleCancel = () => {
onFinish(Promise.resolve('cancel'))
}
const handleConfirm = () => {
onFinish(Promise.resolve('confirm'))
}
return (
<BaseModal onFinish={onFinish} title={title} width={360}>
<div className="w-full px-6 pb-6">
<div className="text-sm">{msg}</div>
<div className="flex items-center justify-end mt-6">
<button
className="rounded shrink-0 h-8 py-0 px-3 flex items-center justify-center text-[var(--text-on-accent)] bg-[var(--interactive-accent)] transition shadow-none outline-none border-none cursor-pointer hover:bg-[var(--interactive-accent-hover)] hover:text-[var(--text-on-accent)] mr-4 active:scale-90"
onClick={handleConfirm}
>
{t('CONFIRM')}
</button>
<button
className="rounded shrink-0 h-8 py-0 px-3 flex items-center justify-center text-[var(--text-muted)] bg-[var(--interactive-normal)]transition shadow-none outline-none cursor-pointer hover:bg-[var(--interactive-hover)] hover:text-[var(--text-normal)] active:scale-90 border border-solid border-[var(--background-modifier-border)]"
onClick={handleCancel}
>
{t('CANCEL')}
</button>
</div>
</div>
</BaseModal>
)
}

View file

@ -0,0 +1,423 @@
import { t } from '@/lang/helper'
import { noopAsync, WithFinishFC } from '../common'
import BaseModal from './BaseModal'
import type { FinalSettings, GobanSGFPluginFrontmatterSettings } from '@/settings'
import { useCallback, useState } from 'preact/hooks'
import {
convertBool,
convertLastMoves,
convertKomi,
convertHandicap,
convertSize,
convertGobanRange,
} from '@/utils/utils'
import jstr from 'json-stable-stringify'
export interface DisplaySettingProps {
initData: GobanSGFPluginFrontmatterSettings
onConfirm?: () => void
onCancel?: () => void
}
const BoolSelectOptions = [
{
label: t('SELECT_INHERIT'),
value: 'i',
},
{
label: t('SELECT_YES'),
value: 'y',
},
{
label: t('SELECT_NO'),
value: 'n',
},
]
const mapBoolToSelectValue = (v: any) => {
const cv = convertBool(v, false)
if (cv === undefined) {
return 'i'
}
return cv ? 'y' : 'n'
}
export const DisplaySetting: WithFinishFC<DisplaySettingProps> = ({ onFinish, initData }) => {
const handleCancel = () => {
onFinish(Promise.resolve(null))
}
const gobanRange = convertGobanRange(
initData.gobanRange,
isNaN(convertSize(initData.size)) ? 19 : convertSize(initData.size)
)
const [d, setD] = useState({
gotoEndAtBeginning: mapBoolToSelectValue(initData.gotoEndAtBeginning),
fuzzyStonePlacement: mapBoolToSelectValue(initData.fuzzyStonePlacement),
showNextMoves: mapBoolToSelectValue(initData.showNextMoves),
showSiblings: mapBoolToSelectValue(initData.showSiblings),
showMoveNumbers: mapBoolToSelectValue(initData.showMoveNumbers),
showLastMoves: convertLastMoves(initData.showLastMoves),
komi: convertKomi(initData.komi),
handicap: convertHandicap(initData.handicap),
size: convertSize(initData.size),
gobanRangeX1: gobanRange?.x?.[0],
gobanRangeX2: gobanRange?.x?.[1],
gobanRangeY1: gobanRange?.y?.[0],
gobanRangeY2: gobanRange?.y?.[1],
initMode:
initData.initMode === 'edit' ? 'edit' : initData.initMode === 'play' ? 'play' : 'inherit',
initCommentMode:
initData.initCommentMode === 'edit'
? 'edit'
: initData.initCommentMode === 'view'
? 'view'
: 'inherit',
})
const handleConfirm = () => {
const res: GobanSGFPluginFrontmatterSettings = {
}
let sizeInUse = 19
if (!isNaN(Number(d.size)) && Number(d.size) >= 2 && Number(d.size) <= 25) {
res.size = Number(d.size)
sizeInUse = Number(d.size)
} else {
res.size = undefined
}
if (d.gobanRangeX1 >= 0 || d.gobanRangeX2 >= 0 || d.gobanRangeY1 >= 0 || d.gobanRangeY2 >= 0) {
let x1 = d.gobanRangeX1 >= 0 && d.gobanRangeX1 < sizeInUse ? d.gobanRangeX1 : 0
let x2 = d.gobanRangeX2 >= 0 && d.gobanRangeX2 < sizeInUse ? d.gobanRangeX2 : sizeInUse - 1
let y1 = d.gobanRangeY1 >= 0 && d.gobanRangeY1 < sizeInUse ? d.gobanRangeY1 : 0
let y2 = d.gobanRangeY2 >= 0 && d.gobanRangeY2 < sizeInUse ? d.gobanRangeY2 : sizeInUse - 1
if (x1 > x2) {
const temp = x1
x1 = x2
x2 = temp
}
if (y1 > y2) {
const temp = y1
y1 = y2
y2 = temp
}
const gobanRange = {
x: [x1, x2],
y: [y1, y2],
}
res.gobanRange = jstr(gobanRange)
} else {
res.gobanRange = undefined
}
for (let k in d) {
if (['size', 'gobanRangeX1', 'gobanRangeX2', 'gobanRangeY1', 'gobanRangeY2'].includes(k)) {
continue
}
if ([
'gotoEndAtBeginning',
'fuzzyStonePlacement',
'showNextMoves',
'showSiblings',
'showMoveNumbers',
].includes(k)) {
if (d[k] !== 'i') {
res[k] = d[k] === 'y'
} else {
res[k] = undefined
}
} else if (['showLastMoves', 'komi', 'handicap'].includes(k)) {
if (!isNaN(Number(d[k]))) {
res[k] = Number(d[k])
} else {
res[k] = undefined
}
} else if (['initMode', 'initCommentMode'].includes(k)) {
res[k] = d[k] === 'inherit' ? undefined : d[k]
}
}
onFinish(Promise.resolve(res))
}
const handleSelectChange = useCallback((e, field) => {
const v = e.target.value
if (
[
'gotoEndAtBeginning',
'fuzzyStonePlacement',
'showNextMoves',
'showSiblings',
'showMoveNumbers',
'initMode',
'initCommentMode',
].includes(field)
) {
setD((pre) => ({ ...pre, [field]: v }))
} else if (['gobanRangeX1', 'gobanRangeX2', 'gobanRangeY1', 'gobanRangeY2'].includes(field)) {
setD((pre) => ({ ...pre, [field]: Number(v) >= 0 ? Number(v) : undefined }))
} else if (field === 'size') {
setD((pre) => ({ ...pre, [field]: Number(v) >= 2 && Number(v) <= 25 ? Number(v) : undefined }))
}
}, [])
const handleNumberChange = useCallback((e, field) => {
const v = e.target.value || undefined
if (field === 'showLastMoves') {
setD((pre) => ({ ...pre, [field]: convertLastMoves(v) }))
} else if (field === 'komi') {
setD((pre) => ({ ...pre, [field]: convertKomi(v) === Number(v) ? v : convertKomi(v) }))
} else if (field === 'handicap') {
setD((pre) => ({ ...pre, [field]: convertHandicap(v) }))
}
}, [])
return (
<BaseModal onFinish={onFinish} title={t('DISPLAY_SETTINGS')} width={800}>
<div className="w-full px-6 pt-1 pb-6">
<div className="text-xs text-[var(-text-muted)] mb-6">*{t('PAGE_SETTINGS_INFO')}</div>
<div className="flex text-xs">
<div className="w-[calc(50%-12px)] shrink-0 grow mr-6">
<div className="mb-4">
<div className="mb-1 px-0.5">{t('GOTO_END_AT_BEGINNING')}</div>
<div className="flex items-center w-28">
<select
value={d.gotoEndAtBeginning}
onChange={(e) => handleSelectChange(e, 'gotoEndAtBeginning')}
className="w-full shadow-none border-solid border border-[var(--background-modifier-border)]"
>
{BoolSelectOptions.map((it) => (
<option value={it.value}>{it.label}</option>
))}
</select>
</div>
</div>
<div className="mb-4">
<div className="mb-1 px-0.5">{t('FUZZY_STONE_PLACEMENT')}</div>
<div className="flex items-center w-28">
<select
value={d.fuzzyStonePlacement}
onChange={(e) => handleSelectChange(e, 'fuzzyStonePlacement')}
className="w-full shadow-none border-solid border border-[var(--background-modifier-border)]"
>
{BoolSelectOptions.map((it) => (
<option value={it.value}>{it.label}</option>
))}
</select>
</div>
</div>
<div className="mb-4">
<div className="mb-1 px-0.5">{t('SHOW_NEXT_MOVE')}</div>
<div className="flex items-center w-28">
<select
value={d.showNextMoves}
onChange={(e) => handleSelectChange(e, 'showNextMoves')}
className="w-full shadow-none border-solid border border-[var(--background-modifier-border)]"
>
{BoolSelectOptions.map((it) => (
<option value={it.value}>{it.label}</option>
))}
</select>
</div>
</div>
<div className="mb-4">
<div className="mb-1 px-0.5">{t('SHOW_SIBLINGS')}</div>
<div className="flex items-center w-28">
<select
value={d.showSiblings}
onChange={(e) => handleSelectChange(e, 'showSiblings')}
className="w-full shadow-none border-solid border border-[var(--background-modifier-border)]"
>
{BoolSelectOptions.map((it) => (
<option value={it.value}>{it.label}</option>
))}
</select>
</div>
</div>
<div className="mb-4">
<div className="mb-1 px-0.5">{t('SHOW_MOVE_NUMBER')}</div>
<div className="flex items-center w-28">
<select
value={d.showMoveNumbers}
onChange={(e) => handleSelectChange(e, 'showMoveNumbers')}
className="w-full shadow-none border-solid border border-[var(--background-modifier-border)]"
>
{BoolSelectOptions.map((it) => (
<option value={it.value}>{it.label}</option>
))}
</select>
</div>
</div>
<div className="mb-4">
<div className="mb-1 px-0.5">{t('SHOW_LATS_MOVES')}</div>
<div className="flex items-center w-28">
<input
className="w-full"
type="number"
min={0}
max={20}
value={d.showLastMoves}
placeholder={t('SELECT_INHERIT')}
onInput={(e) => handleNumberChange(e, 'showLastMoves')}
/>
</div>
</div>
</div>
<div className="w-[calc(50%-12px)] shrink-0 grow">
<div className="mb-4">
<div className="mb-1 px-0.5">{t('INIT_KOMI')}</div>
<div className="flex items-center w-28">
<input
className="w-full"
type="number"
min={0}
max={361}
step={0.5}
value={d.komi}
placeholder={t('SELECT_INHERIT')}
onInput={(e) => handleNumberChange(e, 'komi')}
/>
</div>
</div>
<div className="mb-4">
<div className="mb-1 px-0.5">{t('INIT_HANDICAP')}</div>
<div className="flex items-center w-28">
<input
className="w-full"
type="number"
min={2}
max={9}
value={d.handicap}
placeholder={t('SELECT_INHERIT')}
onInput={(e) => handleNumberChange(e, 'handicap')}
/>
</div>
</div>
<div className="mb-4">
<div className="mb-1 px-0.5">{t('INIT_MODE')}</div>
<div className="flex items-center w-28">
<select
value={d.initMode}
onChange={(e) => handleSelectChange(e, 'initMode')}
className="w-full shadow-none border-solid border border-[var(--background-modifier-border)]"
>
<option value="inherit">{t('SELECT_INHERIT')}</option>
<option value="play">{t('PLAY_MODE')}</option>
<option value="edit">{t('EDIT_MODE')}</option>
</select>
</div>
</div>
<div className="mb-4">
<div className="mb-1 px-0.5">{t('INIT_COMMENT_MODE')}</div>
<div className="flex items-center w-28">
<select
value={d.initCommentMode}
onChange={(e) => handleSelectChange(e, 'initCommentMode')}
className="w-full shadow-none border-solid border border-[var(--background-modifier-border)]"
>
<option value="inherit">{t('SELECT_INHERIT')}</option>
<option value="view">{t('VIEW_MODE')}</option>
<option value="edit">{t('EDIT_MODE')}</option>
</select>
</div>
</div>
<div className="mb-4">
<div className="mb-1 px-0.5">{t('INIT_SIZE')}</div>
<div className="flex items-center w-28">
<select
value={d.size}
onChange={(e) => handleSelectChange(e, 'size')}
className="w-full shadow-none border-solid border border-[var(--background-modifier-border)]"
>
<option value={-1}>{t('SELECT_INHERIT')}</option>
{[...Array(24)].map((_, i) => (
<option value={i + 2}>{i + 2}</option>
))}
</select>
</div>
</div>
<div className="mb-4">
<div className="mb-1 px-0.5">{t('SHOW_RANGE')}</div>
<div className="flex items-center w-[80%] mb-1">
<div className="flex items-center mr-1 shrink-0 text-[var(--text-muted)]">
<span className="inline-block h-1 w-1 rounded-md bg-[var(--text-faint)] ml-0.5 mr-1"></span>
{t('AXIOS_X')}:
</div>
<select
value={d.gobanRangeX1}
onChange={(e) => handleSelectChange(e, 'gobanRangeX1')}
className="h-6 grow shadow-none border-solid border border-[var(--background-modifier-border)]"
>
<option value={-1}>{t('SELECT_INHERIT')}</option>
{[...Array(d.size || 19)].map((_, i) => (
<option value={i}>{i + 1}</option>
))}
</select>
<div className="mx-0.5 shrink-0">-</div>
<select
value={d.gobanRangeX2}
onChange={(e) => handleSelectChange(e, 'gobanRangeX2')}
className="h-6 grow shadow-none border-solid border border-[var(--background-modifier-border)]"
>
<option value={-1}>{t('SELECT_INHERIT')}</option>
{[...Array(d.size || 19)].map((_, i) => (
<option value={i}>{i + 1}</option>
))}
</select>
</div>
<div className="flex items-center w-[80%]">
<div className="flex items-center mr-1 shrink-0 text-[var(--text-muted)]">
<span className="inline-block h-1 w-1 rounded-md bg-[var(--text-faint)] ml-0.5 mr-1"></span>
{t('AXIOS_Y')}:
</div>
<select
value={d.gobanRangeY1}
onChange={(e) => handleSelectChange(e, 'gobanRangeY1')}
className="h-6 grow shadow-none border-solid border border-[var(--background-modifier-border)]"
>
<option value={-1}>{t('SELECT_INHERIT')}</option>
{[...Array(d.size || 19)].map((_, i) => (
<option value={i}>{i + 1}</option>
))}
</select>
<div className="mx-0.5 shrink-0">-</div>
<select
value={d.gobanRangeY2}
onChange={(e) => handleSelectChange(e, 'gobanRangeY2')}
className="h-6 grow shadow-none border-solid border border-[var(--background-modifier-border)]"
>
<option value={-1}>{t('SELECT_INHERIT')}</option>
{[...Array(d.size || 19)].map((_, i) => (
<option value={i}>{i + 1}</option>
))}
</select>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end mt-6">
<button
className="rounded shrink-0 h-8 py-0 px-3 flex items-center justify-center text-[var(--text-on-accent)] bg-[var(--interactive-accent)] transition shadow-none outline-none border-none cursor-pointer hover:bg-[var(--interactive-accent-hover)] hover:text-[var(--text-on-accent)] mr-4 active:scale-90"
onClick={handleConfirm}
>
{t('CONFIRM')}
</button>
<button
className="rounded shrink-0 h-8 py-0 px-3 flex items-center justify-center text-[var(--text-muted)] bg-[var(--interactive-normal)]transition shadow-none outline-none cursor-pointer hover:bg-[var(--interactive-hover)] hover:text-[var(--text-normal)] active:scale-90 border border-solid border-[var(--background-modifier-border)]"
onClick={handleCancel}
>
{t('CANCEL')}
</button>
</div>
</div>
</BaseModal>
)
}

View file

@ -0,0 +1,53 @@
import { t } from '@/lang/helper'
import { noopAsync, WithFinishFC } from '../common'
import BaseModal from './BaseModal'
import { useState } from 'preact/hooks'
export interface InputPopupProps {
title: any
initValue?: string
onConfirm?: () => void
onCancel?: () => void
}
export const InputPopup: WithFinishFC<InputPopupProps> = ({ onFinish, title, initValue }) => {
const [str, setStr] = useState<string | undefined>(initValue)
const handleCancel = () => {
onFinish(Promise.resolve(''))
}
const handleChange = (e) => {
const v = e.target.value
setStr(v.trim())
}
const handleConfirm = () => {
onFinish(Promise.resolve(str.trim().slice(0, 8)))
}
return (
<BaseModal onFinish={onFinish} title={title} width={360}>
<div className="w-full px-6 pt-1 pb-6">
<div className="text-sm">
<input className="w-full" type="text" value={str} onInput={handleChange} />
</div>
<div className="flex items-center justify-end mt-6">
<button
className="rounded shrink-0 h-8 py-0 px-3 flex items-center justify-center text-[var(--text-on-accent)] bg-[var(--interactive-accent)] transition shadow-none outline-none border-none cursor-pointer hover:bg-[var(--interactive-accent-hover)] hover:text-[var(--text-on-accent)] mr-4 active:scale-90"
onClick={handleConfirm}
>
{t('CONFIRM')}
</button>
<button
className="rounded shrink-0 h-8 py-0 px-3 flex items-center justify-center text-[var(--text-muted)] bg-[var(--interactive-normal)]transition shadow-none outline-none cursor-pointer hover:bg-[var(--interactive-hover)] hover:text-[var(--text-normal)] active:scale-90 border border-solid border-[var(--background-modifier-border)]"
onClick={handleCancel}
>
{t('CANCEL')}
</button>
</div>
</div>
</BaseModal>
)
}

84
src/modals/modals.ts Normal file
View file

@ -0,0 +1,84 @@
import { render, FunctionalComponent, h } from 'preact'
import { Confirm, ConfirmProps } from './components/Confirm'
import { InputPopup, InputPopupProps } from './components/InputPopup'
import { DisplaySetting, DisplaySettingProps } from './components/DisplaySetting'
interface ModalApiExtProps {
onFinish: (unMountReady?: Promise<void>) => Promise<void>
}
interface ModalApiParams<K extends object> {
props: K
id: string | number
}
interface Config {
uniqKey?: string
}
const mountedUniqModals = new Map<string, { mountedDiv: HTMLDivElement }>()
function createMountDiv(cssSelector: string) {
const div = document.createElement('div')
;(document.querySelector(cssSelector) || document.body).appendChild(div)
return div
}
function modalApi<T extends object = {}>(
Comp: FunctionalComponent<T & ModalApiExtProps>,
config?: Config
) {
const { uniqKey } = config || {}
return async function (params: ModalApiParams<T>) {
if (uniqKey && mountedUniqModals.get(uniqKey)) {
return
}
let resolveFunc: (value?: any) => void
const toResolve = async (unMountReady?: Promise<void>) => {
let v = undefined
if (unMountReady) {
v = await unMountReady
}
resolveFunc(v)
}
const modalPromise = new Promise((resolve: (value?: any) => void) => {
resolveFunc = resolve
})
const mountDiv = createMountDiv(`.goban-sgf-plugin-pop-layer-${params.id.toString()}`)
const renderComp = () => {
render(
h(Comp, {
...params.props,
onFinish: toResolve,
}),
mountDiv
)
}
try {
renderComp()
uniqKey && mountedUniqModals.set(uniqKey, { mountedDiv: mountDiv })
return await modalPromise
} catch {
// ignore
} finally {
mountDiv.remove()
uniqKey && mountedUniqModals.delete(uniqKey)
}
}
}
const modals = {
Confirm: modalApi<ConfirmProps>(Confirm, {
uniqKey: 'Confirm',
}),
InputPopup: modalApi<InputPopupProps>(InputPopup, {
uniqKey: 'InputPopup',
}),
DisplaySetting: modalApi<DisplaySettingProps>(DisplaySetting, {
uniqKey: 'DisplaySetting',
}),
}
export default modals

201
src/settings.ts Normal file
View file

@ -0,0 +1,201 @@
import {
errorlog,
convertBool,
convertLastMoves,
convertKomi,
convertHandicap,
convertSize,
} from './utils/utils'
import * as matter from 'hexo-front-matter'
export interface GobanSGFPluginSettings {
folder: string
gotoEndAtBeginning: boolean
fuzzyStonePlacement: boolean
dftShowNextMoves: boolean
dftShowSiblings: boolean
dftShowMoveNumbers: boolean
dftKomi: number
dftHandicap: number
dftSize: number
}
export const DEFAULT_SETTINGS: GobanSGFPluginSettings = {
folder: 'Goban',
gotoEndAtBeginning: true,
fuzzyStonePlacement: true,
dftShowNextMoves: true,
dftShowSiblings: true,
dftShowMoveNumbers: false,
dftKomi: 5.5,
dftHandicap: 0,
dftSize: 19,
}
export interface GobanSGFPluginFrontmatterSettings {
gotoEndAtBeginning?: boolean | null
fuzzyStonePlacement?: boolean | null
showNextMoves?: boolean | null
showSiblings?: boolean | null
showMoveNumbers?: boolean | null
showLastMoves?: number
gobanRange?: string // like: JSON.stringify({ x: [0, 18], y: [0, 18] })
initMode?: 'play' | 'edit'
initCommentMode?: 'edit' | 'view'
komi?: number
handicap?: number
size?: number
[key: string]: any
}
export interface FinalSettings {
folder: string
gotoEndAtBeginning: boolean
fuzzyStonePlacement: boolean
showNextMoves: boolean
showSiblings: boolean
showMoveNumbers: boolean
showLastMoves: number
gobanRange: {
x: [number, number]
y: [number, number]
}
initMode: 'play' | 'edit'
initCommentMode: 'edit' | 'view'
komi: number
handicap: number
size: number
}
export const mergeSettings = (
pa: GobanSGFPluginFrontmatterSettings,
pl: GobanSGFPluginSettings
) => {
const res: FinalSettings = {
folder: pl.folder,
gotoEndAtBeginning: pl.gotoEndAtBeginning,
fuzzyStonePlacement: pl.fuzzyStonePlacement,
showNextMoves: pl.dftShowNextMoves,
showSiblings: pl.dftShowSiblings,
showMoveNumbers: pl.dftShowMoveNumbers,
showLastMoves: 0,
gobanRange: {
x: [0, pl.dftSize - 1],
y: [0, pl.dftSize - 1],
},
initMode: 'play',
initCommentMode: 'view',
komi: pl.dftKomi,
handicap: pl.dftHandicap,
size: pl.dftSize,
}
if (
pa.size !== undefined &&
!isNaN(Number(pa.size)) &&
Number(pa.size) > 1 &&
Number(pa.size) < 26
) {
const s = Math.floor(Number(pa.size))
res.size = s
if (res.gobanRange.x[1] >= s) {
res.gobanRange.x[1] = s - 1
}
if (res.gobanRange.y[1] >= s) {
res.gobanRange.y[1] = s - 1
}
}
for (let k in pa) {
if (k === 'size') {
continue
} else if (k === 'gobanRange') {
try {
let { x = [], y = [] } = JSON.parse(pa[k])
x = x.map((xi, i) =>
isNaN(Number(xi))
? i === 0
? 0
: res.size - 1
: Number(xi) >= res.size
? res.size - 1
: Number(xi)
)
y = y.map((yi, i) =>
isNaN(Number(yi))
? i === 0
? 0
: res.size - 1
: Number(yi) >= res.size
? res.size - 1
: Number(yi)
)
res.gobanRange = { x: x[0] > x[1] ? [x[1], x[0]] : x, y: y[0] > y[1] ? [y[1], y[0]] : y }
} catch (e) {
errorlog({
where: 'mergeSettings',
error: e,
})
}
} else if (
[
'gotoEndAtBeginning',
'fuzzyStonePlacement',
'showNextMoves',
'showSiblings',
'showMoveNumbers',
].includes(k)
) {
res[k] = convertBool(pa[k])
} else if (['showLastMoves', 'komi', 'handicap'].includes(k)) {
const num = Number(pa[k])
if (isNaN(num)) {
continue
}
if (k === 'showLastMoves') {
res.showLastMoves = convertLastMoves(pa[k])
}
if (k === 'komi') {
res.komi = convertKomi(pa[k])
}
if (k === 'handicap') {
res.handicap = convertHandicap(pa[k])
}
if (k === 'size') {
res.size = convertSize(pa[k])
}
} else if (k === 'initMode' && ['play', 'edit'].includes(pa[k])) {
res.initMode = pa[k]
} else if (k === 'initCommentMode' && ['view', 'edit'].includes(pa[k])) {
res.initCommentMode = pa[k]
} else if (res[k] === undefined) {
res[k] = pa[k]
}
}
return res
}
export const stringifyFrontmatterData = (st: GobanSGFPluginFrontmatterSettings) => {
const r = st
Object.keys(r).forEach((k) => {
if (r[k] === null || r[k] === undefined) {
delete r[k]
}
})
try {
const str = matter.stringify(r, { prefixSeparator: true })
return str
} catch (e) {
errorlog({
where: 'stringifyFrontmatterData',
error: e,
})
return ''
}
}

815
src/store/actions.ts Normal file
View file

@ -0,0 +1,815 @@
import { batch } from '@preact/signals'
import { stringifyVertex, parseVertex, parseCompressedVertices, stringify } from '@sabaki/sgf'
import { Store } from './store'
import { clearBoardCache, getBoard } from './common'
import modals from '@/modals/modals'
import { t } from '@/lang/helper'
import { shallowEquals } from '@/utils/utils'
import type { GobanSGFPluginFrontmatterSettings } from '@/settings'
export const switchGame = (store: Store, index: number) => {
const {
curGameIndex,
gameTrees,
curTreePositionNodeID,
rootNode,
currents,
history,
historyCursor,
} = store
batch(() => {
curGameIndex.value = index >= 0 && index < gameTrees.value.length ? index : 0
curTreePositionNodeID.value = rootNode.value?.id || ''
currents.value = {}
history.value = [
{
gameTrees: gameTrees.value,
gameIndex: curGameIndex.value,
treePosition: curTreePositionNodeID.value,
timestamp: Date.now(),
},
]
historyCursor.value = 0
clearBoardCache()
})
}
export const setCurrentTreePosition = (store: Store, treePosition, newTree?, { clearCache = false } = {}) => {
if (clearCache) {
clearBoardCache()
}
const { curGame, currents, curTreePositionNodeID, gameTrees, curGameIndex } = store
let tree = curGame.value
if (newTree) {
tree = newTree
gameTrees.value = gameTrees.value.map((t, i) => (i === curGameIndex.value ? newTree : t))
}
let n = tree.get(treePosition)
if (!tree.onCurrentLine(n.id, currents.value)) {
currents.value = {}
}
while (n.parentId != null) {
currents.value = {
...(currents.value || {}),
[n.parentId]: n.id,
}
n = tree.get(n.parentId)
}
const prevTreePosition = curTreePositionNodeID.value
curTreePositionNodeID.value = treePosition
recordHistory(store, { prevGameIndex: curGameIndex.value, prevTreePosition })
}
export const goStep = (store: Store, step: number) => {
let { curGame, curTreePositionNodeID, currents } = store
let node = curGame.value?.navigate(curTreePositionNodeID.value, step, currents.value)
if (node) {
setCurrentTreePosition(store, node.id)
}
}
let autoscrollId: NodeJS.Timeout | null = null
export const startAutoscrolling = (store: Store, step: number) => {
if (autoscrollId != null) {
return
}
let first = true
let maxDelay = 200
let minDelay = 50
let diff = 10
let scroll = (delay = null) => {
goStep(store, step)
clearTimeout(autoscrollId)
autoscrollId = setTimeout(() => {
scroll(first ? maxDelay : Math.max(minDelay, delay - diff))
first = false
}, delay)
}
scroll(320)
}
export const stopAutoscrolling = () => {
clearTimeout(autoscrollId)
autoscrollId = null
}
export const goToEnd = (store: Store) => {
const { curGame, currents } = store
const [node] = [...curGame.value?.listCurrentNodes(currents.value)].slice(-1)
setCurrentTreePosition(store, node.id)
}
export const setPlayer = (store: Store, treePosition, sign) => {
const { curGame } = store
const newTree = curGame.value.mutate((draft) => {
const node = draft.get(treePosition)
const intendedSign =
node.data.B != null || (node.data.HA != null && +node.data.HA[0] >= 1)
? -1
: +(node.data.W != null)
if (intendedSign === sign || sign === 0) {
draft.removeProperty(treePosition, 'PL')
} else {
draft.updateProperty(treePosition, 'PL', [sign > 0 ? 'B' : 'W'])
}
})
setCurrentTreePosition(store, treePosition, newTree)
}
export const setMode = (store: Store, m) => {
const { mode } = store
if (mode.value === m) {
return
}
mode.value = m
}
export const setGameInfo = (store: Store, data) => {
const { curGame, curTreePositionNodeID } = store
const newTree = curGame.value.mutate((draft) => {
if ('size' in data) {
if (data.size) {
let value = data.size
value = value.map((x) => (isNaN(x) || !x ? 19 : Math.min(25, Math.max(2, x))))
if (value[0] === value[1]) {
value = value[0].toString()
} else {
value = value.join(':')
}
draft.updateProperty(draft.root.id, 'SZ', [value])
} else {
draft.removeProperty(draft.root.id, 'SZ')
}
}
})
const tree = newTree.mutate((draft) => {
const props = {
blackName: 'PB',
blackRank: 'BR',
whiteName: 'PW',
whiteRank: 'WR',
gameName: 'GN',
eventName: 'EV',
gameComment: 'GC',
date: 'DT',
result: 'RE',
komi: 'KM',
handicap: 'HA',
}
for (let key in props) {
if (!(key in data)) continue
let value = data[key]
if (value && value.toString() !== '') {
if (key === 'komi') {
if (isNaN(value)) value = 0
} else if (key === 'handicap') {
let board = getBoard(newTree, newTree.root.id)
let stones = board.getHandicapPlacement(+value)
value = stones.length
if (value <= 1) {
draft.removeProperty(draft.root.id, props[key])
draft.removeProperty(draft.root.id, 'AB')
continue
}
draft.updateProperty(draft.root.id, 'AB', stones.map(stringifyVertex))
}
draft.updateProperty(draft.root.id, props[key], [value.toString()])
} else {
draft.removeProperty(draft.root.id, props[key])
}
}
})
setCurrentTreePosition(store, curTreePositionNodeID.value, tree)
}
export const setTool = (store: Store, tool) => {
const { curTool } = store
if (curTool.value === tool) {
return
}
curTool.value = tool
}
export const toggleCurStoneTool = (store: Store) => {
const { curStoneTool } = store
curStoneTool.value = -curStoneTool.value
}
export const setCommentMode = (store: Store, m) => {
const { commentMode } = store
if (commentMode.value === m) {
return
}
commentMode.value = m
}
export const setComment = (store: Store, treePosition, data) => {
const { curGame } = store
const newTree = curGame.value.mutate((draft) => {
for (let [key, prop] of [
['title', 'N'],
['comment', 'C'],
]) {
if (key in data) {
if (data[key] && data[key] !== '') {
draft.updateProperty(treePosition, prop, [data[key]])
} else {
draft.removeProperty(treePosition, prop)
}
}
}
if ('hotspot' in data) {
if (data.hotspot) {
draft.updateProperty(treePosition, 'HO', ['1'])
} else {
draft.removeProperty(treePosition, 'HO')
}
}
let clearProperties = (properties) =>
properties.forEach((p) => draft.removeProperty(treePosition, p))
if ('moveAnnotation' in data) {
let moveProps = { BM: '1', DO: '', IT: '', TE: '1' }
clearProperties(Object.keys(moveProps))
if (data.moveAnnotation != null) {
draft.updateProperty(treePosition, data.moveAnnotation, [moveProps[data.moveAnnotation]])
}
}
if ('positionAnnotation' in data) {
let positionProps = { UC: '1', GW: '1', GB: '1', DM: '1' }
clearProperties(Object.keys(positionProps))
if (data.positionAnnotation != null) {
draft.updateProperty(treePosition, data.positionAnnotation, [
positionProps[data.positionAnnotation],
])
}
}
})
setCurrentTreePosition(store, treePosition, newTree)
}
export const setSelectedVertices = (store: Store, vs) => {
const { selectedVertices } = store
selectedVertices.value = vs
}
export const makeMainVariation = (store: Store, treePosition) => {
const { currents, curGame } = store
const newTree = curGame.value.mutate((draft) => {
let id = treePosition
while (id != null) {
draft.shiftNode(id, 'main')
id = draft.get(id).parentId
}
})
currents.value = {}
setCurrentTreePosition(store, treePosition, newTree)
}
export const shiftVariation = (store: Store, treePosition, sign) => {
const { curGame } = store
let shiftNode = null
for (let node of curGame.value.listNodesVertically(treePosition, -1, {})) {
let parent = curGame.value.get(node.parentId)
if (parent.children.length >= 2) {
shiftNode = node
break
}
}
if (shiftNode == null) {
return
}
const newTree = curGame.value.mutate((draft) => {
draft.shiftNode(shiftNode.id, sign >= 0 ? 'right' : 'left')
})
setCurrentTreePosition(store, treePosition, newTree)
}
export const removeNode = (store: Store, node) => {
const { curGame, currents } = store
const noParent = node.parentId == null
const newTree = curGame.value.mutate((draft) => {
if (!noParent) {
draft.removeNode(node.id)
} else {
for (let child of node.children) {
draft.removeNode(child.id)
}
for (let prop of ['AB', 'AW', 'AE', 'B', 'W']) {
draft.removeProperty(node.id, prop)
}
}
})
if (!noParent) {
if (currents.value?.[node.parentId] === node.id) {
const crs = { ...(currents.value || {}) }
delete crs?.[node.parentId]
currents.value = crs
}
} else {
const crs = { ...(currents.value || {}) }
delete crs?.[node.id]
currents.value = crs
}
setCurrentTreePosition(store, noParent ? node.id : node.parentId, newTree)
}
export const CommentProperties = ['C', 'N', 'UC', 'GW', 'DM', 'GB', 'BM', 'TE', 'DO', 'IT']
export const goToComment = (store: Store, step) => {
const { curGame, curTreePositionNodeID, currents } = store
let newTreePosition = null
for (let node of curGame.value.listNodesVertically(
curTreePositionNodeID.value,
step,
currents.value
)) {
if (
node.id !== curTreePositionNodeID.value &&
CommentProperties.some((prop) => node.data[prop] != null)
) {
newTreePosition = node.id
break
}
}
if (newTreePosition != null) {
setCurrentTreePosition(store, newTreePosition)
}
}
export const goToFocus = (store: Store, step) => {
const { curGame, curTreePositionNodeID, currents } = store
let newTreePosition = null
for (let node of curGame.value.listNodesVertically(
curTreePositionNodeID.value,
step,
currents.value
)) {
if (node.id !== curTreePositionNodeID.value && node.data.HO !== null) {
newTreePosition = node.id
break
}
}
if (newTreePosition != null) {
setCurrentTreePosition(store, newTreePosition)
}
}
export const goToBE = (store: Store, sign) => {
const { curGame, rootNode, currents } = store
if (sign < 0) {
setCurrentTreePosition(store, rootNode.value.id)
return
}
const [node] = [...curGame.value.listCurrentNodes(currents.value)].slice(-1)
setCurrentTreePosition(store, node.id)
}
export const goToNextBranch = (store: Store) => {
const { curGame, curTreePositionNodeID, currents } = store
const next = curGame.value.navigate(curTreePositionNodeID.value, 1, currents.value)
if (next == null) {
return
}
const sequence = [...curGame.value.getSequence(next.id)]
setCurrentTreePosition(store, sequence.slice(-1)[0].id)
}
export const goToPrevBranch = (store: Store) => {
const { curGame, currents, curNode, rootNode } = store
const prevNode = curGame.value.get(curNode.value.parentId)
if (prevNode === null) {
return
}
let newTreePosition = rootNode.value.id
for (let node of curGame.value.listNodesVertically(prevNode.id, -1, currents.value)) {
if (node.children.length > 1) {
newTreePosition = node.id
break
}
}
setCurrentTreePosition(store, newTreePosition)
}
export const makeMove = (store: Store, vertex, { player = null } = {}) => {
const { mode } = store
if (mode.value !== 'play') {
mode.value = 'play'
}
const { curGame, curTreePositionNodeID, curPlayer } = store
const board = getBoard(curGame.value, curTreePositionNodeID.value)
if (!player) {
player = curPlayer.value
}
if (typeof vertex == 'string') {
vertex = board.parseVertex(vertex)
}
const { pass, overwrite } = board.analyzeMove(player, vertex)
if (!pass && overwrite) {
return
}
const color = player > 0 ? 'B' : 'W'
// Update data
let nextTreePosition
const newTree = curGame.value.mutate((draft) => {
nextTreePosition = draft.appendNode(curTreePositionNodeID.value, {
[color]: [stringifyVertex(vertex)],
})
})
setCurrentTreePosition(store, nextTreePosition, newTree)
}
export const makeResign = (store: Store, { player = null } = {}) => {
const { curGame, curPlayer, curTreePositionNodeID } = store
if (player == null) {
player = curPlayer.value
}
const color = player > 0 ? 'W' : 'B'
let nextTreePosition
const newTree = curGame.value.mutate((draft) => {
draft.updateProperty(draft.root.id, 'RE', [`${color}+Resign`])
nextTreePosition = draft.appendNode(curTreePositionNodeID.value, {
N: [`${color}+Resign`],
})
})
makeMainVariation(store, curTreePositionNodeID.value)
setCurrentTreePosition(store, nextTreePosition, newTree)
}
export const clickVertex = async (store: Store, vertex, button, ctrlKey, x, y) => {
const { curGame, board, curNode, curTreePositionNodeID, mode, curTool, curStoneTool, id } = store
if (typeof vertex == 'string') {
vertex = board.value.parseVertex(vertex)
}
const [vx, vy] = vertex
if (mode.value === 'play') {
if (button === 0) {
if (board.value.get(vertex) === 0) {
makeMove(store, vertex)
} else if (
board.value.markers[vy][vx] != null &&
board.value.markers[vy][vx].type === 'point'
) {
removeNode(store, curNode.value)
}
}
} else if (mode.value === 'edit') {
if (ctrlKey) {
// Add coordinates to comment
let coord = board.value.stringifyVertex(vertex)
let commentText = curNode.value.data.C ? curNode.value.data.C[0] : ''
let newTree = curGame.value.mutate((draft) => {
draft.updateProperty(
curTreePositionNodeID.value,
'C',
commentText !== '' ? [commentText.trim() + ' ' + coord] : [coord]
)
})
setCurrentTreePosition(store, curTreePositionNodeID.value, newTree)
return
}
if (button === 2) {
// Right mouse click
if (curTool.value === 'stone') {
// Switch stone tool
curStoneTool.value = -curStoneTool.value
} else if (['number', 'label'].includes(curTool.value)) {
// Show label editing context menu
const res = await modals.InputPopup({
props: {
title: t('EDIT_LABEL'),
initValue: board.value?.markers?.[vy]?.[vx]?.label || '',
},
id: id.value,
})
if (res) {
useTool(store, 'label', vertex, res)
}
return
}
}
useTool(store, curTool.value, vertex)
}
}
export const useTool = (store: Store, tool, vertex, argument = null) => {
const { curGame, curStoneTool, curPlayer, curTreePositionNodeID, curNode } = store
let bd = getBoard(curGame.value, curTreePositionNodeID.value)
let node = curNode.value
if (typeof vertex == 'string') {
vertex = bd.parseVertex(vertex)
}
const data = {
cross: 'MA',
triangle: 'TR',
circle: 'CR',
square: 'SQ',
number: 'LB',
label: 'LB',
}
const newTree = curGame.value.mutate((draft) => {
if (tool === 'stone') {
if (node.data.B != null || node.data.W != null || node.children.length > 0) {
const id = draft.appendNode(curTreePositionNodeID.value, {
PL: curPlayer.value > 0 ? ['B'] : ['W'],
})
node = draft.get(id)
}
const sign = curStoneTool.value
const oldSign = bd.get(vertex)
const properties = ['AW', 'AE', 'AB']
const point = stringifyVertex(vertex)
for (let prop of properties) {
if (node.data[prop] == null) {
continue
}
// Resolve compressed lists
if (node.data[prop].some((x) => x.includes(':'))) {
draft.updateProperty(
node.id,
prop,
node.data[prop]
.map((value) => parseCompressedVertices(value).map(stringifyVertex))
.reduce((list, x) => [...list, x])
)
}
// Remove residue
draft.removeFromProperty(node.id, prop, point)
}
const prop = oldSign !== sign ? properties[sign + 1] : 'AE'
draft.addToProperty(node.id, prop, point)
} else {
// Mutate board first, then apply changes to actual game tree
const [x, y] = vertex
if (tool === 'number') {
if (bd.markers[y][x] != null && bd.markers[y][x].type === 'label') {
bd.markers[y][x] = null
} else {
let number =
node.data.LB == null
? 1
: node.data.LB.map((x) => parseFloat(x.slice(3)))
.filter((x) => !isNaN(x))
.sort((a, b) => a - b)
.filter((x, i, arr) => i === 0 || x !== arr[i - 1])
.concat([null])
.findIndex((x, i) => i + 1 !== x) + 1
argument = number.toString()
bd.markers[y][x] = { type: tool, label: number.toString() }
}
} else if (tool === 'label') {
let label = argument
if (
(label != null && label.trim() === '') ||
(label == null && bd.markers[y][x] != null && bd.markers[y][x].type === 'label')
) {
bd.markers[y][x] = null
} else {
if (label == null) {
let alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
let letterIndex = Math.max(
node.data.LB == null
? 0
: node.data.LB.filter((x) => x.length === 4)
.map((x) => alpha.indexOf(x[3]))
.filter((x) => x >= 0)
.sort((a, b) => a - b)
.filter((x, i, arr) => i === 0 || x !== arr[i - 1])
.concat([null])
.findIndex((x, i) => i !== x),
node.data.L == null ? 0 : node.data.L.length
)
label = alpha[Math.min(letterIndex, alpha.length - 1)]
argument = label
}
bd.markers[y][x] = { type: 'label', label }
}
} else {
if (bd.markers[y][x] != null && bd.markers[y][x].type === tool) {
bd.markers[y][x] = null
} else {
bd.markers[y][x] = { type: tool }
}
}
draft.removeProperty(node.id, 'L')
for (let id in data) {
draft.removeProperty(node.id, data[id])
}
// Now apply changes to game tree
for (let x = 0; x < bd.width; x++) {
for (let y = 0; y < bd.height; y++) {
let v = [x, y]
if (bd.markers[y][x] == null) {
continue
}
let prop = data[bd.markers[y][x].type]
let value = stringifyVertex(v)
if (prop === 'LB') {
value += ':' + bd.markers[y][x].label
}
draft.addToProperty(node.id, prop, value)
}
}
}
})
setCurrentTreePosition(store, node.id, newTree)
}
export const recordHistory = (store: Store, { prevGameIndex = undefined, prevTreePosition = undefined } = {}) => {
const { history, historyCursor, curTreePositionNodeID, gameTrees, curGameIndex } = store
const currentEntry = history.value[historyCursor.value]
const newEntry = {
gameTrees: gameTrees.value,
gameIndex: curGameIndex.value,
treePosition: curTreePositionNodeID.value,
timestamp: Date.now(),
}
if (currentEntry && shallowEquals(currentEntry.gameTrees, newEntry.gameTrees)) {
return
}
history.value = history.value.slice(-666, historyCursor.value + 1)
if (currentEntry && newEntry.timestamp - currentEntry.timestamp < 600) {
const hs = [...history.value]
hs[historyCursor.value] = newEntry
history.value = hs
} else {
if (
currentEntry &&
prevGameIndex >= 0 &&
prevTreePosition
) {
currentEntry.gameIndex = prevGameIndex
currentEntry.treePosition = prevTreePosition
}
history.value = [...history.value, newEntry]
historyCursor.value = history.value.length - 1
}
}
export const clearHistory = (store: Store) => {
const { history, historyCursor, gameTrees, curGameIndex, curTreePositionNodeID } = store
history.value = [
{
gameTrees: gameTrees.value,
gameIndex: curGameIndex.value,
treePosition: curTreePositionNodeID.value,
timestamp: Date.now(),
},
]
historyCursor.value = 0
}
export const checkoutHistory = (store: Store, hc) => {
const { history, historyCursor, currents, curGameIndex, gameTrees } = store
let entry = history.value[hc]
if (!entry) {
return
}
const gameTree = entry.gameTrees[entry.gameIndex]
historyCursor.value = hc
curGameIndex.value = entry.gameIndex
gameTrees.value = entry.gameTrees
currents.value = {}
setCurrentTreePosition(store, entry.treePosition, gameTree, { clearCache: true })
}
export const undo = (store: Store) => {
const { historyCursor } = store
checkoutHistory(store, historyCursor.value - 1)
}
export const redo = (store: Store) => {
const { historyCursor } = store
checkoutHistory(store, historyCursor.value + 1)
}
export const getSGF = (store: Store) => {
const { gameTrees } = store
let gts = gameTrees.value
gts = gts.map(tree =>
tree.mutate(draft => {
draft.updateProperty(draft.root.id, 'AP', [
`Obsidian Goban SGF Plugin:${process.env.PLUGIN_VERSION}`
])
draft.updateProperty(draft.root.id, 'CA', ['UTF-8'])
})
)
gameTrees.value = gts
recordHistory(store)
return stringify(
gts.map(tree => tree.root),
{
linebreak: ''
}
)
}
export const setFrontmatterSetting = (store: Store, fm: GobanSGFPluginFrontmatterSettings) => {
const { frontmatterData } = store
const newFM = { ...frontmatterData.value }
for (let k in fm) {
if (fm[k] === undefined && newFM.hasOwnProperty(k)) {
delete newFM[k]
} else {
newFM[k] = fm[k]
}
}
frontmatterData.value = newFM
}

460
src/store/common.ts Normal file
View file

@ -0,0 +1,460 @@
import { fromDimensions } from '@sabaki/go-board'
import { parseVertex, parseCompressedVertices, stringifyVertex } from '@sabaki/sgf'
import { t } from '@/lang/helper'
import { typographer } from '@/utils/utils'
import boardmatcher from '@sabaki/boardmatcher'
let boardCache: { [id: string]: any } = {}
export interface HistoryItem {
gameTrees: any[]
gameIndex: number
treePosition: string
timestamp: number
}
const alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
export const clearBoardCache = () => {
boardCache = {}
}
export const getBoard = (tree: any, id: string) => {
let treePositions = []
let board = null
for (let node of tree.listNodesVertically(id, -1, {})) {
if (boardCache[node.id] != null && node.id !== id) {
board = boardCache[node.id]
break
}
treePositions.unshift(node.id)
}
if (!board) {
let size = [19, 19]
if (tree.root.data.SZ != null) {
let value = tree.root.data.SZ[0]
if (value.includes(':')) size = value.split(':')
else size = [value, value]
size = size.map((x) => (isNaN(x) ? 19 : +x))
}
board = fromDimensions(...size)
}
let inner = (tree: any, id: any, baseboard: any) => {
let node = tree.get(id)
let parent = tree.get(node.parentId)
if (node == null) return null
let vertex = null
let board = null
// Make move
let propData: { [k: string]: any } = { B: 1, W: -1 }
for (let prop in propData) {
if (node.data[prop] == null) continue
vertex = parseVertex(node.data[prop][0])
board = baseboard.makeMove(propData[prop], vertex)
board.currentVertex = vertex
break
}
if (!board) board = baseboard.clone()
// Add markup
propData = { AW: -1, AE: 0, AB: 1 }
for (let prop in propData) {
if (node.data[prop] == null) continue
for (let value of node.data[prop]) {
for (let vertex of parseCompressedVertices(value)) {
if (!board.has(vertex)) continue
board.set(vertex, propData[prop])
}
}
}
Object.assign(board, {
markers: board.signMap.map((row: any) => row.map((_: any) => null)),
lines: [],
childrenInfo: [],
siblingsInfo: [],
})
if (vertex != null && board.has(vertex)) {
let [x, y] = vertex
board.markers[y][x] = { type: 'point' }
}
propData = { CR: 'circle', MA: 'cross', SQ: 'square', TR: 'triangle' }
for (let prop in propData) {
if (node.data[prop] == null) continue
for (let value of node.data[prop]) {
for (let [x, y] of parseCompressedVertices(value)) {
if (board.markers[y] == null) continue
board.markers[y][x] = { type: propData[prop] }
}
}
}
if (node.data.LB != null) {
for (let composed of node.data.LB) {
let sep = composed.indexOf(':')
let point = composed.slice(0, sep)
let label = composed.slice(sep + 1)
let [x, y] = parseVertex(point)
if (board.markers[y] == null) continue
board.markers[y][x] = { type: 'label', label }
}
}
if (node.data.L != null) {
for (let i = 0; i < node.data.L.length; i++) {
let point = node.data.L[i]
let label = alpha[i]
if (label == null) return
let [x, y] = parseVertex(point)
if (board.markers[y] == null) continue
board.markers[y][x] = { type: 'label', label }
}
}
for (let type of ['AR', 'LN']) {
if (node.data[type] == null) continue
for (let composed of node.data[type]) {
let sep = composed.indexOf(':')
let [v1, v2] = [composed.slice(0, sep), composed.slice(sep + 1)].map(parseVertex)
board.lines.push({ v1, v2, type: type === 'AR' ? 'arrow' : 'line' })
}
}
// Add variation overlays
let addInfo = (node: any, list: any) => {
let v: any, sign: any
if (node.data.B != null) {
v = parseVertex(node.data.B[0])
sign = 1
} else if (node.data.W != null) {
v = parseVertex(node.data.W[0])
sign = -1
} else {
return
}
if (!board.has(v)) return
let type = null
if (node.data.BM != null) {
type = 'bad'
} else if (node.data.DO != null) {
type = 'doubtful'
} else if (node.data.IT != null) {
type = 'interesting'
} else if (node.data.TE != null) {
type = 'good'
}
list[v] = { sign, type }
}
for (let child of node.children) {
addInfo(child, board.childrenInfo)
}
if (parent != null) {
for (let sibling of parent.children) {
addInfo(sibling, board.siblingsInfo)
}
}
boardCache[id] = board
return board
}
for (let id of treePositions) {
board = inner(tree, id, board)
}
return board
}
export const getRootProperty = (tree, property, fallback = null) => {
let result = ''
if (property in tree.root.data) result = tree.root.data[property][0]
return result === '' ? fallback : result
}
export const getGameInfo = (tree) => {
let komi = getRootProperty(tree, 'KM')
if (komi != null && !isNaN(komi)) komi = +komi
else komi = null
let size = getRootProperty(tree, 'SZ')
if (size == null) {
size = [19, 19]
} else {
let s = size.toString().split(':')
size = [+s[0], +s[s.length - 1]]
}
let handicap = getRootProperty(tree, 'HA', 0)
handicap = Math.max(1, Math.min(9, Math.round(handicap)))
if (handicap === 1) handicap = 0
let playerNames = ['B', 'W'].map(
(x) => getRootProperty(tree, `P${x}`) || getRootProperty(tree, `${x}T`)
)
let playerRanks = ['BR', 'WR'].map((x) => getRootProperty(tree, x))
return {
playerNames,
playerRanks,
blackName: playerNames[0],
blackRank: playerRanks[0],
whiteName: playerNames[1],
whiteRank: playerRanks[1],
gameName: getRootProperty(tree, 'GN'),
eventName: getRootProperty(tree, 'EV'),
gameComment: getRootProperty(tree, 'GC'),
date: getRootProperty(tree, 'DT'),
result: getRootProperty(tree, 'RE'),
komi,
handicap,
size,
}
}
export const getPlayer = (tree, treePosition) => {
const { data } = tree.get(treePosition)
return data.PL != null
? data.PL[0] === 'W'
? -1
: 1
: data.B != null || (data.HA != null && +data.HA[0] >= 1)
? -1
: 1
}
export const positionAnnotationOptions = [
{
label: t('AN_GOOD_FOR_BLACK'),
value: 'GB',
},
{
label: t('AN_UNCLEAR_POSITION'),
value: 'UC',
},
{
label: t('AN_EVEN_POSITION'),
value: 'DM',
},
{
label: t('AN_GOOD_FOR_WHITE'),
value: 'GW',
},
]
export const moveAnnotationOptions = [
{
label: t('AN_GOOD_MOVE'),
value: 'TE',
},
{
label: t('AN_INTERESTING_MOVE'),
value: 'IT',
},
{
label: t('AN_DOUBTFUL_MOVE'),
value: 'DO',
},
{
label: t('AN_BAD_MOVE'),
value: 'BM',
},
]
export const hotspotOption = {
label: t('AN_HOTSPOT'),
value: 'HO',
}
export const getAnnos = (node) => {
if (!node) {
return {
moveAnnotation: [null, 1],
positionAnnotation: [null, 1],
isHotspot: false,
}
}
return {
moveAnnotation:
node.data.BM != null
? ['BM', node.data.BM[0]]
: node.data.DO != null
? ['DO', 1]
: node.data.IT != null
? ['IT', 1]
: node.data.TE != null
? ['TE', node.data.TE[0]]
: [null, 1],
positionAnnotation:
node.data.UC != null
? ['UC', node.data.UC[0]]
: node.data.GW != null
? ['GW', node.data.GW[0]]
: node.data.DM != null
? ['DM', node.data.DM[0]]
: node.data.GB != null
? ['GB', node.data.GB[0]]
: [null, 1],
isHotspot: node.data.HO != null,
}
}
export const getMoveInterpretation = (node, gameTree, treePosition) => {
// Determine root node
if (node.parentId == null) {
let result: any = []
if (node.data.EV != null) {
result.push(node.data.EV[0])
}
if (node.data.GN != null) {
result.push(node.data.GN[0])
}
result = result.filter((x) => x.trim() !== '').join(' — ')
if (result !== '') {
return typographer(result)
}
return ''
}
// Determine end of main variation and show game result
if (node.children.length === 0 && gameTree.onMainLine(treePosition)) {
let result = getRootProperty(gameTree, 'RE', '')
if (result.trim() !== '') {
return t('GAME_RESULT') + ': ' + result.trim()
}
}
// Get current vertex
let vertex, sign
if (node.data.B != null) {
sign = 1
vertex = parseVertex(node.data.B[0])
} else if (node.data.W != null) {
sign = -1
vertex = parseVertex(node.data.W[0])
} else {
return ''
}
let prevBoard = getBoard(gameTree, node.parentId)
let patternMatch = boardmatcher.findPatternInMove(prevBoard.signMap, sign, vertex)
if (patternMatch == null) {
let diff = vertex
.map((z, i) => Math.min(z + 1, [prevBoard.width, prevBoard.height][i] - z))
.sort((a, b) => a - b)
if (diff[0] > 6) {
return ''
}
return t(`BM_${diff[0]}-${diff[1]}_POINT` as any)
}
const board = getBoard(gameTree, treePosition)
const matchedVertices = [...patternMatch.match.anchors, ...patternMatch.match.vertices].filter(
(v) => board.get(v) !== 0
)
return {
patternName: t(getPatternNameI18NKey(patternMatch.pattern.name)),
url: patternMatch.pattern.url,
matchedVertices,
}
}
export const getPatternNameI18NKey = (name: string): any => {
let n = name
n = n.replace(/\s/g, '_').toUpperCase()
return `BM_${n}` as any
}
export const getMatrixDict= (tree) => {
let matrix = [...Array(tree.getHeight() + 1)].map(_ => [])
let dict = {}
let inner = (node, matrix, dict, xshift, yshift) => {
let sequence = [...tree.getSequence(node.id)]
let hasCollisions = true
while (hasCollisions) {
hasCollisions = false
for (let y = 0; y <= sequence.length; y++) {
if (xshift >= matrix[yshift + y].length - (Number(y === sequence.length)))
continue
hasCollisions = true
xshift++
break
}
}
for (let y = 0; y < sequence.length; y++) {
matrix[yshift + y][xshift] = sequence[y].id
dict[sequence[y].id] = [xshift, yshift + y]
}
let lastSequenceNode = sequence.slice(-1)[0]
for (let k = 0; k < lastSequenceNode.children.length; k++) {
let child = lastSequenceNode.children[k]
inner(child, matrix, dict, xshift + k, yshift + sequence.length)
}
return [matrix, dict]
}
return inner(tree.root, matrix, dict, 0, 0)
}
export const getMatrixWidth = (y, matrix) => {
let keys = [...Array(10)]
.map((_, i) => i + y - 4)
.filter(i => i >= 0 && i < matrix.length)
let padding = Math.min(
...keys.map(i => {
for (let j = 0; j < matrix[i].length; j++)
if (matrix[i][j] != null) return j
return 0
})
)
let width = Math.max(...keys.map(i => matrix[i].length)) - padding
return [width, padding]
}

239
src/store/store.ts Normal file
View file

@ -0,0 +1,239 @@
import { App } from 'obsidian'
import { signal, computed, effect } from '@preact/signals'
import { sgf2Games } from '@/utils/goban'
import sgf from '@sabaki/sgf'
import { createContext } from 'preact'
import {
getBoard,
getGameInfo,
getPlayer,
getAnnos,
getMoveInterpretation,
getMatrixDict,
HistoryItem,
} from './common'
import { GobanSGFPluginFrontmatterSettings, GobanSGFPluginSettings, mergeSettings } from '../settings'
import type GobanSGFView from '@/GobanSGFView'
import type GobanSGFPlugin from '@/main'
interface ObObjects {
obViewID?: number | string
obView?: GobanSGFView
obPlugin?: GobanSGFPlugin
obApp?: App
}
export const createStore = (
data: string,
fmData: GobanSGFPluginFrontmatterSettings,
pSettings: GobanSGFPluginSettings,
{ obViewID, obView, obPlugin, obApp: oa }: ObObjects,
{
onFrontmatterChange,
}: {
onFrontmatterChange?: (fm: GobanSGFPluginFrontmatterSettings) => void
}
) => {
const games = sgf2Games(data)
const id = computed(() => obViewID || '-1')
const view = computed(() => obView)
const plugin = computed(() => obPlugin)
const obApp = computed(() => oa)
const frontmatterData = signal(fmData)
const pluginSettings = signal(pSettings)
const settings = computed(() => mergeSettings(frontmatterData.value, pluginSettings.value))
effect(() => {
onFrontmatterChange && onFrontmatterChange(frontmatterData.value)
})
const gameTrees = signal(games)
const curGameIndex = signal(0)
const curGame = computed(
() => gameTrees?.value?.[curGameIndex.value] || gameTrees?.value?.[0] || null
)
const rootNode = computed(() => curGame?.value?.root || null)
// main path in the current gameTree, key for parent node id, value for node id
const currents = signal<{
[id: string]: string | number
}>({})
const defaultCurTreePositionNodeID = (() => {
if (settings.value.gotoEndAtBeginning) {
const [node] = [...curGame.value?.listCurrentNodes(currents.value)]?.slice(-1)
return node.id || rootNode.value?.id || ''
}
return rootNode.value?.id || ''
})()
const curTreePositionNodeID = signal(defaultCurTreePositionNodeID)
const curNode = computed(() => curGame.value?.get(curTreePositionNodeID.value) || null)
const curStep = computed(() => curGame.value?.getLevel(curTreePositionNodeID.value) || 0)
const totalSteps = computed(() => {
const r = Math.max((curGame.value?.getCurrentHeight(currents.value) || 0) - 1, 0)
return r
})
const curNodeCommentTitle = computed(() =>
curNode.value?.data.N ? curNode.value?.data.N[0] : ''
)
const curNodeCommentContent = computed(() =>
curNode.value?.data.C ? curNode.value?.data.C[0] : ''
)
const board = computed(() =>
getBoard(curGame?.value, curTreePositionNodeID.value || rootNode?.value?.id || '')
)
const gobanRange = computed(() => settings.value.gobanRange)
const signMap = computed(() => board.value?.signMap)
const showMoveNumbers = computed(() => settings.value.showMoveNumbers)
const showLastMoveNumbers = computed(() => settings.value.showLastMoves)
const markerMap = computed(() => {
let markerMap = board.value?.markers || []
if (showMoveNumbers.value) {
markerMap = markerMap.map((row) => row.map((_) => null))
const history = [
...curGame.value.listNodesVertically(curTreePositionNodeID.value, -1, {}),
].reverse()
for (let i = 0; i < history.length; i++) {
const node = history[i]
let vertex = [-1, -1]
if (node.data.B != null) {
vertex = sgf.parseVertex(node.data.B[0])
} else if (node.data.W != null) {
vertex = sgf.parseVertex(node.data.W[0])
}
const [x, y] = vertex
if (markerMap[y] != null && x < markerMap[y].length) {
markerMap[y][x] = { type: 'label', label: i.toString() }
}
}
} else if (showLastMoveNumbers.value > 0) {
markerMap = markerMap.map((row) => row.map((it) => it))
const history = [
...curGame.value.listNodesVertically(curTreePositionNodeID.value, -1, {}),
].reverse()
const start = history.length > showLastMoveNumbers.value ? history.length - showLastMoveNumbers.value : 0
for (let i = start; i < history.length; i++) {
const node = history[i]
let vertex = [-1, -1]
if (node.data.B != null) {
vertex = sgf.parseVertex(node.data.B[0])
} else if (node.data.W != null) {
vertex = sgf.parseVertex(node.data.W[0])
}
const [x, y] = vertex
if (markerMap[y] != null && x < markerMap[y].length) {
markerMap[y][x] = { type: 'label', label: i.toString() }
}
}
}
return markerMap
})
const showNextMoves = computed(() => settings.value.showNextMoves)
const showSiblings = computed(() => settings.value.showSiblings)
const ghostStoneMap = computed(() => {
let ghostStoneMap = []
if (showNextMoves.value || showSiblings.value) {
ghostStoneMap = board.value.signMap.map((row) => row.map((_) => null))
if (showSiblings.value) {
for (let v in board.value.siblingsInfo) {
let [x, y] = v.split(',').map((x) => +x)
let { sign } = board.value.siblingsInfo[v]
ghostStoneMap[y][x] = { sign, faint: showNextMoves }
}
}
if (showNextMoves.value) {
for (let v in board.value.childrenInfo) {
let [x, y] = v.split(',').map((x) => +x)
let { sign, type } = board.value.childrenInfo[v]
ghostStoneMap[y][x] = { sign, type }
}
}
}
return ghostStoneMap
})
const fuzzyStonePlacement = computed(() => settings.value.fuzzyStonePlacement)
const mode = signal<'play' | 'edit' | 'gameInfo'>(settings.value.initMode || 'play')
const gameInfo = computed(() => getGameInfo(curGame.value))
const curPlayer = computed(() => getPlayer(curGame.value, curTreePositionNodeID.value))
const playerCaptures = computed(() => [1, -1].map((sign) => board.value?.getCaptures(sign)))
const gameInfos = computed(() => {
const games = gameTrees.value || []
return games.map((game) => getGameInfo(game))
})
const curTool = signal<'stone' | 'cross' | 'triangle' | 'square' | 'circle' | 'label' | 'number'>(
'stone'
)
const curStoneTool = signal<number>(curPlayer.value || 1)
const commentMode = signal<'edit' | 'view'>(settings.value.initCommentMode || 'view')
const curAnnos = computed(() => getAnnos(curNode.value))
const moveInterpretation = computed(() =>
getMoveInterpretation(curNode.value, curGame.value, curTreePositionNodeID.value)
)
const selectedVertices = signal<any[]>([])
const matrixDict = computed(() => getMatrixDict(curGame.value))
const history = signal<HistoryItem[]>([
{
gameTrees: gameTrees.value,
gameIndex: curGameIndex.value,
treePosition: curTreePositionNodeID.value,
timestamp: Date.now(),
},
])
const historyCursor = signal(0)
const gameTreesHashs = computed(() => gameTrees.value.map((gt) => gt.getHash()).join(','))
return {
id,
view,
plugin,
obApp,
gameTrees,
frontmatterData,
curGameIndex,
curGame,
rootNode,
board,
curTreePositionNodeID,
curNode,
curNodeCommentTitle,
curNodeCommentContent,
currents,
signMap,
markerMap,
ghostStoneMap,
fuzzyStonePlacement,
mode,
gameInfo,
curPlayer,
playerCaptures,
gameInfos,
curTool,
curStoneTool,
commentMode,
curAnnos,
moveInterpretation,
selectedVertices,
matrixDict,
curStep,
totalSteps,
history,
historyCursor,
gameTreesHashs,
gobanRange,
settings,
}
}
export type Store = ReturnType<typeof createStore>
export const GobanContext = createContext<Store>(null)

122
src/style.css Normal file

File diff suppressed because one or more lines are too long

28
src/type.d.ts vendored Normal file
View file

@ -0,0 +1,28 @@
declare module '@sabaki/sgf' {
import sgf, {
parseVertex as pv,
parseCompressedVertices as pcv,
stringifyVertex as sv,
stringify as s,
} from '@sabaki/sgf'
export default sgf as any
export const parseVertex = pv as any
export const parseCompressedVertices = pcv as any
export const stringifyVertex = sv as any
export const stringify = s as any
}
declare module '@sabaki/immutable-gametree' {
import GameTree from '@sabaki/immutable-gametree'
export default GameTree as any
}
declare module '@sabaki/boardmatcher' {
import boardmatcher from '@sabaki/boardmatcher'
export default boardmatcher as any
}
declare module '@sabaki/go-board' {
import { fromDimensions as fd } from '@sabaki/go-board'
export const fromDimensions = fd as any
}

62
src/utils/filesUtils.ts Normal file
View file

@ -0,0 +1,62 @@
import {
loadPdfJs,
normalizePath,
Notice,
requestUrl,
RequestUrlResponse,
TAbstractFile,
TFile,
TFolder,
Vault,
} from 'obsidian'
import type GobanSGFPlugin from '../main'
import { GobanSGFPluginSettings } from '../settings'
export const checkAndCreateFolder = async (
plugin: GobanSGFPlugin,
folderpath: string
): Promise<TFolder> => {
const vault = plugin.app.vault
folderpath = normalizePath(folderpath)
const folder = vault.getAbstractFileByPath(folderpath)
if (folder && folder instanceof TFolder) {
return
}
if (folder && folder instanceof TFile) {
new Notice(`The folder cannot be created because it already exists as a file: ${folderpath}.`)
}
return await vault.createFolder(folderpath)
}
export const getNewUniqueFilepath = (
vault: Vault,
fullFilename: string,
folderpath: string
): string => {
let fname = normalizePath(`${folderpath}/${fullFilename}`)
let file: TAbstractFile = vault.getAbstractFileByPath(fname)
let i = 0
const extension = fullFilename.slice(fullFilename.lastIndexOf('.'))
while (file) {
fname = normalizePath(
`${folderpath}/${fullFilename.slice(0, fullFilename.lastIndexOf(extension))}_${i}${extension}`
)
i++
file = vault.getAbstractFileByPath(fname)
}
return fname
}
export const getGobanFilename = (settings: GobanSGFPluginSettings, baseName: string): string => {
let fullName = baseName.trim()
if (fullName.endsWith('.sgf')) {
fullName = fullName + '.md'
} else if (fullName.endsWith('.md') && !fullName.endsWith('.sgf.md')) {
fullName = fullName.slice(0, fullName.lastIndexOf('.md')) + '.sgf.md'
} else if (!fullName.endsWith('.sgf.md')) {
fullName += '.sgf.md'
}
return fullName
}

38
src/utils/goban.ts Normal file
View file

@ -0,0 +1,38 @@
import { moment } from 'obsidian'
import sgf from '@sabaki/sgf'
import GameTree from '@sabaki/immutable-gametree'
import { errorlog } from './utils'
export const getId = () => {
function S4() {
return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1)
}
return S4() + S4() + '-' + S4() + '-' + S4()
}
export const genMarkdownSGFSection = (sgfString: string) => {
return `\x60\x60\x60sgf\n${sgfString}\n\x60\x60\x60`
}
export const sgf2Games = (data: string) => {
let games: any[] = []
try {
const rootNodes = sgf.parse(data, { getId })
const gs = rootNodes.map((root: any) => new GameTree({ getId, root }))
games = gs
} catch (e) {
errorlog({
where: 'sgf2Games(goban.ts): sgf.parse',
error: e,
})
games = []
}
return games
}
export const genInitSGF = (size: number, handicap: number, komi: number) => {
return `(;GM[1]FF[4]CA[UTF-8]AP[Obsidian Goban SGF Plugin:${
process.env.PLUGIN_VERSION
}]KM[${komi}]SZ[${size}]HA[${handicap}]DT[${moment().format('YYYY-MM-DD')}])`
}

169
src/utils/utils.ts Normal file
View file

@ -0,0 +1,169 @@
import jstr from 'json-stable-stringify'
export const errorlog = (data: {}) => {
console.error({ plugin: 'Goban SGF', ...data })
}
export const isTextLikeElement = (element: any) => {
return (
['textarea', 'select'].includes(element.tagName.toLowerCase()) ||
(element.tagName.toLowerCase() === 'input' &&
!['submit', 'reset', 'button', 'checkbox', 'radio', 'color', 'file'].includes(element?.type)) || element.isContentEditable
)
}
export const noop = () => {}
export const typographer = (input) => {
return input
.replace(/\.{3}/g, '…')
.replace(/(\S)'/g, '$1')
.replace(/(\S)"/g, '$1”')
.replace(/'(\S)/g, '$1')
.replace(/"(\S)/g, '“$1')
.replace(/(\s)-(\s)/g, '$1$2')
}
export const shallowEquals = (a, b) => {
return a == null || b == null
? a === b
: a === b || (a.length === b.length && a.every((x, i) => x == b[i]))
}
export const shallowObjEquals = (a, b) => {
if (a === b) {
return true
}
if (Object.keys(a).length !== Object.keys(b).length) {
return false
}
for (const key in a) {
if (typeof a[key] !== typeof b[key]) {
return false
} else if (typeof a[key] === 'object') {
const aStr = jstr(a[key])
const bStr = jstr(b[key])
if (aStr !== bStr) {
return false
} else {
continue
}
}
if (a[key] !== b[key]) {
return false
}
}
return true
}
export const getAutoIncrID = (
(id) => () =>
id++
)(0)
export const convertBool = (v: any, convertUndefined = true): boolean | undefined => {
if (!convertUndefined && v === undefined) {
return undefined
}
const convertedFalse =
(typeof v === 'string' &&
(v.toLowerCase() === 'false' || v.toLowerCase() === 'null' || v.toLowerCase() === '0')) ||
(typeof v === 'number' && v === 0)
return convertedFalse ? false : Boolean(v)
}
export const convertKomi = (v: any): number | undefined => {
const numV = Number(v)
if (isNaN(numV)) {
return undefined
}
const n = Math.floor(numV * 10) / 10
return n > 361 ? 361 : n < -361 ? -361 : n
}
export const convertLastMoves = (v: any): number | undefined => {
const numV = Number(v)
if (isNaN(numV)) {
return undefined
}
const n = Math.floor(numV)
return n > 20 ? 20 : n < 0 ? 0 : n
}
export const convertHandicap = (v: any): number | undefined => {
const numV = Number(v)
if (isNaN(numV)) {
return undefined
}
const n = Math.floor(numV)
return n > 9 ? 9 : n < 2 ? 2 : n
}
export const convertSize = (v: any): number | undefined => {
const numV = Number(v)
if (isNaN(numV)) {
return undefined
}
const n = Math.floor(numV)
return n > 25 ? 25 : n < 2 ? 2 : n
}
export const convertGobanRange = (v: string, size: number): { x: [number, number]; y: [number, number] } | undefined => {
if (typeof v !== 'string') {
return undefined
}
if (v.trim() === '') {
return undefined
}
try {
let { x = [], y = [] } = JSON.parse(v)
x = x.map((xi, i) =>
isNaN(Number(xi))
? i === 0
? 0
: size - 1
: Number(xi) >= size
? size - 1
: Number(xi)
)
y = y.map((yi, i) =>
isNaN(Number(yi))
? i === 0
? 0
: size - 1
: Number(yi) >= size
? size - 1
: Number(yi)
)
return { x: x[0] > x[1] ? [x[1], x[0]] : x, y: y[0] > y[1] ? [y[1], y[0]] : y }
} catch (e) {
errorlog({
where: 'convertGobanRange',
error: e,
})
return undefined
}
}
export const newLinkClick = (
href: string,
options?: {
target?: string
download?: string
}
) => {
const link = document.createElement('a')
link.href = href
if (options?.download) {
link.download = options?.download
}
if (options?.target) {
link.target = options?.target
}
document.body.appendChild(link)
link.click()
link.remove()
}

1658
styles.css Normal file

File diff suppressed because one or more lines are too long

15
tailwind.config.js Normal file
View file

@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
export default {
important: ".goban-sgf-plugin-container",
darkMode: ['class', 'body.theme-dark'],
content: ["./src/**/*.{html,js,ts,tsx}"],
theme: {
extend: {
},
},
plugins: [],
corePlugins: {
preflight: false,
}
}

37
tsconfig.json Normal file
View file

@ -0,0 +1,37 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"allowJs": true,
"checkJs": false,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7",
"ES2021",
],
/* Preact Config */
"jsx": "react-jsx",
"jsxImportSource": "preact",
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"react": [
"./node_modules/preact/compat/"
],
"react-dom": [
"./node_modules/preact/compat/"
],
"@/*": [
"src/*"
]
}
},
"include": [
"node_modules/vite/client.d.ts",
"**/*"
]
}

14
version-bump.mjs Normal file
View file

@ -0,0 +1,14 @@
import { readFileSync, writeFileSync } from 'fs'
const targetVersion = process.env.npm_package_version
// read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync('manifest.json', 'utf8'))
const { minAppVersion } = manifest
manifest.version = targetVersion
writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t'))
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync('versions.json', 'utf8'))
versions[targetVersion] = minAppVersion
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'))

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"0.7.0": "0.7.0"
}

71
vite.config.ts Normal file
View file

@ -0,0 +1,71 @@
import { defineConfig } from 'vite'
import path from 'path'
import preact from '@preact/preset-vite'
import builtins from 'builtin-modules'
import manifest from './manifest.json'
const prod = process.argv[2] === 'production'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [preact()],
build: {
sourcemap: prod ? false : 'inline',
minify: prod,
commonjsOptions: {
ignoreTryCatch: false,
},
lib: {
entry: path.resolve(__dirname, './src/main.ts'),
formats: ['cjs'],
},
rollupOptions: {
output: {
entryFileNames: 'main.js',
assetFileNames: 'styles.css',
},
external: [
'obsidian',
'electron',
'codemirror',
'@codemirror/autocomplete',
'@codemirror/closebrackets',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/comment',
'@codemirror/fold',
'@codemirror/gutter',
'@codemirror/highlight',
'@codemirror/history',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/matchbrackets',
'@codemirror/panel',
'@codemirror/rangeset',
'@codemirror/rectangular-selection',
'@codemirror/search',
'@codemirror/state',
'@codemirror/stream-parser',
'@codemirror/text',
'@codemirror/tooltip',
'@codemirror/view',
'@lezer/common',
'@lezer/lr',
'@lezer/highlight',
...builtins,
],
},
emptyOutDir: false,
outDir: '.',
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
define: {
'process.env': {
PLUGIN_VERSION: manifest.version,
},
},
})