mirror of
https://github.com/stinsonzhao/obsidian-plugin-goban-sgf.git
synced 2026-07-22 05:02:23 +00:00
First Commitment
This commit is contained in:
commit
228a2f3a7c
67 changed files with 12560 additions and 0 deletions
34
.github/workflows/release.yml
vendored
Normal file
34
.github/workflows/release.yml
vendored
Normal 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
28
.gitignore
vendored
Normal 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
20
LICENSE.md
Normal 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
64
README.md
Normal 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).
|
||||
|
||||

|
||||
|
||||
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
63
README_CN.md
Normal 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(去掉了所有引擎、分析相关的功能)。
|
||||
|
||||

|
||||
|
||||
该插件的目的是为了帮助学习围棋的朋友,现在他们可以使用该插件在 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
BIN
assets/goban-sgf.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
9
manifest.json
Normal file
9
manifest.json
Normal 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
41
package.json
Normal 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
3242
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
}
|
||||
}
|
||||
56
src/GobanCodeBlock.ts
Normal file
56
src/GobanCodeBlock.ts
Normal 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
156
src/GobanController.ts
Normal 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
117
src/GobanSGFSettingsTab.ts
Normal 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
165
src/GobanSGFView.ts
Normal 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
146
src/components/App.tsx
Normal 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
|
||||
33
src/components/AppOnlyGobanReadonly.tsx
Normal file
33
src/components/AppOnlyGobanReadonly.tsx
Normal 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
387
src/components/Comment.tsx
Normal 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
149
src/components/EditBar.tsx
Normal 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
171
src/components/FuncBar.tsx
Normal 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
|
||||
177
src/components/GameGoban.tsx
Normal file
177
src/components/GameGoban.tsx
Normal 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
|
||||
156
src/components/GameGobanReadonly.tsx
Normal file
156
src/components/GameGobanReadonly.tsx
Normal 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
|
||||
748
src/components/GameGraph.tsx
Normal file
748
src/components/GameGraph.tsx
Normal 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
286
src/components/GameInfo.tsx
Normal 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
177
src/components/PlayBar.tsx
Normal 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
|
||||
36
src/components/Tooltip.tsx
Normal file
36
src/components/Tooltip.tsx
Normal 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
24
src/consts/consts.ts
Normal 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
37
src/hooks/useMeasure.ts
Normal 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
114
src/icons/annos.tsx
Normal 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
16
src/icons/annotation.tsx
Normal 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
11
src/icons/edit.tsx
Normal 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
24
src/icons/exit.tsx
Normal 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
11
src/icons/goto.tsx
Normal 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
23
src/icons/info.tsx
Normal 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
26
src/icons/mark.tsx
Normal 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
26
src/icons/menu.tsx
Normal 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
19
src/icons/navs.tsx
Normal 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
11
src/icons/ques.tsx
Normal 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
20
src/icons/resign.tsx
Normal 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
18
src/icons/skip.tsx
Normal 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
128
src/icons/tools.tsx
Normal 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
14
src/lang/helper.ts
Normal 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
162
src/lang/locales/en.ts
Normal 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_TIGER’S_MOUTH': 'Tiger’s 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_DOG’S_HEAD': 'Dog’s Head',
|
||||
'BM_HORSE’S_HEAD': 'Horse’s 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 Sensei’s Library'
|
||||
}
|
||||
162
src/lang/locales/ja.ts
Normal file
162
src/lang/locales/ja.ts
Normal 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_TIGER’S_MOUTH': '虎の口',
|
||||
BM_EMPTY_TRIANGLE: '空三角',
|
||||
BM_TURN: '曲がり',
|
||||
BM_STRETCH: '伸び',
|
||||
BM_DIAGONAL: '尖り',
|
||||
BM_WEDGE: '挿入',
|
||||
BM_HANE: '扳き',
|
||||
BM_CUT: '切断',
|
||||
BM_SQUARE: '正方形',
|
||||
BM_THROWING_STAR: '小飛び方形',
|
||||
BM_PARALLELOGRAM: '平行四辺形',
|
||||
'BM_DOG’S_HEAD': '犬の顔',
|
||||
'BM_HORSE’S_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/ko.ts
Normal file
162
src/lang/locales/ko.ts
Normal 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_TIGER’S_MOUTH': '호',
|
||||
BM_EMPTY_TRIANGLE: '빈 삼각형',
|
||||
BM_TURN: '꺾기',
|
||||
BM_STRETCH: '길게 뻗기',
|
||||
BM_DIAGONAL: '뾰족하게',
|
||||
BM_WEDGE: '쐐기',
|
||||
BM_HANE: '번',
|
||||
BM_CUT: '끊기',
|
||||
BM_SQUARE: '정사각형',
|
||||
BM_THROWING_STAR: '작은 비행 정사각형',
|
||||
BM_PARALLELOGRAM: '평행사변형',
|
||||
'BM_DOG’S_HEAD': '개 머리',
|
||||
'BM_HORSE’S_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
162
src/lang/locales/zh-cn.ts
Normal 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_TIGER’S_MOUTH': '虎',
|
||||
BM_EMPTY_TRIANGLE: '空三角',
|
||||
BM_TURN: '拐',
|
||||
BM_STRETCH: '长',
|
||||
BM_DIAGONAL: '尖',
|
||||
BM_WEDGE: '挖',
|
||||
BM_HANE: '扳',
|
||||
BM_CUT: '断',
|
||||
BM_SQUARE: '正方方阵',
|
||||
BM_THROWING_STAR: '小飞方阵',
|
||||
BM_PARALLELOGRAM: '错位方阵',
|
||||
'BM_DOG’S_HEAD': '猴脸',
|
||||
'BM_HORSE’S_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 上查看文章',
|
||||
}
|
||||
331
src/main.ts
Normal file
331
src/main.ts
Normal 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
5
src/modals/common.ts
Normal 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 () => {}
|
||||
52
src/modals/components/BaseModal.tsx
Normal file
52
src/modals/components/BaseModal.tsx
Normal 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
|
||||
43
src/modals/components/Confirm.tsx
Normal file
43
src/modals/components/Confirm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
423
src/modals/components/DisplaySetting.tsx
Normal file
423
src/modals/components/DisplaySetting.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
src/modals/components/InputPopup.tsx
Normal file
53
src/modals/components/InputPopup.tsx
Normal 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
84
src/modals/modals.ts
Normal 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
201
src/settings.ts
Normal 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
815
src/store/actions.ts
Normal 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
460
src/store/common.ts
Normal 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
239
src/store/store.ts
Normal 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
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
28
src/type.d.ts
vendored
Normal 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
62
src/utils/filesUtils.ts
Normal 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
38
src/utils/goban.ts
Normal 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
169
src/utils/utils.ts
Normal 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
1658
styles.css
Normal file
File diff suppressed because one or more lines are too long
15
tailwind.config.js
Normal file
15
tailwind.config.js
Normal 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
37
tsconfig.json
Normal 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
14
version-bump.mjs
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.7.0": "0.7.0"
|
||||
}
|
||||
71
vite.config.ts
Normal file
71
vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue