Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cafd4dd2c4 | ||
|
|
c250efd8ae | ||
|
|
754b1d5d85 | ||
|
|
afd9af1914 | ||
|
|
3749f267b5 | ||
|
|
c180642fa8 | ||
|
|
2321c068f9 | ||
|
|
0838487f4a | ||
|
|
785daa23c2 | ||
|
|
eacf8a1581 | ||
|
|
2c388e3bdb | ||
|
|
b383fcf844 | ||
|
|
37ed6d5186 | ||
|
|
a4eabe3691 | ||
|
|
b314309e8c | ||
|
|
50e672768b | ||
|
|
e5678d79e6 | ||
|
|
f64d632136 | ||
|
|
9254d0649b | ||
|
|
0697e7a3b4 | ||
|
|
812e2edfc2 | ||
|
|
e50722948d | ||
|
|
38954e9b36 | ||
|
|
fe1b048ea4 | ||
|
|
e92708dc12 | ||
|
|
d110320cb8 | ||
|
|
85f333cb24 | ||
|
|
5c9f4c75ab | ||
|
|
4d1eb23cf8 | ||
|
|
beac52c20b | ||
|
|
fd21d64bcf | ||
|
|
af2aa31518 | ||
|
|
1d24de0e79 | ||
|
|
88f18b3b20 | ||
|
|
65db845dbb | ||
|
|
d7c35add15 | ||
|
|
2ecc535c16 | ||
|
|
101ffb73d1 | ||
|
|
3894df8069 | ||
|
|
c5ac41b1cc | ||
|
|
f604ca4043 | ||
|
|
8f6e74758e | ||
|
|
91f21dc191 | ||
|
|
f03858daa9 | ||
|
|
ef55ff9c81 | ||
|
|
c23513bae7 | ||
|
|
7695d0f51d | ||
|
|
fccbc28bee | ||
|
|
937fc27dbd |
@@ -0,0 +1,4 @@
|
|||||||
|
// VS Codeのユーザー設定(markdownlint.config)と同じ規則にそろえる
|
||||||
|
{
|
||||||
|
"MD007": { "indent": 4 }
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"[markdown]": {
|
||||||
|
"editor.detectIndentation": false,
|
||||||
|
"editor.tabSize": 4,
|
||||||
|
"editor.insertSpaces": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,9 @@ PHY(物理配置) / ELC(電気配線) / IDX(キー識別) / MAP(キーマップ)
|
|||||||
- ドキュメントを更新するときは、上のルール1〜5のどこに属する情報かを判断してから書く。分類に迷うものは書く前に確認する。
|
- ドキュメントを更新するときは、上のルール1〜5のどこに属する情報かを判断してから書く。分類に迷うものは書く前に確認する。
|
||||||
- 調査タスクの結果は、結論だけをメインドキュメントに書き、調査過程は `journal/` に落とす。
|
- 調査タスクの結果は、結論だけをメインドキュメントに書き、調査過程は `journal/` に落とす。
|
||||||
- 既存ドキュメントの記述と食い違う事実を見つけたら、勝手に上書きせず指摘する。
|
- 既存ドキュメントの記述と食い違う事実を見つけたら、勝手に上書きせず指摘する。
|
||||||
|
- Markdownをコミットする前に、変更したファイルへ `npx markdownlint-cli2 --fix <files>` をかける。
|
||||||
|
エディタが保存時に同じ修正を入れるため、かけずにコミットするとコミット直後に差分が出る。
|
||||||
|
規則は `.markdownlint.jsonc`。変更していないファイルの指摘は直さない。
|
||||||
|
|
||||||
## 未確認事項
|
## 未確認事項
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,15 @@
|
|||||||
- 完了条件: 対象方式の配線と整合する走査方法を確認し、必要な出力設定と対応範囲を定める。
|
- 完了条件: 対象方式の配線と整合する走査方法を確認し、必要な出力設定と対応範囲を定める。
|
||||||
全方式で走査手順が一意に決まることの証明は求めない。
|
全方式で走査手順が一意に決まることの証明は求めない。
|
||||||
|
|
||||||
|
### ELC-P-4 GPIOに直結したLEDの表し方
|
||||||
|
|
||||||
|
- 関連仕様: [ELCレイヤー](../ELCレイヤー.md)「LEDの識別番号」
|
||||||
|
- 未決事項: インジケータなど、チェーンに入らずマイコンのピンで直接点灯するLEDをELCにどう持つか。
|
||||||
|
LEDの部品ノードとピンの対応だけで足りるか、極性(ピン側がアノードかカソードか)も要るかを含む。
|
||||||
|
- 保留できる理由: チェーン方式(WS2812系)のLEDとキーの配線は、直結LEDと独立に扱える。
|
||||||
|
- 再開条件: インジケータLEDの出力に対応するとき、またはLED配線の編集画面の仕様を作るとき。
|
||||||
|
- 完了条件: 実在の基板で直結LEDの配線を確認し、ELCでの表し方と、ファームウェア出力に必要な情報を定める。
|
||||||
|
|
||||||
## IDX
|
## IDX
|
||||||
|
|
||||||
### IDX-P-1 単一のorderから既存コミュニティレイアウトの並びを得られる範囲
|
### IDX-P-1 単一のorderから既存コミュニティレイアウトの並びを得られる範囲
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
## 検証ログ
|
## 検証ログ
|
||||||
|
|
||||||
### ijprest/keyboard-layout-editor
|
### ijprest/keyboard-layout-editor
|
||||||
|
|
||||||
- LICENSE.md (ローカル HEAD d2945e5 / 本家 master 2026-09-18 取得、同文)
|
- LICENSE.md (ローカル HEAD d2945e5 / 本家 master 2026-09-18 取得、同文)
|
||||||
- "Copyright(C) 2013-2018 by Ian Prest. All rights reserved."
|
- "Copyright(C) 2013-2018 by Ian Prest. All rights reserved."
|
||||||
- "But it's not permissively licensed!"
|
- "But it's not permissively licensed!"
|
||||||
@@ -37,10 +38,12 @@
|
|||||||
- ローカルクローン(Documents/Dev/keyboard-layout-editor)の未コミット差分はPNG解像度調整と円形プロファイル追加のみ(ユーザー談)。本件と無関係
|
- ローカルクローン(Documents/Dev/keyboard-layout-editor)の未コミット差分はPNG解像度調整と円形プロファイル追加のみ(ユーザー談)。本件と無関係
|
||||||
|
|
||||||
### kle-serial (ijprest)
|
### kle-serial (ijprest)
|
||||||
|
|
||||||
- MIT, Copyright (c) 2013-2019 Ian Prest。npm @ijprest/kle-serial 0.15.1 (2019-06-30)
|
- MIT, Copyright (c) 2013-2019 Ian Prest。npm @ijprest/kle-serial 0.15.1 (2019-06-30)
|
||||||
- README: "This library currently only handles deserialization; the serialization code still needs to be ported."
|
- README: "This library currently only handles deserialization; the serialization code still needs to be ported."
|
||||||
|
|
||||||
### kle-serial2 (adamws, ijprest/kle-serial のフォーク)
|
### kle-serial2 (adamws, ijprest/kle-serial のフォーク)
|
||||||
|
|
||||||
- MIT。2025-08-22作成、最終push 2026-02-14、star 1
|
- MIT。2025-08-22作成、最終push 2026-02-14、star 1
|
||||||
- serialize あり。書き出しプロパティ: r rx ry x y c t ta g p sm sb st _r_rs a f fa w h w2 h2 x2 y2 l n d
|
- serialize あり。書き出しプロパティ: r rx ry x y c t ta g p sm sb st _r_rs a f fa w h w2 h2 x2 y2 l n d
|
||||||
- 出自の懸念: 元READMEは serialize を「KLE本体から移植予定」としていた。kle-serial2 の serialize 冒頭
|
- 出自の懸念: 元READMEは serialize を「KLE本体から移植予定」としていた。kle-serial2 の serialize 冒頭
|
||||||
@@ -49,6 +52,7 @@
|
|||||||
→ 気になるなら書き出しは自前で書く(畳み込み後の絶対座標 → KLE行形式。rx/ry選択は PHY P-1 と一体)
|
→ 気になるなら書き出しは自前で書く(畳み込み後の絶対座標 → KLE行形式。rx/ry選択は PHY P-1 と一体)
|
||||||
|
|
||||||
### adamws/kle-ng
|
### adamws/kle-ng
|
||||||
|
|
||||||
- MIT, Copyright (c) 2025 adamws。Vue 3 + Pinia + TS + Vite, bootstrap 5, three.js, ergogen, makerjs
|
- MIT, Copyright (c) 2025 adamws。Vue 3 + Pinia + TS + Vite, bootstrap 5, three.js, ergogen, makerjs
|
||||||
- 最終コミット 9eb350f 2026-09-03 (0.48.0)。star 267
|
- 最終コミット 9eb350f 2026-09-03 (0.48.0)。star 267
|
||||||
- 規模: TypeScript 2.97MB + Vue 0.99MB (GitHub languages API, テスト込み)
|
- 規模: TypeScript 2.97MB + Vue 0.99MB (GitHub languages API, テスト込み)
|
||||||
@@ -60,6 +64,7 @@
|
|||||||
- ijprest のコードから派生したかは README に記載なし。未確認
|
- ijprest のコードから派生したかは README に記載なし。未確認
|
||||||
|
|
||||||
### その他
|
### その他
|
||||||
|
|
||||||
- ymkn/keyboard-layout-studio: MIT, Vue3, SVG, KLE import, QMK keyboard.json / Vial 出力, matrix割り当て。star 4, 21 commits。回転・グループの記載なし
|
- ymkn/keyboard-layout-studio: MIT, Vue3, SVG, KLE import, QMK keyboard.json / Vial 出力, matrix割り当て。star 4, 21 commits。回転・グループの記載なし
|
||||||
- ローカル Documents/Dev/kbd-editor: Nuxt3 + Pinia, 2025-07, 約2,500行 (deserializer.ts, Key.vue, KeyPropEditor.vue 等)。ユーザーが以前自作を試みたもの。うまくいかなかった記憶あり(ユーザー談)。中身の確認は保留
|
- ローカル Documents/Dev/kbd-editor: Nuxt3 + Pinia, 2025-07, 約2,500行 (deserializer.ts, Key.vue, KeyPropEditor.vue 等)。ユーザーが以前自作を試みたもの。うまくいかなかった記憶あり(ユーザー談)。中身の確認は保留
|
||||||
|
|
||||||
|
|||||||
@@ -66,10 +66,11 @@ ZMKの matrix transform `map`:
|
|||||||
引数の数え間違いでキーが1つずれるのが定番のバグであることも、この層が手書きである傍証になる。
|
引数の数え間違いでキーが1つずれるのが定番のバグであることも、この層が手書きである傍証になる。
|
||||||
|
|
||||||
出典:
|
出典:
|
||||||
- https://docs.qmk.fm/reference_configurator_support
|
|
||||||
- https://docs.qmk.fm/reference_info_json
|
- <https://docs.qmk.fm/reference_configurator_support>
|
||||||
- https://zmk.dev/docs/config/layout
|
- <https://docs.qmk.fm/reference_info_json>
|
||||||
- https://github.com/KMKfw/kmk_firmware/blob/main/docs/en/porting_to_kmk.md
|
- <https://zmk.dev/docs/config/layout>
|
||||||
|
- <https://github.com/KMKfw/kmk_firmware/blob/main/docs/en/porting_to_kmk.md>
|
||||||
|
|
||||||
## 根拠: マトリクス順 ≠ キー番号順 の実物
|
## 根拠: マトリクス順 ≠ キー番号順 の実物
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ QMKの分割も同じ構造。
|
|||||||
> Since the matrix scanning procedure operates on entire rows, it first populates the left half's rows,
|
> Since the matrix scanning procedure operates on entire rows, it first populates the left half's rows,
|
||||||
> then the right half's. Thus, the matrix as QMK views it has double the rows instead of double the columns.
|
> then the right half's. Thus, the matrix as QMK views it has double the rows instead of double the columns.
|
||||||
|
|
||||||
出典: https://docs.qmk.fm/features/split_keyboard
|
出典: <https://docs.qmk.fm/features/split_keyboard>
|
||||||
|
|
||||||
## 保存形式を `order: string[]` にした理由
|
## 保存形式を `order: string[]` にした理由
|
||||||
|
|
||||||
|
|||||||
@@ -28,16 +28,16 @@ VIA向けのキーボード定義生成と、MAPの割り当ての読み書き
|
|||||||
公式文書・実装を参照した。実機による読み書きの検証は行っていない。
|
公式文書・実装を参照した。実機による読み書きの検証は行っていない。
|
||||||
|
|
||||||
- RemapはWebHIDを使い、QMKのVIAクライアント向け機能へアクセスする。Workbenchにはソース編集・ビルド・書き込みもある。
|
- RemapはWebHIDを使い、QMKのVIAクライアント向け機能へアクセスする。Workbenchにはソース編集・ビルド・書き込みもある。
|
||||||
参照: https://github.com/remap-keys/remap#references
|
参照: <https://github.com/remap-keys/remap#references>
|
||||||
参照: https://remap-keys.app/docs/workbench
|
参照: <https://remap-keys.app/docs/workbench>
|
||||||
- VIAのキーボード定義には配置とmatrix座標の対応がある。QMKのVIA実装はキー単位の読み書きにlayer・row・colを使う。
|
- VIAのキーボード定義には配置とmatrix座標の対応がある。QMKのVIA実装はキー単位の読み書きにlayer・row・colを使う。
|
||||||
参照: https://caniusevia.com/docs/specification/
|
参照: <https://caniusevia.com/docs/specification/>
|
||||||
参照: https://github.com/qmk/qmk_firmware/blob/master/quantum/via.c
|
参照: <https://github.com/qmk/qmk_firmware/blob/master/quantum/via.c>
|
||||||
- Vialではタップダンスやコンボを実機へ保存できるが、枠数等にはビルド時の制約がある。
|
- Vialではタップダンスやコンボを実機へ保存できるが、枠数等にはビルド時の制約がある。
|
||||||
参照: https://get.vial.today/manual/tap-dance.html
|
参照: <https://get.vial.today/manual/tap-dance.html>
|
||||||
参照: https://get.vial.today/manual/combos.html
|
参照: <https://get.vial.today/manual/combos.html>
|
||||||
- ZMK Studioは既存behaviorの割り当てを変更できるが、新しいbehavior定義等は対象外。実機に保存した変更とソース側の設定の扱いにも注意が必要。
|
- ZMK Studioは既存behaviorの割り当てを変更できるが、新しいbehavior定義等は対象外。実機に保存した変更とソース側の設定の扱いにも注意が必要。
|
||||||
参照: https://zmk.dev/docs/features/studio
|
参照: <https://zmk.dev/docs/features/studio>
|
||||||
|
|
||||||
## 未採用の提案との区別
|
## 未採用の提案との区別
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
## 結論
|
## 結論
|
||||||
|
|
||||||
当面の対象ファームウェアはQMK。
|
当面の対象ファームウェアはQMK。
|
||||||
|
|
||||||
- Dynamic: QMK本家のVIAプロトコルが公開する項目のみ
|
- Dynamic: QMK本家のVIAプロトコルが公開する項目のみ
|
||||||
- Static: QMK本家の公式機能のうちVIAで変更できないものは、Firmware Generatorで生成する対象
|
- Static: QMK本家の公式機能のうちVIAで変更できないものは、Firmware Generatorで生成する対象
|
||||||
- 非公式機能(Vial等のフォーク、XAP等のmaster以外のブランチ)は当面非対応
|
- 非公式機能(Vial等のフォーク、XAP等のmaster以外のブランチ)は当面非対応
|
||||||
@@ -29,10 +30,12 @@
|
|||||||
実機での検証はしていない。
|
実機での検証はしていない。
|
||||||
|
|
||||||
### A. QMK本家 VIA
|
### A. QMK本家 VIA
|
||||||
参照: https://raw.githubusercontent.com/qmk/qmk_firmware/master/quantum/via.h
|
|
||||||
|
参照: <https://raw.githubusercontent.com/qmk/qmk_firmware/master/quantum/via.h>
|
||||||
VIA_PROTOCOL_VERSION = 0x000D
|
VIA_PROTOCOL_VERSION = 0x000D
|
||||||
|
|
||||||
command:
|
command:
|
||||||
|
|
||||||
- 0x01 get_protocol_version
|
- 0x01 get_protocol_version
|
||||||
- 0x02/0x03 get/set_keyboard_value
|
- 0x02/0x03 get/set_keyboard_value
|
||||||
- 0x01 uptime(get), 0x02 layout_options(get/set), 0x03 switch_matrix_state(get),
|
- 0x01 uptime(get), 0x02 layout_options(get/set), 0x03 switch_matrix_state(get),
|
||||||
@@ -57,8 +60,9 @@ MAPに関係するもの: キーコード、レイヤー数、エンコーダ、
|
|||||||
照明・オーディオはMAPの所有データ外(MAPレイヤー.md「持たないもの」)。
|
照明・オーディオはMAPの所有データ外(MAPレイヤー.md「持たないもの」)。
|
||||||
|
|
||||||
### B. Vial(フォーク、今回非対応)
|
### B. Vial(フォーク、今回非対応)
|
||||||
参照: https://raw.githubusercontent.com/vial-kb/vial-qmk/vial/quantum/vial.h
|
|
||||||
参照: https://raw.githubusercontent.com/vial-kb/vial-qmk/vial/quantum/qmk_settings.c
|
参照: <https://raw.githubusercontent.com/vial-kb/vial-qmk/vial/quantum/vial.h>
|
||||||
|
参照: <https://raw.githubusercontent.com/vial-kb/vial-qmk/vial/quantum/qmk_settings.c>
|
||||||
VIAL_PROTOCOL_VERSION = 6
|
VIAL_PROTOCOL_VERSION = 6
|
||||||
|
|
||||||
- keyboard_id、圧縮キーボード定義JSONの取得(get_size/get_def)
|
- keyboard_id、圧縮キーボード定義JSONの取得(get_size/get_def)
|
||||||
@@ -75,7 +79,9 @@ VIAL_PROTOCOL_VERSION = 6
|
|||||||
- VialRGB(RGB Matrixエフェクト列挙・設定)。ソース未確認
|
- VialRGB(RGB Matrixエフェクト列挙・設定)。ソース未確認
|
||||||
|
|
||||||
### C. プロトコル外で実機上だけ変わるもの(Dynamicの定義外)
|
### C. プロトコル外で実機上だけ変わるもの(Dynamicの定義外)
|
||||||
|
|
||||||
当初「すべてEEPROMに保存される」と書いたが誤り。確認結果:
|
当初「すべてEEPROMに保存される」と書いたが誤り。確認結果:
|
||||||
|
|
||||||
- 保存されない(再起動で消える): ダイナミックマクロ(docs/features/dynamic_macros.md
|
- 保存されない(再起動で消える): ダイナミックマクロ(docs/features/dynamic_macros.md
|
||||||
"lost when the keyboard is unplugged or otherwise rebooted")、DT_UP/DT_DOWN(docs/tap_hold.md "*not* persistent")
|
"lost when the keyboard is unplugged or otherwise rebooted")、DT_UP/DT_DOWN(docs/tap_hold.md "*not* persistent")
|
||||||
- EEPROMに保存される(quantum/eeconfig.h): keymap config(Magic)、default layer、unicode mode、audio、backlight、
|
- EEPROMに保存される(quantum/eeconfig.h): keymap config(Magic)、default layer、unicode mode、audio、backlight、
|
||||||
@@ -86,4 +92,5 @@ VIAL_PROTOCOL_VERSION = 6
|
|||||||
初期化する。再ビルドして書き込むとDynamicの変更は消える前提になる。
|
初期化する。再ビルドして書き込むとDynamicの変更は消える前提になる。
|
||||||
|
|
||||||
### XAP
|
### XAP
|
||||||
|
|
||||||
QMK公式の次世代プロトコルとして開発されていたが、masterには入っていない認識。未確認。
|
QMK公式の次世代プロトコルとして開発されていたが、masterには入っていない認識。未確認。
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ Key Lock、One Shot on/off/toggle、Key Override on/off/toggle、Secure 4、Dyna
|
|||||||
Autocorrect 3、Tri Layer Lower/Upper、Repeat/Alt Repeat、Layer Lock。
|
Autocorrect 3、Tri Layer Lower/Upper、Repeat/Alt Repeat、Layer Lock。
|
||||||
|
|
||||||
補足:
|
補足:
|
||||||
|
|
||||||
- 20250831で旧RGB・マウスの非推奨キーコードを削除(#25444)。
|
- 20250831で旧RGB・マウスの非推奨キーコードを削除(#25444)。
|
||||||
- VIAはキーマップを16bit値で読み書きするため、上のどの値もキーへ書ける。ファームウェアで無効な機能の値は何もしない。
|
- VIAはキーマップを16bit値で読み書きするため、上のどの値もキーへ書ける。ファームウェアで無効な機能の値は何もしない。
|
||||||
|
|
||||||
@@ -147,6 +148,7 @@ QMK本家VIAにはこれらを読み出すコマンドがない(9/23調査の再
|
|||||||
## VIA 0x000D の読み書き範囲(0.34.4で再確認)
|
## VIA 0x000D の読み書き範囲(0.34.4で再確認)
|
||||||
|
|
||||||
9/23の journal(qmk-dynamic-scope)と同じコマンド構成。差分・補足:
|
9/23の journal(qmk-dynamic-scope)と同じコマンド構成。差分・補足:
|
||||||
|
|
||||||
- `id_switch_matrix_state` は既定で0を返す(上記)。
|
- `id_switch_matrix_state` は既定で0を返す(上記)。
|
||||||
- `id_eeprom_reset` は `VIA_EEPROM_ALLOW_RESET` 定義時のみ有効。
|
- `id_eeprom_reset` は `VIA_EEPROM_ALLOW_RESET` 定義時のみ有効。
|
||||||
- エンコーダは `ENCODER_MAP_ENABLE` 時のみ。
|
- エンコーダは `ENCODER_MAP_ENABLE` 時のみ。
|
||||||
@@ -158,5 +160,5 @@ QMK本家VIAにはこれらを読み出すコマンドがない(9/23調査の再
|
|||||||
- 機能一覧: docs/_sidebar.json、builddefs/generic_features.mk の GENERIC_FEATURES。
|
- 機能一覧: docs/_sidebar.json、builddefs/generic_features.mk の GENERIC_FEATURES。
|
||||||
- キーコード: data/constants/keycodes/keycodes_*.hjson をバージョン順にマージ。
|
- キーコード: data/constants/keycodes/keycodes_*.hjson をバージョン順にマージ。
|
||||||
- 変更点: docs/ChangeLog/20250223〜20260830。
|
- 変更点: docs/ChangeLog/20250223〜20260830。
|
||||||
- VIA移行PR: https://github.com/qmk/qmk_firmware/pull/26315(develop向けDraft。VIA_EEPROM_MAGIC_ADDR 以外は後方互換を維持する方針と記載)
|
- VIA移行PR: <https://github.com/qmk/qmk_firmware/pull/26315(develop向けDraft。VIA_EEPROM_MAGIC_ADDR> 以外は後方互換を維持する方針と記載)
|
||||||
- 実機での検証はしていない。
|
- 実機での検証はしていない。
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
- docs/ChangeLog/20260830.md(QMK 0.34.4):
|
- docs/ChangeLog/20260830.md(QMK 0.34.4):
|
||||||
"upkeep of the VIA integration will transition to the VIA team" /
|
"upkeep of the VIA integration will transition to the VIA team" /
|
||||||
"in a future release, the old VIA feature-based implementation is expected to be removed."
|
"in a future release, the old VIA feature-based implementation is expected to be removed."
|
||||||
- PR https://github.com/qmk/qmk_firmware/pull/26315 : develop向けDraft。追加ファイルは
|
- PR <https://github.com/qmk/qmk_firmware/pull/26315> : develop向けDraft。追加ファイルは
|
||||||
modules/qmk/via/{qmk_module.json, via.c, via.h, post_config.h, post_rules.mk}。
|
modules/qmk/via/{qmk_module.json, via.c, via.h, post_config.h, post_rules.mk}。
|
||||||
qmk_module.json にバージョン欄なし。VIA_EEPROM_MAGIC_ADDR 以外は後方互換を維持すると記載。
|
qmk_module.json にバージョン欄なし。VIA_EEPROM_MAGIC_ADDR 以外は後方互換を維持すると記載。
|
||||||
- 0.34.4時点のVIAは従来どおり quantum/via.c、VIA_PROTOCOL_VERSION 0x000D。
|
- 0.34.4時点のVIAは従来どおり quantum/via.c、VIA_PROTOCOL_VERSION 0x000D。
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# スタビライザー位置とswitchOffsetの裏取り(T-30 単位8)
|
||||||
|
|
||||||
|
## 結論
|
||||||
|
|
||||||
|
- 6U以上の幅の中には、スタビライザー位置が何通りもあるものがある。6U・6.25U・7Uがそう。
|
||||||
|
6.5U と 8〜10U は、出典にある範囲では1通り。
|
||||||
|
- switchOffset が要るのは2U未満のキーに限らない。6U offset・6.25U(Cherry G80のWindowsキー付き)・BAE(ANSI互換)では
|
||||||
|
スイッチがキーの中心から外れる。逆に、2U未満でよくあるステップドCaps Lockは、KLEの表し方のおかげで offset 0 になる。
|
||||||
|
- ISO Enterは「(w,h)矩形の中心にスイッチ、2U間隔のスタビを縦向きに置く」とすれば、出典にある生成ツールの扱いと合う。
|
||||||
|
実物の寸法の出典はまだ見つけていない。
|
||||||
|
- BAEは、スイッチとスタビの配置が型によって違う。決まった形はない。
|
||||||
|
|
||||||
|
座標はすべてキーのローカル座標で、(w,h)矩形の中心を原点にした値(U、1U = 19.05mm)。
|
||||||
|
PHYの `switchOffset` と同じ基準になる。スタビは中心から±の位置に2つ置く。
|
||||||
|
|
||||||
|
## 候補表(Cherry MX系、横向き。縦長のキーは長辺の向きに読み替える)
|
||||||
|
|
||||||
|
| 幅 | スタビ(中心から) | switchOffset.x | 使っているキーボード・出典 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 2〜2.75U | ±11.938mm(±0.6267U) | 0 | kad・kiswitch・ai03 で一致 |
|
||||||
|
| 3U | ±1U | 0 | DT(Deskthority wiki、以下同じ)、kad、kiswitch |
|
||||||
|
| 4U | ±1.5U | 0 | DT、kad(ai03 は3U以上をすべて±1Uにしている) |
|
||||||
|
| 4.5U | ±34.671mm(±1.82U) | 0 | DT、kad |
|
||||||
|
| 5.5U | ±2.25U | 0 | DT、kad |
|
||||||
|
| 6U offset | ±2.5U(芯々95mm) | +0.5U | DT: Cherry G80-1800/11900/11908/11800。kad、kiswitch `6uOffset` |
|
||||||
|
| 6U 中央A | ±2U | 0 | DT: Razer BlackWidow、TG3 BL-82 |
|
||||||
|
| 6U 中央B | ±2.5U(47.625mm) | 0 | kiswitch の 6U スタビ、kad の6U値。フォーラム: FC660C、Topre |
|
||||||
|
| 6.25U 標準 | ±50mm(±2.6247U) | 0 | DT: Filco、Ducky、Poker、Das 3 ほか。kad・kiswitch・ai03 で一致 |
|
||||||
|
| 6.25U G80 | ±50mm | +0.625U(11.9mm) | DT: Cherry G80-1000/3000(Windowsキー付き)、Das 2。キーキャップに中央のマウントがない |
|
||||||
|
| 6.25U 40mm | ±40mm(±2.1U) | 0 | DT: Leopold FC200、SteelSeries 6GV2/7G、Zowie Celeritas |
|
||||||
|
| 6.25U 42.5mm | ±42.5mm | 0 | DT: Leopold FC500、Keycool 87(2012年より前のモデル) |
|
||||||
|
| 6.5U | ±2.75U(52.38mm) | 0 | DT: Corsair K60〜K95、QuickFire TK、G710+。kad |
|
||||||
|
| 7U | ±3U | 0 | DT: Cherry G80-1000/1800/3000(Windowsキーなし)ほか。kad・kiswitch・ai03 で一致 |
|
||||||
|
| 8U / 9U / 9.75U / 10U | ±3.5U | 0 | DT(9U以上も8Uと同じ間隔)。kiswitch は8U〜10Uに同じフットプリントを使う |
|
||||||
|
|
||||||
|
候補表から外したもの(実物の位置が出典から決まらない):
|
||||||
|
|
||||||
|
- 6U CMStorm QuickFire Ultimate: 「43.5mm, 49mm」「芯々98mm」とだけ書かれていて、軸がどれだけずれているか読み取れない
|
||||||
|
- 7U Compaq MX-11800: 軸から見てスタビが左3U・右2.25U。キーキャップの中でどう対称になっているかは書かれていない。
|
||||||
|
スタビがキーの中心に対して対称なら±2.625U、switchOffset +0.375U になり、6.25U と同じ100mmのワイヤーになるが、これは推測
|
||||||
|
- 7U Propac: 「55mm apart」とあるだけ
|
||||||
|
|
||||||
|
## 異形キー
|
||||||
|
|
||||||
|
KLEのプリセット(`keys.json`)と、PHYの `size` の主矩形(w,h):
|
||||||
|
|
||||||
|
| キー | KLE | スイッチ | スタビ |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ISO Enter | w1.25 h2, w2 1.5 h2 1, x 0.25, x2 -0.25 | 主矩形の中心(0,0) | 縦向きに±0.6267U。kad は h>w なら自動で縦にする。ai03 は長辺を幅として扱うが、向きは利用者が `_rs` で回す |
|
||||||
|
| BAE (a) ANSI互換 | w1.5 h2, w2 2.25 h2 1, x 0.75, x2 -0.75, y2 1 | 下段2.25Uの中心 = (-0.375, +0.5) | (-0.375±0.6267, +0.5) の横向き。GMK CYL BAE: 「ANSIの2.25U Enterのスイッチとスタビの位置、1.5UのPipeキーの位置にマウントがある」 |
|
||||||
|
| BAE (b) 旧Cherry | 同上 | 不明 | 検索の要約には「上段1.5Uを中心にISO風のスタビ、補助のMXスイッチ」とあったが、原文は確認できていない |
|
||||||
|
| ステップドCaps Lock | w1.25 w2 1.75 l | 主矩形 = 高い側の1.25Uの中心。よくある軸の位置(左寄り)と一致し、offset 0 | なし |
|
||||||
|
| ステップドCaps Lock(軸が中央) | 同上 | 1.75Uの中心 = +0.25U | なし。数は少ない(Leopoldのセットにある) |
|
||||||
|
| Center-stepped | w1 w2 1.5, x 0.25, x2 -0.25 l | 主矩形の中心 | なし |
|
||||||
|
|
||||||
|
KLEのステップドキーは、`w` が高い側、`w2` が全幅(kb.js の `stepped` の処理)。
|
||||||
|
kad も主矩形の中心にスイッチを置く(`AltWidth > Width` のときは外形の計算だけを広げる)。
|
||||||
|
|
||||||
|
## switchOffset が0でないケース
|
||||||
|
|
||||||
|
- 2U以上: 6U offset (+0.5U)、6.25U G80 (+0.625U)、BAE(a) (-0.375, +0.5)、おそらく7U Compaq
|
||||||
|
- 2U未満: 軸が中央にあるステップドCaps Lock (+0.25U) だけ。よくある左寄りの軸は0
|
||||||
|
- 「2U未満のみ」という想定は当たらない
|
||||||
|
|
||||||
|
## ツールどうしの食い違い(参考)
|
||||||
|
|
||||||
|
- ai03 は6Uで、スイッチをKLEのキーの中心に置いたまま、スタビを+38.1/-57.15mmと左右非対称に置く。
|
||||||
|
kad はスタビをキーの中心に対して±47.5mmで対称に置き、スイッチを+0.5Uずらす。DTの実測(マウントは左から0.5/3.5/5.5U)と合うのは kad
|
||||||
|
- ai03 は3U〜5.5Uをすべて±19.05mmにしている。DT・kad とは4U以上で合わない
|
||||||
|
- kad は2Uの値を11.9mmに丸めている。kiswitch・ai03 は11.938mm
|
||||||
|
|
||||||
|
## 出典
|
||||||
|
|
||||||
|
- Deskthority wiki "Space bar dimensions"(`?action=raw` で原文を確認): <https://deskthority.net/wiki/Space_bar_dimensions>
|
||||||
|
- swill/kad `key.go` の `GetCherryStabOffset`、`Draw`: <https://github.com/swill/kad>
|
||||||
|
- perigoso/keyswitch-kicad-library `KiSwitch/switch.py` の `StabilizerCherryMX.LU_TABLE`、`KiSwitch/keycap.py`:
|
||||||
|
<https://github.com/perigoso/keyswitch-kicad-library>
|
||||||
|
- ai03-2725/another-keyboard-builder `plategen.py` の `generate_stabs`: <https://github.com/ai03-2725/another-keyboard-builder>
|
||||||
|
- KLE `keys.json`(異形キーのプリセット)、`kb.js` の `stepped`: <https://github.com/ijprest/keyboard-layout-editor>
|
||||||
|
- GMK CYL BAE addon: <https://geon.works/products/gmk-cyl-bae-addon-kits>
|
||||||
|
- ステップドCaps Lockの軸の位置(フォーラム): <https://deskthority.net/viewtopic.php?p=112099>、
|
||||||
|
<https://deskthority.net/keyboards-f2/keycap-cherry-mx-stepped-capslock-t3794.html>
|
||||||
|
- 6Uの中央軸とTopreの値(フォーラム): <https://www.keebtalk.com/t/topre-6u-stabilizer-spacing-spec/17047>
|
||||||
|
|
||||||
|
Alpsのスタビ(kad `GetAlpsStabOffset`: 1.75U 11.938、2〜2.75U 14.096、6.25U 41.859、6.5U 45.3mm。DT: 7U ±55mm)は、モックにスイッチの種別がないので扱っていない。
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 二乗マトリクスの一般知識(ELC Editorの要検討の前提)
|
||||||
|
|
||||||
|
## 結論
|
||||||
|
|
||||||
|
- 二乗マトリクス: N本のピンがどれも駆動側にも受信側にもなる。キーは異なる2ピンの順序付きの組 (駆動, 受信) に1つ。
|
||||||
|
上限 N(N−1) キー。対角 (i, i) は使えない。考案は ikeji 氏(2019)。英語では Square / Round-robin / Charlieplex matrix。
|
||||||
|
- 実用されているのは改良二乗マトリクス(各ピンに追加ダイオード1つ)。素の二乗マトリクスは主要MCUでゴーストが出る。
|
||||||
|
- 通常・倍マトリクスは、二乗マトリクスの部分集合とみなせる(使うピンの組を row→col に限ったもの、その両向き)。
|
||||||
|
- 追加ダイオードはELCで持たない。マトリクス方式に依存し、位置が決定的なのでGeneratorで扱える(ユーザー判断)。
|
||||||
|
- QMKとZMKで row/col の向きが逆になる問題はGeneratorで扱う(ユーザー判断)。
|
||||||
|
- GND直結で対角を使う変形(N² キー)は標準対応しない。Directとの組み合わせで表せるかは、仕様が固まってから確認する(ユーザー判断)。
|
||||||
|
|
||||||
|
## 方式の比較(ikeji 2022)
|
||||||
|
|
||||||
|
| 方式 | 正常経路のダイオード数 | ゴースト経路のダイオード数 | 成立条件 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 倍 | 1 | 3 | Vf < Vth < 3Vf |
|
||||||
|
| 二乗 | 1 | 2 | Vf < Vth < 2Vf |
|
||||||
|
| 改良二乗 | 2 | 4 | 2Vf < Vth < 4Vf |
|
||||||
|
|
||||||
|
同じピン数Nでのキー数: 通常 N²/4、倍 N²/2、二乗 N(N−1)(N=18 で 81 / 162 / 306)。
|
||||||
|
|
||||||
|
## ファームウェア
|
||||||
|
|
||||||
|
### QMK
|
||||||
|
|
||||||
|
- 標準の二乗マトリクス対応はない。ただし標準機能の組み合わせで実現できる:
|
||||||
|
- `matrix_pins.rows` と `cols` に同じピンを同じ順で並べる。
|
||||||
|
- `config.h` に `#define MATRIX_MASKED` を置き、`<keyboard>.c` の `matrix_mask` で対角のビットを0にする。
|
||||||
|
- 対角のマスクは改良版かどうかに関係なく必須。Lowに駆動したrowピンを同じピンのcolとして読むので、常に押された扱いになる
|
||||||
|
(gachiham: マスクしないとPCに認識されなくなった。STM32/RP2040)。
|
||||||
|
- `matrix_mask` は使わない位置も0にできる。対角はその一例。
|
||||||
|
- 実例 ewderna/cr0wn/cr0wn60(`Dev/KBDs/qmk_firmware/keyboards/ewderna/cr0wn/cr0wn60`):
|
||||||
|
RP2040、9ピン(GP8 GP9 GP10 GP11 GP19 GP21 GP20 GP12 GP18)を rows/cols の両方に列挙、`diode_direction: COL2ROW`。
|
||||||
|
`matrix_mask` は row0〜6 で対角と未使用の位置を0にし、row7 は一部だけ1、row8 は全部0。
|
||||||
|
- 対応関係: row = Lowに駆動するピン、col = 読むピン。電流は col → row に流れる。
|
||||||
|
|
||||||
|
### ZMK `zmk,kscan-gpio-charlieplex`
|
||||||
|
|
||||||
|
- 専用ドライバ。`gpios` にピンを並べるだけで、row/colの区別はない。
|
||||||
|
- `interrupt-gpios` なしで n本 → n(n−1)キー(ポーリング)。ありで (n−1)(n−2)キー(割り込み、省電力)。
|
||||||
|
- 割り込みピンは `gpios` に含めない別のピン(ソース上、セルは `gpios` だけから作る)。
|
||||||
|
- 待機中は全セルを出力1にし、割り込みピンのレベルを待つ。割り込みピンの配線はドキュメントに書かれていない。
|
||||||
|
- 低電力改良二乗マトリクス(Cheena: 1ピンを復帰用にして (N−1)(N−2))と同じ構成と思われるが、回路は追えていない。
|
||||||
|
- キーの位置は matrix transform の `RC(row, col)` で書き、row = 駆動ピン、col = 受信ピン。対角は transform から除くよう明記されている。
|
||||||
|
- ドキュメントは `GPIO_ACTIVE_HIGH` を指定するよう求めている。この場合は入力プルダウン・High駆動で、電流は row → col に流れる。
|
||||||
|
- QMK COL2ROW と逆向き。同じ基板では row/col が転置される。
|
||||||
|
- ソース上は `GPIO_ACTIVE_LOW` ならプルアップ・Low駆動になり、QMKと同じ向きにできる。割り込みと併用できるかは未確認。
|
||||||
|
- 待ち時間の設定: `CONFIG_ZMK_KSCAN_CHARLIEPLEX_WAIT_BEFORE_INPUTS` / `_WAIT_BETWEEN_OUTPUTS`。
|
||||||
|
|
||||||
|
## 改良二乗マトリクスの追加ダイオードの位置
|
||||||
|
|
||||||
|
記事の図は読めなかった。以下は経路の段数から組み立てたもので、ユーザーの認識(各ピンに追加ダイオード)と合う。
|
||||||
|
|
||||||
|
- ピン i を、GPIOにつながる線 G_i と、ダイオード D_i(アノードが G_i 側)を挟んだ線 S_i に分ける。
|
||||||
|
- キー (駆動 a, 受信 b) は、S_b → G_a の向きのダイオードを付ける(QMK COL2ROW の場合)。
|
||||||
|
- 正常経路: G_b →D_b→ S_b →キー→ G_a で2段。
|
||||||
|
- ゴースト経路: G_c →D_c→ S_c →キー→ G_b →D_b→ S_b →キー→ G_a で4段。中継するピンでも D_b を通らされる。
|
||||||
|
- 回路図を格子に描くと D_i は対角 (i, i) の位置に来る。gachiham の「[0,0] はダイオードを入れているだけなのでスイッチとして使えない」と合う。
|
||||||
|
|
||||||
|
## 実測値(ikeji 2022)
|
||||||
|
|
||||||
|
| MCU | Vth | Vf | 2Vf | 3Vf | 4Vf |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| ATmega32u4 | 1.45〜1.56V | 0.513V | 1.034V | 1.495V | 1.963V |
|
||||||
|
| RP2040 | 1.26〜1.36V | 0.474V | 0.949V | 1.418V | 1.862V |
|
||||||
|
|
||||||
|
- 素の二乗マトリクスは両MCUとも Vth > 2Vf で、ゴーストがLowと読まれる。改良版は両MCUとも成立する。
|
||||||
|
- ATmega32u4 では倍マトリクスの条件(Vth < 3Vf)が際どい。著者は測定条件(5V弱の電源、非選択線の電流)を疑っている。
|
||||||
|
- MCUとダイオードの相性は Vf と Vth の比で決まる。
|
||||||
|
|
||||||
|
## 出典
|
||||||
|
|
||||||
|
- ikeji 2019: ikeji「キーボードのマトリクス方式の分類」ikejiのblog、2019-12-14
|
||||||
|
- 参照先: <https://blog.ikejima.org/make/keyboard/2019/12/14/keyboard-circuit.html>
|
||||||
|
- 二乗マトリクスの初出。ゴーストはダイオード2つ分なので部品の組み合わせに注意、1キー2ダイオードが要るかも、と書いている。
|
||||||
|
- ikeji 2022: ikeji「変則マトリクスキーボード設計」ikejiのblog、2022-12-07
|
||||||
|
- 参照先: <https://blog.ikejima.org/make/keyboard/2022/12/07/atypical-matrix-design.html>
|
||||||
|
- gachiham: gachiham「QMK 0.19.2で改良二乗マトリクスのキーボードファームウェアを作っていく話」gachiham's blog、2022-12-17
|
||||||
|
- 参照先: <https://gachiham.hatenablog.com/entry/2022/12/17/000000>
|
||||||
|
- 9ピン72キーから63キー。rows/colsに同じピンを並べ、MATRIX_MASKEDを使う。
|
||||||
|
- Cheena: Cheena「自作キーボード初心者のためのでかいキーボード設計法」note、2025-05-26(2025-09 追記あり)
|
||||||
|
- 参照先: <https://note.com/cheena/n/n846846c3efce>
|
||||||
|
- 低電力改良二乗マトリクス。
|
||||||
|
- ZMK kscan: 「Keyboard Scan Configuration」ZMK Firmware ドキュメント
|
||||||
|
- 参照先: <https://zmk.dev/docs/config/kscan>
|
||||||
|
- ZMK ドライバ: zmkfirmware/zmk のソース `app/module/drivers/kscan/kscan_gpio_charlieplex.c`(main ブランチ)
|
||||||
|
- 参照先: <https://raw.githubusercontent.com/zmkfirmware/zmk/main/app/module/drivers/kscan/kscan_gpio_charlieplex.c>
|
||||||
|
|
||||||
|
## 捨てた記述
|
||||||
|
|
||||||
|
- 二乗マトリクスを e3w2q 氏の発案とした記憶は誤り。初出は ikeji 2019。
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "printWidth": 110 }
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import path from "node:path";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
async function projectJson(page: import("@playwright/test").Page) {
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent("download"),
|
||||||
|
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||||
|
]);
|
||||||
|
const file = path.join(test.info().outputDir, download.suggestedFilename());
|
||||||
|
await download.saveAs(file);
|
||||||
|
return JSON.parse(await readFile(file, "utf8"));
|
||||||
|
}
|
||||||
|
async function loadProject(page: import("@playwright/test").Page, project: unknown) {
|
||||||
|
const chooser = page.waitForEvent("filechooser");
|
||||||
|
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||||
|
await (
|
||||||
|
await chooser
|
||||||
|
).setFiles({
|
||||||
|
name: "case.bkle.json",
|
||||||
|
mimeType: "application/json",
|
||||||
|
buffer: Buffer.from(JSON.stringify(project)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("provider, key terminals and diode edit wiring and show collisions", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "ELC" }).click();
|
||||||
|
await page.getByTestId("provider-id").fill("MCU");
|
||||||
|
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
|
||||||
|
await expect(page.getByText(/MCU · mcu · RP2040/)).toBeVisible();
|
||||||
|
const view = page.locator(".wiring-box [data-testid=keyboard-view]");
|
||||||
|
const first = view.locator(".kv-key").nth(0);
|
||||||
|
await first.click();
|
||||||
|
await page.locator(".edit-form select").nth(0).selectOption("MCU.GP0");
|
||||||
|
await page.locator(".edit-form select").nth(1).selectOption("MCU.GP1");
|
||||||
|
await page.locator(".edit-form select").nth(2).selectOption("a-to-b");
|
||||||
|
await page.getByRole("button", { name: "配線を保存" }).click();
|
||||||
|
await expect(page.getByTestId("unwired-count")).toHaveText("15");
|
||||||
|
await expect(first).toHaveClass(/ok/);
|
||||||
|
await expect(first).toContainText("GP0/GP1");
|
||||||
|
|
||||||
|
await view.locator(".kv-key").nth(1).click();
|
||||||
|
await page.locator(".edit-form select").nth(0).selectOption("MCU.GP0");
|
||||||
|
await page.locator(".edit-form select").nth(1).selectOption("MCU.GP1");
|
||||||
|
await page.locator(".edit-form select").nth(2).selectOption("a-to-b");
|
||||||
|
await page.getByRole("button", { name: "配線を保存" }).click();
|
||||||
|
await expect(page.getByText("wiring-collision", { exact: false }).first()).toBeVisible();
|
||||||
|
await expect(view.locator(".kv-key.error")).toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("batch wiring applies selected keys in row-major PHY order", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "ELC" }).click();
|
||||||
|
await page.getByTestId("provider-id").fill("MCU");
|
||||||
|
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
|
||||||
|
const views = page.locator(".wiring-box [data-testid=keyboard-view]");
|
||||||
|
const batchView = views.nth(1);
|
||||||
|
await batchView.locator(".kv-key").nth(0).click();
|
||||||
|
await batchView.locator(".kv-key").nth(1).click();
|
||||||
|
await page.getByTestId("batch-row-select").selectOption("MCU.GP0");
|
||||||
|
await page.getByRole("button", { name: "rowへ追加" }).click();
|
||||||
|
await page.getByTestId("batch-col-select").selectOption("MCU.GP1");
|
||||||
|
await page.getByRole("button", { name: "colへ追加" }).click();
|
||||||
|
await page.getByTestId("batch-col-select").selectOption("MCU.GP2");
|
||||||
|
await page.getByRole("button", { name: "colへ追加" }).click();
|
||||||
|
await page.getByTestId("batch-wire").click();
|
||||||
|
await expect(page.getByTestId("batch-preview")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("batch-preview").locator("tbody tr").nth(0)).toContainText("MCU.GP0");
|
||||||
|
await page.getByTestId("batch-confirm").click();
|
||||||
|
await expect(page.getByTestId("unwired-count")).toHaveText("14");
|
||||||
|
await expect(views.first().locator(".kv-key").nth(0)).toContainText("GP0/GP1");
|
||||||
|
await expect(views.first().locator(".kv-key").nth(1)).toContainText("GP0/GP2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("batch wiring previews overwritten assignments and blocks insufficient capacity", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "ELC" }).click();
|
||||||
|
await page.getByTestId("provider-id").fill("MCU");
|
||||||
|
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
|
||||||
|
const view = page.locator(".wiring-box [data-testid=keyboard-view]");
|
||||||
|
await view.locator(".kv-key").first().click();
|
||||||
|
await page.locator(".edit-form select").nth(0).selectOption("MCU.GP5");
|
||||||
|
await page.locator(".edit-form select").nth(1).selectOption("MCU.GP6");
|
||||||
|
await page.getByRole("button", { name: "配線を保存" }).click();
|
||||||
|
const batchView = page.locator(".wiring-box [data-testid=keyboard-view]").nth(1);
|
||||||
|
for (let i = 0; i < 3; i++) await batchView.locator(".kv-key").nth(i).click();
|
||||||
|
await page.getByTestId("batch-row-select").selectOption("MCU.GP0");
|
||||||
|
await page.getByRole("button", { name: "rowへ追加" }).click();
|
||||||
|
await page.getByTestId("batch-col-select").selectOption("MCU.GP1");
|
||||||
|
await page.getByRole("button", { name: "colへ追加" }).click();
|
||||||
|
await page.getByTestId("batch-wire").click();
|
||||||
|
await expect(page.getByTestId("batch-capacity-error")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("batch-confirm")).toBeDisabled();
|
||||||
|
await page.getByTestId("batch-col-select").selectOption("MCU.GP2");
|
||||||
|
await page.getByRole("button", { name: "colへ追加" }).click();
|
||||||
|
await page.getByTestId("batch-wire").click();
|
||||||
|
const rows = page.getByTestId("batch-preview").locator("tbody tr");
|
||||||
|
await expect(rows).toHaveCount(3);
|
||||||
|
await expect(rows.first()).toContainText("上書き");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diode direction is part of collision identity and unknown MCU parts accept free pin names", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
let source = await projectJson(page);
|
||||||
|
const [k1, k2] = source.idx.order;
|
||||||
|
source.elc.providers = [{ id: "MCU", kind: "mcu", part: "RP2040" }];
|
||||||
|
source.elc.keys = [
|
||||||
|
{ key: k1, detect: "contact", terminals: { a: "MCU.GP0", b: "MCU.GP1" }, diode: "a-to-b" },
|
||||||
|
{ key: k2, detect: "contact", terminals: { a: "MCU.GP0", b: "MCU.GP1" }, diode: "b-to-a" },
|
||||||
|
];
|
||||||
|
await loadProject(page, source);
|
||||||
|
await page.getByRole("button", { name: "ELC" }).click();
|
||||||
|
await expect(page.getByText("wiring-collision", { exact: false })).toHaveCount(0);
|
||||||
|
source.elc.keys[1].diode = "a-to-b";
|
||||||
|
await loadProject(page, source);
|
||||||
|
await page.getByRole("button", { name: "ELC" }).click();
|
||||||
|
await expect(page.getByText("wiring-collision", { exact: false }).first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("provider-id").fill("ALT");
|
||||||
|
await page.locator("input[list=mcu-parts]").fill("Unknown-MCU");
|
||||||
|
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
|
||||||
|
await expect(page.getByText("未知の品番はピン名を自由入力します。")).toBeVisible();
|
||||||
|
const key = page.locator(".wiring-box .kv-key").nth(2);
|
||||||
|
await key.click();
|
||||||
|
await page.getByLabel("a端子自由入力").fill("ALT.PIN_X");
|
||||||
|
await page.getByLabel("b端子自由入力").fill("ALT.PIN_Y");
|
||||||
|
await page.getByRole("button", { name: "配線を保存" }).click();
|
||||||
|
await expect(page.getByTestId("unwired-count")).toHaveText("13");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("LED chain candidates include only per-key LED keys and @led parts across all variants", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
const source = await projectJson(page);
|
||||||
|
const keys = [] as any[];
|
||||||
|
const walk = (nodes: any[]) =>
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
if (n.type === "key") keys.push(n);
|
||||||
|
else if (n.type === "cluster") walk(n.children);
|
||||||
|
else if (n.type === "variantGroup") n.children.forEach((v: any) => walk(v.children));
|
||||||
|
});
|
||||||
|
walk(source.phy.root);
|
||||||
|
keys[0].perKeyLED = true;
|
||||||
|
keys[1].perKeyLED = false;
|
||||||
|
source.phy.root.push({
|
||||||
|
type: "component",
|
||||||
|
id: "cmp-test-led",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
partGroup: "@led",
|
||||||
|
});
|
||||||
|
source.phy.root.push({
|
||||||
|
type: "component",
|
||||||
|
id: "cmp-test-other",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
partGroup: "@screw",
|
||||||
|
});
|
||||||
|
await loadProject(page, source);
|
||||||
|
await page.getByRole("button", { name: "ELC" }).click();
|
||||||
|
await page.getByLabel("チェーンID").fill("LED1");
|
||||||
|
await page.getByLabel("データピン").fill("MCU.DATA");
|
||||||
|
await page.getByRole("button", { name: "作成" }).click();
|
||||||
|
await expect(page.getByTestId("led-candidate").locator(`option[value="${keys[0].id}"]`)).toHaveCount(1);
|
||||||
|
await expect(page.getByTestId("led-candidate").locator('option[value="cmp-test-led"]')).toHaveCount(1);
|
||||||
|
await expect(page.getByTestId("led-candidate").locator(`option[value="${keys[1].id}"]`)).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("led-candidate").locator('option[value="cmp-test-other"]')).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
test("generates the 4x4 preview and blocks output when a wire is removed", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent("download"),
|
||||||
|
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||||
|
]);
|
||||||
|
const file = path.join(test.info().outputDir, download.suggestedFilename());
|
||||||
|
await download.saveAs(file);
|
||||||
|
const source = JSON.parse(await (await import("node:fs/promises")).readFile(file, "utf8"));
|
||||||
|
const keyIds: string[] = source.idx.order;
|
||||||
|
source.elc.providers = [{ id: "MCU", kind: "mcu", part: "RP2040" }];
|
||||||
|
source.elc.keys = keyIds.map((key, i) => ({
|
||||||
|
key,
|
||||||
|
detect: "contact",
|
||||||
|
terminals: { a: `MCU.GP${Math.floor(i / 4)}`, b: `MCU.GP${4 + (i % 4)}` },
|
||||||
|
diode: "a-to-b",
|
||||||
|
}));
|
||||||
|
source.map.layers[0].keymap = keyIds.map((key, i) => ({ keyId: key, keycode: `KC_${i % 10}` }));
|
||||||
|
const uploadProject = async (obj: unknown) => {
|
||||||
|
const chooserPromise = page.waitForEvent("filechooser");
|
||||||
|
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||||
|
await (
|
||||||
|
await chooserPromise
|
||||||
|
).setFiles({
|
||||||
|
name: "fixture.bkle.json",
|
||||||
|
mimeType: "application/json",
|
||||||
|
buffer: Buffer.from(JSON.stringify(obj)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
await uploadProject(source);
|
||||||
|
await page.getByRole("button", { name: "Generator" }).click();
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
await page.getByTestId("row-pin-select").selectOption(`MCU.GP${i}`);
|
||||||
|
await page.getByRole("button", { name: "row追加" }).click();
|
||||||
|
await page.getByTestId("col-pin-select").selectOption(`MCU.GP${i + 4}`);
|
||||||
|
await page.getByRole("button", { name: "col追加" }).click();
|
||||||
|
}
|
||||||
|
await page.getByTestId("generate-button").click();
|
||||||
|
await expect(page.getByTestId("mapping-count")).toHaveText("16行");
|
||||||
|
const rows = page.locator(".preview tbody tr");
|
||||||
|
await expect(rows).toHaveCount(16);
|
||||||
|
await expect(rows.nth(0).locator("td").nth(2)).toHaveText("0");
|
||||||
|
await expect(rows.nth(0).locator("td").nth(3)).toHaveText("0");
|
||||||
|
await expect(rows.nth(4).locator("td").nth(2)).toHaveText("1");
|
||||||
|
await expect(rows.nth(4).locator("td").nth(3)).toHaveText("0");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "ELC" }).click();
|
||||||
|
await page.locator(".wiring-box [data-testid=keyboard-view] .kv-key").first().click();
|
||||||
|
await page.getByRole("button", { name: "配線解除" }).click();
|
||||||
|
await page.getByRole("button", { name: "Generator" }).click();
|
||||||
|
await page.getByTestId("generate-button").click();
|
||||||
|
await expect(page.locator(".errors").getByText("unwired-key", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("mapping-count")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("project undo invalidates a result generated from the newer project state", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "ELC" }).click();
|
||||||
|
await page.getByTestId("provider-id").fill("MCU");
|
||||||
|
await page.getByRole("button", { name: "MCUを追加・更新" }).click();
|
||||||
|
await page.getByRole("button", { name: "Generator" }).click();
|
||||||
|
await page.getByTestId("generate-button").click();
|
||||||
|
await expect(page.locator(".errors")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Undo" }).click();
|
||||||
|
await expect(page.locator(".errors")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("mapping-count")).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: "Redo" }).click();
|
||||||
|
await expect(page.locator(".errors")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("mapping-count")).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const canvasKeys = (page: Page) => page.locator('[data-testid="idx-canvas"] .key');
|
||||||
|
const numberOf = (page: Page, i: number) => canvasKeys(page).nth(i).getByTestId("idx-number");
|
||||||
|
|
||||||
|
/** Drags key `from` onto key `to`, releasing at `fraction` of the target's width. */
|
||||||
|
async function dragKey(page: Page, from: number, to: number, fraction: number) {
|
||||||
|
const a = (await canvasKeys(page).nth(from).locator("rect").boundingBox())!;
|
||||||
|
const b = (await canvasKeys(page).nth(to).locator("rect").boundingBox())!;
|
||||||
|
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(a.x + a.width / 2 + 10, a.y + a.height / 2, { steps: 3 });
|
||||||
|
await page.mouse.move(b.x + b.width * fraction, b.y + b.height / 2, { steps: 5 });
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||||
|
await expect(canvasKeys(page)).toHaveCount(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drops at the edge of a key insert before or after it, and undo restores", async ({ page }) => {
|
||||||
|
await dragKey(page, 0, 3, 0.9);
|
||||||
|
await expect(numberOf(page, 0)).toHaveText("3");
|
||||||
|
await expect(numberOf(page, 3)).toHaveText("2");
|
||||||
|
await expect(numberOf(page, 1)).toHaveText("0");
|
||||||
|
await dragKey(page, 0, 1, 0.1);
|
||||||
|
await expect(numberOf(page, 0)).toHaveText("0");
|
||||||
|
await page.getByRole("button", { name: "Undo" }).click();
|
||||||
|
await expect(numberOf(page, 0)).toHaveText("3");
|
||||||
|
await page.getByRole("button", { name: "Undo" }).click();
|
||||||
|
await expect(numberOf(page, 0)).toHaveText("0");
|
||||||
|
await expect(numberOf(page, 3)).toHaveText("3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a drop in the middle of a key swaps the two and leaves the others", async ({ page }) => {
|
||||||
|
await dragKey(page, 0, 5, 0.5);
|
||||||
|
await expect(numberOf(page, 0)).toHaveText("5");
|
||||||
|
await expect(numberOf(page, 5)).toHaveText("0");
|
||||||
|
await expect(numberOf(page, 1)).toHaveText("1");
|
||||||
|
await expect(numberOf(page, 4)).toHaveText("4");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a key added in PHY is registered at the end and can be dragged to the front", async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "PHY", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: "キーを足す" }).click();
|
||||||
|
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||||
|
await expect(numberOf(page, 16)).toHaveText("16");
|
||||||
|
await dragKey(page, 16, 0, 0.1);
|
||||||
|
await expect(numberOf(page, 16)).toHaveText("0");
|
||||||
|
await expect(numberOf(page, 0)).toHaveText("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the variant tree switches the shown configuration without touching other keys' order", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.getByRole("button", { name: "PHY", exact: true }).click();
|
||||||
|
const keys = page.locator('[data-node-type="key"]');
|
||||||
|
const firstId = await keys.nth(0).getAttribute("data-node-id");
|
||||||
|
await keys.nth(0).click();
|
||||||
|
await keys.nth(1).click({ modifiers: ["Shift"] });
|
||||||
|
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
|
||||||
|
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||||
|
const optionA = page.locator(".tree").getByRole("button", { name: /Option A/ });
|
||||||
|
const optionB = page.locator(".tree").getByRole("button", { name: /Option B/ });
|
||||||
|
await expect(optionA).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(page.locator(`[data-testid="idx-canvas"] [data-key-id="${firstId}"]`)).toHaveCount(1);
|
||||||
|
await optionB.click();
|
||||||
|
await expect(optionB).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(page.locator(`[data-testid="idx-canvas"] [data-key-id="${firstId}"]`)).toHaveCount(0);
|
||||||
|
await expect(canvasKeys(page)).toHaveCount(14);
|
||||||
|
await expect(numberOf(page, 0)).toHaveText("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a column-major preset previews, applies as one undo step", async ({ page }) => {
|
||||||
|
await page.getByLabel("走査").selectOption({ label: "列優先" });
|
||||||
|
await page.getByRole("button", { name: "プレビュー" }).click();
|
||||||
|
await expect(numberOf(page, 4)).toHaveText("1");
|
||||||
|
await expect(page.locator(".scan-line")).toHaveCount(4);
|
||||||
|
await page.getByRole("button", { name: "適用" }).click();
|
||||||
|
await expect(numberOf(page, 4)).toHaveText("1");
|
||||||
|
await expect(numberOf(page, 1)).toHaveText("4");
|
||||||
|
await page.getByRole("button", { name: "Undo" }).click();
|
||||||
|
await expect(numberOf(page, 1)).toHaveText("1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the preview shifts a picked column by one row and Esc discards it", async ({ page }) => {
|
||||||
|
await page.getByRole("button", { name: "プレビュー" }).click();
|
||||||
|
await canvasKeys(page).nth(0).click();
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
// column 0 now belongs one row lower: row 0 is keys 1..3
|
||||||
|
await expect(numberOf(page, 1)).toHaveText("0");
|
||||||
|
await expect(numberOf(page, 0)).toHaveText("3");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByRole("button", { name: "プレビュー" })).toBeVisible();
|
||||||
|
await expect(numberOf(page, 0)).toHaveText("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a range selection renumbers from its first key's position", async ({ page }) => {
|
||||||
|
const k2 = (await canvasKeys(page).nth(2).locator("rect").boundingBox())!;
|
||||||
|
const k15 = (await canvasKeys(page).nth(15).locator("rect").boundingBox())!;
|
||||||
|
await page.mouse.move(k2.x + 5, k2.y - 20);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(k15.x + k15.width + 20, k15.y + k15.height + 20, { steps: 6 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.getByText("選択範囲(8)")).toBeVisible();
|
||||||
|
await page.getByText("選択範囲(8)").click();
|
||||||
|
await page.getByLabel("走査").selectOption({ label: "列優先" });
|
||||||
|
await page.getByRole("button", { name: "プレビュー" }).click();
|
||||||
|
await page.getByRole("button", { name: "適用" }).click();
|
||||||
|
// 0, 1, then the right half column by column, then the rest of the left half
|
||||||
|
await expect(numberOf(page, 2)).toHaveText("2");
|
||||||
|
await expect(numberOf(page, 6)).toHaveText("3");
|
||||||
|
await expect(numberOf(page, 3)).toHaveText("6");
|
||||||
|
await expect(numberOf(page, 4)).toHaveText("10");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("importing a QMK layout copies its key order onto matching keys", async ({ page }) => {
|
||||||
|
const layout = [0, 1, 2, 3].flatMap((x) => [0, 1, 2, 3].map((y) => ({ matrix: [y, x], x, y })));
|
||||||
|
await page.getByTestId("idx-import-file").setInputFiles({
|
||||||
|
name: "keyboard.json",
|
||||||
|
mimeType: "application/json",
|
||||||
|
buffer: Buffer.from(JSON.stringify({ layouts: { LAYOUT: { layout } } })),
|
||||||
|
});
|
||||||
|
await expect(page.getByTestId("idx-import-result")).toContainText("16件を照合");
|
||||||
|
await expect(numberOf(page, 4)).toHaveText("1");
|
||||||
|
await expect(numberOf(page, 1)).toHaveText("4");
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import path from "node:path";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "MAP", exact: true }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("assign codes and rename referenced layers safely", async ({ page }) => {
|
||||||
|
await page.getByTestId("map-add-layer").click();
|
||||||
|
await expect(page.getByRole("alert")).toContainText("レイヤー名を入力してください");
|
||||||
|
await page.getByLabel("追加するレイヤー名").fill("Base");
|
||||||
|
await page.getByTestId("map-add-layer").click();
|
||||||
|
await expect(page.getByRole("alert")).toContainText("already exists");
|
||||||
|
|
||||||
|
const keys = page.locator('[data-testid="keyboard-view"] [data-key-id]');
|
||||||
|
const firstId = await keys.nth(0).getAttribute("data-key-id");
|
||||||
|
const secondId = await keys.nth(1).getAttribute("data-key-id");
|
||||||
|
await keys.nth(0).click();
|
||||||
|
await page.getByLabel("キーコード").fill("KC_A");
|
||||||
|
await page.getByRole("button", { name: "割り当て", exact: true }).click();
|
||||||
|
await expect(page.locator(`[data-key-id="${firstId}"] text`)).toContainText("KC_A");
|
||||||
|
|
||||||
|
await page.getByLabel("追加するレイヤー名").fill("Nav");
|
||||||
|
await page.getByTestId("map-add-layer").click();
|
||||||
|
await expect(page.getByRole("tab", { name: "Nav" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
await keys.nth(1).click();
|
||||||
|
await page.getByLabel("キーコード").fill("MO(Nav)");
|
||||||
|
await page.getByRole("button", { name: "割り当て", exact: true }).click();
|
||||||
|
await expect(page.locator(`[data-key-id="${secondId}"] text`)).toContainText("MO(Nav)");
|
||||||
|
|
||||||
|
await page.getByLabel("新しい名前").fill("Navigation");
|
||||||
|
await page.getByTestId("map-rename-layer").click();
|
||||||
|
await expect(page.locator(`[data-key-id="${secondId}"] text`)).toContainText("MO(Navigation)");
|
||||||
|
await page.getByTestId("map-delete-layer").click();
|
||||||
|
await expect(page.getByRole("alert")).toContainText("referenced");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("assignment click creates one undo step and unknown existing codes stay read-only", async ({ page }) => {
|
||||||
|
const key = page.locator('[data-testid="keyboard-view"] [data-key-id]').first();
|
||||||
|
const id = await key.getAttribute("data-key-id");
|
||||||
|
await key.click();
|
||||||
|
await page.getByLabel("キーコード").fill("KC_A");
|
||||||
|
await page.getByRole("button", { name: "割り当て", exact: true }).click();
|
||||||
|
await expect(page.locator(`[data-key-id="${id}"] text`)).toContainText("KC_A");
|
||||||
|
await page.getByRole("button", { name: "Undo" }).click();
|
||||||
|
await expect(page.locator(`[data-key-id="${id}"] text`)).not.toContainText("KC_A");
|
||||||
|
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent("download"),
|
||||||
|
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||||
|
]);
|
||||||
|
const filePath = path.join(test.info().outputDir, download.suggestedFilename());
|
||||||
|
await download.saveAs(filePath);
|
||||||
|
const file = await readFile(filePath, "utf8");
|
||||||
|
const source = JSON.parse(file);
|
||||||
|
source.map.layers[0].keymap = [{ keyId: id, keycode: "KC_UNKNOWN_VENDOR_CODE" }];
|
||||||
|
const chooser = page.waitForEvent("filechooser");
|
||||||
|
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||||
|
await (
|
||||||
|
await chooser
|
||||||
|
).setFiles({
|
||||||
|
name: "unknown.bkle.json",
|
||||||
|
mimeType: "application/json",
|
||||||
|
buffer: Buffer.from(JSON.stringify(source)),
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "MAP", exact: true }).click();
|
||||||
|
await page.locator(`[data-testid="keyboard-view"] [data-key-id="${id}"]`).click();
|
||||||
|
await expect(page.getByRole("status")).toContainText("未知のキーコード");
|
||||||
|
await expect(page.getByRole("button", { name: "割り当て", exact: true })).toBeDisabled();
|
||||||
|
await expect(page.getByRole("button", { name: "割り当て解除" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MAP editing controls are disabled when profile unit sizes differ", async ({ page }) => {
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent("download"),
|
||||||
|
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||||
|
]);
|
||||||
|
const filePath = path.join(test.info().outputDir, download.suggestedFilename());
|
||||||
|
await download.saveAs(filePath);
|
||||||
|
const source = JSON.parse(await readFile(filePath, "utf8"));
|
||||||
|
source.phy.profiles.push({
|
||||||
|
...source.phy.profiles[0],
|
||||||
|
id: "alternate",
|
||||||
|
name: "Alternate",
|
||||||
|
unitSize: { x: 18, y: 18 },
|
||||||
|
});
|
||||||
|
const chooser = page.waitForEvent("filechooser");
|
||||||
|
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||||
|
await (
|
||||||
|
await chooser
|
||||||
|
).setFiles({
|
||||||
|
name: "unit-mismatch.bkle.json",
|
||||||
|
mimeType: "application/json",
|
||||||
|
buffer: Buffer.from(JSON.stringify(source)),
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "MAP", exact: true }).click();
|
||||||
|
await expect(page.getByTestId("map-add-layer")).toBeDisabled();
|
||||||
|
await expect(page.getByTestId("map-rename-layer")).toBeDisabled();
|
||||||
|
await expect(page.getByTestId("map-delete-layer")).toBeDisabled();
|
||||||
|
await expect(page.getByLabel("キーコード")).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,771 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function makeCluster(page: import("@playwright/test").Page) {
|
||||||
|
const keys = page.locator('[data-node-type="key"]');
|
||||||
|
await keys.nth(0).click();
|
||||||
|
await keys.nth(1).click({ modifiers: ["Shift"] });
|
||||||
|
await page.getByRole("button", { name: "クラスターにする" }).click();
|
||||||
|
return page.locator('[data-node-type="cluster"]').first();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("階層パネルを閉じると、キャンバスが空いた幅を埋める", async ({ page }) => {
|
||||||
|
const canvas = page.locator(".canvas-pane");
|
||||||
|
const properties = page.locator(".properties");
|
||||||
|
const before = (await canvas.boundingBox())!;
|
||||||
|
await page.getByRole("button", { name: "階層" }).click();
|
||||||
|
await expect(page.locator(".tree")).toHaveCount(0);
|
||||||
|
await expect.poll(async () => (await canvas.boundingBox())!.width).toBeGreaterThan(before.width + 200);
|
||||||
|
const after = (await canvas.boundingBox())!;
|
||||||
|
const side = (await properties.boundingBox())!;
|
||||||
|
const workspace = (await page.locator(".workspace").boundingBox())!;
|
||||||
|
expect(Math.abs(after.x - workspace.x)).toBeLessThan(1);
|
||||||
|
expect(Math.abs(side.x + side.width - (workspace.x + workspace.width))).toBeLessThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("選択レイヤーはキーより前面にあり、回転とフォーカスに追従する", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
const keys = page.locator('[data-node-type="key"]');
|
||||||
|
const leftId = await keys.first().getAttribute("data-node-id");
|
||||||
|
await keys.first().click();
|
||||||
|
const order = await page.locator("svg.canvas").evaluate((svg, id) => {
|
||||||
|
const face = svg.querySelector(`[data-node-id="${id}"]`)!;
|
||||||
|
const layer = svg.querySelector(`[data-sel-id="${id}"]`)!;
|
||||||
|
return [
|
||||||
|
face.compareDocumentPosition(layer) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
layer.parentElement?.classList.contains("selection-layer"),
|
||||||
|
];
|
||||||
|
}, leftId);
|
||||||
|
expect(order).toEqual([4, true]);
|
||||||
|
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
const clusterId = await cluster.getAttribute("data-node-id");
|
||||||
|
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
|
||||||
|
await page.getByLabel("Rotation").fill("35");
|
||||||
|
await page.getByLabel("Rotation").dispatchEvent("change");
|
||||||
|
const key = cluster.locator('[data-node-type="key"]').first();
|
||||||
|
const id = await key.getAttribute("data-node-id");
|
||||||
|
await key.click();
|
||||||
|
const aligned = async () =>
|
||||||
|
page.evaluate((keyId) => {
|
||||||
|
const face = document.querySelector(`[data-node-id="${keyId}"] .key-face`)!.getBoundingClientRect();
|
||||||
|
const outline = document.querySelector(`[data-sel-id="${keyId}"]`)!.getBoundingClientRect();
|
||||||
|
return [
|
||||||
|
Math.abs(face.x - outline.x),
|
||||||
|
Math.abs(face.y - outline.y),
|
||||||
|
Math.abs(face.width - outline.width),
|
||||||
|
Math.abs(face.height - outline.height),
|
||||||
|
];
|
||||||
|
}, id);
|
||||||
|
expect((await aligned()).every((delta) => delta < 1)).toBe(true);
|
||||||
|
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
|
||||||
|
await page.locator(".canvas-pane").focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
const focused = page.locator(`[data-node-id="${id}"]`).last();
|
||||||
|
await focused.click();
|
||||||
|
expect((await aligned()).every((delta) => delta < 1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ドラッグの刻みをUndo/Redoでき、コンソールにエラーがない", async ({ page }) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") errors.push(message.text());
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
|
||||||
|
const key = page.locator('[data-node-type="key"]').first();
|
||||||
|
const before = await key.getAttribute("transform");
|
||||||
|
const box = await key.boundingBox();
|
||||||
|
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box!.x + box!.width / 2 + 24, box!.y + box!.height / 2, { steps: 3 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect.poll(() => key.getAttribute("transform")).not.toBe(before);
|
||||||
|
const after = await key.getAttribute("transform");
|
||||||
|
await page.getByRole("button", { name: "Undo" }).click();
|
||||||
|
await expect(key).toHaveAttribute("transform", before!);
|
||||||
|
await page.getByRole("button", { name: "Redo" }).click();
|
||||||
|
await expect(key).toHaveAttribute("transform", after!);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ドラッグ中に右端で自動スクロールし、Undo 1回で戻る", async ({ page }) => {
|
||||||
|
const key = page.locator('[data-node-type="key"]').first();
|
||||||
|
const before = await key.getAttribute("transform");
|
||||||
|
const keyBox = (await key.boundingBox())!;
|
||||||
|
const canvasBox = (await page.locator("svg.canvas").boundingBox())!;
|
||||||
|
const y = keyBox.y + keyBox.height / 2;
|
||||||
|
await page.mouse.move(keyBox.x + keyBox.width / 2, y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(canvasBox.x + canvasBox.width - 8, y, { steps: 8 });
|
||||||
|
const atEdge = await key.getAttribute("transform");
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
expect(await key.getAttribute("transform")).not.toBe(atEdge);
|
||||||
|
await page.mouse.up();
|
||||||
|
const released = await key.getAttribute("transform");
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
expect(await key.getAttribute("transform")).toBe(released);
|
||||||
|
await page.getByRole("button", { name: "Undo" }).click();
|
||||||
|
await expect(key).toHaveAttribute("transform", before!);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("選択中のクラスターにだけ原点の丸を出す", async ({ page }) => {
|
||||||
|
const keys = page.locator('[data-node-type="key"]');
|
||||||
|
const corner = (await keys.nth(0).locator(".key-face").boundingBox())!;
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
const clusterId = await cluster.getAttribute("data-node-id");
|
||||||
|
await keys.nth(5).click();
|
||||||
|
await expect(page.locator(".origin")).toHaveCount(0);
|
||||||
|
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
|
||||||
|
const origin = page.locator(`[data-origin-id="${clusterId}"]`);
|
||||||
|
await expect(origin).toHaveCount(1);
|
||||||
|
const box = (await origin.boundingBox())!;
|
||||||
|
expect(Math.abs(box.x + box.width / 2 - corner.x)).toBeLessThan(2);
|
||||||
|
expect(Math.abs(box.y + box.height / 2 - corner.y)).toBeLessThan(2);
|
||||||
|
await keys.nth(5).click();
|
||||||
|
await expect(page.locator(".origin")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("クラスター化で画面位置を保つ", async ({ page }) => {
|
||||||
|
const keys = page.locator('[data-node-type="key"]');
|
||||||
|
const centers = async () =>
|
||||||
|
keys.evaluateAll((es) =>
|
||||||
|
es.slice(0, 2).map((e) => {
|
||||||
|
const r = e.getBoundingClientRect();
|
||||||
|
return [r.x + r.width / 2, r.y + r.height / 2];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const before = await centers();
|
||||||
|
await keys.nth(0).click();
|
||||||
|
await keys.nth(1).click({ modifiers: ["Shift"] });
|
||||||
|
await page.getByRole("button", { name: "クラスターにする" }).click();
|
||||||
|
const after = await centers();
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
expect(after[i]![0]).toBeCloseTo(before[i]![0]!, 0);
|
||||||
|
expect(after[i]![1]).toBeCloseTo(before[i]![1]!, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("複数選択のドラッグが全件に適用される", async ({ page }) => {
|
||||||
|
const keys = page.locator('[data-node-type="key"]');
|
||||||
|
const before = await keys.evaluateAll((es) => es.slice(0, 2).map((e) => e.getAttribute("transform")));
|
||||||
|
await keys.nth(0).click();
|
||||||
|
await keys.nth(1).click({ modifiers: ["Shift"] });
|
||||||
|
const box = await keys.nth(0).boundingBox();
|
||||||
|
if (!box) throw new Error("key missing");
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box.x + box.width / 2 + 27, box.y + box.height / 2 + 10, { steps: 3 });
|
||||||
|
await page.mouse.up();
|
||||||
|
const after = await keys.evaluateAll((es) => es.slice(0, 2).map((e) => e.getAttribute("transform")));
|
||||||
|
expect(after[0]).not.toBe(before[0]);
|
||||||
|
expect(after[1]).not.toBe(before[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("回転クラスターの矢印移動は親軸で動き、ドラッグもポインタに追従する", async ({ page }) => {
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
const clusterId = await cluster.getAttribute("data-node-id");
|
||||||
|
await page.locator(`[data-tree-id="${clusterId}"]`).click();
|
||||||
|
await page.getByLabel("Rotation").fill("90");
|
||||||
|
await page.getByLabel("Rotation").dispatchEvent("change");
|
||||||
|
const key = page.locator('[data-node-type="key"]').first();
|
||||||
|
const beforeBox = await key.boundingBox();
|
||||||
|
const beforeTransform = await key.getAttribute("transform");
|
||||||
|
await key.click();
|
||||||
|
const pane = page.locator(".canvas-pane");
|
||||||
|
await pane.focus();
|
||||||
|
await page.keyboard.press("ArrowRight");
|
||||||
|
const afterBox = await key.boundingBox();
|
||||||
|
expect(afterBox!.y).toBeGreaterThan(beforeBox!.y + 4);
|
||||||
|
const dragBox = await key.boundingBox();
|
||||||
|
await page.mouse.move(dragBox!.x + dragBox!.width / 2, dragBox!.y + dragBox!.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(dragBox!.x + dragBox!.width / 2 + 28, dragBox!.y + dragBox!.height / 2, { steps: 3 });
|
||||||
|
await page.mouse.up();
|
||||||
|
const dragAfterBox = await key.boundingBox();
|
||||||
|
expect(await key.getAttribute("transform")).not.toBe(beforeTransform);
|
||||||
|
expect(dragAfterBox!.x).toBeGreaterThan(dragBox!.x + 10);
|
||||||
|
expect(dragAfterBox!.y).toBeCloseTo(dragBox!.y, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("長押しでクラスターを選び、ダブルクリックでフォーカスしてEscで戻る", async ({ page }) => {
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
const clusterId = await cluster.getAttribute("data-node-id");
|
||||||
|
const key = page.locator('[data-node-type="key"]').first();
|
||||||
|
const b = await key.boundingBox();
|
||||||
|
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect(page.locator(".hold-row.active")).toContainText("Cluster", { timeout: 2000 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
||||||
|
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
|
||||||
|
const keyBox = await key.boundingBox();
|
||||||
|
await page.mouse.dblclick(keyBox!.x + keyBox!.width / 2, keyBox!.y + keyBox!.height / 2, { delay: 80 });
|
||||||
|
await expect(page.getByRole("button", { name: "root", exact: true })).toBeVisible();
|
||||||
|
const pane = page.locator(".canvas-pane");
|
||||||
|
await pane.focus();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByRole("button", { name: "root", exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("入れ子の回転クラスターをフォーカスできる", async ({ page }) => {
|
||||||
|
const outer = await makeCluster(page);
|
||||||
|
const outerId = await outer.getAttribute("data-node-id");
|
||||||
|
const childIds = await outer
|
||||||
|
.locator('[data-node-type="key"]')
|
||||||
|
.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||||
|
await page.getByText("キー・部品 2件", { exact: true }).click();
|
||||||
|
await page.locator(`[data-tree-id="${childIds[0]}"]`).first().click();
|
||||||
|
await page
|
||||||
|
.locator(`[data-tree-id="${childIds[1]}"]`)
|
||||||
|
.first()
|
||||||
|
.click({ modifiers: ["Shift"] });
|
||||||
|
await page.getByRole("button", { name: "クラスターにする" }).click();
|
||||||
|
const inner = outer.locator('[data-node-type="cluster"]').first();
|
||||||
|
const innerId = await inner.getAttribute("data-node-id");
|
||||||
|
await page.locator(`[data-tree-id="${outerId}"]`).first().click();
|
||||||
|
await page.getByLabel("Rotation").fill("35");
|
||||||
|
await page.getByLabel("Rotation").dispatchEvent("change");
|
||||||
|
const key = inner.locator('[data-node-type="key"]').first();
|
||||||
|
await expect(key).toBeVisible();
|
||||||
|
await page.locator(`[data-tree-id="${outerId}"]`).first().click();
|
||||||
|
await page.locator(".canvas-pane").focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page.getByRole("button", { name: "root", exact: true })).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-node-id="${childIds[0]}"]`).last()).toBeVisible();
|
||||||
|
const focusedKey = page.locator(`[data-node-id="${childIds[0]}"]`).last();
|
||||||
|
const focusedBox = await focusedKey.boundingBox();
|
||||||
|
await page.mouse.move(focusedBox!.x + focusedBox!.width / 2, focusedBox!.y + focusedBox!.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
await expect(page.locator(`[data-sel-id="${innerId}"].candidate`)).toBeVisible({
|
||||||
|
timeout: 1800,
|
||||||
|
});
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.screenshot({ path: path.join(test.info().outputDir, "rotated-long-press-in-focus.png") });
|
||||||
|
const clickBox = await focusedKey.boundingBox();
|
||||||
|
await page.mouse.dblclick(clickBox!.x + clickBox!.width / 2, clickBox!.y + clickBox!.height / 2, {
|
||||||
|
delay: 80,
|
||||||
|
});
|
||||||
|
await expect(page.locator(`[data-node-id="${innerId}"]`)).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("選択枠は画面上で見え、バリアントバッジで表示を切り替えられる", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
const keys = page.locator('[data-node-type="key"]');
|
||||||
|
await keys.first().click();
|
||||||
|
const outline = page.locator(".selection-layer .sel").first();
|
||||||
|
await expect(outline).toBeVisible();
|
||||||
|
expect(await outline.evaluate((e) => getComputedStyle(e).strokeWidth)).toBe("3.5px");
|
||||||
|
await keys.nth(1).click({ modifiers: ["Shift"] });
|
||||||
|
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
|
||||||
|
const group = page.locator('[data-node-type="variantGroup"]').first();
|
||||||
|
await page.getByLabel("Rotation").fill("35");
|
||||||
|
await page.getByLabel("Rotation").dispatchEvent("change");
|
||||||
|
const badge = page.locator(`[data-badge-id="${await group.first().getAttribute("data-node-id")}"]`);
|
||||||
|
await expect(badge).toBeVisible();
|
||||||
|
await badge.locator("rect").click();
|
||||||
|
await page
|
||||||
|
.locator(".popover")
|
||||||
|
.getByRole("button", { name: /Option B/ })
|
||||||
|
.click();
|
||||||
|
await expect(keys).toHaveCount(14);
|
||||||
|
await page.locator(".canvas-pane").focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page.getByRole("button", { name: "root", exact: true })).toBeVisible();
|
||||||
|
await badge.locator("rect").click();
|
||||||
|
const focusedBadgeBox = await badge.boundingBox();
|
||||||
|
const focusedPopoverBox = await page.locator(".popover").boundingBox();
|
||||||
|
expect(
|
||||||
|
Math.hypot(focusedPopoverBox!.x - focusedBadgeBox!.x, focusedPopoverBox!.y - focusedBadgeBox!.y),
|
||||||
|
).toBeLessThanOrEqual(80);
|
||||||
|
await page.screenshot({ path: path.join(test.info().outputDir, "rotated-badge-in-focus.png") });
|
||||||
|
await page
|
||||||
|
.locator(".popover")
|
||||||
|
.getByRole("button", { name: /Option A/ })
|
||||||
|
.click();
|
||||||
|
await expect(keys).toHaveCount(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("クラスター内のキーから作ったバリアントグループはクラスターの中にでき、バッジは名前を書かない", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
const key = cluster.locator('[data-node-type="key"]').first();
|
||||||
|
await key.click();
|
||||||
|
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
|
||||||
|
const group = cluster.locator('[data-node-type="variantGroup"]');
|
||||||
|
await expect(group).toHaveCount(1);
|
||||||
|
const badge = page.locator(`[data-badge-id="${await group.first().getAttribute("data-node-id")}"]`);
|
||||||
|
await expect(badge.locator("text")).toHaveText("⇄");
|
||||||
|
await expect(badge.locator("title")).toHaveText("Variant Group: Option A(既定)");
|
||||||
|
// バッジはキーより後に描き、隣のキーの下に隠れない
|
||||||
|
const onTop = await badge.evaluate((element) => {
|
||||||
|
const keys = [...document.querySelectorAll('[data-node-type="key"]')];
|
||||||
|
const box = element.getBoundingClientRect();
|
||||||
|
const hit = document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
|
return [
|
||||||
|
keys.every((key) => key.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||||||
|
element.contains(hit),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
expect(onTop).toEqual([true, true]);
|
||||||
|
await badge.locator("rect").click();
|
||||||
|
const popover = page.locator(".popover");
|
||||||
|
await expect(popover.getByRole("button", { name: /Option A/ }).locator(".default-tag")).toHaveText("既定");
|
||||||
|
await expect(popover.getByRole("button", { name: /Option B/ }).locator(".default-tag")).toHaveCount(0);
|
||||||
|
await popover.getByRole("button", { name: /Option B/ }).click();
|
||||||
|
await expect(badge.locator("title")).toHaveText("Variant Group: Option B");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("削除確認は参照を列挙し、実行後はIDXからも消える", async ({ page }) => {
|
||||||
|
const key = page.locator('[data-node-type="key"]').first();
|
||||||
|
const id = await key.getAttribute("data-node-id");
|
||||||
|
await key.click();
|
||||||
|
await page.getByRole("button", { name: "削除", exact: true }).click();
|
||||||
|
const confirm = page.locator(".confirm-float");
|
||||||
|
await expect(confirm).toContainText("参照");
|
||||||
|
await expect(confirm).toContainText(id!);
|
||||||
|
await confirm.getByRole("button", { name: "削除", exact: true }).click();
|
||||||
|
await expect(page.locator(`[data-node-id="${id}"]`)).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: "IDX", exact: true }).click();
|
||||||
|
await expect(page.locator(`[data-testid="idx-canvas"] [data-key-id="${id}"]`)).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("idx-register-all")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("コピー貼り付けはIDを変えて1刻みずらし、KLE貼り付けはキーを追加する", async ({ page }) => {
|
||||||
|
const key = page.locator('[data-node-type="key"]').first();
|
||||||
|
const id = await key.getAttribute("data-node-id");
|
||||||
|
const original = await key.getAttribute("transform");
|
||||||
|
await key.click();
|
||||||
|
const marker = await page.evaluate(() => {
|
||||||
|
const data = new DataTransfer();
|
||||||
|
window.dispatchEvent(
|
||||||
|
new ClipboardEvent("copy", { bubbles: true, cancelable: true, clipboardData: data }),
|
||||||
|
);
|
||||||
|
return data.getData("text/plain");
|
||||||
|
});
|
||||||
|
await page.evaluate((marker) => {
|
||||||
|
const data = new DataTransfer();
|
||||||
|
data.setData("text/plain", marker);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }),
|
||||||
|
);
|
||||||
|
}, marker);
|
||||||
|
await expect(page.locator('[data-node-type="key"]')).toHaveCount(17);
|
||||||
|
const pasted = page.locator('[data-node-type="key"]').last();
|
||||||
|
expect(await pasted.getAttribute("data-node-id")).not.toBe(id);
|
||||||
|
expect(await pasted.getAttribute("transform")).not.toBe(original);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const data = new DataTransfer();
|
||||||
|
data.setData("text/plain", JSON.stringify([["Pasted"]]));
|
||||||
|
window.dispatchEvent(
|
||||||
|
new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await expect(page.locator('[data-node-type="key"]')).toHaveCount(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("カット貼り付けはIDとワールド位置を保つ", async ({ page }) => {
|
||||||
|
const key = page.locator('[data-node-type="key"]').first();
|
||||||
|
const id = await key.getAttribute("data-node-id");
|
||||||
|
const transform = await key.getAttribute("transform");
|
||||||
|
await key.click();
|
||||||
|
const marker = await page.evaluate(() => {
|
||||||
|
const data = new DataTransfer();
|
||||||
|
window.dispatchEvent(new ClipboardEvent("cut", { bubbles: true, cancelable: true, clipboardData: data }));
|
||||||
|
return data.getData("text/plain");
|
||||||
|
});
|
||||||
|
await expect(key).toHaveClass(/cut/);
|
||||||
|
await page.evaluate((value) => {
|
||||||
|
const data = new DataTransfer();
|
||||||
|
data.setData("text/plain", value);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }),
|
||||||
|
);
|
||||||
|
}, marker);
|
||||||
|
const cutKey = page.locator(`[data-node-id="${id}"]`);
|
||||||
|
await expect(cutKey).toHaveAttribute("transform", transform!);
|
||||||
|
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("プロジェクト保存読み込みとKLEから60キー新規を往復する", async ({ page }) => {
|
||||||
|
await page.locator('[data-node-type="key"]').first().click();
|
||||||
|
await page.getByLabel("Width").fill("1.5");
|
||||||
|
await page.getByLabel("Width").dispatchEvent("change");
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent("download"),
|
||||||
|
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||||
|
]);
|
||||||
|
const savePath = path.join(test.info().outputDir, "project.json");
|
||||||
|
await download.saveAs(savePath);
|
||||||
|
const saved = await readFile(savePath);
|
||||||
|
const chooser = page.waitForEvent("filechooser");
|
||||||
|
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||||
|
await (
|
||||||
|
await chooser
|
||||||
|
).setFiles({ name: "roundtrip.bkle.json", mimeType: "application/json", buffer: saved });
|
||||||
|
await expect(page.locator('[data-node-type="key"]')).toHaveCount(16);
|
||||||
|
await expect(page.locator('[data-node-type="key"] .key-face').first()).toHaveAttribute("width", "1.5");
|
||||||
|
const kle = await readFile(path.join(process.cwd(), "..", "samples", "kle", "cr0wn60.json"));
|
||||||
|
const kleChooser = page.waitForEvent("filechooser");
|
||||||
|
await page.getByRole("button", { name: "KLE JSONから新規" }).click();
|
||||||
|
await (await kleChooser).setFiles({ name: "cr0wn60.json", mimeType: "application/json", buffer: kle });
|
||||||
|
await expect(page.locator('[data-node-type="key"]')).toHaveCount(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unitSize不一致では追加と数値編集が無効", async ({ page }) => {
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent("download"),
|
||||||
|
page.getByRole("button", { name: "プロジェクトを保存" }).click(),
|
||||||
|
]);
|
||||||
|
const savePath = path.join(test.info().outputDir, "project.json");
|
||||||
|
await download.saveAs(savePath);
|
||||||
|
const source = JSON.parse(await readFile(savePath, "utf8"));
|
||||||
|
source.phy.profiles.push({
|
||||||
|
...source.phy.profiles[0],
|
||||||
|
id: "alternate",
|
||||||
|
name: "Alternate",
|
||||||
|
unitSize: { x: 18, y: 18 },
|
||||||
|
});
|
||||||
|
const chooser = page.waitForEvent("filechooser");
|
||||||
|
await page.getByRole("button", { name: "プロジェクトを開く" }).click();
|
||||||
|
await (
|
||||||
|
await chooser
|
||||||
|
).setFiles({
|
||||||
|
name: "unit-mismatch.bkle.json",
|
||||||
|
mimeType: "application/json",
|
||||||
|
buffer: Buffer.from(JSON.stringify(source)),
|
||||||
|
});
|
||||||
|
await expect(page.getByText("unitSize が一致しないため編集できません。")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "キーを足す" })).toBeDisabled();
|
||||||
|
await page.locator('[data-node-type="key"]').first().click();
|
||||||
|
await expect(page.getByLabel("Width")).toBeDisabled();
|
||||||
|
const before = await page.locator('[data-node-type="key"] .key-face').first().getAttribute("width");
|
||||||
|
await page.getByLabel("Width").evaluate((e: HTMLInputElement) => {
|
||||||
|
e.value = "2";
|
||||||
|
e.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await expect(page.locator('[data-node-type="key"] .key-face').first()).toHaveAttribute("width", before!);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("KLE読込直後に全体表示し、cr0wn60の全キーがキャンバス内に収まる", async ({ page }) => {
|
||||||
|
const kle = await readFile(path.join(process.cwd(), "..", "samples", "kle", "cr0wn60.json"));
|
||||||
|
const chooser = page.waitForEvent("filechooser");
|
||||||
|
await page.getByRole("button", { name: "KLE JSONから新規" }).click();
|
||||||
|
await (await chooser).setFiles({ name: "cr0wn60.json", mimeType: "application/json", buffer: kle });
|
||||||
|
await expect(page.locator('[data-node-type="key"]')).toHaveCount(60);
|
||||||
|
const canvas = await page.locator("svg.canvas").boundingBox();
|
||||||
|
const boxes = await page.locator('[data-node-type="key"]').evaluateAll((es) =>
|
||||||
|
es.map((e) => {
|
||||||
|
const r = e.getBoundingClientRect();
|
||||||
|
return { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(canvas).not.toBeNull();
|
||||||
|
for (const b of boxes) {
|
||||||
|
expect(b.left).toBeGreaterThanOrEqual(canvas!.x);
|
||||||
|
expect(b.top).toBeGreaterThanOrEqual(canvas!.y);
|
||||||
|
expect(b.right).toBeLessThanOrEqual(canvas!.x + canvas!.width);
|
||||||
|
expect(b.bottom).toBeLessThanOrEqual(canvas!.y + canvas!.height);
|
||||||
|
}
|
||||||
|
await page.screenshot({ path: path.join(test.info().outputDir, "cr0wn60-fit.png") });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("キャンバス上のShift選択で2件をクラスター化し、選択解除と親ドラッグを行う", async ({ page }) => {
|
||||||
|
const keys = page.locator('[data-node-type="key"]');
|
||||||
|
await keys.nth(0).click();
|
||||||
|
await keys.nth(1).click({ modifiers: ["Shift"] });
|
||||||
|
await expect(page.locator(".sel")).toHaveCount(2);
|
||||||
|
await page.locator(".canvas-pane").focus();
|
||||||
|
await page.keyboard.press("Control+g");
|
||||||
|
const cluster = page.locator('[data-node-type="cluster"]').first();
|
||||||
|
await expect(cluster.locator('[data-node-type="key"]')).toHaveCount(2);
|
||||||
|
const clusterBefore = await cluster.getAttribute("transform");
|
||||||
|
const childrenBefore = await cluster
|
||||||
|
.locator('[data-node-type="key"]')
|
||||||
|
.evaluateAll((es) => es.map((e) => e.getAttribute("transform")));
|
||||||
|
const key = cluster.locator('[data-node-type="key"]').first();
|
||||||
|
const b = await key.boundingBox();
|
||||||
|
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(b!.x + b!.width / 2 + 28, b!.y + b!.height / 2 + 5, { steps: 3 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(cluster).not.toHaveAttribute("transform", clusterBefore!);
|
||||||
|
expect(
|
||||||
|
await cluster
|
||||||
|
.locator('[data-node-type="key"]')
|
||||||
|
.evaluateAll((es) => es.map((e) => e.getAttribute("transform"))),
|
||||||
|
).toEqual(childrenBefore);
|
||||||
|
const third = keys.nth(3);
|
||||||
|
await third.click();
|
||||||
|
await third.click({ modifiers: ["Shift"] });
|
||||||
|
await expect(page.locator(".sel")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("削除のキーボード確認とバリアントポップオーバーを対象の近くに置く", async ({ page }) => {
|
||||||
|
const key = page.locator('[data-node-type="key"]').first();
|
||||||
|
await key.click();
|
||||||
|
const keyBox = await key.boundingBox();
|
||||||
|
await page.locator(".canvas-pane").focus();
|
||||||
|
await page.keyboard.press("Delete");
|
||||||
|
const confirm = page.locator(".confirm-float");
|
||||||
|
const confirmBox = await confirm.boundingBox();
|
||||||
|
const pane = await page.locator(".canvas-pane").boundingBox();
|
||||||
|
expect(confirmBox!.width).toBeLessThanOrEqual(400);
|
||||||
|
expect(confirmBox!.x).toBeGreaterThanOrEqual(pane!.x);
|
||||||
|
expect(confirmBox!.y).toBeGreaterThanOrEqual(pane!.y);
|
||||||
|
expect(confirmBox!.x + confirmBox!.width).toBeLessThanOrEqual(pane!.x + pane!.width);
|
||||||
|
expect(confirmBox!.y + confirmBox!.height).toBeLessThanOrEqual(pane!.y + pane!.height);
|
||||||
|
const gapX = Math.max(
|
||||||
|
0,
|
||||||
|
confirmBox!.x - (keyBox!.x + keyBox!.width),
|
||||||
|
keyBox!.x - (confirmBox!.x + confirmBox!.width),
|
||||||
|
);
|
||||||
|
const gapY = Math.max(
|
||||||
|
0,
|
||||||
|
confirmBox!.y - (keyBox!.y + keyBox!.height),
|
||||||
|
keyBox!.y - (confirmBox!.y + confirmBox!.height),
|
||||||
|
);
|
||||||
|
expect(Math.hypot(gapX, gapY)).toBeLessThanOrEqual(150);
|
||||||
|
await page.screenshot({ path: path.join(test.info().outputDir, "delete-confirm.png") });
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
await keysSelectVariantGroup(page);
|
||||||
|
const badge = page.locator(".badge-layer:not(.dim) .badge").first();
|
||||||
|
await badge.locator("rect").click();
|
||||||
|
const badgeBox = await badge.boundingBox();
|
||||||
|
const popoverBox = await page.locator(".popover").boundingBox();
|
||||||
|
expect(Math.hypot(popoverBox!.x - badgeBox!.x, popoverBox!.y - badgeBox!.y)).toBeLessThanOrEqual(80);
|
||||||
|
await page.screenshot({ path: path.join(test.info().outputDir, "variant-popover.png") });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function keysSelectVariantGroup(page: import("@playwright/test").Page) {
|
||||||
|
const keys = page.locator('[data-node-type="key"]');
|
||||||
|
await keys.nth(0).click();
|
||||||
|
await keys.nth(1).click({ modifiers: ["Shift"] });
|
||||||
|
await page.getByRole("button", { name: "選択からバリアントグループ" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Alt+上矢印は選択を親へ上げ、フォーカス描画にID重複を作らない", async ({ page }) => {
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
const clusterId = await cluster.getAttribute("data-node-id");
|
||||||
|
await page.locator(`[data-tree-id="${clusterId}"]`).click();
|
||||||
|
await page.getByLabel("Rotation").fill("35");
|
||||||
|
await page.getByLabel("Rotation").dispatchEvent("change");
|
||||||
|
const key = cluster.locator('[data-node-type="key"]').first();
|
||||||
|
const transform = await key.getAttribute("transform");
|
||||||
|
await key.click();
|
||||||
|
await page.locator(".canvas-pane").focus();
|
||||||
|
await page.keyboard.press("Alt+ArrowUp");
|
||||||
|
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
||||||
|
await expect(key).toHaveAttribute("transform", transform!);
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
const ids = await page
|
||||||
|
.locator("svg.canvas [data-node-id]")
|
||||||
|
.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
await page.screenshot({ path: path.join(test.info().outputDir, "focus-mode.png") });
|
||||||
|
const pane = await page.locator("svg.canvas").boundingBox();
|
||||||
|
await page.mouse.click(pane!.x + 8, pane!.y + 8);
|
||||||
|
await expect(page.locator(".sel")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("長押し候補は離すまで選択を変えず、離すとクラスターを選ぶ", async ({ page }) => {
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
const clusterId = await cluster.getAttribute("data-node-id");
|
||||||
|
const keys = page.locator('[data-node-type="key"]');
|
||||||
|
await keys.nth(4).click();
|
||||||
|
await expect(
|
||||||
|
page.locator(`[data-sel-id="${await keys.nth(4).getAttribute("data-node-id")}"]`),
|
||||||
|
).toBeVisible();
|
||||||
|
const b = await cluster.locator('[data-node-type="key"]').first().boundingBox();
|
||||||
|
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect(page.locator(".hold-row.active")).toContainText("Cluster", { timeout: 1800 });
|
||||||
|
await expect(
|
||||||
|
page.locator(`[data-sel-id="${await keys.nth(4).getAttribute("data-node-id")}"]`),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.screenshot({ path: path.join(test.info().outputDir, "long-press-candidate.png") });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("リスト方式では、リストに入ると巡回が止まり、指した行で離すと確定する", async ({ page }) => {
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
await page.getByRole("button", { name: "長押し: ループ" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "長押し: リスト" })).toBeVisible();
|
||||||
|
const key = cluster.locator('[data-node-type="key"]').first();
|
||||||
|
const keyId = await key.getAttribute("data-node-id");
|
||||||
|
const b = await key.boundingBox();
|
||||||
|
await page.mouse.move(b!.x + b!.width / 2, b!.y + b!.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect(page.locator(".hold-row.active")).toContainText("Cluster", { timeout: 2000 });
|
||||||
|
const row = await page.locator(".hold-row", { hasText: "キー" }).boundingBox();
|
||||||
|
await page.mouse.move(row!.x + row!.width / 2, row!.y + row!.height / 2, { steps: 5 });
|
||||||
|
await expect(page.locator(".hold-row.active")).toHaveText("キー");
|
||||||
|
await expect(page.locator(".hold-ring")).toHaveCount(0);
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await expect(page.locator(".hold-row.active")).toHaveText("キー");
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.locator(".hold-list")).toHaveCount(0);
|
||||||
|
await expect(page.locator(`[data-sel-id="${keyId}"]`)).toBeVisible();
|
||||||
|
// リストへ動かしてもキーはドラッグされない
|
||||||
|
expect(await key.boundingBox()).toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ループ方式では、長押しのあと動かすと選んだ先をドラッグする", async ({ page }) => {
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
const clusterId = await cluster.getAttribute("data-node-id");
|
||||||
|
const keys = cluster.locator('[data-node-type="key"]');
|
||||||
|
const before = await keys.nth(1).boundingBox();
|
||||||
|
const b = (await keys.first().boundingBox())!;
|
||||||
|
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect(page.locator(".hold-ring")).toBeVisible({ timeout: 2000 });
|
||||||
|
await page.mouse.move(b.x + b.width / 2 + 80, b.y + b.height / 2, { steps: 5 });
|
||||||
|
await expect(page.locator(".hold-list")).toHaveCount(0);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.locator(`[data-tree-id="${clusterId}"]`).first()).toHaveClass(/chosen/);
|
||||||
|
// クラスターごと動くので、押していないキーも動く
|
||||||
|
expect((await keys.nth(1).boundingBox())!.x).toBeGreaterThan(before!.x + 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("「その他」の部品はpartGroupを設定せず、選択後は追加メニューを戻す", async ({ page }) => {
|
||||||
|
const select = page.getByRole("combobox", { name: "部品を足す" });
|
||||||
|
await select.selectOption("その他");
|
||||||
|
await expect(select).toHaveValue("");
|
||||||
|
await expect(page.locator('[data-node-type="component"] .part-label').last()).toHaveText("part");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("クラスター内でキーを足すと、足したキーを選ぶ", async ({ page }) => {
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
const clusterKeys = cluster.locator('[data-node-type="key"]');
|
||||||
|
const before = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||||
|
await page.getByText("キー・部品 2件", { exact: true }).click();
|
||||||
|
await page.locator(`[data-tree-id="${before[0]}"]`).first().click();
|
||||||
|
await page.getByRole("button", { name: "キーを足す" }).click();
|
||||||
|
await expect(clusterKeys).toHaveCount(3);
|
||||||
|
const after = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||||
|
const added = after.find((id) => !before.includes(id));
|
||||||
|
await expect(page.locator(`[data-sel-id="${added}"]`)).toHaveCount(1);
|
||||||
|
await expect(page.locator("[data-sel-id]")).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("クラスターを選んでキーを足すと、そのクラスターに新しい行を作る", async ({ page }) => {
|
||||||
|
const cluster = await makeCluster(page);
|
||||||
|
const clusterId = await cluster.getAttribute("data-node-id");
|
||||||
|
await page.locator(`[data-tree-id="${clusterId}"]`).first().click();
|
||||||
|
const clusterKeys = cluster.locator('[data-node-type="key"]');
|
||||||
|
const before = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||||
|
const boxes = await Promise.all(before.map((id) => page.locator(`[data-node-id="${id}"]`).boundingBox()));
|
||||||
|
await page.getByRole("button", { name: "キーを足す" }).click();
|
||||||
|
await expect(clusterKeys).toHaveCount(3);
|
||||||
|
const after = await clusterKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||||
|
const added = after.find((id) => !before.includes(id));
|
||||||
|
await expect(page.locator(`[data-sel-id="${added}"]`)).toHaveCount(1);
|
||||||
|
const box = (await page.locator(`[data-node-id="${added}"]`).boundingBox())!;
|
||||||
|
// The cluster holds two keys of the first row, 1U apart; the new row starts 1U below its left end.
|
||||||
|
const [left, right] = [...boxes].sort((a, b) => a!.x - b!.x);
|
||||||
|
const unit = right!.x - left!.x;
|
||||||
|
expect(Math.abs(box.x - left!.x)).toBeLessThan(1);
|
||||||
|
expect(Math.abs(box.y - (left!.y + unit))).toBeLessThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("バリアントグループを選んでキーを足すと、表示中のバリアントに新しい行を作る", async ({ page }) => {
|
||||||
|
await keysSelectVariantGroup(page);
|
||||||
|
const group = page.locator('[data-node-type="variantGroup"]').first();
|
||||||
|
const groupId = await group.getAttribute("data-node-id");
|
||||||
|
await page.locator(`[data-tree-id="${groupId}"]`).first().click();
|
||||||
|
const groupKeys = group.locator('[data-node-type="key"]');
|
||||||
|
const before = await groupKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||||
|
const boxes = await Promise.all(before.map((id) => page.locator(`[data-node-id="${id}"]`).boundingBox()));
|
||||||
|
await page.getByRole("button", { name: "キーを足す" }).click();
|
||||||
|
await expect(groupKeys).toHaveCount(before.length + 1);
|
||||||
|
const after = await groupKeys.evaluateAll((es) => es.map((e) => e.getAttribute("data-node-id")));
|
||||||
|
const added = after.find((id) => !before.includes(id));
|
||||||
|
await expect(page.locator(`[data-sel-id="${added}"]`)).toHaveCount(1);
|
||||||
|
const box = (await page.locator(`[data-node-id="${added}"]`).boundingBox())!;
|
||||||
|
// The variant holds two keys of the first row, 1U apart; the new row starts 1U below its left end.
|
||||||
|
const [left, right] = [...boxes].sort((a, b) => a!.x - b!.x);
|
||||||
|
const unit = right!.x - left!.x;
|
||||||
|
expect(Math.abs(box.x - left!.x)).toBeLessThan(1);
|
||||||
|
expect(Math.abs(box.y - (left!.y + unit))).toBeLessThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("キャンバスのviewBoxはツールバーを除いたSVGの大きさに合う", async ({ page }) => {
|
||||||
|
const canvas = page.locator("svg.canvas");
|
||||||
|
const box = (await canvas.boundingBox())!;
|
||||||
|
const [, , w, h] = (await canvas.getAttribute("viewBox"))!.split(" ").map(Number);
|
||||||
|
expect(Math.abs(w! - box.width)).toBeLessThan(1);
|
||||||
|
expect(Math.abs(h! - box.height)).toBeLessThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("スイッチ・スタビの十字はトグルで出し、スタビ候補を複数選べる", async ({ page }) => {
|
||||||
|
const key = page.locator('[data-node-type="key"]').first();
|
||||||
|
const id = await key.getAttribute("data-node-id");
|
||||||
|
await expect(page.locator(".mount-layer")).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
|
||||||
|
const mark = page.locator(`[data-mount-id="${id}"]`);
|
||||||
|
await expect(mark.locator(".switch")).toHaveCount(1);
|
||||||
|
await expect(mark.locator(".stab")).toHaveCount(0);
|
||||||
|
|
||||||
|
await key.click();
|
||||||
|
await expect(page.locator(".stabilizers")).toContainText("候補なし");
|
||||||
|
await page.getByLabel("Width").fill("6.25");
|
||||||
|
await page.getByLabel("Width").blur();
|
||||||
|
for (const label of ["±50mm", "±40mm", "±42.5mm"]) await expect(page.getByLabel(label)).not.toBeChecked();
|
||||||
|
await page.getByLabel("±50mm").check();
|
||||||
|
await page.getByLabel("±40mm").check();
|
||||||
|
await expect(mark.locator(".stab")).toHaveCount(4);
|
||||||
|
|
||||||
|
const switchX = async () => (await mark.locator(".switch").boundingBox())!.x;
|
||||||
|
const before = await switchX();
|
||||||
|
await page.getByLabel("switchOffset X").fill("0.625");
|
||||||
|
await page.getByLabel("switchOffset X").dispatchEvent("change");
|
||||||
|
await expect.poll(switchX).toBeGreaterThan(before + 10);
|
||||||
|
await expect(mark.locator(".stab")).toHaveCount(4);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Undo" }).click();
|
||||||
|
await page.getByRole("button", { name: "Undo" }).click();
|
||||||
|
await expect(mark.locator(".stab")).toHaveCount(2);
|
||||||
|
await expect(page.getByLabel("±40mm")).not.toBeChecked();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "スイッチ・スタビライザーの位置" }).click();
|
||||||
|
await expect(page.locator(".mount-layer")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ISO Enterを足すと外形で描き、プロパティの異形キーで2つ目の矩形を出し入れできる", async ({ page }) => {
|
||||||
|
const keys = page.locator('[data-node-type="key"]');
|
||||||
|
await keys.first().click();
|
||||||
|
const select = page.getByRole("combobox", { name: "異形キーを足す" });
|
||||||
|
await select.selectOption("ISO Enter");
|
||||||
|
await expect(select).toHaveValue("");
|
||||||
|
await expect(keys).toHaveCount(17);
|
||||||
|
const added = keys.last();
|
||||||
|
const addedId = await added.getAttribute("data-node-id");
|
||||||
|
await expect(added.locator("path.key-face")).toHaveCount(1);
|
||||||
|
await expect(page.locator(`path[data-sel-id="${addedId}"]`)).toHaveCount(1);
|
||||||
|
const irregular = page.getByLabel("異形キー", { exact: true });
|
||||||
|
await expect(irregular).toBeChecked();
|
||||||
|
await expect(page.getByLabel("X2", { exact: true })).toHaveValue("-0.25");
|
||||||
|
await expect(page.getByLabel("Width2", { exact: true })).toHaveValue("1.5");
|
||||||
|
|
||||||
|
await page.getByLabel("Width2", { exact: true }).fill("2");
|
||||||
|
await page.getByLabel("Width2", { exact: true }).press("Enter");
|
||||||
|
await expect(page.getByLabel("Width2", { exact: true })).toHaveValue("2");
|
||||||
|
|
||||||
|
await irregular.uncheck();
|
||||||
|
await expect(added.locator("rect.key-face")).toHaveAttribute("width", "1.25");
|
||||||
|
await expect(page.getByLabel("X2", { exact: true })).toHaveCount(0);
|
||||||
|
await irregular.check();
|
||||||
|
await expect(page.getByLabel("X2", { exact: true })).toHaveValue("0");
|
||||||
|
await expect(page.getByLabel("Width2", { exact: true })).toHaveValue("1.25");
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head><meta charset="UTF-8" /><title>Beyond KLE Mock</title></head>
|
||||||
|
<body><div id="app"></div><script type="module" src="/src/main.ts"></script></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "beyond-kle-mock",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "^8.20.0",
|
||||||
|
"ajv-formats": "^3.0.1",
|
||||||
|
"vue": "^3.5.43"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.63.0",
|
||||||
|
"@types/node": "^26.6.2",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.9",
|
||||||
|
"prettier": "^3.9.9",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.3.1",
|
||||||
|
"vitest": "^5.0.1",
|
||||||
|
"vue-tsc": "^3.3.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
use: { browserName: "chromium", headless: true, baseURL: "http://127.0.0.1:5173" },
|
||||||
|
webServer: {
|
||||||
|
command: "npx vite --host 127.0.0.1",
|
||||||
|
url: "http://127.0.0.1:5173",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+991
@@ -0,0 +1,991 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
ajv:
|
||||||
|
specifier: ^8.20.0
|
||||||
|
version: 8.20.0
|
||||||
|
ajv-formats:
|
||||||
|
specifier: ^3.0.1
|
||||||
|
version: 3.0.1(ajv@8.20.0)
|
||||||
|
vue:
|
||||||
|
specifier: ^3.5.43
|
||||||
|
version: 3.5.43(typescript@6.0.3)
|
||||||
|
devDependencies:
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: ^1.63.0
|
||||||
|
version: 1.63.0
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^26.6.2
|
||||||
|
version: 26.6.2
|
||||||
|
'@vitejs/plugin-vue':
|
||||||
|
specifier: ^6.0.9
|
||||||
|
version: 6.0.9(vite@8.3.1(@types/node@26.6.2))(vue@3.5.43(typescript@6.0.3))
|
||||||
|
prettier:
|
||||||
|
specifier: ^3.9.9
|
||||||
|
version: 3.9.9
|
||||||
|
typescript:
|
||||||
|
specifier: ^6.0.3
|
||||||
|
version: 6.0.3
|
||||||
|
vite:
|
||||||
|
specifier: ^8.3.1
|
||||||
|
version: 8.3.1(@types/node@26.6.2)
|
||||||
|
vitest:
|
||||||
|
specifier: ^5.0.1
|
||||||
|
version: 5.0.1(@types/node@26.6.2)(vite@8.3.1(@types/node@26.6.2))
|
||||||
|
vue-tsc:
|
||||||
|
specifier: ^3.3.11
|
||||||
|
version: 3.3.11(typescript@6.0.3)
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@babel/helper-string-parser@7.29.7':
|
||||||
|
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.29.7':
|
||||||
|
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/parser@7.29.9':
|
||||||
|
resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
'@babel/types@7.29.8':
|
||||||
|
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@jridgewell/resolve-uri@3.1.2':
|
||||||
|
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.6.0':
|
||||||
|
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
|
||||||
|
|
||||||
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@oxc-project/types@0.151.0':
|
||||||
|
resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==}
|
||||||
|
|
||||||
|
'@playwright/test@1.63.0':
|
||||||
|
resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
'@rolldown/binding-android-arm-eabi@1.2.11':
|
||||||
|
resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@rolldown/binding-android-arm64@1.2.11':
|
||||||
|
resolution: {integrity: sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@rolldown/binding-darwin-arm64@1.2.11':
|
||||||
|
resolution: {integrity: sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@rolldown/binding-darwin-x64@1.2.11':
|
||||||
|
resolution: {integrity: sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@rolldown/binding-freebsd-x64@1.2.11':
|
||||||
|
resolution: {integrity: sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm-gnueabihf@1.2.11':
|
||||||
|
resolution: {integrity: sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm64-gnu@1.2.11':
|
||||||
|
resolution: {integrity: sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm64-musl@1.2.11':
|
||||||
|
resolution: {integrity: sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-ppc64-gnu@1.2.11':
|
||||||
|
resolution: {integrity: sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-s390x-gnu@1.2.11':
|
||||||
|
resolution: {integrity: sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-x64-gnu@1.2.11':
|
||||||
|
resolution: {integrity: sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-x64-musl@1.2.11':
|
||||||
|
resolution: {integrity: sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rolldown/binding-openharmony-arm64@1.2.11':
|
||||||
|
resolution: {integrity: sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@rolldown/binding-win32-arm64-msvc@1.2.11':
|
||||||
|
resolution: {integrity: sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rolldown/binding-win32-x64-msvc@1.2.11':
|
||||||
|
resolution: {integrity: sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rolldown/pluginutils@1.0.1':
|
||||||
|
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||||
|
|
||||||
|
'@types/chai@5.2.3':
|
||||||
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
|
'@types/deep-eql@4.0.2':
|
||||||
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
|
'@types/estree@1.0.9':
|
||||||
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
|
'@types/node@26.6.2':
|
||||||
|
resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==}
|
||||||
|
|
||||||
|
'@vitejs/plugin-vue@6.0.9':
|
||||||
|
resolution: {integrity: sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
peerDependencies:
|
||||||
|
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||||
|
vue: ^3.2.25
|
||||||
|
|
||||||
|
'@vitest/mocker@5.0.1':
|
||||||
|
resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==}
|
||||||
|
peerDependencies:
|
||||||
|
msw: ^2.4.9
|
||||||
|
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
msw:
|
||||||
|
optional: true
|
||||||
|
vite:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@vitest/spy@5.0.1':
|
||||||
|
resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==}
|
||||||
|
|
||||||
|
'@volar/language-core@2.4.28':
|
||||||
|
resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==}
|
||||||
|
|
||||||
|
'@volar/source-map@2.4.28':
|
||||||
|
resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==}
|
||||||
|
|
||||||
|
'@volar/typescript@2.4.28':
|
||||||
|
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
|
||||||
|
|
||||||
|
'@vue/compiler-core@3.5.43':
|
||||||
|
resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==}
|
||||||
|
|
||||||
|
'@vue/compiler-dom@3.5.43':
|
||||||
|
resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==}
|
||||||
|
|
||||||
|
'@vue/compiler-sfc@3.5.43':
|
||||||
|
resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==}
|
||||||
|
|
||||||
|
'@vue/compiler-ssr@3.5.43':
|
||||||
|
resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==}
|
||||||
|
|
||||||
|
'@vue/language-core@3.3.11':
|
||||||
|
resolution: {integrity: sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==}
|
||||||
|
|
||||||
|
'@vue/reactivity@3.5.43':
|
||||||
|
resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==}
|
||||||
|
|
||||||
|
'@vue/runtime-core@3.5.43':
|
||||||
|
resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==}
|
||||||
|
|
||||||
|
'@vue/runtime-dom@3.5.43':
|
||||||
|
resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==}
|
||||||
|
|
||||||
|
'@vue/server-renderer@3.5.43':
|
||||||
|
resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==}
|
||||||
|
|
||||||
|
'@vue/shared@3.5.43':
|
||||||
|
resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==}
|
||||||
|
|
||||||
|
ajv-formats@3.0.1:
|
||||||
|
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||||
|
peerDependencies:
|
||||||
|
ajv: ^8.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
ajv:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
ajv@8.20.0:
|
||||||
|
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
|
||||||
|
|
||||||
|
alien-signals@3.2.1:
|
||||||
|
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
|
||||||
|
|
||||||
|
assertion-error@2.0.1:
|
||||||
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
chai@6.2.2:
|
||||||
|
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
csstype@3.2.3:
|
||||||
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
entities@7.0.1:
|
||||||
|
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
es-module-lexer@2.3.2:
|
||||||
|
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
|
||||||
|
|
||||||
|
estree-walker@2.0.2:
|
||||||
|
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||||
|
|
||||||
|
estree-walker@3.0.3:
|
||||||
|
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||||
|
|
||||||
|
expect-type@1.4.0:
|
||||||
|
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
fast-deep-equal@3.1.3:
|
||||||
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
|
fast-uri@3.1.8:
|
||||||
|
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
|
||||||
|
|
||||||
|
fdir@6.5.0:
|
||||||
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
picomatch: ^3 || ^4
|
||||||
|
peerDependenciesMeta:
|
||||||
|
picomatch:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
json-schema-traverse@1.0.0:
|
||||||
|
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||||
|
|
||||||
|
lightningcss-android-arm64@1.33.0:
|
||||||
|
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
lightningcss-darwin-arm64@1.33.0:
|
||||||
|
resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
lightningcss-darwin-x64@1.33.0:
|
||||||
|
resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
lightningcss-freebsd-x64@1.33.0:
|
||||||
|
resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
lightningcss-linux-arm-gnueabihf@1.33.0:
|
||||||
|
resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-gnu@1.33.0:
|
||||||
|
resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-musl@1.33.0:
|
||||||
|
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-linux-x64-gnu@1.33.0:
|
||||||
|
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-linux-x64-musl@1.33.0:
|
||||||
|
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-win32-arm64-msvc@1.33.0:
|
||||||
|
resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
lightningcss-win32-x64-msvc@1.33.0:
|
||||||
|
resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
lightningcss@1.33.0:
|
||||||
|
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
|
magic-string@0.30.21:
|
||||||
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
magic-string@1.4.2:
|
||||||
|
resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==}
|
||||||
|
|
||||||
|
muggle-string@0.4.1:
|
||||||
|
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||||
|
|
||||||
|
nanoid@3.3.19:
|
||||||
|
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
|
||||||
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
obug@2.2.1:
|
||||||
|
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
|
||||||
|
path-browserify@1.0.1:
|
||||||
|
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||||
|
|
||||||
|
picocolors@1.1.1:
|
||||||
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
picomatch@4.0.7:
|
||||||
|
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
playwright-core@1.63.0:
|
||||||
|
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
playwright@1.63.0:
|
||||||
|
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
postcss@8.5.28:
|
||||||
|
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||||
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
prettier@3.9.9:
|
||||||
|
resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
require-from-string@2.0.2:
|
||||||
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
rolldown@1.2.11:
|
||||||
|
resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
siginfo@2.0.0:
|
||||||
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
|
source-map-js@1.2.1:
|
||||||
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
stackback@0.0.2:
|
||||||
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
std-env@4.2.0:
|
||||||
|
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
|
||||||
|
|
||||||
|
tinybench@6.1.4:
|
||||||
|
resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
tinyexec@1.3.0:
|
||||||
|
resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
tinyglobby@0.2.17:
|
||||||
|
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
typescript@6.0.3:
|
||||||
|
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
|
||||||
|
engines: {node: '>=14.17'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
undici-types@8.9.0:
|
||||||
|
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
|
||||||
|
|
||||||
|
vite@8.3.1:
|
||||||
|
resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': ^20.19.0 || >=22.12.0
|
||||||
|
'@vitejs/devtools': ^0.7.1
|
||||||
|
esbuild: ^0.27.0 || ^0.28.0
|
||||||
|
jiti: '>=1.21.0'
|
||||||
|
less: ^4.0.0
|
||||||
|
sass: ^1.70.0
|
||||||
|
sass-embedded: ^1.70.0
|
||||||
|
stylus: '>=0.54.8'
|
||||||
|
sugarss: ^5.0.0
|
||||||
|
terser: ^5.16.0
|
||||||
|
tsx: ^4.8.1
|
||||||
|
yaml: ^2.4.2
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
'@vitejs/devtools':
|
||||||
|
optional: true
|
||||||
|
esbuild:
|
||||||
|
optional: true
|
||||||
|
jiti:
|
||||||
|
optional: true
|
||||||
|
less:
|
||||||
|
optional: true
|
||||||
|
sass:
|
||||||
|
optional: true
|
||||||
|
sass-embedded:
|
||||||
|
optional: true
|
||||||
|
stylus:
|
||||||
|
optional: true
|
||||||
|
sugarss:
|
||||||
|
optional: true
|
||||||
|
terser:
|
||||||
|
optional: true
|
||||||
|
tsx:
|
||||||
|
optional: true
|
||||||
|
yaml:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
vitest@5.0.1:
|
||||||
|
resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==}
|
||||||
|
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@edge-runtime/vm': '*'
|
||||||
|
'@opentelemetry/api': ^1.9.0
|
||||||
|
'@types/node': ^22.0.0 || >=24.0.0
|
||||||
|
'@vitest/browser-playwright': 5.0.1
|
||||||
|
'@vitest/browser-preview': 5.0.1
|
||||||
|
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
|
||||||
|
'@vitest/coverage-istanbul': 5.0.1
|
||||||
|
'@vitest/coverage-v8': 5.0.1
|
||||||
|
'@vitest/ui': 5.0.1
|
||||||
|
happy-dom: '*'
|
||||||
|
jsdom: '*'
|
||||||
|
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@edge-runtime/vm':
|
||||||
|
optional: true
|
||||||
|
'@opentelemetry/api':
|
||||||
|
optional: true
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser-playwright':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser-preview':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser-webdriverio':
|
||||||
|
optional: true
|
||||||
|
'@vitest/coverage-istanbul':
|
||||||
|
optional: true
|
||||||
|
'@vitest/coverage-v8':
|
||||||
|
optional: true
|
||||||
|
'@vitest/ui':
|
||||||
|
optional: true
|
||||||
|
happy-dom:
|
||||||
|
optional: true
|
||||||
|
jsdom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
vscode-uri@3.2.0:
|
||||||
|
resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==}
|
||||||
|
|
||||||
|
vue-tsc@3.3.11:
|
||||||
|
resolution: {integrity: sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '>=5.0.0'
|
||||||
|
|
||||||
|
vue@3.5.43:
|
||||||
|
resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@babel/helper-string-parser@7.29.7': {}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.29.7': {}
|
||||||
|
|
||||||
|
'@babel/parser@7.29.9':
|
||||||
|
dependencies:
|
||||||
|
'@babel/types': 7.29.8
|
||||||
|
|
||||||
|
'@babel/types@7.29.8':
|
||||||
|
dependencies:
|
||||||
|
'@babel/helper-string-parser': 7.29.7
|
||||||
|
'@babel/helper-validator-identifier': 7.29.7
|
||||||
|
|
||||||
|
'@jridgewell/resolve-uri@3.1.2': {}
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.6.0': {}
|
||||||
|
|
||||||
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
|
'@jridgewell/sourcemap-codec': 1.6.0
|
||||||
|
|
||||||
|
'@oxc-project/types@0.151.0': {}
|
||||||
|
|
||||||
|
'@playwright/test@1.63.0':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.63.0
|
||||||
|
|
||||||
|
'@rolldown/binding-android-arm-eabi@1.2.11':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-android-arm64@1.2.11':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-darwin-arm64@1.2.11':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-darwin-x64@1.2.11':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-freebsd-x64@1.2.11':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm-gnueabihf@1.2.11':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm64-gnu@1.2.11':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-arm64-musl@1.2.11':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-ppc64-gnu@1.2.11':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-s390x-gnu@1.2.11':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-x64-gnu@1.2.11':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-linux-x64-musl@1.2.11':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-openharmony-arm64@1.2.11':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-win32-arm64-msvc@1.2.11':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/binding-win32-x64-msvc@1.2.11':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rolldown/pluginutils@1.0.1': {}
|
||||||
|
|
||||||
|
'@types/chai@5.2.3':
|
||||||
|
dependencies:
|
||||||
|
'@types/deep-eql': 4.0.2
|
||||||
|
assertion-error: 2.0.1
|
||||||
|
|
||||||
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
|
'@types/node@26.6.2':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 8.9.0
|
||||||
|
|
||||||
|
'@vitejs/plugin-vue@6.0.9(vite@8.3.1(@types/node@26.6.2))(vue@3.5.43(typescript@6.0.3))':
|
||||||
|
dependencies:
|
||||||
|
'@rolldown/pluginutils': 1.0.1
|
||||||
|
vite: 8.3.1(@types/node@26.6.2)
|
||||||
|
vue: 3.5.43(typescript@6.0.3)
|
||||||
|
|
||||||
|
'@vitest/mocker@5.0.1(vite@8.3.1(@types/node@26.6.2))':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
'@vitest/spy': 5.0.1
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
magic-string: 1.4.2
|
||||||
|
optionalDependencies:
|
||||||
|
vite: 8.3.1(@types/node@26.6.2)
|
||||||
|
|
||||||
|
'@vitest/spy@5.0.1': {}
|
||||||
|
|
||||||
|
'@volar/language-core@2.4.28':
|
||||||
|
dependencies:
|
||||||
|
'@volar/source-map': 2.4.28
|
||||||
|
|
||||||
|
'@volar/source-map@2.4.28': {}
|
||||||
|
|
||||||
|
'@volar/typescript@2.4.28':
|
||||||
|
dependencies:
|
||||||
|
'@volar/language-core': 2.4.28
|
||||||
|
path-browserify: 1.0.1
|
||||||
|
vscode-uri: 3.2.0
|
||||||
|
|
||||||
|
'@vue/compiler-core@3.5.43':
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.29.9
|
||||||
|
'@vue/shared': 3.5.43
|
||||||
|
entities: 7.0.1
|
||||||
|
estree-walker: 2.0.2
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
'@vue/compiler-dom@3.5.43':
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-core': 3.5.43
|
||||||
|
'@vue/shared': 3.5.43
|
||||||
|
|
||||||
|
'@vue/compiler-sfc@3.5.43':
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.29.9
|
||||||
|
'@vue/compiler-core': 3.5.43
|
||||||
|
'@vue/compiler-dom': 3.5.43
|
||||||
|
'@vue/compiler-ssr': 3.5.43
|
||||||
|
'@vue/shared': 3.5.43
|
||||||
|
estree-walker: 2.0.2
|
||||||
|
magic-string: 0.30.21
|
||||||
|
postcss: 8.5.28
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
'@vue/compiler-ssr@3.5.43':
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-dom': 3.5.43
|
||||||
|
'@vue/shared': 3.5.43
|
||||||
|
|
||||||
|
'@vue/language-core@3.3.11':
|
||||||
|
dependencies:
|
||||||
|
'@volar/language-core': 2.4.28
|
||||||
|
'@vue/compiler-dom': 3.5.43
|
||||||
|
'@vue/shared': 3.5.43
|
||||||
|
alien-signals: 3.2.1
|
||||||
|
muggle-string: 0.4.1
|
||||||
|
path-browserify: 1.0.1
|
||||||
|
picomatch: 4.0.7
|
||||||
|
|
||||||
|
'@vue/reactivity@3.5.43':
|
||||||
|
dependencies:
|
||||||
|
'@vue/shared': 3.5.43
|
||||||
|
|
||||||
|
'@vue/runtime-core@3.5.43':
|
||||||
|
dependencies:
|
||||||
|
'@vue/reactivity': 3.5.43
|
||||||
|
'@vue/shared': 3.5.43
|
||||||
|
|
||||||
|
'@vue/runtime-dom@3.5.43':
|
||||||
|
dependencies:
|
||||||
|
'@vue/reactivity': 3.5.43
|
||||||
|
'@vue/runtime-core': 3.5.43
|
||||||
|
'@vue/shared': 3.5.43
|
||||||
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@vue/server-renderer@3.5.43':
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-ssr': 3.5.43
|
||||||
|
'@vue/runtime-dom': 3.5.43
|
||||||
|
'@vue/shared': 3.5.43
|
||||||
|
|
||||||
|
'@vue/shared@3.5.43': {}
|
||||||
|
|
||||||
|
ajv-formats@3.0.1(ajv@8.20.0):
|
||||||
|
optionalDependencies:
|
||||||
|
ajv: 8.20.0
|
||||||
|
|
||||||
|
ajv@8.20.0:
|
||||||
|
dependencies:
|
||||||
|
fast-deep-equal: 3.1.3
|
||||||
|
fast-uri: 3.1.8
|
||||||
|
json-schema-traverse: 1.0.0
|
||||||
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
|
alien-signals@3.2.1: {}
|
||||||
|
|
||||||
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
|
chai@6.2.2: {}
|
||||||
|
|
||||||
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
|
entities@7.0.1: {}
|
||||||
|
|
||||||
|
es-module-lexer@2.3.2: {}
|
||||||
|
|
||||||
|
estree-walker@2.0.2: {}
|
||||||
|
|
||||||
|
estree-walker@3.0.3:
|
||||||
|
dependencies:
|
||||||
|
'@types/estree': 1.0.9
|
||||||
|
|
||||||
|
expect-type@1.4.0: {}
|
||||||
|
|
||||||
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
|
fast-uri@3.1.8: {}
|
||||||
|
|
||||||
|
fdir@6.5.0(picomatch@4.0.7):
|
||||||
|
optionalDependencies:
|
||||||
|
picomatch: 4.0.7
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
json-schema-traverse@1.0.0: {}
|
||||||
|
|
||||||
|
lightningcss-android-arm64@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-darwin-arm64@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-darwin-x64@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-freebsd-x64@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-arm-gnueabihf@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-gnu@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-musl@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-x64-gnu@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-x64-musl@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-win32-arm64-msvc@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-win32-x64-msvc@1.33.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss@1.33.0:
|
||||||
|
dependencies:
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
optionalDependencies:
|
||||||
|
lightningcss-android-arm64: 1.33.0
|
||||||
|
lightningcss-darwin-arm64: 1.33.0
|
||||||
|
lightningcss-darwin-x64: 1.33.0
|
||||||
|
lightningcss-freebsd-x64: 1.33.0
|
||||||
|
lightningcss-linux-arm-gnueabihf: 1.33.0
|
||||||
|
lightningcss-linux-arm64-gnu: 1.33.0
|
||||||
|
lightningcss-linux-arm64-musl: 1.33.0
|
||||||
|
lightningcss-linux-x64-gnu: 1.33.0
|
||||||
|
lightningcss-linux-x64-musl: 1.33.0
|
||||||
|
lightningcss-win32-arm64-msvc: 1.33.0
|
||||||
|
lightningcss-win32-x64-msvc: 1.33.0
|
||||||
|
|
||||||
|
magic-string@0.30.21:
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/sourcemap-codec': 1.6.0
|
||||||
|
|
||||||
|
magic-string@1.4.2:
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/sourcemap-codec': 1.6.0
|
||||||
|
|
||||||
|
muggle-string@0.4.1: {}
|
||||||
|
|
||||||
|
nanoid@3.3.19: {}
|
||||||
|
|
||||||
|
obug@2.2.1: {}
|
||||||
|
|
||||||
|
path-browserify@1.0.1: {}
|
||||||
|
|
||||||
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
|
picomatch@4.0.7: {}
|
||||||
|
|
||||||
|
playwright-core@1.63.0: {}
|
||||||
|
|
||||||
|
playwright@1.63.0:
|
||||||
|
dependencies:
|
||||||
|
playwright-core: 1.63.0
|
||||||
|
|
||||||
|
postcss@8.5.28:
|
||||||
|
dependencies:
|
||||||
|
nanoid: 3.3.19
|
||||||
|
picocolors: 1.1.1
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
prettier@3.9.9: {}
|
||||||
|
|
||||||
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
|
rolldown@1.2.11:
|
||||||
|
dependencies:
|
||||||
|
'@oxc-project/types': 0.151.0
|
||||||
|
'@rolldown/pluginutils': 1.0.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@rolldown/binding-android-arm-eabi': 1.2.11
|
||||||
|
'@rolldown/binding-android-arm64': 1.2.11
|
||||||
|
'@rolldown/binding-darwin-arm64': 1.2.11
|
||||||
|
'@rolldown/binding-darwin-x64': 1.2.11
|
||||||
|
'@rolldown/binding-freebsd-x64': 1.2.11
|
||||||
|
'@rolldown/binding-linux-arm-gnueabihf': 1.2.11
|
||||||
|
'@rolldown/binding-linux-arm64-gnu': 1.2.11
|
||||||
|
'@rolldown/binding-linux-arm64-musl': 1.2.11
|
||||||
|
'@rolldown/binding-linux-ppc64-gnu': 1.2.11
|
||||||
|
'@rolldown/binding-linux-s390x-gnu': 1.2.11
|
||||||
|
'@rolldown/binding-linux-x64-gnu': 1.2.11
|
||||||
|
'@rolldown/binding-linux-x64-musl': 1.2.11
|
||||||
|
'@rolldown/binding-openharmony-arm64': 1.2.11
|
||||||
|
'@rolldown/binding-win32-arm64-msvc': 1.2.11
|
||||||
|
'@rolldown/binding-win32-x64-msvc': 1.2.11
|
||||||
|
|
||||||
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
|
std-env@4.2.0: {}
|
||||||
|
|
||||||
|
tinybench@6.1.4: {}
|
||||||
|
|
||||||
|
tinyexec@1.3.0: {}
|
||||||
|
|
||||||
|
tinyglobby@0.2.17:
|
||||||
|
dependencies:
|
||||||
|
fdir: 6.5.0(picomatch@4.0.7)
|
||||||
|
picomatch: 4.0.7
|
||||||
|
|
||||||
|
typescript@6.0.3: {}
|
||||||
|
|
||||||
|
undici-types@8.9.0: {}
|
||||||
|
|
||||||
|
vite@8.3.1(@types/node@26.6.2):
|
||||||
|
dependencies:
|
||||||
|
lightningcss: 1.33.0
|
||||||
|
picomatch: 4.0.7
|
||||||
|
postcss: 8.5.28
|
||||||
|
rolldown: 1.2.11
|
||||||
|
tinyglobby: 0.2.17
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 26.6.2
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
vitest@5.0.1(@types/node@26.6.2)(vite@8.3.1(@types/node@26.6.2)):
|
||||||
|
dependencies:
|
||||||
|
'@types/chai': 5.2.3
|
||||||
|
'@vitest/mocker': 5.0.1(vite@8.3.1(@types/node@26.6.2))
|
||||||
|
chai: 6.2.2
|
||||||
|
es-module-lexer: 2.3.2
|
||||||
|
expect-type: 1.4.0
|
||||||
|
magic-string: 1.4.2
|
||||||
|
obug: 2.2.1
|
||||||
|
picomatch: 4.0.7
|
||||||
|
std-env: 4.2.0
|
||||||
|
tinybench: 6.1.4
|
||||||
|
tinyexec: 1.3.0
|
||||||
|
tinyglobby: 0.2.17
|
||||||
|
vite: 8.3.1(@types/node@26.6.2)
|
||||||
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 26.6.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- msw
|
||||||
|
|
||||||
|
vscode-uri@3.2.0: {}
|
||||||
|
|
||||||
|
vue-tsc@3.3.11(typescript@6.0.3):
|
||||||
|
dependencies:
|
||||||
|
'@volar/typescript': 2.4.28
|
||||||
|
'@vue/language-core': 3.3.11
|
||||||
|
typescript: 6.0.3
|
||||||
|
|
||||||
|
vue@3.5.43(typescript@6.0.3):
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-dom': 3.5.43
|
||||||
|
'@vue/compiler-sfc': 3.5.43
|
||||||
|
'@vue/runtime-dom': 3.5.43
|
||||||
|
'@vue/server-renderer': 3.5.43
|
||||||
|
'@vue/shared': 3.5.43
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 6.0.3
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
siginfo: 2.0.0
|
||||||
|
stackback: 0.0.2
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { ProjectFile } from "./project";
|
||||||
|
import type { Node, Variant, VariantGroupNode } from "./phy";
|
||||||
|
|
||||||
|
export type CascadeReference = { layer: "idx" | "elc" | "map" | "ledChains"; location: string; id: string };
|
||||||
|
type DeleteTarget =
|
||||||
|
{ kind: "node"; node: Node } | { kind: "variant"; variant: Variant; group: VariantGroupNode };
|
||||||
|
|
||||||
|
function descendants(node: Node | Variant): string[] {
|
||||||
|
const ids = [node.id];
|
||||||
|
if (node.type === "cluster") node.children.forEach((child) => ids.push(...descendants(child)));
|
||||||
|
if (node.type === "variantGroup") for (const variant of node.children) ids.push(...descendants(variant));
|
||||||
|
if (node.type === "variant") node.children.forEach((child) => ids.push(...descendants(child)));
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lists all saved references to a PHY node, Variant, and its descendants. T-15. */
|
||||||
|
export function listDeleteReferences(p: ProjectFile, nodeId: string): CascadeReference[] {
|
||||||
|
const target = findTarget(p.phy.root, nodeId);
|
||||||
|
if (!target) throw new Error(`Unknown node: ${nodeId}`);
|
||||||
|
const ids = new Set(descendants(target.kind === "node" ? target.node : target.variant));
|
||||||
|
const out: CascadeReference[] = [];
|
||||||
|
p.idx.order.forEach((id) => {
|
||||||
|
if (ids.has(id)) out.push({ layer: "idx", location: "/idx/order", id });
|
||||||
|
});
|
||||||
|
p.elc.keys.forEach((w, i) => {
|
||||||
|
if (ids.has(w.key)) out.push({ layer: "elc", location: `/elc/keys/${i}`, id: w.key });
|
||||||
|
});
|
||||||
|
p.elc.ledChains?.forEach((chain) =>
|
||||||
|
chain.order.forEach((id) => {
|
||||||
|
if (ids.has(id)) out.push({ layer: "ledChains", location: `/elc/ledChains/${chain.id}`, id });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
p.map.layers.forEach((layer) =>
|
||||||
|
layer.keymap.forEach((assignment) => {
|
||||||
|
if (ids.has(assignment.keyId))
|
||||||
|
out.push({ layer: "map", location: `/map/layers/${layer.name}`, id: assignment.keyId });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes a PHY node or Variant and its references in one immutable project result. T-15. */
|
||||||
|
export function deleteNodeCascade(p: ProjectFile, nodeId: string): ProjectFile {
|
||||||
|
const o = structuredClone(p);
|
||||||
|
const target = findTarget(o.phy.root, nodeId);
|
||||||
|
if (!target) throw new Error(`Unknown node: ${nodeId}`);
|
||||||
|
const deletedIds = new Set(descendants(target.kind === "node" ? target.node : target.variant));
|
||||||
|
removeTarget(o.phy.root, target);
|
||||||
|
|
||||||
|
o.idx.order = o.idx.order.filter((id) => !deletedIds.has(id));
|
||||||
|
o.elc.keys = o.elc.keys.filter((w) => !deletedIds.has(w.key));
|
||||||
|
if (o.elc.ledChains)
|
||||||
|
o.elc.ledChains = o.elc.ledChains.map((chain) => ({
|
||||||
|
...chain,
|
||||||
|
order: chain.order.filter((id) => !deletedIds.has(id)),
|
||||||
|
}));
|
||||||
|
o.map.layers = o.map.layers.map((layer) => ({
|
||||||
|
...layer,
|
||||||
|
keymap: layer.keymap.filter((assignment) => !deletedIds.has(assignment.keyId)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (target.kind === "variant") {
|
||||||
|
if (target.group.default === nodeId) target.group.default = target.group.children[0]!.id;
|
||||||
|
if (o.output.variantSelection[target.group.id] === nodeId)
|
||||||
|
o.output.variantSelection[target.group.id] = target.group.default;
|
||||||
|
}
|
||||||
|
pruneVariantSelection(o);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTarget(nodes: Node[], id: string): DeleteTarget | undefined {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.id === id) return { kind: "node", node };
|
||||||
|
if (node.type === "cluster") {
|
||||||
|
const found = findTarget(node.children, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
if (node.type === "variantGroup")
|
||||||
|
for (const variant of node.children) {
|
||||||
|
if (variant.id === id) return { kind: "variant", variant, group: node };
|
||||||
|
const found = findTarget(variant.children, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTarget(nodes: Node[], target: DeleteTarget): void {
|
||||||
|
const id = target.kind === "node" ? target.node.id : target.variant.id;
|
||||||
|
if (target.kind === "variant") {
|
||||||
|
const { group, variant } = target;
|
||||||
|
if (group.children.length <= 2)
|
||||||
|
throw new Error("Cannot delete a variant leaving fewer than two variants");
|
||||||
|
group.children.splice(group.children.indexOf(variant), 1);
|
||||||
|
if (group.default === id) group.default = group.children[0]!.id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = nodes.findIndex((node) => node.id === id);
|
||||||
|
if (index >= 0) {
|
||||||
|
nodes.splice(index, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === "cluster") removeTarget(node.children, target);
|
||||||
|
if (node.type === "variantGroup")
|
||||||
|
for (const variant of node.children) removeTarget(variant.children, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneVariantSelection(project: ProjectFile): void {
|
||||||
|
const valid = new Map<string, { ids: Set<string>; defaultId: string }>();
|
||||||
|
const walk = (nodes: Node[]) =>
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.type === "cluster") walk(node.children);
|
||||||
|
if (node.type === "variantGroup") {
|
||||||
|
valid.set(node.id, {
|
||||||
|
ids: new Set(node.children.map((variant) => variant.id)),
|
||||||
|
defaultId: node.default,
|
||||||
|
});
|
||||||
|
node.children.forEach((variant) => walk(variant.children));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
walk(project.phy.root);
|
||||||
|
for (const [groupId, variantId] of Object.entries(project.output.variantSelection)) {
|
||||||
|
const group = valid.get(groupId);
|
||||||
|
if (!group) delete project.output.variantSelection[groupId];
|
||||||
|
else if (!group.ids.has(variantId)) project.output.variantSelection[groupId] = group.defaultId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { PhyReference } from "./idx";
|
||||||
|
export type PinRef = string;
|
||||||
|
export type McuProvider = { id: string; kind: "mcu"; part: string; [key: string]: unknown };
|
||||||
|
export type AnalogMuxProvider = {
|
||||||
|
id: string;
|
||||||
|
kind: "analog-mux";
|
||||||
|
channels: number;
|
||||||
|
select: PinRef[];
|
||||||
|
common: PinRef;
|
||||||
|
enable?: PinRef;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
export type ContactWiring = {
|
||||||
|
key: string;
|
||||||
|
detect: "contact";
|
||||||
|
terminals: { a: PinRef; b: PinRef };
|
||||||
|
diode?: "a-to-b" | "b-to-a";
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
export type AnalogWiring = {
|
||||||
|
key: string;
|
||||||
|
detect: "analog";
|
||||||
|
terminals: { out: PinRef; vcc: PinRef; gnd: PinRef };
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
export type KeyWiring = ContactWiring | AnalogWiring;
|
||||||
|
export type LedChain = { id: string; data: PinRef; order: string[]; [key: string]: unknown };
|
||||||
|
export type ElcFile = {
|
||||||
|
version: string;
|
||||||
|
phyReference: PhyReference;
|
||||||
|
providers: (McuProvider | AnalogMuxProvider)[];
|
||||||
|
keys: KeyWiring[];
|
||||||
|
ledChains?: LedChain[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
/** Returns RP2040 GPIO pin suggestions, or no fixed list for unknown parts. T-26. */
|
||||||
|
export function mcuPinCandidates(provider: McuProvider): string[] {
|
||||||
|
return provider.part.toUpperCase() === "RP2040" ? Array.from({ length: 30 }, (_, i) => `GP${i}`) : [];
|
||||||
|
}
|
||||||
|
/** Creates an empty ELC file. */
|
||||||
|
export function createElc(ref: PhyReference): ElcFile {
|
||||||
|
return { version: "0.1.0", phyReference: structuredClone(ref), providers: [], keys: [], ledChains: [] };
|
||||||
|
}
|
||||||
|
/** Adds or replaces provider settings, rejecting IDs that conflict with PinRef syntax. T-26. */
|
||||||
|
export function setProvider(elc: ElcFile, p: McuProvider | AnalogMuxProvider): ElcFile {
|
||||||
|
if (!p.id || p.id.includes(".")) throw new Error("Provider ID must be nonempty and cannot contain '.'");
|
||||||
|
const o = structuredClone(elc);
|
||||||
|
const i = o.providers.findIndex((x) => x.id === p.id);
|
||||||
|
if (i < 0) o.providers.push(structuredClone(p));
|
||||||
|
else {
|
||||||
|
const old = o.providers[i]!;
|
||||||
|
const known = new Set(["id", "kind", "part", "channels", "select", "common", "enable"]);
|
||||||
|
const extensions = Object.fromEntries(Object.entries(old).filter(([key]) => !known.has(key)));
|
||||||
|
o.providers[i] = { ...extensions, ...(old.kind === p.kind ? old : {}), ...p } as
|
||||||
|
McuProvider | AnalogMuxProvider;
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** Removes a provider and its dependent references. */
|
||||||
|
export function removeProvider(elc: ElcFile, id: string): ElcFile {
|
||||||
|
const o = structuredClone(elc),
|
||||||
|
removed = new Set([id]);
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const p of o.providers)
|
||||||
|
if (p.kind === "analog-mux" && !removed.has(p.id)) {
|
||||||
|
const refs = [...p.select, p.common, ...(p.enable ? [p.enable] : [])];
|
||||||
|
if (refs.some((ref) => [...removed].some((owner) => ref.startsWith(owner + ".")))) {
|
||||||
|
removed.add(p.id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pin = (s: string) => [...removed].some((owner) => s.startsWith(owner + "."));
|
||||||
|
o.providers = o.providers.filter((p) => !removed.has(p.id));
|
||||||
|
o.keys = o.keys.filter((k) => !Object.values(k.terminals).some((v) => pin(v)));
|
||||||
|
if (o.ledChains) o.ledChains = o.ledChains.filter((c) => !pin(c.data));
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** Adds or edits terminal and diode configuration for a key. */
|
||||||
|
export function setKeyWiring(elc: ElcFile, w: KeyWiring): ElcFile {
|
||||||
|
const o = structuredClone(elc);
|
||||||
|
const i = o.keys.findIndex((x) => x.key === w.key);
|
||||||
|
if (i < 0) o.keys.push(structuredClone(w));
|
||||||
|
else {
|
||||||
|
const old = o.keys[i]!;
|
||||||
|
const known = new Set(["key", "detect", "terminals", "diode"]);
|
||||||
|
const extensions = Object.fromEntries(Object.entries(old).filter(([key]) => !known.has(key)));
|
||||||
|
const knownTerminals = old.detect === "contact" ? new Set(["a", "b"]) : new Set(["out", "vcc", "gnd"]);
|
||||||
|
const terminalExtensions = Object.fromEntries(
|
||||||
|
Object.entries(old.terminals).filter(([key]) => !knownTerminals.has(key)),
|
||||||
|
);
|
||||||
|
const updated = {
|
||||||
|
...extensions,
|
||||||
|
...(old.detect === w.detect ? old : {}),
|
||||||
|
...w,
|
||||||
|
terminals: { ...terminalExtensions, ...(old.detect === w.detect ? old.terminals : {}), ...w.terminals },
|
||||||
|
} as KeyWiring;
|
||||||
|
if (w.detect === "contact" && w.diode === undefined) delete (updated as ContactWiring).diode;
|
||||||
|
o.keys[i] = updated;
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** Removes wiring for a key. */
|
||||||
|
export function removeKeyWiring(elc: ElcFile, keyId: string): ElcFile {
|
||||||
|
const o = structuredClone(elc);
|
||||||
|
o.keys = o.keys.filter((x) => x.key !== keyId);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** Returns keys without any ELC entry. */
|
||||||
|
export function unwiredKeys(keyIds: string[], elc: ElcFile): string[] {
|
||||||
|
const wired = new Set(elc.keys.map((k) => k.key));
|
||||||
|
return keyIds.filter((k) => !wired.has(k));
|
||||||
|
}
|
||||||
|
/** Creates an LED chain with an independent ordered node list. T-11. */
|
||||||
|
export function createLedChain(elc: ElcFile, id: string, data: PinRef): ElcFile {
|
||||||
|
const o = structuredClone(elc);
|
||||||
|
o.ledChains ??= [];
|
||||||
|
if (o.ledChains.some((c) => c.id === id)) throw new Error("Duplicate LED chain ID");
|
||||||
|
o.ledChains.push({ id, data, order: [] });
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** Replaces LED order while preserving the chain's other fields. */
|
||||||
|
export function setLedOrder(elc: ElcFile, id: string, order: string[]): ElcFile {
|
||||||
|
const o = structuredClone(elc);
|
||||||
|
const c = o.ledChains?.find((x) => x.id === id);
|
||||||
|
if (!c) throw new Error("Unknown LED chain");
|
||||||
|
c.order = [...order];
|
||||||
|
return o;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { setKeyWiring, type ContactWiring, type ElcFile } from "./elc";
|
||||||
|
|
||||||
|
/** Assigns a row-major matrix to the supplied keys. The input order is preserved. */
|
||||||
|
export function wireMatrixKeys(
|
||||||
|
elc: ElcFile,
|
||||||
|
keyIds: string[],
|
||||||
|
rowPins: string[],
|
||||||
|
colPins: string[],
|
||||||
|
diode?: "a-to-b" | "b-to-a",
|
||||||
|
): ElcFile {
|
||||||
|
if (!rowPins.length || !colPins.length) throw new Error("rowPins and colPins are required");
|
||||||
|
if (keyIds.length > rowPins.length * colPins.length)
|
||||||
|
throw new Error("Matrix does not have enough row/column positions");
|
||||||
|
let next = elc;
|
||||||
|
keyIds.forEach((key, i) => {
|
||||||
|
const wiring: ContactWiring = {
|
||||||
|
key,
|
||||||
|
detect: "contact",
|
||||||
|
terminals: { a: rowPins[Math.floor(i / colPins.length)]!, b: colPins[i % colPins.length]! },
|
||||||
|
...(diode ? { diode } : {}),
|
||||||
|
};
|
||||||
|
// Keep unknown extension fields on an existing ELC entry, just like normal edits.
|
||||||
|
next = setKeyWiring(next, wiring);
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { ProjectFile } from "./project";
|
||||||
|
import { flattenKeys } from "./geometry";
|
||||||
|
import { validateProject, type ValidationIssue } from "./validate";
|
||||||
|
export type MockTarget = {
|
||||||
|
format: "mock-target";
|
||||||
|
version: "0.1.0";
|
||||||
|
layout: Array<{ keyId: string; x: number; y: number; rotation: number; w: number; h: number }>;
|
||||||
|
idx: string[];
|
||||||
|
matrix: { rowPins: string[]; colPins: string[]; rows: number[]; cols: number[] };
|
||||||
|
keymaps: Array<{ name: string; keycodes: string[] }>;
|
||||||
|
mapping: Array<{ number: number; keyId: string; row: number; col: number; label: string }>;
|
||||||
|
};
|
||||||
|
export type GeneratorResult = { target?: MockTarget; errors: ValidationIssue[] };
|
||||||
|
/** Generates preview output after validation, without modifying the project. T-2/T-19. */
|
||||||
|
export function generateMockTarget(p: ProjectFile): GeneratorResult {
|
||||||
|
const errors = validateProject(p).filter(
|
||||||
|
(x) => x.severity === "blocksOutput" || x.severity === "blocksSave",
|
||||||
|
);
|
||||||
|
if (!p.output.matrix)
|
||||||
|
errors.push({
|
||||||
|
severity: "blocksOutput",
|
||||||
|
code: "matrix-settings-missing",
|
||||||
|
message: "Matrix rowPins and colPins must be configured",
|
||||||
|
});
|
||||||
|
if (p.output.matrix?.kind && p.output.matrix.kind !== "matrix")
|
||||||
|
errors.push({
|
||||||
|
severity: "blocksOutput",
|
||||||
|
code: "unsupported-scan-mode",
|
||||||
|
message: `${p.output.matrix.kind} scan mode is not supported`,
|
||||||
|
});
|
||||||
|
if (errors.length) return { errors };
|
||||||
|
const selected = flattenKeys(p.phy, p.output.variantSelection),
|
||||||
|
active = new Set(selected.map((x) => x.key.id)),
|
||||||
|
order = p.idx.order.filter((id) => active.has(id)),
|
||||||
|
matrix = p.output.matrix!;
|
||||||
|
const rowIndex = new Map(matrix.rowPins.map((x, i) => [x, i])),
|
||||||
|
colIndex = new Map(matrix.colPins.map((x, i) => [x, i]));
|
||||||
|
const wiring = new Map(p.elc.keys.map((x) => [x.key, x]));
|
||||||
|
const row: number[] = [],
|
||||||
|
col: number[] = [];
|
||||||
|
for (const id of order) {
|
||||||
|
const w = wiring.get(id);
|
||||||
|
if (!w || w.detect !== "contact")
|
||||||
|
return {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
severity: "blocksOutput",
|
||||||
|
code: "unsupported-detection",
|
||||||
|
message: "Only contact matrix wiring is supported",
|
||||||
|
ids: [id],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const a = w.terminals.a,
|
||||||
|
b = w.terminals.b;
|
||||||
|
let r: number | undefined, c: number | undefined;
|
||||||
|
if (rowIndex.has(a) && colIndex.has(b)) {
|
||||||
|
r = rowIndex.get(a);
|
||||||
|
c = colIndex.get(b);
|
||||||
|
} else if (rowIndex.has(b) && colIndex.has(a)) {
|
||||||
|
r = rowIndex.get(b);
|
||||||
|
c = colIndex.get(a);
|
||||||
|
}
|
||||||
|
if (r === undefined || c === undefined)
|
||||||
|
return {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
severity: "blocksOutput",
|
||||||
|
code: "unsupported-matrix-wiring",
|
||||||
|
message: "Wiring is not a supported row/column matrix",
|
||||||
|
ids: [id],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
row.push(r);
|
||||||
|
col.push(c);
|
||||||
|
}
|
||||||
|
const poses = new Map(selected.map((x) => [x.key.id, x]));
|
||||||
|
const keymaps = p.map.layers.map((l) => ({
|
||||||
|
name: l.name,
|
||||||
|
keycodes: order.map((id) => l.keymap.find((a) => a.keyId === id)!.keycode),
|
||||||
|
}));
|
||||||
|
const target: MockTarget = {
|
||||||
|
format: "mock-target",
|
||||||
|
version: "0.1.0",
|
||||||
|
layout: order.map((id) => {
|
||||||
|
const x = poses.get(id)!;
|
||||||
|
return { keyId: id, x: x.x, y: x.y, rotation: x.rotation, w: x.key.size.w, h: x.key.size.h };
|
||||||
|
}),
|
||||||
|
idx: order,
|
||||||
|
matrix: { rowPins: [...matrix.rowPins], colPins: [...matrix.colPins], rows: row, cols: col },
|
||||||
|
keymaps,
|
||||||
|
mapping: order.map((id, i) => ({
|
||||||
|
number: i,
|
||||||
|
keyId: id,
|
||||||
|
row: row[i]!,
|
||||||
|
col: col[i]!,
|
||||||
|
label: poses.get(id)!.key.labels?.find(Boolean) ?? "",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
return { target, errors: [] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { FlatKey, Node, PhyFile, Transform } from "./phy";
|
||||||
|
|
||||||
|
/** Translate·Rotate のアフィン行列を返す。 */
|
||||||
|
export function matrix(t: Transform): Affine {
|
||||||
|
const a = (t.rotation * Math.PI) / 180,
|
||||||
|
c = Math.cos(a),
|
||||||
|
s = Math.sin(a);
|
||||||
|
return { a: c, b: s, c: -s, d: c, e: t.x, f: t.y };
|
||||||
|
}
|
||||||
|
export type Affine = { a: number; b: number; c: number; d: number; e: number; f: number };
|
||||||
|
/** 左から右の順でなく、通常の行列積 left·right を返す。 */
|
||||||
|
export function compose(left: Affine, right: Affine): Affine {
|
||||||
|
return {
|
||||||
|
a: left.a * right.a + left.c * right.b,
|
||||||
|
b: left.b * right.a + left.d * right.b,
|
||||||
|
c: left.a * right.c + left.c * right.d,
|
||||||
|
d: left.b * right.c + left.d * right.d,
|
||||||
|
e: left.a * right.e + left.c * right.f + left.e,
|
||||||
|
f: left.b * right.e + left.d * right.f + left.f,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** 剛体変換の逆行列を返す。 */
|
||||||
|
export function inverse(m: Affine): Affine {
|
||||||
|
return { a: m.a, b: m.c, c: m.b, d: m.d, e: -(m.a * m.e + m.b * m.f), f: -(m.c * m.e + m.d * m.f) };
|
||||||
|
}
|
||||||
|
/** 点に行列を適用する。 */
|
||||||
|
export function apply(m: Affine, p: { x: number; y: number }): { x: number; y: number } {
|
||||||
|
return { x: m.a * p.x + m.c * p.y + m.e, y: m.b * p.x + m.d * p.y + m.f };
|
||||||
|
}
|
||||||
|
/** PHY ノードのワールド行列を計算する。 */
|
||||||
|
export function worldTransform(file: PhyFile, id: string): Affine {
|
||||||
|
const walk = (nodes: Node[], parent: Affine): Affine | undefined => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
const own = compose(parent, matrix(n.transform));
|
||||||
|
if (n.id === id) return own;
|
||||||
|
if (n.type === "cluster") {
|
||||||
|
const r = walk(n.children, own);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
if (n.type === "variantGroup")
|
||||||
|
for (const v of n.children) {
|
||||||
|
const r = walk(v.children, own);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const found = walk(file.root, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 });
|
||||||
|
if (!found) throw new Error(`Unknown node: ${id}`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
/** 選択したVariantに従いキーを平坦化する。省略時は各Groupのdefaultを使う。 */
|
||||||
|
export function flattenKeys(file: PhyFile, selection: Record<string, string> = {}): FlatKey[] {
|
||||||
|
const out: FlatKey[] = [];
|
||||||
|
const visit = (nodes: Node[], parent: Affine, parentRotation: number, path: string[]) => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
const wm = compose(parent, matrix(n.transform)),
|
||||||
|
p = [...path, n.id],
|
||||||
|
absoluteRotation = parentRotation + n.transform.rotation;
|
||||||
|
if (n.type === "key") out.push({ key: n, x: wm.e, y: wm.f, rotation: absoluteRotation, path: p });
|
||||||
|
else if (n.type === "cluster") visit(n.children, wm, absoluteRotation, p);
|
||||||
|
else if (n.type === "variantGroup") {
|
||||||
|
const v = n.children.find((x) => x.id === (selection[n.id] ?? n.default));
|
||||||
|
if (!v) throw new Error(`Unknown variant for ${n.id}`);
|
||||||
|
visit(v.children, wm, absoluteRotation, [...p, v.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(file.root, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, 0, []);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { ProjectFile } from "./project";
|
||||||
|
/** Snapshot history for whole-project undo/redo. T-17. */
|
||||||
|
export class ProjectHistory {
|
||||||
|
private items: ProjectFile[];
|
||||||
|
private cursor = 0;
|
||||||
|
private pending = false;
|
||||||
|
constructor(
|
||||||
|
initial: ProjectFile,
|
||||||
|
private readonly limit = 100,
|
||||||
|
) {
|
||||||
|
this.items = [structuredClone(initial)];
|
||||||
|
}
|
||||||
|
/** Adds one undoable snapshot unless a grouped edit is active or nothing changed. */
|
||||||
|
push(value: ProjectFile): void {
|
||||||
|
if (this.pending) {
|
||||||
|
this.replaceTop(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (JSON.stringify(value) === JSON.stringify(this.items[this.cursor])) return;
|
||||||
|
this.add(value);
|
||||||
|
}
|
||||||
|
/** Starts grouping subsequent pushes into a single operation. T-17. */
|
||||||
|
begin(): void {
|
||||||
|
if (this.pending) throw new Error("History group already active");
|
||||||
|
this.items = this.items.slice(0, this.cursor + 1);
|
||||||
|
this.items.push(structuredClone(this.items[this.cursor]!));
|
||||||
|
this.cursor = this.items.length - 1;
|
||||||
|
if (this.items.length > this.limit) {
|
||||||
|
this.items.shift();
|
||||||
|
this.cursor--;
|
||||||
|
}
|
||||||
|
this.pending = true;
|
||||||
|
}
|
||||||
|
/** Commits the final grouped snapshot as one undo step. T-17. */
|
||||||
|
commit(value?: ProjectFile): void {
|
||||||
|
if (!this.pending) throw new Error("No history group active");
|
||||||
|
if (value) this.items[this.cursor] = structuredClone(value);
|
||||||
|
if (JSON.stringify(this.items[this.cursor]) === JSON.stringify(this.items[this.cursor - 1])) {
|
||||||
|
this.items.pop();
|
||||||
|
this.cursor--;
|
||||||
|
}
|
||||||
|
this.pending = false;
|
||||||
|
}
|
||||||
|
/** Replaces the current snapshot during a grouped drag. T-17. */
|
||||||
|
replaceTop(value: ProjectFile): void {
|
||||||
|
this.items[this.cursor] = structuredClone(value);
|
||||||
|
}
|
||||||
|
/** Moves to the previous snapshot. */
|
||||||
|
undo(): ProjectFile {
|
||||||
|
if (!this.canUndo) throw new Error("Nothing to undo");
|
||||||
|
return structuredClone(this.items[--this.cursor]!);
|
||||||
|
}
|
||||||
|
/** Moves to the next snapshot. */
|
||||||
|
redo(): ProjectFile {
|
||||||
|
if (!this.canRedo) throw new Error("Nothing to redo");
|
||||||
|
return structuredClone(this.items[++this.cursor]!);
|
||||||
|
}
|
||||||
|
get canUndo(): boolean {
|
||||||
|
return this.cursor > 0;
|
||||||
|
}
|
||||||
|
get canRedo(): boolean {
|
||||||
|
return this.cursor < this.items.length - 1;
|
||||||
|
}
|
||||||
|
/** Returns a copy of the current snapshot. */
|
||||||
|
current(): ProjectFile {
|
||||||
|
return structuredClone(this.items[this.cursor]!);
|
||||||
|
}
|
||||||
|
private add(value: ProjectFile) {
|
||||||
|
this.items = this.items.slice(0, this.cursor + 1);
|
||||||
|
this.items.push(structuredClone(value));
|
||||||
|
this.cursor = this.items.length - 1;
|
||||||
|
if (this.items.length > this.limit) {
|
||||||
|
this.items.shift();
|
||||||
|
this.cursor--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/** T-11: UUID先頭12文字を種別接頭辞に付ける。生成器を注入できる。 */
|
||||||
|
export function createId(prefix: string, uuid: () => string = () => globalThis.crypto.randomUUID()): string {
|
||||||
|
return `${prefix}${uuid().replaceAll("-", "").slice(0, 12)}`;
|
||||||
|
}
|
||||||
|
/** T-11: ノード種別ごとのIDを生成する。 */
|
||||||
|
export function createNodeId(
|
||||||
|
kind: "key" | "cluster" | "component" | "variantGroup" | "variant",
|
||||||
|
uuid?: () => string,
|
||||||
|
): string {
|
||||||
|
return createId(
|
||||||
|
{ key: "key-", cluster: "cl-", component: "cmp-", variantGroup: "vg-", variant: "v-" }[kind],
|
||||||
|
uuid,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { PhyFile, Node, KeyNode } from "./phy";
|
||||||
|
export type PhyReference = { source: string; checksum: string; [key: string]: unknown };
|
||||||
|
export type IdxFile = {
|
||||||
|
version: string;
|
||||||
|
phyReference: PhyReference;
|
||||||
|
order: string[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
/** Collects keys in document order, including ghost keys and excluding decals. T-18. */
|
||||||
|
export function eligibleKeyIds(phy: PhyFile): string[] {
|
||||||
|
const ids: string[] = [];
|
||||||
|
const visit = (nodes: Node[]) =>
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
if (n.type === "key") {
|
||||||
|
if (!n.decal) ids.push(n.id);
|
||||||
|
} else if (n.type === "cluster") visit(n.children);
|
||||||
|
else if (n.type === "variantGroup") n.children.forEach((v) => visit(v.children));
|
||||||
|
});
|
||||||
|
visit(phy.root);
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
/** Makes initial index order from KLE occurrence order. T-18. */
|
||||||
|
export function createIdx(phy: PhyFile, keyOrder: string[], phyReference: PhyReference): IdxFile {
|
||||||
|
const valid = new Set(eligibleKeyIds(phy));
|
||||||
|
return {
|
||||||
|
version: "0.1.0",
|
||||||
|
phyReference: structuredClone(phyReference),
|
||||||
|
order: keyOrder.filter((id) => valid.has(id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** Lists eligible PHY keys omitted from the saved order. T-18. */
|
||||||
|
export function unregisteredKeys(phy: PhyFile, idx: IdxFile): string[] {
|
||||||
|
const registered = new Set(idx.order);
|
||||||
|
return eligibleKeyIds(phy).filter((id) => !registered.has(id));
|
||||||
|
}
|
||||||
|
/** Registers new keys at the end without rebuilding existing order. T-18. */
|
||||||
|
export function appendKeys(idx: IdxFile, ids: string[]): IdxFile {
|
||||||
|
const out = structuredClone(idx);
|
||||||
|
for (const id of ids) if (!out.order.includes(id)) out.order.push(id);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
/** Removes one key from index order. */
|
||||||
|
export function excludeKey(idx: IdxFile, keyId: string): IdxFile {
|
||||||
|
const o = structuredClone(idx);
|
||||||
|
o.order = o.order.filter((x) => x !== keyId);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** Reorders the saved key list. */
|
||||||
|
export function reorderKeys(idx: IdxFile, order: string[]): IdxFile {
|
||||||
|
if (
|
||||||
|
order.length !== idx.order.length ||
|
||||||
|
new Set(order).size !== order.length ||
|
||||||
|
order.some((x) => !idx.order.includes(x))
|
||||||
|
)
|
||||||
|
throw new Error("Order must contain every registered key exactly once");
|
||||||
|
return { ...structuredClone(idx), order: [...order] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { FlatKey } from "./phy";
|
||||||
|
|
||||||
|
/** World position of a key origin and its rotation, as read from an existing definition. T-31. */
|
||||||
|
export type Placement = { x: number; y: number; rotation: number };
|
||||||
|
|
||||||
|
const TOLERANCE = 0.01;
|
||||||
|
|
||||||
|
const angle = (deg: number) => ((deg % 360) + 360) % 360;
|
||||||
|
|
||||||
|
function sameRotation(a: number, b: number): boolean {
|
||||||
|
const d = Math.abs(angle(a) - angle(b));
|
||||||
|
return Math.min(d, 360 - d) <= TOLERANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches placements one-to-one to the shown keys by position and rotation, in placement order. */
|
||||||
|
export function matchPlacements(
|
||||||
|
placements: readonly Placement[],
|
||||||
|
keys: readonly FlatKey[],
|
||||||
|
): { ids: string[]; unmatched: number } {
|
||||||
|
const used = new Set<string>();
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const p of placements) {
|
||||||
|
let best: FlatKey | undefined,
|
||||||
|
distance = Infinity;
|
||||||
|
for (const k of keys) {
|
||||||
|
if (used.has(k.key.id) || !sameRotation(p.rotation, k.rotation)) continue;
|
||||||
|
const dx = Math.abs(p.x - k.x),
|
||||||
|
dy = Math.abs(p.y - k.y);
|
||||||
|
if (dx > TOLERANCE || dy > TOLERANCE || dx + dy >= distance) continue;
|
||||||
|
best = k;
|
||||||
|
distance = dx + dy;
|
||||||
|
}
|
||||||
|
if (best) {
|
||||||
|
used.add(best.key.id);
|
||||||
|
ids.push(best.key.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ids, unmatched: placements.length - ids.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { Node, PhyFile } from "./phy";
|
||||||
|
|
||||||
|
/** Keys shown in the edited configuration and the VariantGroups each key sits under. T-31. */
|
||||||
|
export type OrderContext = {
|
||||||
|
visible: ReadonlySet<string>;
|
||||||
|
groups: ReadonlyMap<string, readonly string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Lists the VariantGroup ancestors of every key, outermost first. Common keys get an empty list. */
|
||||||
|
export function keyVariantGroups(phy: PhyFile): Map<string, string[]> {
|
||||||
|
const out = new Map<string, string[]>();
|
||||||
|
const visit = (nodes: Node[], groups: string[]) =>
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
if (n.type === "key") out.set(n.id, groups);
|
||||||
|
else if (n.type === "cluster") visit(n.children, groups);
|
||||||
|
else if (n.type === "variantGroup") n.children.forEach((v) => visit(v.children, [...groups, n.id]));
|
||||||
|
});
|
||||||
|
visit(phy.root, []);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sharesGroup(ctx: OrderContext, a: string, b: string): boolean {
|
||||||
|
const mine = ctx.groups.get(a) ?? [];
|
||||||
|
return (ctx.groups.get(b) ?? []).some((g) => mine.includes(g));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits `order` into one segment per visible key, each holding the hidden keys attached to it.
|
||||||
|
* A hidden key attaches to the nearer visible key that shares a VariantGroup with it,
|
||||||
|
* preferring the one before it; once a hidden key attaches forward, the rest of that run does too.
|
||||||
|
* Concatenating the segments gives `order` back. Without visible keys there are no segments.
|
||||||
|
*/
|
||||||
|
export function orderSegments(order: readonly string[], ctx: OrderContext): string[][] {
|
||||||
|
const at = order.flatMap((id, i) => (ctx.visible.has(id) ? [i] : []));
|
||||||
|
if (!at.length) return [];
|
||||||
|
const segments = at.map((i) => [order[i]!]);
|
||||||
|
segments[0]!.unshift(...order.slice(0, at[0]));
|
||||||
|
segments.at(-1)!.push(...order.slice(at.at(-1)! + 1));
|
||||||
|
for (let k = 0; k + 1 < at.length; k++) {
|
||||||
|
const prev = order[at[k]!]!,
|
||||||
|
next = order[at[k + 1]!]!,
|
||||||
|
run = order.slice(at[k]! + 1, at[k + 1]);
|
||||||
|
let split = run.findIndex((h) => sharesGroup(ctx, h, next) && !sharesGroup(ctx, h, prev));
|
||||||
|
if (split < 0) split = run.length;
|
||||||
|
segments[k]!.push(...run.slice(0, split));
|
||||||
|
segments[k + 1]!.unshift(...run.slice(split));
|
||||||
|
}
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves `keyId` to gap `gap` (0..V) between the visible keys left after taking it out.
|
||||||
|
* Hidden keys that were attached to it stay where they are. Unregistered keys are registered there.
|
||||||
|
*/
|
||||||
|
export function insertAtGap(
|
||||||
|
order: readonly string[],
|
||||||
|
ctx: OrderContext,
|
||||||
|
keyId: string,
|
||||||
|
gap: number,
|
||||||
|
): string[] {
|
||||||
|
const rest = order.filter((id) => id !== keyId);
|
||||||
|
const segments = orderSegments(rest, ctx);
|
||||||
|
if (gap >= segments.length) return [...rest, keyId];
|
||||||
|
return [...segments.slice(0, gap).flat(), keyId, ...segments.slice(gap).flat()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves `keyId` right before (`after` false) or right after the visible key `target`. */
|
||||||
|
export function insertNextTo(
|
||||||
|
order: readonly string[],
|
||||||
|
ctx: OrderContext,
|
||||||
|
keyId: string,
|
||||||
|
target: string,
|
||||||
|
after: boolean,
|
||||||
|
): string[] {
|
||||||
|
if (keyId === target) return [...order];
|
||||||
|
const visibleRest = order.filter((id) => id !== keyId && ctx.visible.has(id));
|
||||||
|
const index = visibleRest.indexOf(target);
|
||||||
|
if (index < 0) throw new Error(`Not a visible registered key: ${target}`);
|
||||||
|
return insertAtGap(order, ctx, keyId, index + (after ? 1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Swaps two registered keys. Every other key keeps its position. */
|
||||||
|
export function swapKeys(order: readonly string[], a: string, b: string): string[] {
|
||||||
|
const i = order.indexOf(a),
|
||||||
|
j = order.indexOf(b);
|
||||||
|
if (i < 0 || j < 0) throw new Error("Both keys must be registered");
|
||||||
|
const out = [...order];
|
||||||
|
[out[i], out[j]] = [out[j]!, out[i]!];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places the visible keys of `sequence` consecutively, starting where its first registered key was
|
||||||
|
* among the other visible keys. Other visible keys keep their relative order, hidden keys move with
|
||||||
|
* the visible key they are attached to, and unregistered keys in `sequence` are registered. T-31.
|
||||||
|
*/
|
||||||
|
export function renumber(order: readonly string[], ctx: OrderContext, sequence: readonly string[]): string[] {
|
||||||
|
const registered = new Set(order);
|
||||||
|
const seq = [...new Set(sequence)].filter((id) => ctx.visible.has(id) || !registered.has(id));
|
||||||
|
const inSeq = new Set(seq);
|
||||||
|
const anchor = seq.find((id) => registered.has(id));
|
||||||
|
if (!anchor) return [...order, ...seq];
|
||||||
|
const segments = orderSegments(order, ctx);
|
||||||
|
const byKey = new Map(segments.map((s) => [s.find((id) => ctx.visible.has(id))!, s]));
|
||||||
|
const others = segments.filter((s) => !s.some((id) => inSeq.has(id)));
|
||||||
|
const anchorAt = order.indexOf(anchor);
|
||||||
|
const gap = others.filter((s) => order.indexOf(s.find((id) => ctx.visible.has(id))!) < anchorAt).length;
|
||||||
|
const block = seq.flatMap((id) => byKey.get(id) ?? [id]);
|
||||||
|
return [...others.slice(0, gap).flat(), ...block, ...others.slice(gap).flat()];
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import type { FlatKey } from "./phy";
|
||||||
|
import type { ProjectFile } from "./project";
|
||||||
|
import { detectColumns, detectRows, type KeyRect } from "./rows";
|
||||||
|
|
||||||
|
/** Gaps narrower than this do not separate the two halves. T-31. */
|
||||||
|
const MIN_HALF_GAP = 0.5;
|
||||||
|
|
||||||
|
export type ScanKind = "rows" | "columns" | "halves" | "alternating";
|
||||||
|
export type Direction = "normal" | "reversed";
|
||||||
|
export type ScanOptions = {
|
||||||
|
kind: ScanKind;
|
||||||
|
/** Whole-board direction for `rows` and `columns`. */
|
||||||
|
direction: Direction;
|
||||||
|
/** Per-half directions for `halves` and `alternating`. */
|
||||||
|
left: Direction;
|
||||||
|
right: Direction;
|
||||||
|
/** Shift of the line assignment per cross line index (columns for row scans, rows for `columns`). */
|
||||||
|
offsets: ReadonlyMap<number, number>;
|
||||||
|
};
|
||||||
|
export type ScanResult = {
|
||||||
|
sequence: string[];
|
||||||
|
/** Lines the scan walks, for the preview: rows, or columns for `columns`. */
|
||||||
|
lines: string[][];
|
||||||
|
/** Lines that can be shifted with `offsets`, in index order. */
|
||||||
|
crossLines: string[][];
|
||||||
|
/** x of the split between the halves, for scans that use halves. */
|
||||||
|
boundary?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Axis-aligned bounding box of a key in world coordinates; rotated keys are judged by it. T-31. */
|
||||||
|
export function keyRect(k: FlatKey): KeyRect {
|
||||||
|
const r = (k.rotation * Math.PI) / 180,
|
||||||
|
c = Math.cos(r),
|
||||||
|
s = Math.sin(r);
|
||||||
|
const { w, h } = k.key.size;
|
||||||
|
const xs = [0, w * c, -h * s, w * c - h * s].map((dx) => k.x + dx);
|
||||||
|
const ys = [0, w * s, h * c, w * s + h * c].map((dy) => k.y + dy);
|
||||||
|
const x = Math.min(...xs),
|
||||||
|
y = Math.min(...ys);
|
||||||
|
return { id: k.key.id, x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits keys into left and right halves at the widest gap between detected columns.
|
||||||
|
* When no gap reaches MIN_HALF_GAP, splits at the middle of the keys' x range by key center.
|
||||||
|
*/
|
||||||
|
export function splitHalves(rects: KeyRect[]): { left: Set<string>; right: Set<string>; boundary: number } {
|
||||||
|
const columns = detectColumns(rects);
|
||||||
|
const byId = new Map(rects.map((r) => [r.id, r]));
|
||||||
|
const span = columns.map((col) => {
|
||||||
|
const rs = col.map((id) => byId.get(id)!);
|
||||||
|
return { min: Math.min(...rs.map((r) => r.x)), max: Math.max(...rs.map((r) => r.x + r.w)) };
|
||||||
|
});
|
||||||
|
let best = -1,
|
||||||
|
width = -Infinity;
|
||||||
|
for (let i = 0; i + 1 < span.length; i++) {
|
||||||
|
const gap = span[i + 1]!.min - span[i]!.max;
|
||||||
|
if (gap > width) [best, width] = [i, gap];
|
||||||
|
}
|
||||||
|
if (best >= 0 && width >= MIN_HALF_GAP) {
|
||||||
|
const left = new Set(columns.slice(0, best + 1).flat());
|
||||||
|
return {
|
||||||
|
left,
|
||||||
|
right: new Set(rects.map((r) => r.id).filter((id) => !left.has(id))),
|
||||||
|
boundary: (span[best]!.max + span[best + 1]!.min) / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const boundary = rects.length
|
||||||
|
? (Math.min(...rects.map((r) => r.x)) + Math.max(...rects.map((r) => r.x + r.w))) / 2
|
||||||
|
: 0;
|
||||||
|
const left = new Set(rects.filter((r) => r.x + r.w / 2 < boundary).map((r) => r.id));
|
||||||
|
return { left, right: new Set(rects.map((r) => r.id).filter((id) => !left.has(id))), boundary };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects rows (or columns), then moves every key of cross line `i` by `offsets.get(i)` lines.
|
||||||
|
* Rows are rebuilt top to bottom and sorted by x; columns left to right and sorted by y.
|
||||||
|
*/
|
||||||
|
export function assignLines(
|
||||||
|
rects: KeyRect[],
|
||||||
|
kind: "row" | "column",
|
||||||
|
offsets: ReadonlyMap<number, number>,
|
||||||
|
): { lines: string[][]; crossLines: string[][] } {
|
||||||
|
const base = kind === "row" ? detectRows(rects) : detectColumns(rects);
|
||||||
|
const crossLines = kind === "row" ? detectColumns(rects) : detectRows(rects);
|
||||||
|
const line = new Map(base.flatMap((ids, i) => ids.map((id) => [id, i] as const)));
|
||||||
|
crossLines.forEach((ids, i) => ids.forEach((id) => line.set(id, line.get(id)! + (offsets.get(i) ?? 0))));
|
||||||
|
const byId = new Map(rects.map((r) => [r.id, r]));
|
||||||
|
const along = (id: string) => (kind === "row" ? byId.get(id)!.x : byId.get(id)!.y);
|
||||||
|
const indexes = [...new Set(line.values())].sort((a, b) => a - b);
|
||||||
|
const lines = indexes.map((i) =>
|
||||||
|
[...line]
|
||||||
|
.filter(([, at]) => at === i)
|
||||||
|
.map(([id]) => id)
|
||||||
|
.sort((a, b) => along(a) - along(b)),
|
||||||
|
);
|
||||||
|
return { lines, crossLines };
|
||||||
|
}
|
||||||
|
|
||||||
|
const directed = <T>(items: T[], d: Direction) => (d === "reversed" ? [...items].reverse() : items);
|
||||||
|
|
||||||
|
/** Builds the key sequence of a preset scan over the target keys. T-31. */
|
||||||
|
export function scanKeys(rects: KeyRect[], options: ScanOptions): ScanResult {
|
||||||
|
if (options.kind === "columns") {
|
||||||
|
const { lines, crossLines } = assignLines(rects, "column", options.offsets);
|
||||||
|
return { sequence: directed(lines, options.direction).flat(), lines, crossLines };
|
||||||
|
}
|
||||||
|
const { lines, crossLines } = assignLines(rects, "row", options.offsets);
|
||||||
|
if (options.kind === "rows")
|
||||||
|
return { sequence: lines.flatMap((row) => directed(row, options.direction)), lines, crossLines };
|
||||||
|
const { left, boundary } = splitHalves(rects);
|
||||||
|
const leftPart = (row: string[]) =>
|
||||||
|
directed(
|
||||||
|
row.filter((id) => left.has(id)),
|
||||||
|
options.left,
|
||||||
|
);
|
||||||
|
const rightPart = (row: string[]) =>
|
||||||
|
directed(
|
||||||
|
row.filter((id) => !left.has(id)),
|
||||||
|
options.right,
|
||||||
|
);
|
||||||
|
const sequence =
|
||||||
|
options.kind === "halves"
|
||||||
|
? [...lines.flatMap(leftPart), ...lines.flatMap(rightPart)]
|
||||||
|
: lines.flatMap((row) => [...leftPart(row), ...rightPart(row)]);
|
||||||
|
return { sequence, lines, crossLines, boundary };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds each contact key's (row, col) from the matrix pins, by the same rule as the Generator.
|
||||||
|
* Returns undefined when the project has no matrix settings. T-19.
|
||||||
|
*/
|
||||||
|
export function matrixPositions(p: ProjectFile): Map<string, { row: number; col: number }> | undefined {
|
||||||
|
const matrix = p.output.matrix;
|
||||||
|
if (!matrix) return undefined;
|
||||||
|
const rowIndex = new Map(matrix.rowPins.map((x, i) => [x, i])),
|
||||||
|
colIndex = new Map(matrix.colPins.map((x, i) => [x, i]));
|
||||||
|
const out = new Map<string, { row: number; col: number }>();
|
||||||
|
for (const w of p.elc.keys) {
|
||||||
|
if (w.detect !== "contact") continue;
|
||||||
|
const { a, b } = w.terminals;
|
||||||
|
if (rowIndex.has(a) && colIndex.has(b)) out.set(w.key, { row: rowIndex.get(a)!, col: colIndex.get(b)! });
|
||||||
|
else if (rowIndex.has(b) && colIndex.has(a))
|
||||||
|
out.set(w.key, { row: rowIndex.get(b)!, col: colIndex.get(a)! });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sorts keys by row then col. Keys without a matrix position keep their input order at the end. */
|
||||||
|
export function matrixSequence(
|
||||||
|
ids: readonly string[],
|
||||||
|
positions: ReadonlyMap<string, { row: number; col: number }>,
|
||||||
|
) {
|
||||||
|
const placed = ids.filter((id) => positions.has(id));
|
||||||
|
const rest = ids.filter((id) => !positions.has(id));
|
||||||
|
const key = (id: string) => positions.get(id)!;
|
||||||
|
return [...placed.sort((a, b) => key(a).row - key(b).row || key(a).col - key(b).col), ...rest];
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import type { Point, Size } from "./phy";
|
||||||
|
|
||||||
|
export type Rect = { x: number; y: number; w: number; h: number };
|
||||||
|
|
||||||
|
/** ISO Enter, BAE and stepped keys carry a second rectangle (KLE's x2/y2/w2/h2). */
|
||||||
|
export function hasSecondRect(size: Size): boolean {
|
||||||
|
return [size.x2, size.y2, size.w2, size.h2].some((v) => v !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The key's rectangles in key-local coordinates. Missing second-rectangle fields take KLE's defaults (0, 0, w, h). */
|
||||||
|
export function keyRects(size: Size): Rect[] {
|
||||||
|
const main = { x: 0, y: 0, w: size.w, h: size.h };
|
||||||
|
if (!hasSecondRect(size)) return [main];
|
||||||
|
return [main, { x: size.x2 ?? 0, y: size.y2 ?? 0, w: size.w2 ?? size.w, h: size.h2 ?? size.h }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Corners of every rectangle of the key, for bounding boxes. */
|
||||||
|
export function keyCorners(size: Size): Point[] {
|
||||||
|
return keyRects(size).flatMap((r) => [
|
||||||
|
{ x: r.x, y: r.y },
|
||||||
|
{ x: r.x + r.w, y: r.y },
|
||||||
|
{ x: r.x + r.w, y: r.y + r.h },
|
||||||
|
{ x: r.x, y: r.y + r.h },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-left of the key's local bounding box. */
|
||||||
|
export function keyOrigin(size: Size): Point {
|
||||||
|
const corners = keyCorners(size);
|
||||||
|
return { x: Math.min(...corners.map((p) => p.x)), y: Math.min(...corners.map((p) => p.y)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outline of the union of the key's rectangles, as closed loops running clockwise on screen (y down).
|
||||||
|
* The plane is cut into cells along every rectangle edge; boundary edges of filled cells are chained into loops.
|
||||||
|
*/
|
||||||
|
export function keyOutline(size: Size): Point[][] {
|
||||||
|
const rects = keyRects(size).filter((r) => r.w > 0 && r.h > 0);
|
||||||
|
const xs = [...new Set(rects.flatMap((r) => [r.x, r.x + r.w]))].sort((a, b) => a - b);
|
||||||
|
const ys = [...new Set(rects.flatMap((r) => [r.y, r.y + r.h]))].sort((a, b) => a - b);
|
||||||
|
const filled = (i: number, j: number) => {
|
||||||
|
if (i < 0 || j < 0 || i >= xs.length - 1 || j >= ys.length - 1) return false;
|
||||||
|
const cx = (xs[i]! + xs[i + 1]!) / 2,
|
||||||
|
cy = (ys[j]! + ys[j + 1]!) / 2;
|
||||||
|
return rects.some((r) => cx > r.x && cx < r.x + r.w && cy > r.y && cy < r.y + r.h);
|
||||||
|
};
|
||||||
|
const key = (p: Point) => `${p.x},${p.y}`;
|
||||||
|
const edges = new Map<string, Point[]>();
|
||||||
|
const edge = (from: Point, to: Point) => edges.set(key(from), [...(edges.get(key(from)) ?? []), to]);
|
||||||
|
for (let i = 0; i < xs.length - 1; i++)
|
||||||
|
for (let j = 0; j < ys.length - 1; j++) {
|
||||||
|
if (!filled(i, j)) continue;
|
||||||
|
const [x0, x1, y0, y1] = [xs[i]!, xs[i + 1]!, ys[j]!, ys[j + 1]!];
|
||||||
|
if (!filled(i, j - 1)) edge({ x: x0, y: y0 }, { x: x1, y: y0 });
|
||||||
|
if (!filled(i + 1, j)) edge({ x: x1, y: y0 }, { x: x1, y: y1 });
|
||||||
|
if (!filled(i, j + 1)) edge({ x: x1, y: y1 }, { x: x0, y: y1 });
|
||||||
|
if (!filled(i - 1, j)) edge({ x: x0, y: y1 }, { x: x0, y: y0 });
|
||||||
|
}
|
||||||
|
const loops: Point[][] = [];
|
||||||
|
for (const start of edges.keys()) {
|
||||||
|
while (edges.get(start)?.length) {
|
||||||
|
const loop: Point[] = [];
|
||||||
|
let at = start;
|
||||||
|
for (let next = edges.get(at)?.pop(); next; next = edges.get(at)?.pop()) {
|
||||||
|
loop.push(next);
|
||||||
|
at = key(next);
|
||||||
|
if (at === start) break;
|
||||||
|
}
|
||||||
|
loops.push(dropCollinear(loop));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return loops;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropCollinear(loop: Point[]): Point[] {
|
||||||
|
return loop.filter((p, i) => {
|
||||||
|
const prev = loop[(i + loop.length - 1) % loop.length]!,
|
||||||
|
next = loop[(i + 1) % loop.length]!;
|
||||||
|
return !((prev.x === p.x && p.x === next.x) || (prev.y === p.y && p.y === next.y));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A key shape the editor adds in one step. provisional/decisions.md T-32. */
|
||||||
|
export type KeyPreset = { id: string; label: string; size: Size };
|
||||||
|
|
||||||
|
/** Non-rectangular keys offered by「異形キーを足す」. Sizes follow KLE's presets (keys.json). */
|
||||||
|
export const IRREGULAR_KEY_PRESETS: KeyPreset[] = [
|
||||||
|
{ id: "iso-enter", label: "ISO Enter", size: { w: 1.25, h: 2, x2: -0.25, w2: 1.5, h2: 1 } },
|
||||||
|
];
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { qmkBasicKeycodes } from "./qmkBasicKeycodes";
|
||||||
|
|
||||||
|
/** Small US keyboard display map for common QMK basic keys. T-23. */
|
||||||
|
export const keycodeLabels: Record<string, string> = {
|
||||||
|
KC_A: "A",
|
||||||
|
KC_B: "B",
|
||||||
|
KC_C: "C",
|
||||||
|
KC_D: "D",
|
||||||
|
KC_E: "E",
|
||||||
|
KC_F: "F",
|
||||||
|
KC_G: "G",
|
||||||
|
KC_H: "H",
|
||||||
|
KC_I: "I",
|
||||||
|
KC_J: "J",
|
||||||
|
KC_K: "K",
|
||||||
|
KC_L: "L",
|
||||||
|
KC_M: "M",
|
||||||
|
KC_N: "N",
|
||||||
|
KC_O: "O",
|
||||||
|
KC_P: "P",
|
||||||
|
KC_Q: "Q",
|
||||||
|
KC_R: "R",
|
||||||
|
KC_S: "S",
|
||||||
|
KC_T: "T",
|
||||||
|
KC_U: "U",
|
||||||
|
KC_V: "V",
|
||||||
|
KC_W: "W",
|
||||||
|
KC_X: "X",
|
||||||
|
KC_Y: "Y",
|
||||||
|
KC_Z: "Z",
|
||||||
|
KC_1: "1",
|
||||||
|
KC_2: "2",
|
||||||
|
KC_3: "3",
|
||||||
|
KC_4: "4",
|
||||||
|
KC_5: "5",
|
||||||
|
KC_6: "6",
|
||||||
|
KC_7: "7",
|
||||||
|
KC_8: "8",
|
||||||
|
KC_9: "9",
|
||||||
|
KC_0: "0",
|
||||||
|
KC_SPC: "Space",
|
||||||
|
KC_ENTER: "Enter",
|
||||||
|
KC_ESC: "Escape",
|
||||||
|
KC_TAB: "Tab",
|
||||||
|
KC_MINUS: "-",
|
||||||
|
KC_EQUAL: "=",
|
||||||
|
KC_LBRC: "[",
|
||||||
|
KC_RBRC: "]",
|
||||||
|
KC_BSLS: "\\",
|
||||||
|
KC_SCLN: ";",
|
||||||
|
KC_QUOT: "'",
|
||||||
|
KC_GRV: "`",
|
||||||
|
KC_COMM: ",",
|
||||||
|
KC_DOT: ".",
|
||||||
|
KC_SLSH: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
const qmkCodeByName = new Map(
|
||||||
|
qmkBasicKeycodes.flatMap((entry) =>
|
||||||
|
[entry.key, ...entry.aliases].map((name) => [name, entry.key] as const),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Looks up the existing US display label, resolving a QMK alias to its formal name. */
|
||||||
|
export function getKeycodeLabel(code: string): string | undefined {
|
||||||
|
const formalName = qmkCodeByName.get(code);
|
||||||
|
if (!formalName) return keycodeLabels[code];
|
||||||
|
return (
|
||||||
|
keycodeLabels[code] ??
|
||||||
|
keycodeLabels[formalName] ??
|
||||||
|
qmkBasicKeycodes
|
||||||
|
.find((entry) => entry.key === formalName)
|
||||||
|
?.aliases.map((alias) => keycodeLabels[alias])
|
||||||
|
.find((label): label is string => label !== undefined)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { PhyReference } from "./idx";
|
||||||
|
import { qmkBasicKeycodes } from "./qmkBasicKeycodes";
|
||||||
|
export type KeyAssignment = { keyId: string; keycode: string; [key: string]: unknown };
|
||||||
|
export type MapLayer = { name: string; keymap: KeyAssignment[]; [key: string]: unknown };
|
||||||
|
export type MapFile = {
|
||||||
|
version: string;
|
||||||
|
phyReference: PhyReference;
|
||||||
|
layers: MapLayer[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
export type ParsedKeycode = {
|
||||||
|
kind: "basic" | "modifier" | "none" | "transparent" | "layer" | "modified" | "unknown";
|
||||||
|
code: string;
|
||||||
|
layer?: string;
|
||||||
|
inner?: ParsedKeycode;
|
||||||
|
};
|
||||||
|
const codesFor = (groups: QmkKeyGroup[]) =>
|
||||||
|
new Set(
|
||||||
|
qmkBasicKeycodes
|
||||||
|
.filter((keycode) => groups.includes(keycode.group))
|
||||||
|
.flatMap(({ key, aliases }) => [key, ...aliases]),
|
||||||
|
);
|
||||||
|
type QmkKeyGroup = (typeof qmkBasicKeycodes)[number]["group"];
|
||||||
|
const codesForKey = (key: string) => {
|
||||||
|
const entry = qmkBasicKeycodes.find((keycode) => keycode.key === key);
|
||||||
|
return new Set(entry ? [entry.key, ...entry.aliases] : []);
|
||||||
|
};
|
||||||
|
const basic = codesFor(["basic"]);
|
||||||
|
const modifiers = codesFor(["modifiers"]);
|
||||||
|
const noneCodes = codesForKey("KC_NO");
|
||||||
|
const transparentCodes = codesForKey("KC_TRANSPARENT");
|
||||||
|
/** Parses supported QMK keycode syntax without evaluation. T-22. */
|
||||||
|
export function parseKeycode(s: string): ParsedKeycode {
|
||||||
|
const m = s.match(/^(MO|TG|TO|TT|DF|OSL)\(([^()]+)\)$/);
|
||||||
|
if (m) return { kind: "layer", code: m[1]!, layer: m[2]! };
|
||||||
|
const lt = s.match(/^LT\(([^(),]+),\s*([A-Za-z0-9_]+)\)$/);
|
||||||
|
if (lt) return { kind: "layer", code: "LT", layer: lt[1]!, inner: parseKeycode(lt[2]!) };
|
||||||
|
const mod = s.match(/^(LCTL|LSFT|LALT|LGUI|RCTL|RSFT|RALT|RGUI)\(([A-Za-z0-9_]+)\)$/);
|
||||||
|
if (mod) return { kind: "modified", code: mod[1]!, inner: parseKeycode(mod[2]!) };
|
||||||
|
if (noneCodes.has(s)) return { kind: "none", code: s };
|
||||||
|
if (transparentCodes.has(s)) return { kind: "transparent", code: s };
|
||||||
|
if (basic.has(s)) return { kind: "basic", code: s };
|
||||||
|
if (modifiers.has(s)) return { kind: "modifier", code: s };
|
||||||
|
return { kind: "unknown", code: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
function simpleEditableKeycode(parsed: ParsedKeycode): boolean {
|
||||||
|
return (
|
||||||
|
parsed.kind === "basic" ||
|
||||||
|
parsed.kind === "modifier" ||
|
||||||
|
parsed.kind === "none" ||
|
||||||
|
parsed.kind === "transparent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns whether a keycode uses syntax editable under T-22. */
|
||||||
|
export function isEditableKeycode(keycode: string): boolean {
|
||||||
|
const parsed = parseKeycode(keycode);
|
||||||
|
if (parsed.kind === "unknown") return false;
|
||||||
|
if (parsed.kind === "modified") return !!parsed.inner && simpleEditableKeycode(parsed.inner);
|
||||||
|
if (parsed.kind === "layer")
|
||||||
|
return parsed.code !== "LT" || (!!parsed.inner && simpleEditableKeycode(parsed.inner));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/** Creates a MAP with one empty Base layer. T-22. */
|
||||||
|
export function createMap(ref: PhyReference): MapFile {
|
||||||
|
return { version: "0.1.0", phyReference: structuredClone(ref), layers: [{ name: "Base", keymap: [] }] };
|
||||||
|
}
|
||||||
|
/** Adds a uniquely named layer. T-22. */
|
||||||
|
export function addLayer(map: MapFile, name: string): MapFile {
|
||||||
|
if (map.layers.some((l) => l.name === name)) throw new Error("Layer name already exists");
|
||||||
|
const o = structuredClone(map);
|
||||||
|
o.layers.push({ name, keymap: [] });
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** Renames a layer and rewrites encoded layer references. T-22. */
|
||||||
|
export function renameLayer(map: MapFile, oldName: string, newName: string): MapFile {
|
||||||
|
if (map.layers.some((l) => l.name === newName)) throw new Error("Layer name already exists");
|
||||||
|
const o = structuredClone(map);
|
||||||
|
const l = o.layers.find((x) => x.name === oldName);
|
||||||
|
if (!l) throw new Error("Unknown layer");
|
||||||
|
l.name = newName;
|
||||||
|
for (const x of o.layers)
|
||||||
|
for (const a of x.keymap) a.keycode = rewriteLayerRef(a.keycode, oldName, newName);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
function rewriteLayerRef(s: string, oldName: string, newName: string): string {
|
||||||
|
const p = parseKeycode(s);
|
||||||
|
if (p.kind === "layer" && p.layer === oldName) {
|
||||||
|
if (p.code === "LT") return `LT(${newName}, ${p.inner?.code ?? ""})`;
|
||||||
|
return `${p.code}(${newName})`;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
/** Moves a layer to a new array position. */
|
||||||
|
export function reorderLayer(map: MapFile, name: string, index: number): MapFile {
|
||||||
|
const o = structuredClone(map),
|
||||||
|
i = o.layers.findIndex((x) => x.name === name);
|
||||||
|
if (i < 0) throw new Error("Unknown layer");
|
||||||
|
const [l] = o.layers.splice(i, 1);
|
||||||
|
o.layers.splice(Math.max(0, Math.min(index, o.layers.length)), 0, l!);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** Deletes an unreferenced layer; references block deletion. T-22. */
|
||||||
|
export function deleteLayer(map: MapFile, name: string): MapFile {
|
||||||
|
if (map.layers.length < 2) throw new Error("At least one layer is required");
|
||||||
|
if (
|
||||||
|
map.layers.some((l) =>
|
||||||
|
l.keymap.some((a) => {
|
||||||
|
const p = parseKeycode(a.keycode);
|
||||||
|
return p.kind === "layer" && p.layer === name;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error("Layer is referenced");
|
||||||
|
const o = structuredClone(map);
|
||||||
|
o.layers = o.layers.filter((l) => l.name !== name);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** Assigns a keycode to a key, replacing an existing assignment. T-22. */
|
||||||
|
export function assignKey(map: MapFile, layerName: string, keyId: string, keycode: string): MapFile {
|
||||||
|
if (!isEditableKeycode(keycode)) throw new Error(`Unsupported editable keycode: ${keycode}`);
|
||||||
|
const parsed = parseKeycode(keycode);
|
||||||
|
if (parsed.kind === "layer" && !map.layers.some((layer) => layer.name === parsed.layer))
|
||||||
|
throw new Error(`Unknown MAP layer reference: ${parsed.layer}`);
|
||||||
|
const o = structuredClone(map),
|
||||||
|
l = o.layers.find((x) => x.name === layerName);
|
||||||
|
if (!l) throw new Error("Unknown layer");
|
||||||
|
const i = l.keymap.findIndex((a) => a.keyId === keyId),
|
||||||
|
old = i < 0 ? undefined : l.keymap[i],
|
||||||
|
a = { ...old, keyId, keycode };
|
||||||
|
if (i < 0) l.keymap.push(a);
|
||||||
|
else l.keymap[i] = a;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** Removes a key assignment. */
|
||||||
|
export function unassignKey(map: MapFile, layerName: string, keyId: string): MapFile {
|
||||||
|
const o = structuredClone(map),
|
||||||
|
l = o.layers.find((x) => x.name === layerName);
|
||||||
|
if (!l) throw new Error("Unknown layer");
|
||||||
|
l.keymap = l.keymap.filter((a) => a.keyId !== keyId);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** Lists keys not assigned in a layer. T-22. */
|
||||||
|
export function unassignedKeys(map: MapFile, layerName: string, keyIds: string[]): string[] {
|
||||||
|
const l = map.layers.find((x) => x.name === layerName);
|
||||||
|
const assigned = new Set(l?.keymap.map((x) => x.keyId));
|
||||||
|
return keyIds.filter((x) => !assigned?.has(x));
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/** PHY JSON Schema §1 の型。D-1: Schema の必須 type/name/compatibility を採用。 */
|
||||||
|
export type Point = { x: number; y: number };
|
||||||
|
export type Transform = { x: number; y: number; rotation: number };
|
||||||
|
export type Size = { w: number; h: number; x2?: number; y2?: number; w2?: number; h2?: number };
|
||||||
|
export type Profile = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
unitSize: Point;
|
||||||
|
visualOffset?: { top: number; right: number; bottom: number; left: number; radius: number };
|
||||||
|
};
|
||||||
|
export type KleCompatibility = Record<string, unknown>;
|
||||||
|
export type KeyNode = {
|
||||||
|
type: "key";
|
||||||
|
id: string;
|
||||||
|
profile: string;
|
||||||
|
transform: Transform;
|
||||||
|
size: Size;
|
||||||
|
switchOffset?: Point;
|
||||||
|
partGroup?: string;
|
||||||
|
orientation?: 0 | 90 | 180 | 270;
|
||||||
|
perKeyLED?: boolean;
|
||||||
|
decal?: boolean;
|
||||||
|
ghost?: boolean;
|
||||||
|
labels?: string[];
|
||||||
|
kleCompatibility?: KleCompatibility;
|
||||||
|
};
|
||||||
|
export type ComponentNode = { type: "component"; id: string; transform: Transform; partGroup?: string };
|
||||||
|
export type ClusterNode = {
|
||||||
|
type: "cluster";
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
transform: Transform;
|
||||||
|
children: Node[];
|
||||||
|
};
|
||||||
|
export type Variant = { type: "variant"; id: string; name: string; children: Node[] };
|
||||||
|
export type VariantGroupNode = {
|
||||||
|
type: "variantGroup";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
transform: Transform;
|
||||||
|
default: string;
|
||||||
|
children: Variant[];
|
||||||
|
};
|
||||||
|
export type Node = KeyNode | ComponentNode | ClusterNode | VariantGroupNode;
|
||||||
|
export type PhyFile = {
|
||||||
|
version: string;
|
||||||
|
metadata: { name?: string; author?: string; description?: string; created?: string; modified?: string };
|
||||||
|
profiles: Profile[];
|
||||||
|
root: Node[];
|
||||||
|
kleCompatibility?: KleCompatibility;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlatKey = { key: KeyNode; x: number; y: number; rotation: number; path: string[] };
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
import type { ClusterNode, KeyNode, Node, PhyFile, Size, Transform, Variant, VariantGroupNode } from "./phy";
|
||||||
|
import { apply, compose, inverse, matrix } from "./geometry";
|
||||||
|
import { createNodeId } from "./ids";
|
||||||
|
import { keyCorners } from "./keyShape";
|
||||||
|
|
||||||
|
const ident = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
|
||||||
|
type Container = { nodes: Node[]; parentWorld: typeof ident; parentRotation: number };
|
||||||
|
function containers(f: PhyFile): Map<string, Container> {
|
||||||
|
const m = new Map<string, Container>();
|
||||||
|
const rec = (ns: Node[], w: typeof ident, angle: number) => {
|
||||||
|
for (const n of ns) {
|
||||||
|
m.set(n.id, { nodes: ns, parentWorld: w, parentRotation: angle });
|
||||||
|
const nw = compose(w, matrix(n.transform));
|
||||||
|
const nextAngle = angle + n.transform.rotation;
|
||||||
|
if (n.type === "cluster") rec(n.children, nw, nextAngle);
|
||||||
|
if (n.type === "variantGroup")
|
||||||
|
for (const v of n.children) {
|
||||||
|
m.set(v.id, { nodes: v.children, parentWorld: nw, parentRotation: nextAngle });
|
||||||
|
rec(v.children, nw, nextAngle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rec(f.root, ident, 0);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
function find(f: PhyFile, id: string): Node {
|
||||||
|
const c = containers(f).get(id);
|
||||||
|
const n = c?.nodes.find((x) => x.id === id);
|
||||||
|
if (!n) throw new Error(`Unknown node: ${id}`);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
function world(f: PhyFile, id: string): typeof ident {
|
||||||
|
const c = containers(f).get(id);
|
||||||
|
const n = c?.nodes.find((x) => x.id === id);
|
||||||
|
if (!n) throw new Error(`Unknown node: ${id}`);
|
||||||
|
return compose(c!.parentWorld, matrix(n.transform));
|
||||||
|
}
|
||||||
|
function worldRotation(f: PhyFile, id: string): number {
|
||||||
|
const c = containers(f).get(id);
|
||||||
|
const n = c?.nodes.find((x) => x.id === id);
|
||||||
|
if (!n || !c) throw new Error(`Unknown node: ${id}`);
|
||||||
|
return c.parentRotation + n.transform.rotation;
|
||||||
|
}
|
||||||
|
function mutable(f: PhyFile): PhyFile {
|
||||||
|
return structuredClone(f);
|
||||||
|
}
|
||||||
|
function nodeId(kind: Parameters<typeof createNodeId>[0], gen?: () => string) {
|
||||||
|
return createNodeId(kind, gen);
|
||||||
|
}
|
||||||
|
/** 新しいPHYを返す。T-11。 */
|
||||||
|
export function addKey(
|
||||||
|
f: PhyFile,
|
||||||
|
at: Transform,
|
||||||
|
size: Size,
|
||||||
|
profile = f.profiles[0]!.id,
|
||||||
|
gen?: () => string,
|
||||||
|
): PhyFile {
|
||||||
|
const o = mutable(f);
|
||||||
|
o.root.push({ type: "key", id: nodeId("key", gen), profile, transform: { ...at }, size: { ...size } });
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** 新しいPHYを返す。T-11。 */
|
||||||
|
export function addComponent(f: PhyFile, at: Transform, partGroup?: string, gen?: () => string): PhyFile {
|
||||||
|
const o = mutable(f);
|
||||||
|
o.root.push({
|
||||||
|
type: "component",
|
||||||
|
id: nodeId("component", gen),
|
||||||
|
transform: { ...at },
|
||||||
|
...(partGroup === undefined ? {} : { partGroup }),
|
||||||
|
});
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指定コンテナへキーを追加する。 */
|
||||||
|
export function addKeyToParent(
|
||||||
|
f: PhyFile,
|
||||||
|
parentId: string | null,
|
||||||
|
at: Transform,
|
||||||
|
size: Size,
|
||||||
|
profile = f.profiles[0]!.id,
|
||||||
|
gen?: () => string,
|
||||||
|
): PhyFile {
|
||||||
|
const o = mutable(f),
|
||||||
|
target = parentNodes(o, parentId);
|
||||||
|
target.push({ type: "key", id: nodeId("key", gen), profile, transform: { ...at }, size: { ...size } });
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指定コンテナへ部品を追加する。 */
|
||||||
|
export function addComponentToParent(
|
||||||
|
f: PhyFile,
|
||||||
|
parentId: string | null,
|
||||||
|
at: Transform,
|
||||||
|
partGroup?: string,
|
||||||
|
gen?: () => string,
|
||||||
|
): PhyFile {
|
||||||
|
const o = mutable(f),
|
||||||
|
target = parentNodes(o, parentId);
|
||||||
|
target.push({
|
||||||
|
type: "component",
|
||||||
|
id: nodeId("component", gen),
|
||||||
|
transform: { ...at },
|
||||||
|
...(partGroup ? { partGroup } : {}),
|
||||||
|
});
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** root / cluster / variant の指定先にノード群を挿入する。 */
|
||||||
|
export function insertNodes(f: PhyFile, parentId: string | null, nodes: Node[], index?: number): PhyFile {
|
||||||
|
const o = mutable(f),
|
||||||
|
target = parentNodes(o, parentId);
|
||||||
|
target.splice(index ?? target.length, 0, ...structuredClone(nodes));
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentNodes(f: PhyFile, parentId: string | null): Node[] {
|
||||||
|
if (!parentId) return f.root;
|
||||||
|
// variantのIDは containers に子の入れ物として載るので、find ではノードとして引けない
|
||||||
|
const n = containers(f)
|
||||||
|
.get(parentId)
|
||||||
|
?.nodes.find((x) => x.id === parentId);
|
||||||
|
if (n?.type === "cluster") return n.children;
|
||||||
|
if (n?.type === "variantGroup") throw new Error("Choose a variant as the parent");
|
||||||
|
return findVariant(f, parentId).children;
|
||||||
|
}
|
||||||
|
/** ローカル座標の差分でノードを移動する。 */
|
||||||
|
export function moveNode(f: PhyFile, id: string, dx: number, dy: number): PhyFile {
|
||||||
|
const o = mutable(f),
|
||||||
|
n = find(o, id);
|
||||||
|
n.transform.x += dx;
|
||||||
|
n.transform.y += dy;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** ノードを回転する。 */
|
||||||
|
export function rotateNode(f: PhyFile, id: string, delta: number): PhyFile {
|
||||||
|
const o = mutable(f),
|
||||||
|
n = find(o, id);
|
||||||
|
n.transform.rotation += delta;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** キーのサイズを変更する。 */
|
||||||
|
export function resizeKey(f: PhyFile, id: string, size: KeyNode["size"]): PhyFile {
|
||||||
|
const o = mutable(f),
|
||||||
|
n = find(o, id);
|
||||||
|
if (n.type !== "key") throw new Error(`${id} is not a key`);
|
||||||
|
n.size = { ...size };
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** 同じ親のノードを新クラスターにまとめ、絶対変換を保つ。T-11。 */
|
||||||
|
export function clusterNodes(f: PhyFile, ids: string[], name = "Cluster", gen?: () => string): PhyFile {
|
||||||
|
if (ids.length < 1) throw new Error("Select at least one node");
|
||||||
|
const o = mutable(f),
|
||||||
|
cs = containers(o),
|
||||||
|
first = cs.get(ids[0]!);
|
||||||
|
if (!first || ids.some((id) => cs.get(id)?.nodes !== first.nodes))
|
||||||
|
throw new Error("Selected nodes must be siblings");
|
||||||
|
const selected = ids.map((id) => first.nodes.find((n) => n.id === id)!);
|
||||||
|
if (selected.some((n) => !n)) throw new Error("Unknown selection");
|
||||||
|
const angles = selected.map((n) => world(o, n.id));
|
||||||
|
const selectedAngles = selected.map((n) => first.parentRotation + n.transform.rotation);
|
||||||
|
const angle = selectedAngles[0]!;
|
||||||
|
const same = selectedAngles.every((candidate) => Math.abs(candidate - angle) < 1e-9);
|
||||||
|
const basis = matrix({ x: 0, y: 0, rotation: same ? angle : 0 }),
|
||||||
|
basisInverse = inverse(basis);
|
||||||
|
const shapePoints = selected.flatMap((n, i) => boundsPoints(n, angles[i]!));
|
||||||
|
if (shapePoints.length === 0) angles.forEach((m) => shapePoints.push({ x: m.e, y: m.f }));
|
||||||
|
const localOrigins = shapePoints.map((p) => apply(basisInverse, p));
|
||||||
|
const min = { x: Math.min(...localOrigins.map((p) => p.x)), y: Math.min(...localOrigins.map((p) => p.y)) };
|
||||||
|
const origin = apply(basis, min);
|
||||||
|
const wt = matrix({ x: origin.x, y: origin.y, rotation: same ? angle : 0 });
|
||||||
|
const local = compose(inverse(first.parentWorld), wt);
|
||||||
|
const id = nodeId("cluster", gen),
|
||||||
|
cluster: ClusterNode = {
|
||||||
|
type: "cluster",
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
transform: { x: local.e, y: local.f, rotation: (same ? angle : 0) - first.parentRotation },
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
const inv = inverse(wt);
|
||||||
|
cluster.children = selected.map((n) => {
|
||||||
|
const w = world(o, n.id),
|
||||||
|
rel = compose(inv, w);
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
transform: { x: rel.e, y: rel.f, rotation: selectedAngles[selected.indexOf(n)]! - (same ? angle : 0) },
|
||||||
|
} as Node;
|
||||||
|
});
|
||||||
|
const indices = selected.map((n) => first.nodes.indexOf(n)).sort((a, b) => a - b);
|
||||||
|
const insert = indices[0]!;
|
||||||
|
first.nodes.splice(0, first.nodes.length, ...first.nodes.filter((n) => !ids.includes(n.id)));
|
||||||
|
first.nodes.splice(insert, 0, cluster);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** clusterの子を同じ親へ戻し、絶対変換を保つ。 */
|
||||||
|
export function uncluster(f: PhyFile, id: string): PhyFile {
|
||||||
|
const o = mutable(f),
|
||||||
|
cs = containers(o),
|
||||||
|
c = cs.get(id),
|
||||||
|
cluster = c?.nodes.find((n) => n.id === id);
|
||||||
|
if (!cluster || cluster.type !== "cluster") throw new Error(`${id} is not a cluster`);
|
||||||
|
const clusterWorldRotation = c!.parentRotation + cluster.transform.rotation;
|
||||||
|
const cw = compose(c!.parentWorld, matrix(cluster.transform)),
|
||||||
|
converted = cluster.children.map((n) => {
|
||||||
|
const w = compose(cw, matrix(n.transform)),
|
||||||
|
rel = compose(inverse(c!.parentWorld), w);
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
transform: {
|
||||||
|
x: rel.e,
|
||||||
|
y: rel.f,
|
||||||
|
rotation: clusterWorldRotation + n.transform.rotation - c!.parentRotation,
|
||||||
|
},
|
||||||
|
} as Node;
|
||||||
|
});
|
||||||
|
const index = c!.nodes.indexOf(cluster);
|
||||||
|
c!.nodes.splice(index, 1, ...converted);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** ノードを任意の親/rootへ移し、絶対変換を保つ。T-27。 */
|
||||||
|
export function reparent(f: PhyFile, id: string, newParentId: string | null, index?: number): PhyFile {
|
||||||
|
const o = mutable(f),
|
||||||
|
cs = containers(o),
|
||||||
|
src = cs.get(id),
|
||||||
|
n = src?.nodes.find((x) => x.id === id);
|
||||||
|
if (!n) throw new Error(`Unknown node: ${id}`);
|
||||||
|
const oldW = world(o, id),
|
||||||
|
oldRotation = worldRotation(o, id);
|
||||||
|
let target: Node[] = o.root,
|
||||||
|
parentW = ident,
|
||||||
|
parentRotation = 0;
|
||||||
|
if (newParentId) {
|
||||||
|
const p = find(o, newParentId);
|
||||||
|
if (p.type !== "cluster")
|
||||||
|
throw new Error("Target parent must be a cluster; use reparentToVariant for variant children");
|
||||||
|
if (containsNode(n, newParentId)) throw new Error("Cannot reparent a node into its descendant");
|
||||||
|
target = p.children;
|
||||||
|
parentW = world(o, p.id);
|
||||||
|
parentRotation = worldRotation(o, p.id);
|
||||||
|
}
|
||||||
|
const oldIndex = src!.nodes.indexOf(n),
|
||||||
|
sameParent = target === src!.nodes;
|
||||||
|
src!.nodes.splice(oldIndex, 1);
|
||||||
|
const rel = compose(inverse(parentW), oldW);
|
||||||
|
n.transform = { x: rel.e, y: rel.f, rotation: oldRotation - parentRotation };
|
||||||
|
let at = index ?? target.length;
|
||||||
|
if (sameParent && index !== undefined && oldIndex < index) at--;
|
||||||
|
target.splice(Math.max(0, Math.min(at, target.length)), 0, n);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** variant内へ移動するreparent。T-27。 */
|
||||||
|
export function reparentToVariant(f: PhyFile, id: string, variantId: string, index?: number): PhyFile {
|
||||||
|
const o = mutable(f),
|
||||||
|
cs = containers(o),
|
||||||
|
src = cs.get(id),
|
||||||
|
n = src?.nodes.find((x) => x.id === id);
|
||||||
|
const v = findVariant(o, variantId);
|
||||||
|
if (!n || !src) throw new Error(`Unknown node: ${id}`);
|
||||||
|
if (containsNode(n, variantId)) throw new Error("Cannot reparent a node into its descendant");
|
||||||
|
const old = world(o, id),
|
||||||
|
oldRotation = worldRotation(o, id),
|
||||||
|
parent = srcVariantWorld(o, variantId),
|
||||||
|
rel = compose(inverse(parent), old);
|
||||||
|
const parentRotation = srcVariantRotation(o, variantId);
|
||||||
|
src.nodes.splice(src.nodes.indexOf(n), 1);
|
||||||
|
n.transform = { x: rel.e, y: rel.f, rotation: oldRotation - parentRotation };
|
||||||
|
v.children.splice(index ?? v.children.length, 0, n);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
function containsNode(n: Node, id: string): boolean {
|
||||||
|
if (n.id === id) return true;
|
||||||
|
if (n.type === "cluster") return n.children.some((c) => containsNode(c, id));
|
||||||
|
if (n.type === "variantGroup")
|
||||||
|
return n.children.some((v) => v.id === id || v.children.some((c) => containsNode(c, id)));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function boundsPoints(n: Node, worldMatrix: ReturnType<typeof matrix>): { x: number; y: number }[] {
|
||||||
|
if (n.type === "key") {
|
||||||
|
return keyCorners(n.size).map((point) => apply(worldMatrix, point));
|
||||||
|
}
|
||||||
|
if (n.type === "component") return [{ x: worldMatrix.e, y: worldMatrix.f }];
|
||||||
|
if (n.type === "cluster")
|
||||||
|
return n.children.flatMap((child) => boundsPoints(child, compose(worldMatrix, matrix(child.transform))));
|
||||||
|
const selected = n.children.find((variant) => variant.id === n.default);
|
||||||
|
return selected
|
||||||
|
? selected.children.flatMap((child) => boundsPoints(child, compose(worldMatrix, matrix(child.transform))))
|
||||||
|
: [{ x: worldMatrix.e, y: worldMatrix.f }];
|
||||||
|
}
|
||||||
|
function findVariant(f: PhyFile, id: string): Variant {
|
||||||
|
for (const n of f.root) {
|
||||||
|
const r = walk(n, id);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown variant: ${id}`);
|
||||||
|
}
|
||||||
|
function walk(n: Node, id: string): Variant | undefined {
|
||||||
|
if (n.type === "variantGroup") {
|
||||||
|
for (const v of n.children) {
|
||||||
|
if (v.id === id) return v;
|
||||||
|
for (const c of v.children) {
|
||||||
|
const r = walk(c, id);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n.type === "cluster")
|
||||||
|
for (const c of n.children) {
|
||||||
|
const r = walk(c, id);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
function srcVariantWorld(f: PhyFile, id: string): typeof ident {
|
||||||
|
const rec = (ns: Node[], w: typeof ident): typeof ident | undefined => {
|
||||||
|
for (const n of ns) {
|
||||||
|
const nw = compose(w, matrix(n.transform));
|
||||||
|
if (n.type === "variantGroup")
|
||||||
|
for (const v of n.children) {
|
||||||
|
if (v.id === id) return nw;
|
||||||
|
const r = rec(v.children, nw);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
if (n.type === "cluster") {
|
||||||
|
const r = rec(n.children, nw);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const x = rec(f.root, ident);
|
||||||
|
if (!x) throw new Error(`Unknown variant ${id}`);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
function srcVariantRotation(f: PhyFile, id: string): number {
|
||||||
|
const rec = (nodes: Node[], parentRotation: number): number | undefined => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
const angle = parentRotation + n.transform.rotation;
|
||||||
|
if (n.type === "variantGroup")
|
||||||
|
for (const v of n.children) {
|
||||||
|
if (v.id === id) return angle;
|
||||||
|
const found = rec(v.children, angle);
|
||||||
|
if (found !== undefined) return found;
|
||||||
|
}
|
||||||
|
if (n.type === "cluster") {
|
||||||
|
const found = rec(n.children, angle);
|
||||||
|
if (found !== undefined) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const found = rec(f.root, 0);
|
||||||
|
if (found === undefined) throw new Error(`Unknown variant ${id}`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
/** クラスターの座標枠を変え、配下の絶対位置を保つ。PHY-P-13仮決定。 */
|
||||||
|
export function reframe(f: PhyFile, id: string, frame: Transform): PhyFile {
|
||||||
|
const o = mutable(f),
|
||||||
|
n = find(o, id);
|
||||||
|
if (n.type !== "cluster") throw new Error(`${id} is not a cluster`);
|
||||||
|
const c = containers(o).get(id)!,
|
||||||
|
oldRotation = c.parentRotation + n.transform.rotation,
|
||||||
|
old = compose(c.parentWorld, matrix(n.transform)),
|
||||||
|
next = compose(c.parentWorld, matrix(frame)),
|
||||||
|
correction = compose(inverse(next), old);
|
||||||
|
n.children = n.children.map((ch) => {
|
||||||
|
const t = compose(correction, matrix(ch.transform));
|
||||||
|
return {
|
||||||
|
...ch,
|
||||||
|
transform: {
|
||||||
|
x: t.e,
|
||||||
|
y: t.f,
|
||||||
|
rotation: oldRotation + ch.transform.rotation - (c.parentRotation + frame.rotation),
|
||||||
|
},
|
||||||
|
} as Node;
|
||||||
|
});
|
||||||
|
n.transform = { ...frame };
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** VariantGroupを作成する。T-11。 */
|
||||||
|
export function createVariantGroup(
|
||||||
|
f: PhyFile,
|
||||||
|
name: string,
|
||||||
|
at: Transform,
|
||||||
|
variants: [{ name: string; children?: Node[] }, { name: string; children?: Node[] }],
|
||||||
|
gen?: () => string,
|
||||||
|
): PhyFile {
|
||||||
|
return createVariantGroupInParent(f, null, name, at, variants, gen);
|
||||||
|
}
|
||||||
|
/** 指定コンテナへVariantGroupを作成する。T-11。 */
|
||||||
|
export function createVariantGroupInParent(
|
||||||
|
f: PhyFile,
|
||||||
|
parentId: string | null,
|
||||||
|
name: string,
|
||||||
|
at: Transform,
|
||||||
|
variants: [{ name: string; children?: Node[] }, { name: string; children?: Node[] }],
|
||||||
|
gen?: () => string,
|
||||||
|
): PhyFile {
|
||||||
|
const o = mutable(f),
|
||||||
|
children = variants.map((x) => ({
|
||||||
|
type: "variant" as const,
|
||||||
|
id: nodeId("variant", gen),
|
||||||
|
name: x.name,
|
||||||
|
children: structuredClone(x.children ?? []),
|
||||||
|
}));
|
||||||
|
parentNodes(o, parentId).push({
|
||||||
|
type: "variantGroup",
|
||||||
|
id: nodeId("variantGroup", gen),
|
||||||
|
name,
|
||||||
|
transform: { ...at },
|
||||||
|
default: children[0]!.id,
|
||||||
|
children,
|
||||||
|
});
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** Variantを追加する。T-11。 */
|
||||||
|
export function addVariant(f: PhyFile, groupId: string, name: string, gen?: () => string): PhyFile {
|
||||||
|
const o = mutable(f),
|
||||||
|
g = find(o, groupId);
|
||||||
|
if (g.type !== "variantGroup") throw new Error("Not a variant group");
|
||||||
|
g.children.push({ type: "variant", id: nodeId("variant", gen), name, children: [] });
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** Variantを削除する。子が1つになる場合は拒否し、default削除時は先頭へ移す。T-15。 */
|
||||||
|
export function deleteVariant(f: PhyFile, groupId: string, variantId: string): PhyFile {
|
||||||
|
const o = mutable(f),
|
||||||
|
g = find(o, groupId);
|
||||||
|
if (g.type !== "variantGroup") throw new Error("Not a variant group");
|
||||||
|
if (g.children.length <= 2) throw new Error("Cannot leave a VariantGroup with fewer than two variants");
|
||||||
|
const i = g.children.findIndex((v) => v.id === variantId);
|
||||||
|
if (i < 0) throw new Error("Unknown variant");
|
||||||
|
g.children.splice(i, 1);
|
||||||
|
if (g.default === variantId) g.default = g.children[0]!.id;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** default Variantを変更する。 */
|
||||||
|
export function setDefaultVariant(f: PhyFile, groupId: string, variantId: string): PhyFile {
|
||||||
|
const o = mutable(f),
|
||||||
|
g = find(o, groupId);
|
||||||
|
if (g.type !== "variantGroup" || !g.children.some((v) => v.id === variantId))
|
||||||
|
throw new Error("Invalid group or variant");
|
||||||
|
g.default = variantId;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** ノードを削除する。 */
|
||||||
|
export function deleteNode(f: PhyFile, id: string): PhyFile {
|
||||||
|
const o = mutable(f),
|
||||||
|
c = containers(o).get(id);
|
||||||
|
if (!c) throw new Error(`Unknown node: ${id}`);
|
||||||
|
const i = c.nodes.findIndex((n) => n.id === id);
|
||||||
|
c.nodes.splice(i, 1);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** ノードと配下を複製し、すべてのIDを新しくする。T-11。 */
|
||||||
|
export function duplicateNode(f: PhyFile, id: string, gen?: () => string): PhyFile {
|
||||||
|
const o = mutable(f),
|
||||||
|
n = find(o, id),
|
||||||
|
copy = cloneNodesWithNewIds([n], gen)[0]!;
|
||||||
|
copy.transform.x += 0.25;
|
||||||
|
copy.transform.y += 0.25;
|
||||||
|
const c = containers(o).get(id)!;
|
||||||
|
c.nodes.splice(c.nodes.indexOf(n) + 1, 0, copy);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
/** ノード群を複製し、配下のIDと閉じたdefault参照を振り直す。T-11。 */
|
||||||
|
export function cloneNodesWithNewIds(nodes: Node[], gen?: () => string): Node[] {
|
||||||
|
const copies = structuredClone(nodes);
|
||||||
|
const remap = new Map<string, string>();
|
||||||
|
const renew = (x: Node) => {
|
||||||
|
const oldId = x.id;
|
||||||
|
x.id = nodeId(x.type, gen);
|
||||||
|
remap.set(oldId, x.id);
|
||||||
|
if (x.type === "cluster") x.children.forEach(renew);
|
||||||
|
if (x.type === "variantGroup")
|
||||||
|
for (const v of x.children) {
|
||||||
|
const oldVariantId = v.id;
|
||||||
|
v.id = nodeId("variant", gen);
|
||||||
|
remap.set(oldVariantId, v.id);
|
||||||
|
v.children.forEach(renew);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
copies.forEach(renew);
|
||||||
|
const remapDefaults = (x: Node) => {
|
||||||
|
if (x.type === "cluster") x.children.forEach(remapDefaults);
|
||||||
|
if (x.type === "variantGroup") {
|
||||||
|
x.default = remap.get(x.default) ?? x.default;
|
||||||
|
x.children.forEach((v) => v.children.forEach(remapDefaults));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
copies.forEach(remapDefaults);
|
||||||
|
return copies;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { PhyFile } from "./phy";
|
||||||
|
import type { PhyReference } from "./idx";
|
||||||
|
import { sha256Jcs } from "../io/json";
|
||||||
|
/** Computes the project-local PHY reference checksum. T-12. */
|
||||||
|
export async function makePhyReference(phy: PhyFile, source = "project:phy"): Promise<PhyReference> {
|
||||||
|
return { source, checksum: await sha256Jcs(phy) };
|
||||||
|
}
|
||||||
|
/** Checks whether a stored checksum matches PHY. T-12. */
|
||||||
|
export async function phyReferenceMatches(phy: PhyFile, ref: PhyReference): Promise<boolean> {
|
||||||
|
return ref.checksum === (await sha256Jcs(phy));
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { PhyFile, Node } from "./phy";
|
||||||
|
import { createId } from "./ids";
|
||||||
|
import { importKle } from "../io/kleImport";
|
||||||
|
import { sha256Jcs } from "../io/json";
|
||||||
|
import { createIdx, type IdxFile } from "./idx";
|
||||||
|
import { createElc, type ElcFile } from "./elc";
|
||||||
|
import { createMap, type MapFile } from "./map";
|
||||||
|
export type MatrixOutputSettings = {
|
||||||
|
rowPins: string[];
|
||||||
|
colPins: string[];
|
||||||
|
kind?: "matrix" | "charlieplexing" | "direct";
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
export type ProjectFile = {
|
||||||
|
format: "beyond-kle-mock-project";
|
||||||
|
version: string;
|
||||||
|
projectId: string;
|
||||||
|
phy: PhyFile;
|
||||||
|
elc: ElcFile;
|
||||||
|
idx: IdxFile;
|
||||||
|
map: MapFile;
|
||||||
|
output: { variantSelection: Record<string, string>; matrix?: MatrixOutputSettings; [key: string]: unknown };
|
||||||
|
ui?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
/** Creates default variant selections from PHY. T-10/T-20. */
|
||||||
|
export function defaultVariantSelection(phy: PhyFile): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
const walk = (nodes: Node[]) =>
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
if (n.type === "cluster") walk(n.children);
|
||||||
|
if (n.type === "variantGroup") {
|
||||||
|
out[n.id] = n.default;
|
||||||
|
const v = n.children.find((x) => x.id === n.default);
|
||||||
|
if (v) walk(v.children);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
walk(phy.root);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
/** Creates an empty project with default reference values. T-10/T-13. */
|
||||||
|
export async function createProject(phy: PhyFile, gen?: () => string): Promise<ProjectFile> {
|
||||||
|
const ref = { source: "project:phy", checksum: await sha256Jcs(phy) };
|
||||||
|
return {
|
||||||
|
format: "beyond-kle-mock-project",
|
||||||
|
version: "0.1.0",
|
||||||
|
projectId: createId("project-", gen),
|
||||||
|
phy: structuredClone(phy),
|
||||||
|
elc: createElc(ref),
|
||||||
|
idx: createIdx(phy, [], ref),
|
||||||
|
map: createMap(ref),
|
||||||
|
output: { variantSelection: defaultVariantSelection(phy) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** Imports KLE as a new project and initializes IDX from occurrence order. T-18. */
|
||||||
|
export async function createProjectFromKle(
|
||||||
|
input: unknown,
|
||||||
|
options: { idGenerator?: () => string; name?: string; projectIdGenerator?: () => string } = {},
|
||||||
|
): Promise<ProjectFile> {
|
||||||
|
const { phy, keyOrder } = importKle(input, options);
|
||||||
|
const p = await createProject(phy, options.projectIdGenerator);
|
||||||
|
p.idx = createIdx(phy, keyOrder, p.idx.phyReference);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
|||||||
|
/** Axis-aligned key rectangle in one coordinate frame, in U. */
|
||||||
|
export type KeyRect = { id: string; x: number; y: number; w: number; h: number };
|
||||||
|
|
||||||
|
const EPS = 1e-3;
|
||||||
|
/** Keys farther apart than this across adjacent lines are not linked. */
|
||||||
|
const MAX_LINK = 1;
|
||||||
|
|
||||||
|
type Point = { id: string; along: number; across: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects visual rows, top to bottom, each listing key ids left to right.
|
||||||
|
* The aligned axis is grouped by coordinate and the staggered axis is traced through
|
||||||
|
* adjacent lines (idx-editor-feedback.md). Keys are placed by their top-left corner,
|
||||||
|
* so tall keys belong to their top row and wide keys to their left column.
|
||||||
|
*/
|
||||||
|
export function detectRows(keys: KeyRect[]): string[][] {
|
||||||
|
return detectLines(keys, "row");
|
||||||
|
}
|
||||||
|
/** Detects visual columns, left to right, each listing key ids top to bottom. */
|
||||||
|
export function detectColumns(keys: KeyRect[]): string[][] {
|
||||||
|
return detectLines(keys, "column");
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectLines(keys: KeyRect[], kind: "row" | "column"): string[][] {
|
||||||
|
const points: Point[] = keys.map((k) =>
|
||||||
|
kind === "row" ? { id: k.id, along: k.x, across: k.y } : { id: k.id, along: k.y, across: k.x },
|
||||||
|
);
|
||||||
|
const rowsAligned = groupBy(keys, (k) => k.y).length <= groupBy(keys, (k) => k.x).length;
|
||||||
|
return (kind === "row") === rowsAligned ? groupLines(points) : traceLines(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clusters values within EPS, ordered by value. */
|
||||||
|
function groupBy<T>(items: T[], value: (item: T) => number): T[][] {
|
||||||
|
const sorted = [...items].sort((a, b) => value(a) - value(b));
|
||||||
|
const groups: T[][] = [];
|
||||||
|
for (const item of sorted) {
|
||||||
|
const last = groups.at(-1);
|
||||||
|
if (last && value(item) - value(last.at(-1)!) <= EPS) last.push(item);
|
||||||
|
else groups.push([item]);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupLines(points: Point[]): string[][] {
|
||||||
|
return groupBy(points, (p) => p.across).map((line) => sortAlong(line).map((p) => p.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Links each pair of adjacent cross lines one-to-one by nearest `across`, then joins the links. */
|
||||||
|
function traceLines(points: Point[]): string[][] {
|
||||||
|
const cross = groupBy(points, (p) => p.along);
|
||||||
|
const parent = new Map(points.map((p) => [p.id, p.id]));
|
||||||
|
const find = (id: string): string => {
|
||||||
|
const up = parent.get(id)!;
|
||||||
|
if (up === id) return id;
|
||||||
|
const root = find(up);
|
||||||
|
parent.set(id, root);
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
for (let i = 0; i + 1 < cross.length; i++) {
|
||||||
|
const pairs = cross[i]!.flatMap((a) =>
|
||||||
|
cross[i + 1]!.map((b) => ({ a, b, d: Math.abs(a.across - b.across) })),
|
||||||
|
)
|
||||||
|
.filter((pair) => pair.d < MAX_LINK)
|
||||||
|
.sort((p, q) => p.d - q.d);
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const { a, b } of pairs) {
|
||||||
|
if (used.has(a.id) || used.has(b.id)) continue;
|
||||||
|
used.add(a.id);
|
||||||
|
used.add(b.id);
|
||||||
|
parent.set(find(b.id), find(a.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lines = new Map<string, Point[]>();
|
||||||
|
for (const p of points) lines.set(find(p.id), [...(lines.get(find(p.id)) ?? []), p]);
|
||||||
|
return [...lines.values()]
|
||||||
|
.map(sortAlong)
|
||||||
|
.sort((a, b) => mean(a) - mean(b) || a[0]!.along - b[0]!.along)
|
||||||
|
.map((line) => line.map((p) => p.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortAlong(line: Point[]): Point[] {
|
||||||
|
return [...line].sort((a, b) => a.along - b.along);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mean(line: Point[]): number {
|
||||||
|
return line.reduce((sum, p) => sum + p.across, 0) / line.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Position right after the last key of the row containing `keyId`. */
|
||||||
|
export function rowEndPosition(keys: KeyRect[], keyId: string): { x: number; y: number } | undefined {
|
||||||
|
const row = detectRows(keys).find((line) => line.includes(keyId));
|
||||||
|
if (!row) return undefined;
|
||||||
|
const byId = new Map(keys.map((k) => [k.id, k]));
|
||||||
|
const last = row.map((id) => byId.get(id)!).reduce((a, b) => (b.x + b.w > a.x + a.w ? b : a));
|
||||||
|
return { x: last.x + last.w, y: last.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Leftmost position of a new row below every key. */
|
||||||
|
export function newRowPosition(keys: KeyRect[]): { x: number; y: number } | undefined {
|
||||||
|
if (!keys.length) return undefined;
|
||||||
|
return {
|
||||||
|
x: Math.min(...keys.map((k) => k.x)),
|
||||||
|
y: Math.max(...keys.map((k) => k.y + k.h)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Point, Size } from "./phy";
|
||||||
|
import { hasSecondRect } from "./keyShape";
|
||||||
|
|
||||||
|
/** One candidate stabilizer layout: two mounts at ±offset along the key's long axis, in U. */
|
||||||
|
export type StabilizerCandidate = { id: string; label: string; offset: number };
|
||||||
|
|
||||||
|
const MM = 1 / 19.05;
|
||||||
|
/** Cherry MX spacings by key length. provisional/decisions.md「単位8のスタビ候補」. */
|
||||||
|
const TABLE: { lengths: number[]; candidates: StabilizerCandidate[] }[] = [
|
||||||
|
{
|
||||||
|
lengths: [2, 2.25, 2.5, 2.75],
|
||||||
|
candidates: [{ id: "11.938mm", label: "±11.938mm", offset: 11.938 * MM }],
|
||||||
|
},
|
||||||
|
{ lengths: [3], candidates: [{ id: "1u", label: "±1U", offset: 1 }] },
|
||||||
|
{ lengths: [4], candidates: [{ id: "1.5u", label: "±1.5U", offset: 1.5 }] },
|
||||||
|
{ lengths: [4.5], candidates: [{ id: "34.671mm", label: "±34.671mm", offset: 34.671 * MM }] },
|
||||||
|
{ lengths: [5.5], candidates: [{ id: "2.25u", label: "±2.25U", offset: 2.25 }] },
|
||||||
|
{
|
||||||
|
lengths: [6],
|
||||||
|
candidates: [
|
||||||
|
{ id: "2.5u", label: "±2.5U", offset: 2.5 },
|
||||||
|
{ id: "2u", label: "±2U", offset: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lengths: [6.25],
|
||||||
|
candidates: [
|
||||||
|
{ id: "50mm", label: "±50mm", offset: 50 * MM },
|
||||||
|
{ id: "40mm", label: "±40mm", offset: 40 * MM },
|
||||||
|
{ id: "42.5mm", label: "±42.5mm", offset: 42.5 * MM },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ lengths: [6.5], candidates: [{ id: "2.75u", label: "±2.75U", offset: 2.75 }] },
|
||||||
|
{ lengths: [7], candidates: [{ id: "3u", label: "±3U", offset: 3 }] },
|
||||||
|
{ lengths: [8, 9, 9.75, 10], candidates: [{ id: "3.5u", label: "±3.5U", offset: 3.5 }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EPS = 1e-9;
|
||||||
|
export function stabilizerCandidates(size: Size): StabilizerCandidate[] {
|
||||||
|
// Non-rectangular keys (ISO Enter, BAE, stepped) have no candidates yet.
|
||||||
|
if (hasSecondRect(size)) return [];
|
||||||
|
const length = Math.max(size.w, size.h);
|
||||||
|
return TABLE.find((row) => row.lengths.some((l) => Math.abs(l - length) < EPS))?.candidates ?? [];
|
||||||
|
}
|
||||||
|
/** Mount points in key-local coordinates, around the (w, h) rectangle's center, not the switch. */
|
||||||
|
export function stabilizerPoints(size: Size, ids: string[]): Point[] {
|
||||||
|
const cx = size.w / 2,
|
||||||
|
cy = size.h / 2,
|
||||||
|
vertical = size.h > size.w;
|
||||||
|
return stabilizerCandidates(size)
|
||||||
|
.filter((c) => ids.includes(c.id))
|
||||||
|
.flatMap((c) =>
|
||||||
|
vertical
|
||||||
|
? [
|
||||||
|
{ x: cx, y: cy - c.offset },
|
||||||
|
{ x: cx, y: cy + c.offset },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ x: cx - c.offset, y: cy },
|
||||||
|
{ x: cx + c.offset, y: cy },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function switchPoint(key: { size: Size; switchOffset?: Point }): Point {
|
||||||
|
return { x: key.size.w / 2 + (key.switchOffset?.x ?? 0), y: key.size.h / 2 + (key.switchOffset?.y ?? 0) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import type { ProjectFile } from "./project";
|
||||||
|
import { flattenKeys } from "./geometry";
|
||||||
|
import type { Node } from "./phy";
|
||||||
|
import { eligibleKeyIds, unregisteredKeys } from "./idx";
|
||||||
|
import { parseKeycode } from "./map";
|
||||||
|
import { validateSchema } from "../io/validateSchema";
|
||||||
|
export type ValidationIssue = {
|
||||||
|
severity: "blocksSave" | "blocksOutput" | "warning";
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
path?: string;
|
||||||
|
ids?: string[];
|
||||||
|
};
|
||||||
|
/** Performs semantic validation of a complete mock project. T-13/T-16/T-21/T-25. */
|
||||||
|
export function validateProject(p: ProjectFile): ValidationIssue[] {
|
||||||
|
const out: ValidationIssue[] = [];
|
||||||
|
for (const layer of ["phy", "idx", "elc", "map"] as const)
|
||||||
|
for (const e of validateSchema(layer, p[layer]))
|
||||||
|
out.push({ severity: "blocksSave", code: "schema", message: e.message, path: `${layer}${e.path}` });
|
||||||
|
if (p.format !== "beyond-kle-mock-project")
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "project-format",
|
||||||
|
message: "Unsupported ProjectFile format",
|
||||||
|
path: "/format",
|
||||||
|
});
|
||||||
|
if (p.version !== "0.1.0")
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "version",
|
||||||
|
message: "Unsupported ProjectFile version",
|
||||||
|
path: "/version",
|
||||||
|
});
|
||||||
|
for (const layer of ["phy", "idx", "elc", "map"] as const)
|
||||||
|
if (p[layer].version !== "0.1.0")
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "version",
|
||||||
|
message: `Unsupported ${layer} version`,
|
||||||
|
path: `/${layer}/version`,
|
||||||
|
});
|
||||||
|
const ids = new Map<string, string[]>();
|
||||||
|
const add = (id: string, path: string) => ids.set(id, [...(ids.get(id) ?? []), path]);
|
||||||
|
const walk = (ns: Node[], path: string) =>
|
||||||
|
ns.forEach((n, i) => {
|
||||||
|
const q = `${path}/${i}`;
|
||||||
|
add(n.id, q);
|
||||||
|
if (n.type === "cluster") walk(n.children, q + "/children");
|
||||||
|
if (n.type === "variantGroup") {
|
||||||
|
if (!n.children.some((v) => v.id === n.default))
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "missing-default-variant",
|
||||||
|
message: "VariantGroup default does not reference a child",
|
||||||
|
path: q + "/default",
|
||||||
|
ids: [n.id, n.default],
|
||||||
|
});
|
||||||
|
for (const v of n.children) {
|
||||||
|
add(v.id, q + "/children");
|
||||||
|
walk(v.children, q + `/children/${v.id}/children`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
walk(p.phy.root, "/phy/root");
|
||||||
|
for (const [id, paths] of ids)
|
||||||
|
if (paths.length > 1)
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "duplicate-id",
|
||||||
|
message: "Duplicate PHY node ID",
|
||||||
|
ids: [id],
|
||||||
|
path: paths[1],
|
||||||
|
});
|
||||||
|
const nodes = new Set(ids.keys()),
|
||||||
|
keys = new Set(eligibleKeyIds(p.phy));
|
||||||
|
const ref = (id: string, path: string, valid: Set<string> = nodes) => {
|
||||||
|
if (!valid.has(id))
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "missing-reference",
|
||||||
|
message: `Unknown reference: ${id}`,
|
||||||
|
path,
|
||||||
|
ids: [id],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
p.idx.order.forEach((id, i) => ref(id, `/idx/order/${i}`, keys));
|
||||||
|
p.elc.keys.forEach((k, i) => ref(k.key, `/elc/keys/${i}/key`, keys));
|
||||||
|
p.elc.ledChains?.forEach((c, i) => c.order.forEach((id, j) => ref(id, `/elc/ledChains/${i}/order/${j}`)));
|
||||||
|
p.map.layers.forEach((l, i) =>
|
||||||
|
l.keymap.forEach((a, j) => {
|
||||||
|
ref(a.keyId, `/map/layers/${i}/keymap/${j}/keyId`, keys);
|
||||||
|
const keycode = parseKeycode(a.keycode);
|
||||||
|
if (keycode.kind === "layer" && !p.map.layers.some((candidate) => candidate.name === keycode.layer))
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "missing-map-layer-reference",
|
||||||
|
message: `Unknown MAP layer reference: ${keycode.layer}`,
|
||||||
|
path: `/map/layers/${i}/keymap/${j}/keycode`,
|
||||||
|
ids: [l.name, keycode.layer ?? ""],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const providerIds = new Map<string, number>();
|
||||||
|
p.elc.providers.forEach((provider, index) => {
|
||||||
|
const first = providerIds.get(provider.id);
|
||||||
|
if (first !== undefined)
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "duplicate-provider-id",
|
||||||
|
message: "Provider IDs must be unique",
|
||||||
|
path: `/elc/providers/${index}/id`,
|
||||||
|
ids: [provider.id],
|
||||||
|
});
|
||||||
|
else providerIds.set(provider.id, index);
|
||||||
|
if (!provider.id || provider.id.includes("."))
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "invalid-provider-id",
|
||||||
|
message: "Provider IDs must be nonempty and cannot contain '.'",
|
||||||
|
path: `/elc/providers/${index}/id`,
|
||||||
|
ids: [provider.id],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const chainIds = new Set<string>();
|
||||||
|
p.elc.ledChains?.forEach((chain, index) => {
|
||||||
|
if (chainIds.has(chain.id))
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "duplicate-led-chain-id",
|
||||||
|
message: "LED chain IDs must be unique",
|
||||||
|
path: `/elc/ledChains/${index}/id`,
|
||||||
|
ids: [chain.id],
|
||||||
|
});
|
||||||
|
chainIds.add(chain.id);
|
||||||
|
});
|
||||||
|
const wiredKeys = new Set<string>();
|
||||||
|
p.elc.keys.forEach((wiring, index) => {
|
||||||
|
if (wiredKeys.has(wiring.key))
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "duplicate-key-wiring",
|
||||||
|
message: "A key may have only one ELC wiring entry",
|
||||||
|
path: `/elc/keys/${index}/key`,
|
||||||
|
ids: [wiring.key],
|
||||||
|
});
|
||||||
|
wiredKeys.add(wiring.key);
|
||||||
|
});
|
||||||
|
|
||||||
|
const validatePinRef = (pin: string, path: string) => {
|
||||||
|
if (pin === "VCC" || pin === "GND") return;
|
||||||
|
const parts = pin.split(".");
|
||||||
|
const providerId = parts[0];
|
||||||
|
const pinName = parts[1];
|
||||||
|
if (parts.length !== 2 || !providerId || !pinName) {
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "invalid-pin-reference",
|
||||||
|
message: `Malformed PinRef: ${pin}`,
|
||||||
|
path,
|
||||||
|
ids: [pin],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const provider = p.elc.providers.find((candidate) => candidate.id === providerId);
|
||||||
|
if (!provider) {
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "missing-pin-provider",
|
||||||
|
message: `Unknown PinRef provider: ${providerId}`,
|
||||||
|
path,
|
||||||
|
ids: [providerId, pin],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let valid = true;
|
||||||
|
if (provider.kind === "analog-mux")
|
||||||
|
valid = /^(0|[1-9]\d*)$/.test(pinName) && Number(pinName) < provider.channels;
|
||||||
|
else if (provider.part.toUpperCase() === "RP2040") valid = /^GP(?:[0-9]|[12][0-9])$/.test(pinName);
|
||||||
|
if (!valid)
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "unknown-provider-pin",
|
||||||
|
message: `Unknown pin ${pinName} on provider ${providerId}`,
|
||||||
|
path,
|
||||||
|
ids: [providerId, pinName],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
p.elc.providers.forEach((provider, index) => {
|
||||||
|
if (provider.kind === "analog-mux") {
|
||||||
|
provider.select.forEach((pin, i) => validatePinRef(pin, `/elc/providers/${index}/select/${i}`));
|
||||||
|
validatePinRef(provider.common, `/elc/providers/${index}/common`);
|
||||||
|
if (provider.enable !== undefined) validatePinRef(provider.enable, `/elc/providers/${index}/enable`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
p.elc.keys.forEach((wiring, index) =>
|
||||||
|
Object.entries(wiring.terminals).forEach(([terminal, pin]) =>
|
||||||
|
validatePinRef(pin, `/elc/keys/${index}/terminals/${terminal}`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
p.elc.ledChains?.forEach((chain, index) => validatePinRef(chain.data, `/elc/ledChains/${index}/data`));
|
||||||
|
p.output.matrix?.rowPins.forEach((pin, index) => validatePinRef(pin, `/output/matrix/rowPins/${index}`));
|
||||||
|
p.output.matrix?.colPins.forEach((pin, index) => validatePinRef(pin, `/output/matrix/colPins/${index}`));
|
||||||
|
const profileIds = new Set(p.phy.profiles.map((x) => x.id));
|
||||||
|
const checkProfiles = (ns: Node[]) =>
|
||||||
|
ns.forEach((n, i) => {
|
||||||
|
if (n.type === "key" && !profileIds.has(n.profile))
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "missing-profile",
|
||||||
|
message: `Unknown profile: ${n.profile}`,
|
||||||
|
path: `/phy/key/${n.id}/profile`,
|
||||||
|
ids: [n.id, n.profile],
|
||||||
|
});
|
||||||
|
if (n.type === "cluster") checkProfiles(n.children);
|
||||||
|
if (n.type === "variantGroup") n.children.forEach((v) => checkProfiles(v.children));
|
||||||
|
});
|
||||||
|
checkProfiles(p.phy.root);
|
||||||
|
for (const [i, pr] of p.phy.profiles.entries()) {
|
||||||
|
if (pr.unitSize.x <= 0 || pr.unitSize.y <= 0)
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "positive-unit-size",
|
||||||
|
message: "unitSize values must be positive",
|
||||||
|
path: `/phy/profiles/${i}/unitSize`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const keyWalk = (ns: Node[]) =>
|
||||||
|
ns.forEach((n) => {
|
||||||
|
if (n.type === "key" && (n.size.w <= 0 || n.size.h <= 0))
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "positive-key-size",
|
||||||
|
message: "Key size values must be positive",
|
||||||
|
path: `/phy/key/${n.id}/size`,
|
||||||
|
ids: [n.id],
|
||||||
|
});
|
||||||
|
if (n.type === "cluster") keyWalk(n.children);
|
||||||
|
if (n.type === "variantGroup") n.children.forEach((v) => keyWalk(v.children));
|
||||||
|
});
|
||||||
|
keyWalk(p.phy.root);
|
||||||
|
const unreg = unregisteredKeys(p.phy, p.idx);
|
||||||
|
for (const id of unreg)
|
||||||
|
out.push({
|
||||||
|
severity: "blocksOutput",
|
||||||
|
code: "idx-unregistered",
|
||||||
|
message: "Eligible key is not in IDX order",
|
||||||
|
ids: [id],
|
||||||
|
});
|
||||||
|
const profileSizes = p.phy.profiles.map((x) => `${x.unitSize.x},${x.unitSize.y}`);
|
||||||
|
if (new Set(profileSizes).size > 1)
|
||||||
|
out.push({
|
||||||
|
severity: "warning",
|
||||||
|
code: "unit-size-mismatch",
|
||||||
|
message: "Profile unitSize values differ; geometry editing is restricted",
|
||||||
|
path: "/phy/profiles",
|
||||||
|
});
|
||||||
|
let active: string[] = [];
|
||||||
|
try {
|
||||||
|
active = flattenKeys(p.phy, p.output.variantSelection)
|
||||||
|
.filter((x) => !x.key.decal)
|
||||||
|
.map((x) => x.key.id);
|
||||||
|
} catch (e) {
|
||||||
|
out.push({ severity: "blocksSave", code: "invalid-variant-selection", message: String(e) });
|
||||||
|
}
|
||||||
|
for (const id of active) {
|
||||||
|
if (!p.elc.keys.some((k) => k.key === id))
|
||||||
|
out.push({
|
||||||
|
severity: "blocksOutput",
|
||||||
|
code: "unwired-key",
|
||||||
|
message: "Selected key has no wiring",
|
||||||
|
ids: [id],
|
||||||
|
});
|
||||||
|
for (const l of p.map.layers)
|
||||||
|
if (!l.keymap.some((a) => a.keyId === id))
|
||||||
|
out.push({
|
||||||
|
severity: "blocksOutput",
|
||||||
|
code: "unassigned-key",
|
||||||
|
message: `Selected key is not assigned in layer ${l.name}`,
|
||||||
|
ids: [id, l.name],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const branches = new Map<string, Array<[string, string]>>();
|
||||||
|
const exclusivity = (ns: Node[], branch: Array<[string, string]>) =>
|
||||||
|
ns.forEach((n) => {
|
||||||
|
if (n.type === "key") branches.set(n.id, branch);
|
||||||
|
if (n.type === "cluster") exclusivity(n.children, branch);
|
||||||
|
if (n.type === "variantGroup")
|
||||||
|
for (const v of n.children) exclusivity(v.children, [...branch, [n.id, v.id]]);
|
||||||
|
});
|
||||||
|
exclusivity(p.phy.root, []);
|
||||||
|
const mutuallyExclusive = (a: string, b: string) => {
|
||||||
|
for (const [group, variant] of branches.get(a) ?? [])
|
||||||
|
if ((branches.get(b) ?? []).some(([g, v]) => g === group && v !== variant)) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const unordered = (a: string, b: string) => (a < b ? `${a}\0${b}` : `${b}\0${a}`);
|
||||||
|
const contacts = p.elc.keys.filter((w) => w.detect === "contact");
|
||||||
|
for (let i = 0; i < contacts.length; i++)
|
||||||
|
for (let j = i + 1; j < contacts.length; j++) {
|
||||||
|
const a = contacts[i]!,
|
||||||
|
b = contacts[j]!;
|
||||||
|
if (a.key === b.key || mutuallyExclusive(a.key, b.key)) continue;
|
||||||
|
const ap = a.diode
|
||||||
|
? a.diode === "a-to-b"
|
||||||
|
? [a.terminals.a, a.terminals.b]
|
||||||
|
: [a.terminals.b, a.terminals.a]
|
||||||
|
: null;
|
||||||
|
const bp = b.diode
|
||||||
|
? b.diode === "a-to-b"
|
||||||
|
? [b.terminals.a, b.terminals.b]
|
||||||
|
: [b.terminals.b, b.terminals.a]
|
||||||
|
: null;
|
||||||
|
const same =
|
||||||
|
ap && bp
|
||||||
|
? ap[0] === bp[0] && ap[1] === bp[1]
|
||||||
|
: unordered(a.terminals.a, a.terminals.b) === unordered(b.terminals.a, b.terminals.b);
|
||||||
|
if (same)
|
||||||
|
out.push({
|
||||||
|
severity: "blocksOutput",
|
||||||
|
code: "wiring-collision",
|
||||||
|
message: "Non-exclusive keys share the same electrical connection",
|
||||||
|
ids: [a.key, b.key],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const layerNames = new Set<string>();
|
||||||
|
for (const [i, l] of p.map.layers.entries()) {
|
||||||
|
if (layerNames.has(l.name))
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "duplicate-layer-name",
|
||||||
|
message: "Layer names must be unique",
|
||||||
|
path: `/map/layers/${i}/name`,
|
||||||
|
ids: [l.name],
|
||||||
|
});
|
||||||
|
layerNames.add(l.name);
|
||||||
|
}
|
||||||
|
for (const [li, l] of p.map.layers.entries()) {
|
||||||
|
const assignments = new Map<string, number>();
|
||||||
|
for (const [ai, a] of l.keymap.entries()) {
|
||||||
|
const prev = assignments.get(a.keyId);
|
||||||
|
if (prev !== undefined)
|
||||||
|
out.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "duplicate-map-key",
|
||||||
|
message: "A key may occur only once in a layer keymap",
|
||||||
|
path: `/map/layers/${li}/keymap/${ai}/keyId`,
|
||||||
|
ids: [a.keyId, l.name],
|
||||||
|
});
|
||||||
|
assignments.set(a.keyId, ai);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<object, object, unknown>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/** RFC 8785 JCS の UTF-8 対象文字列を生成する。T-12。 */
|
||||||
|
export function canonicalize(value: unknown): string {
|
||||||
|
return encode(value);
|
||||||
|
}
|
||||||
|
function encode(v: unknown): string {
|
||||||
|
if (v === null) return "null";
|
||||||
|
if (typeof v === "string") return JSON.stringify(v);
|
||||||
|
if (typeof v === "boolean") return v ? "true" : "false";
|
||||||
|
if (typeof v === "number") {
|
||||||
|
if (!Number.isFinite(v)) throw new TypeError("JCS does not support non-finite numbers");
|
||||||
|
return JSON.stringify(Object.is(v, -0) ? 0 : v);
|
||||||
|
}
|
||||||
|
if (Array.isArray(v)) return `[${v.map(encode).join(",")}]`;
|
||||||
|
if (typeof v === "object") {
|
||||||
|
const o = v as Record<string, unknown>;
|
||||||
|
const keys = Object.keys(o).sort();
|
||||||
|
return `{${keys.map((k) => `${JSON.stringify(k)}:${encode(o[k])}`).join(",")}}`;
|
||||||
|
}
|
||||||
|
throw new TypeError(`Unsupported JCS value: ${typeof v}`);
|
||||||
|
}
|
||||||
|
/** JCS正規化したUTF-8をSHA-256し、指定形式で返す。T-12。 */
|
||||||
|
export async function sha256Jcs(value: unknown): Promise<string> {
|
||||||
|
const bytes = new TextEncoder().encode(canonicalize(value));
|
||||||
|
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
||||||
|
return `sha256-jcs:${Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { FlatKey, KleCompatibility, PhyFile } from "../domain/phy";
|
||||||
|
import { flattenKeys } from "../domain/geometry";
|
||||||
|
import { labelsToKle } from "./kleImport";
|
||||||
|
|
||||||
|
type Obj = Record<string, unknown>;
|
||||||
|
const defaults: KleCompatibility = { c: "#cccccc", t: "#000000", p: "", a: 4, f: 3, sm: "", sb: "", st: "" };
|
||||||
|
|
||||||
|
/** PHYをバリアント選択に従ってKLE JSONへ平坦化する。座標は6桁丸め。T-20。 */
|
||||||
|
export function exportKle(phy: PhyFile, variantSelection: Record<string, string> = {}): unknown[] {
|
||||||
|
const keys = flattenKeys(phy, variantSelection);
|
||||||
|
const order = [...keys].sort((a, b) => a.rotation - b.rotation || a.y - b.y || a.x - b.x);
|
||||||
|
const centers = new Map<number, { x: number; y: number }>();
|
||||||
|
for (const key of order) if (!centers.has(key.rotation)) centers.set(key.rotation, { x: key.x, y: key.y });
|
||||||
|
const previous: KleCompatibility = { ...defaults };
|
||||||
|
let previousGhost = false;
|
||||||
|
const rows: unknown[] = [];
|
||||||
|
|
||||||
|
for (const flat of order) {
|
||||||
|
const key = flat.key;
|
||||||
|
const center = centers.get(flat.rotation)!;
|
||||||
|
const rad = (flat.rotation * Math.PI) / 180;
|
||||||
|
const dx = flat.x - center.x;
|
||||||
|
const dy = flat.y - center.y;
|
||||||
|
const localX = center.x + Math.cos(rad) * dx + Math.sin(rad) * dy;
|
||||||
|
const localY = center.y - Math.sin(rad) * dx + Math.cos(rad) * dy;
|
||||||
|
const compat = { ...defaults, ...(key.kleCompatibility ?? {}) };
|
||||||
|
const cfg: Obj = {
|
||||||
|
r: flat.rotation,
|
||||||
|
rx: round(center.x),
|
||||||
|
ry: round(center.y),
|
||||||
|
x: round(localX - center.x),
|
||||||
|
y: round(localY - center.y),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const field of ["c", "t", "p", "a", "sm", "sb", "st"] as const) {
|
||||||
|
const next = compat[field];
|
||||||
|
if (!same(next, previous[field])) cfg[field] = structuredClone(next);
|
||||||
|
previous[field] = structuredClone(next);
|
||||||
|
}
|
||||||
|
const oldOverride = previous.fa !== undefined ? "fa" : previous.f2 !== undefined ? "f2" : "none";
|
||||||
|
const nextOverride = compat.fa !== undefined ? "fa" : compat.f2 !== undefined ? "f2" : "none";
|
||||||
|
const fontChanged = !same(compat.f, previous.f);
|
||||||
|
if (fontChanged || (nextOverride === "none" && oldOverride !== "none")) cfg.f = compat.f;
|
||||||
|
if (nextOverride === "fa" && (oldOverride !== "fa" || !same(compat.fa, previous.fa)))
|
||||||
|
cfg.fa = structuredClone(compat.fa);
|
||||||
|
if (nextOverride === "f2" && (oldOverride !== "f2" || !same(compat.f2, previous.f2)))
|
||||||
|
cfg.f2 = structuredClone(compat.f2);
|
||||||
|
previous.f = structuredClone(compat.f);
|
||||||
|
delete previous.f2;
|
||||||
|
delete previous.fa;
|
||||||
|
if (nextOverride === "fa") previous.fa = structuredClone(compat.fa);
|
||||||
|
if (nextOverride === "f2") previous.f2 = structuredClone(compat.f2);
|
||||||
|
|
||||||
|
if (key.ghost !== previousGhost) {
|
||||||
|
cfg.g = key.ghost === true;
|
||||||
|
previousGhost = key.ghost === true;
|
||||||
|
}
|
||||||
|
if (key.decal) cfg.d = true;
|
||||||
|
if (compat.l === true) cfg.l = true;
|
||||||
|
if (compat.n === true) cfg.n = true;
|
||||||
|
|
||||||
|
const w = key.size.w;
|
||||||
|
const h = key.size.h;
|
||||||
|
if (w !== 1) cfg.w = round(w);
|
||||||
|
if (h !== 1) cfg.h = round(h);
|
||||||
|
if (key.size.x2 !== undefined && key.size.x2 !== 0) cfg.x2 = round(key.size.x2);
|
||||||
|
if (key.size.y2 !== undefined && key.size.y2 !== 0) cfg.y2 = round(key.size.y2);
|
||||||
|
const w2 = key.size.w2 ?? w;
|
||||||
|
const h2 = key.size.h2 ?? h;
|
||||||
|
if (w2 !== w) cfg.w2 = round(w2);
|
||||||
|
if (h2 !== h) cfg.h2 = round(h2);
|
||||||
|
|
||||||
|
rows.push([cfg, labelsToKle(key.labels, typeof compat.a === "number" ? compat.a : 4)]);
|
||||||
|
}
|
||||||
|
if (phy.kleCompatibility) rows.unshift(structuredClone(phy.kleCompatibility));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KLE JSON文字列を生成する。 */
|
||||||
|
export function stringifyKle(phy: PhyFile, selection: Record<string, string> = {}): string {
|
||||||
|
return JSON.stringify(exportKle(phy, selection));
|
||||||
|
}
|
||||||
|
function same(a: unknown, b: unknown): boolean {
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
|
function round(value: number): number {
|
||||||
|
return Number(value.toFixed(6));
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import type { KleCompatibility, KeyNode, PhyFile } from "../domain/phy";
|
||||||
|
import { createNodeId } from "../domain/ids";
|
||||||
|
|
||||||
|
type Obj = Record<string, unknown>;
|
||||||
|
type Atom = string | Obj;
|
||||||
|
|
||||||
|
// Output slot -> newline-field index. These fixed tables reproduce KLE's a=0..7 layout.
|
||||||
|
const labelMap: readonly (readonly (number | null)[])[] = [
|
||||||
|
[0, 8, 2, 6, 9, 7, 1, 10, 3, 4, 11, 5],
|
||||||
|
[null, 0, null, null, 6, null, null, 1, null, 4, 11, 5],
|
||||||
|
[null, null, null, 0, 8, 2, null, null, null, 4, 11, 5],
|
||||||
|
[null, null, null, null, 0, null, null, null, null, 4, 11, 5],
|
||||||
|
[0, 8, 2, 6, 9, 7, 1, 10, 3, null, 4, null],
|
||||||
|
[null, 0, null, null, 6, null, null, 1, null, null, 4, null],
|
||||||
|
[null, null, null, 0, 8, 2, null, null, null, null, 4, null],
|
||||||
|
[null, null, null, null, 0, null, null, null, null, null, 4, null],
|
||||||
|
];
|
||||||
|
|
||||||
|
const fileDefaults: Obj = {
|
||||||
|
backcolor: "#eeeeee",
|
||||||
|
name: "",
|
||||||
|
author: "",
|
||||||
|
notes: "",
|
||||||
|
background: null,
|
||||||
|
radii: "",
|
||||||
|
switchMount: "",
|
||||||
|
switchBrand: "",
|
||||||
|
switchType: "",
|
||||||
|
};
|
||||||
|
const keyDefaults: Obj = { c: "#cccccc", t: "#000000", p: "", a: 4, f: 3, sm: "", sb: "", st: "" };
|
||||||
|
|
||||||
|
function isObj(value: unknown): value is Obj {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
function num(value: unknown, fallback: number): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
function labelsFromKle(serialized: string, alignment: number): string[] {
|
||||||
|
const fields = serialized.split("\n");
|
||||||
|
if (fields.length > 12) throw new Error("A KLE label cannot exceed 12 slots");
|
||||||
|
const map = labelMap[alignment] ?? labelMap[4]!;
|
||||||
|
const labels = Array<string>(12).fill("");
|
||||||
|
map.forEach((field, slot) => {
|
||||||
|
if (field !== null && field < fields.length) labels[slot] = fields[field]!;
|
||||||
|
});
|
||||||
|
while (labels.at(-1) === "") labels.pop();
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
function oneShotArray(state: Obj, key: string): void {
|
||||||
|
delete state[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KLEの厳密JSON(またはVIA layouts.keymap)を新規PHYへ取り込む。T-7/T-18/T-20。 */
|
||||||
|
export function importKle(
|
||||||
|
input: unknown,
|
||||||
|
options: { idGenerator?: () => string; name?: string } = {},
|
||||||
|
): { phy: PhyFile; keyOrder: string[] } {
|
||||||
|
let data: unknown = input;
|
||||||
|
if (isObj(data)) {
|
||||||
|
const layouts = data.layouts;
|
||||||
|
if (isObj(layouts) && Array.isArray(layouts.keymap)) data = layouts.keymap;
|
||||||
|
else throw new Error("Expected KLE array or VIA layouts.keymap");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(data)) throw new Error("KLE input must be a JSON array");
|
||||||
|
const rows = [...data] as unknown[];
|
||||||
|
let rawMeta: Obj = {};
|
||||||
|
if (rows.length && isObj(rows[0])) rawMeta = rows.shift() as Obj;
|
||||||
|
|
||||||
|
const gen = options.idGenerator ?? (() => globalThis.crypto.randomUUID());
|
||||||
|
const state: Obj = { ...keyDefaults };
|
||||||
|
let cursorX = 0;
|
||||||
|
let cursorY = 0;
|
||||||
|
let rotation = 0;
|
||||||
|
let rotationX = 0;
|
||||||
|
let rotationY = 0;
|
||||||
|
const root: KeyNode[] = [];
|
||||||
|
const keyOrder: string[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!Array.isArray(row)) throw new Error("KLE rows must be arrays");
|
||||||
|
cursorX = rotationX;
|
||||||
|
for (const atom of row as Atom[]) {
|
||||||
|
if (typeof atom === "string") {
|
||||||
|
const w = num(state.w, 1);
|
||||||
|
const h = num(state.h, 1);
|
||||||
|
cursorX += num(state.x, 0);
|
||||||
|
cursorY += num(state.y, 0);
|
||||||
|
const radians = (rotation * Math.PI) / 180;
|
||||||
|
const dx = cursorX - rotationX;
|
||||||
|
const dy = cursorY - rotationY;
|
||||||
|
const x = rotationX + Math.cos(radians) * dx - Math.sin(radians) * dy;
|
||||||
|
const y = rotationY + Math.sin(radians) * dx + Math.cos(radians) * dy;
|
||||||
|
const a = num(state.a, 4);
|
||||||
|
const id = createNodeId("key", gen);
|
||||||
|
const labels = labelsFromKle(atom, a);
|
||||||
|
const compatibility: KleCompatibility = {
|
||||||
|
c: structuredClone(state.c),
|
||||||
|
t: structuredClone(state.t),
|
||||||
|
p: structuredClone(state.p),
|
||||||
|
a,
|
||||||
|
f: num(state.f, 3),
|
||||||
|
...(state.f2 === undefined ? {} : { f2: structuredClone(state.f2) }),
|
||||||
|
...(state.fa === undefined ? {} : { fa: structuredClone(state.fa) }),
|
||||||
|
l: state.l === true,
|
||||||
|
n: state.n === true,
|
||||||
|
sm: structuredClone(state.sm),
|
||||||
|
sb: structuredClone(state.sb),
|
||||||
|
st: structuredClone(state.st),
|
||||||
|
};
|
||||||
|
const w2 = num(state.w2, 0) || w;
|
||||||
|
const h2 = num(state.h2, 0) || h;
|
||||||
|
const key: KeyNode = {
|
||||||
|
type: "key",
|
||||||
|
id,
|
||||||
|
profile: "default",
|
||||||
|
transform: { x, y, rotation },
|
||||||
|
size: {
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
...(num(state.x2, 0) === 0 ? {} : { x2: num(state.x2, 0) }),
|
||||||
|
...(num(state.y2, 0) === 0 ? {} : { y2: num(state.y2, 0) }),
|
||||||
|
...(w2 === w ? {} : { w2 }),
|
||||||
|
...(h2 === h ? {} : { h2 }),
|
||||||
|
},
|
||||||
|
labels,
|
||||||
|
kleCompatibility: compatibility,
|
||||||
|
};
|
||||||
|
if (state.d === true) key.decal = true;
|
||||||
|
if (state.g === true) key.ghost = true;
|
||||||
|
root.push(key);
|
||||||
|
if (!key.decal) keyOrder.push(id);
|
||||||
|
|
||||||
|
cursorX += w;
|
||||||
|
for (const field of ["w", "h", "x", "y", "x2", "y2", "w2", "h2", "d", "l", "n"])
|
||||||
|
oneShotArray(state, field);
|
||||||
|
} else if (isObj(atom)) {
|
||||||
|
for (const [key, value] of Object.entries(atom)) {
|
||||||
|
if (key === "r") rotation = num(value, rotation);
|
||||||
|
else if (key === "rx") {
|
||||||
|
rotationX = num(value, rotationX);
|
||||||
|
cursorX = rotationX;
|
||||||
|
cursorY = rotationY;
|
||||||
|
} else if (key === "ry") {
|
||||||
|
rotationY = num(value, rotationY);
|
||||||
|
cursorX = rotationX;
|
||||||
|
cursorY = rotationY;
|
||||||
|
} else if (key === "f") {
|
||||||
|
state.f = value;
|
||||||
|
delete state.f2;
|
||||||
|
delete state.fa;
|
||||||
|
} else if (key === "f2") {
|
||||||
|
state.f2 = value;
|
||||||
|
delete state.fa;
|
||||||
|
} else if (key === "fa") {
|
||||||
|
state.fa = structuredClone(value);
|
||||||
|
delete state.f2;
|
||||||
|
} else state[key] = structuredClone(value);
|
||||||
|
}
|
||||||
|
} else throw new Error("KLE row entries must be strings or objects");
|
||||||
|
}
|
||||||
|
cursorY += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = { ...fileDefaults, ...structuredClone(rawMeta) };
|
||||||
|
const phy: PhyFile = {
|
||||||
|
version: "0.1.0",
|
||||||
|
metadata: { ...(options.name ? { name: options.name } : {}) },
|
||||||
|
profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }],
|
||||||
|
root,
|
||||||
|
kleCompatibility: metadata,
|
||||||
|
};
|
||||||
|
return { phy, keyOrder };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON文字列を厳密にパースして KLE を取り込む。 */
|
||||||
|
export function parseKleJson(
|
||||||
|
json: string,
|
||||||
|
options: { idGenerator?: () => string; name?: string } = {},
|
||||||
|
): ReturnType<typeof importKle> {
|
||||||
|
return importKle(JSON.parse(json) as unknown, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 正準12欄のラベルをKLEのa別固定欄順に戻す。 */
|
||||||
|
export function labelsToKle(labels: string[] | undefined, alignment: number): string {
|
||||||
|
const map = labelMap[alignment] ?? labelMap[4]!;
|
||||||
|
const fields = Array<string>(12).fill("");
|
||||||
|
map.forEach((sourceSlot, slot) => {
|
||||||
|
if (sourceSlot !== null) fields[sourceSlot] = labels?.[slot] ?? "";
|
||||||
|
});
|
||||||
|
while (fields.at(-1) === "") fields.pop();
|
||||||
|
return fields.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { flattenKeys } from "../domain/geometry";
|
||||||
|
import type { Placement } from "../domain/idxImport";
|
||||||
|
import { importKle } from "./kleImport";
|
||||||
|
|
||||||
|
type Obj = Record<string, unknown>;
|
||||||
|
const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
|
const num = (v: unknown, fallback = 0) => (typeof v === "number" ? v : fallback);
|
||||||
|
|
||||||
|
/** Result of reading an existing definition. `layouts` lists the choices when one must be picked. */
|
||||||
|
export type LayoutRead = { placements: Placement[]; layouts?: string[]; layout?: string };
|
||||||
|
|
||||||
|
/** QMK keyboard.json/info.json layout names, or undefined when the input is not a QMK definition. */
|
||||||
|
export function qmkLayoutNames(input: unknown): string[] | undefined {
|
||||||
|
if (!isObj(input) || !isObj(input.layouts) || Array.isArray(input.layouts.keymap)) return undefined;
|
||||||
|
const names = Object.entries(input.layouts)
|
||||||
|
.filter(([, v]) => isObj(v) && Array.isArray(v.layout))
|
||||||
|
.map(([name]) => name);
|
||||||
|
return names.length ? names : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads key placements, in definition order, from QMK `layouts.<NAME>.layout[]`, a VIA definition's
|
||||||
|
* `layouts.keymap`, or KLE JSON. KLE and VIA go through importKle so coordinates match the project.
|
||||||
|
* QMK r/rx/ry are read with the KLE meaning. Without `layout`, a QMK file with several layouts
|
||||||
|
* returns only the names. T-31.
|
||||||
|
*/
|
||||||
|
export function readLayoutPlacements(input: unknown, layout?: string): LayoutRead {
|
||||||
|
const names = qmkLayoutNames(input);
|
||||||
|
if (names) {
|
||||||
|
const name = layout ?? (names.length === 1 ? names[0] : undefined);
|
||||||
|
if (!name) return { placements: [], layouts: names };
|
||||||
|
const entries = ((input as { layouts: Obj }).layouts[name] as { layout: unknown[] }).layout;
|
||||||
|
return { placements: entries.filter(isObj).map(qmkPlacement), layouts: names, layout: name };
|
||||||
|
}
|
||||||
|
const { phy, keyOrder } = importKle(input);
|
||||||
|
const flat = new Map(flattenKeys(phy).map((k) => [k.key.id, k]));
|
||||||
|
const placements = keyOrder.flatMap((id) => {
|
||||||
|
const k = flat.get(id);
|
||||||
|
return k && !k.key.decal ? [{ x: k.x, y: k.y, rotation: k.rotation }] : [];
|
||||||
|
});
|
||||||
|
return { placements };
|
||||||
|
}
|
||||||
|
|
||||||
|
function qmkPlacement(e: Obj): Placement {
|
||||||
|
const x = num(e.x),
|
||||||
|
y = num(e.y),
|
||||||
|
r = num(e.r),
|
||||||
|
rx = num(e.rx),
|
||||||
|
ry = num(e.ry);
|
||||||
|
const a = (r * Math.PI) / 180,
|
||||||
|
c = Math.cos(a),
|
||||||
|
s = Math.sin(a);
|
||||||
|
return { x: rx + (x - rx) * c - (y - ry) * s, y: ry + (x - rx) * s + (y - ry) * c, rotation: r };
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import type { ProjectFile } from "../domain/project";
|
||||||
|
import { validateProject, type ValidationIssue } from "../domain/validate";
|
||||||
|
import { makePhyReference, phyReferenceMatches } from "../domain/phyReference";
|
||||||
|
import { validateSchema, type LayerName } from "./validateSchema";
|
||||||
|
|
||||||
|
export type ProjectReadResult = { project?: ProjectFile; errors: ValidationIssue[] };
|
||||||
|
export type ProjectSaveResult = { json?: string; errors: ValidationIssue[] };
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates project and output container shapes before semantic checks. T-10/T-16. */
|
||||||
|
function validateProjectShape(value: unknown): ValidationIssue[] {
|
||||||
|
const errors: ValidationIssue[] = [];
|
||||||
|
if (!isRecord(value))
|
||||||
|
return [{ severity: "blocksSave", code: "schema", message: "Expected a ProjectFile object", path: "/" }];
|
||||||
|
const required = ["format", "version", "projectId", "phy", "idx", "elc", "map", "output"];
|
||||||
|
for (const field of required)
|
||||||
|
if (!(field in value))
|
||||||
|
errors.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "schema",
|
||||||
|
message: `Missing required ProjectFile field: ${field}`,
|
||||||
|
path: `/${field}`,
|
||||||
|
});
|
||||||
|
if (value.format !== "beyond-kle-mock-project")
|
||||||
|
errors.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "schema",
|
||||||
|
message: "Invalid ProjectFile format",
|
||||||
|
path: "/format",
|
||||||
|
});
|
||||||
|
if (typeof value.version !== "string")
|
||||||
|
errors.push({ severity: "blocksSave", code: "schema", message: "Expected a string", path: "/version" });
|
||||||
|
if (typeof value.projectId !== "string")
|
||||||
|
errors.push({ severity: "blocksSave", code: "schema", message: "Expected a string", path: "/projectId" });
|
||||||
|
for (const layer of ["phy", "idx", "elc", "map"] as const) {
|
||||||
|
if (!(layer in value)) continue;
|
||||||
|
for (const issue of validateSchema(layer, value[layer]))
|
||||||
|
errors.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "schema",
|
||||||
|
message: issue.message,
|
||||||
|
path: `/${layer}${issue.path === "/" ? "" : issue.path}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const output = value.output;
|
||||||
|
if (!isRecord(output)) {
|
||||||
|
errors.push({ severity: "blocksSave", code: "schema", message: "Expected an object", path: "/output" });
|
||||||
|
} else {
|
||||||
|
if (!isRecord(output.variantSelection))
|
||||||
|
errors.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "schema",
|
||||||
|
message: "Expected an object of variant IDs",
|
||||||
|
path: "/output/variantSelection",
|
||||||
|
});
|
||||||
|
else
|
||||||
|
for (const [key, variant] of Object.entries(output.variantSelection))
|
||||||
|
if (typeof variant !== "string")
|
||||||
|
errors.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "schema",
|
||||||
|
message: "Expected a variant ID string",
|
||||||
|
path: `/output/variantSelection/${key}`,
|
||||||
|
});
|
||||||
|
if (output.matrix !== undefined) {
|
||||||
|
if (!isRecord(output.matrix))
|
||||||
|
errors.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "schema",
|
||||||
|
message: "Expected an object",
|
||||||
|
path: "/output/matrix",
|
||||||
|
});
|
||||||
|
else {
|
||||||
|
for (const field of ["rowPins", "colPins"] as const) {
|
||||||
|
const pins = output.matrix[field];
|
||||||
|
if (!Array.isArray(pins) || pins.some((pin) => typeof pin !== "string"))
|
||||||
|
errors.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "schema",
|
||||||
|
message: "Expected an array of pin references",
|
||||||
|
path: `/output/matrix/${field}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
output.matrix.kind !== undefined &&
|
||||||
|
!["matrix", "charlieplexing", "direct"].includes(String(output.matrix.kind))
|
||||||
|
)
|
||||||
|
errors.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "schema",
|
||||||
|
message: "Unknown output scan mode",
|
||||||
|
path: "/output/matrix/kind",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializes a project and refreshes PHY references unless T-16 blocks saving. T-12/T-13. */
|
||||||
|
export async function saveProject(project: ProjectFile, pretty = false): Promise<ProjectSaveResult> {
|
||||||
|
const shapeErrors = validateProjectShape(project);
|
||||||
|
if (shapeErrors.length) return { errors: shapeErrors };
|
||||||
|
const errors = validateProject(project);
|
||||||
|
if (errors.some((issue) => issue.severity === "blocksSave")) return { errors };
|
||||||
|
|
||||||
|
const p = structuredClone(project);
|
||||||
|
const ref = await makePhyReference(p.phy);
|
||||||
|
for (const layer of ["idx", "elc", "map"] as const)
|
||||||
|
p[layer].phyReference = { ...p[layer].phyReference, ...ref };
|
||||||
|
return { json: JSON.stringify(p, null, pretty ? 2 : undefined), errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a project, checking all schemas before semantic validation and retaining unknown fields. T-13/T-14. */
|
||||||
|
export async function loadProject(json: string): Promise<ProjectReadResult> {
|
||||||
|
let data: unknown;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(json) as unknown;
|
||||||
|
} catch (e) {
|
||||||
|
return { errors: [{ severity: "blocksSave", code: "invalid-json", message: String(e) }] };
|
||||||
|
}
|
||||||
|
const errors = validateProjectShape(data);
|
||||||
|
if (errors.length) return { errors };
|
||||||
|
|
||||||
|
const project = data as ProjectFile;
|
||||||
|
const semantic = validateProject(project);
|
||||||
|
const unsupportedVersion = semantic.filter((issue) => issue.code === "version");
|
||||||
|
if (unsupportedVersion.length) return { errors: semantic };
|
||||||
|
for (const layer of ["idx", "elc", "map"] as const)
|
||||||
|
if (!(await phyReferenceMatches(project.phy, project[layer].phyReference)))
|
||||||
|
semantic.push({
|
||||||
|
severity: "warning",
|
||||||
|
code: "phy-reference-mismatch",
|
||||||
|
message: `${layer} PHY checksum does not match the current PHY`,
|
||||||
|
path: `/${layer}/phyReference`,
|
||||||
|
});
|
||||||
|
return { project, errors: semantic };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a single layer as JSON. */
|
||||||
|
export function writeLayer(layer: LayerName, value: unknown, pretty = false): string {
|
||||||
|
const errors = validateSchema(layer, value);
|
||||||
|
if (errors.length) throw new Error(errors.map((issue) => `${issue.path} ${issue.message}`).join("; "));
|
||||||
|
return JSON.stringify(value, null, pretty ? 2 : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads and schema-validates one layer file. */
|
||||||
|
export function readLayer(layer: LayerName, json: string): { value?: unknown; errors: ValidationIssue[] } {
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(json) as unknown;
|
||||||
|
} catch (e) {
|
||||||
|
return { errors: [{ severity: "blocksSave", code: "invalid-json", message: String(e) }] };
|
||||||
|
}
|
||||||
|
const errors = validateSchema(layer, value).map((issue) => ({
|
||||||
|
severity: "blocksSave" as const,
|
||||||
|
code: "schema",
|
||||||
|
message: issue.message,
|
||||||
|
path: issue.path,
|
||||||
|
}));
|
||||||
|
if (
|
||||||
|
value &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
"version" in value &&
|
||||||
|
(value as { version?: unknown }).version !== "0.1.0"
|
||||||
|
)
|
||||||
|
errors.push({
|
||||||
|
severity: "blocksSave",
|
||||||
|
code: "version",
|
||||||
|
message: "Unsupported layer version",
|
||||||
|
path: "/version",
|
||||||
|
});
|
||||||
|
return errors.length ? { errors } : { value, errors: [] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://beyond-kle.org/schemas/elc-layer.json",
|
||||||
|
"title": "ELC Layer - Electrical Wiring",
|
||||||
|
"description": "Wiring between provider pins and key terminals",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["version", "phyReference", "providers", "keys"],
|
||||||
|
"properties": {
|
||||||
|
"version": { "type": "string" },
|
||||||
|
"phyReference": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["source", "checksum"],
|
||||||
|
"properties": {
|
||||||
|
"source": { "type": "string" },
|
||||||
|
"checksum": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/provider" }
|
||||||
|
},
|
||||||
|
"keys": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/keyWiring" }
|
||||||
|
},
|
||||||
|
"ledChains": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/ledChain" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"pinRef": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^(VCC|GND|[^.]+\\.[^.]+)$",
|
||||||
|
"description": "\"<provider id>.<pin name>\", or the reserved power rails VCC / GND"
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"oneOf": [{ "$ref": "#/$defs/mcuProvider" }, { "$ref": "#/$defs/analogMuxProvider" }]
|
||||||
|
},
|
||||||
|
"mcuProvider": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "kind", "part"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"kind": { "const": "mcu" },
|
||||||
|
"part": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Part number. Used to interpret pin names"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"analogMuxProvider": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "kind", "channels", "select", "common"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"kind": { "const": "analog-mux" },
|
||||||
|
"channels": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"description": "Number of logical pins provided. Pin names are \"0\"..\"channels-1\""
|
||||||
|
},
|
||||||
|
"select": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/pinRef" },
|
||||||
|
"description": "Select pins, least significant bit first"
|
||||||
|
},
|
||||||
|
"common": { "$ref": "#/$defs/pinRef" },
|
||||||
|
"enable": { "$ref": "#/$defs/pinRef" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"keyWiring": {
|
||||||
|
"oneOf": [{ "$ref": "#/$defs/contactKey" }, { "$ref": "#/$defs/analogKey" }]
|
||||||
|
},
|
||||||
|
"contactKey": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["key", "detect", "terminals"],
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reference to PHY layer key ID"
|
||||||
|
},
|
||||||
|
"detect": { "const": "contact" },
|
||||||
|
"terminals": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Non-polar. a and b carry no ordering meaning",
|
||||||
|
"required": ["a", "b"],
|
||||||
|
"properties": {
|
||||||
|
"a": { "$ref": "#/$defs/pinRef" },
|
||||||
|
"b": { "$ref": "#/$defs/pinRef" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"diode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["a-to-b", "b-to-a"],
|
||||||
|
"description": "Anode side comes first. Omitted means no diode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"analogKey": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["key", "detect", "terminals"],
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reference to PHY layer key ID"
|
||||||
|
},
|
||||||
|
"detect": { "const": "analog" },
|
||||||
|
"terminals": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["out", "vcc", "gnd"],
|
||||||
|
"properties": {
|
||||||
|
"out": { "$ref": "#/$defs/pinRef" },
|
||||||
|
"vcc": { "$ref": "#/$defs/pinRef" },
|
||||||
|
"gnd": { "$ref": "#/$defs/pinRef" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ledChain": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "data", "order"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"data": { "$ref": "#/$defs/pinRef" },
|
||||||
|
"order": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"description": "PHY node IDs. The position in the array is the LED number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://beyond-kle.org/schemas/idx-layer.json",
|
||||||
|
"title": "IDX Layer - Key Identification",
|
||||||
|
"description": "Ordered PHY key IDs across all variants. Output key numbers are contiguous positions after filtering by variant selection",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["version", "phyReference", "order"],
|
||||||
|
"properties": {
|
||||||
|
"version": { "type": "string" },
|
||||||
|
"phyReference": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["source", "checksum"],
|
||||||
|
"properties": {
|
||||||
|
"source": { "type": "string" },
|
||||||
|
"checksum": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"type": "array",
|
||||||
|
"uniqueItems": true,
|
||||||
|
"description": "Each PHY key has its own entry, including mutually exclusive keys wired to the same terminals. Variant selection does not modify this list",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reference to PHY layer key ID"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://beyond-kle.org/schemas/map-layer.json",
|
||||||
|
"title": "MAP Layer - Keymap Configuration",
|
||||||
|
"description": "Key code assignments and layers",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["version", "phyReference", "layers"],
|
||||||
|
"properties": {
|
||||||
|
"version": { "type": "string" },
|
||||||
|
"phyReference": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["source", "checksum"],
|
||||||
|
"properties": {
|
||||||
|
"source": { "type": "string" },
|
||||||
|
"checksum": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"layers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/layer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"layer": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name", "keymap"],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Layer identifier, unique within the MAP. Layer order is the array order"
|
||||||
|
},
|
||||||
|
"keymap": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/keyAssignment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"keyAssignment": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["keyId", "keycode"],
|
||||||
|
"properties": {
|
||||||
|
"keyId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reference to PHY layer key ID"
|
||||||
|
},
|
||||||
|
"keycode": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://beyond-kle.org/schemas/phy-layer.json",
|
||||||
|
"title": "PHY Layer - Physical Key Layout",
|
||||||
|
"description": "Physical positioning and sizing of keys",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["version", "metadata", "profiles", "root"],
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^\\d+\\.\\d+\\.\\d+$",
|
||||||
|
"description": "Schema version (semver)"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"author": { "type": "string" },
|
||||||
|
"description": { "type": "string" },
|
||||||
|
"created": { "type": "string", "format": "date-time" },
|
||||||
|
"modified": { "type": "string", "format": "date-time" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"kleCompatibility": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "File-level opaque KLE fields kept for round-trip output (switchMount, switchBrand, switchType, backcolor, radii, background, css). Kept here for now; the eventual owning layer is deferred in backlog/README.md, PHY-P-6",
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/profile"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"oneOf": [
|
||||||
|
{ "$ref": "#/$defs/key" },
|
||||||
|
{ "$ref": "#/$defs/cluster" },
|
||||||
|
{ "$ref": "#/$defs/component" },
|
||||||
|
{ "$ref": "#/$defs/variantGroup" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"transform": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Placement relative to the parent: rotate by rotation around the local origin, then translate by (x, y)",
|
||||||
|
"required": ["x", "y", "rotation"],
|
||||||
|
"properties": {
|
||||||
|
"x": { "type": "number" },
|
||||||
|
"y": { "type": "number" },
|
||||||
|
"rotation": { "type": "number", "description": "Degrees" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "name", "unitSize"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"unitSize": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Millimeters per 1U",
|
||||||
|
"required": ["x", "y"],
|
||||||
|
"properties": {
|
||||||
|
"x": { "type": "number", "minimum": 0 },
|
||||||
|
"y": { "type": "number", "minimum": 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visualOffset": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Key top rectangle, in millimeters: inward distance from each edge of the outline, plus corner radius",
|
||||||
|
"required": ["top", "right", "bottom", "left", "radius"],
|
||||||
|
"properties": {
|
||||||
|
"top": { "type": "number" },
|
||||||
|
"right": { "type": "number" },
|
||||||
|
"bottom": { "type": "number" },
|
||||||
|
"left": { "type": "number" },
|
||||||
|
"radius": { "type": "number", "minimum": 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["type", "id", "profile", "transform", "size"],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "key"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unique key identifier"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Reference to profile ID"
|
||||||
|
},
|
||||||
|
"transform": { "$ref": "#/$defs/transform" },
|
||||||
|
"size": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Local size. Not affected by the parent transform",
|
||||||
|
"required": ["w", "h"],
|
||||||
|
"properties": {
|
||||||
|
"w": { "type": "number", "minimum": 0 },
|
||||||
|
"h": { "type": "number", "minimum": 0 },
|
||||||
|
"x2": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Second rectangle for non-rectangular keys, relative to the local origin"
|
||||||
|
},
|
||||||
|
"y2": { "type": "number" },
|
||||||
|
"w2": { "type": "number", "minimum": 0 },
|
||||||
|
"h2": { "type": "number", "minimum": 0 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"switchOffset": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Switch position, offset from the center of the (w, h) rectangle. Key local coordinates, unit U. Defaults to 0",
|
||||||
|
"required": ["x", "y"],
|
||||||
|
"properties": {
|
||||||
|
"x": { "type": "number" },
|
||||||
|
"y": { "type": "number" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"partGroup": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Label grouping keys that use the same part. Not interpreted by Beyond KLE"
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"type": "integer",
|
||||||
|
"enum": [0, 90, 180, 270],
|
||||||
|
"default": 0,
|
||||||
|
"description": "Switch orientation in the key local coordinates"
|
||||||
|
},
|
||||||
|
"perKeyLED": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Whether a per-key LED is mounted"
|
||||||
|
},
|
||||||
|
"decal": { "type": "boolean", "default": false },
|
||||||
|
"ghost": { "type": "boolean", "default": false },
|
||||||
|
"labels": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"description": "Reference information. Used for matching on KLE JSON import"
|
||||||
|
},
|
||||||
|
"kleCompatibility": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Opaque KLE fields kept for round-trip output (c, t, p, a, f, f2, fa, l, n, sm, sb, st). Kept here for now; the eventual owning layer is deferred in backlog/README.md, PHY-P-6",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"component": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Non-key part placed by the designer (LED, OLED, encoder, screw hole, ...)",
|
||||||
|
"required": ["type", "id", "transform"],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "component"
|
||||||
|
},
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"transform": { "$ref": "#/$defs/transform" },
|
||||||
|
"partGroup": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Names starting with @ are reserved for editor rendering"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"variantGroup": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Mutually exclusive layout alternatives. Children are variants only",
|
||||||
|
"required": ["type", "id", "name", "transform", "default", "children"],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "variantGroup"
|
||||||
|
},
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Heading shown for the choice in the editor"
|
||||||
|
},
|
||||||
|
"transform": { "$ref": "#/$defs/transform" },
|
||||||
|
"default": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "id of the variant used when a single layout must be chosen"
|
||||||
|
},
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 2,
|
||||||
|
"description": "Display order",
|
||||||
|
"items": { "$ref": "#/$defs/variant" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"variant": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One alternative. Appears only directly under a variantGroup. Carries no transform",
|
||||||
|
"required": ["type", "id", "name", "children"],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "variant"
|
||||||
|
},
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "May be empty",
|
||||||
|
"items": {
|
||||||
|
"oneOf": [
|
||||||
|
{ "$ref": "#/$defs/key" },
|
||||||
|
{ "$ref": "#/$defs/cluster" },
|
||||||
|
{ "$ref": "#/$defs/component" },
|
||||||
|
{ "$ref": "#/$defs/variantGroup" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cluster": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["type", "id", "transform", "children"],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "cluster"
|
||||||
|
},
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"transform": { "$ref": "#/$defs/transform" },
|
||||||
|
"children": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"oneOf": [
|
||||||
|
{ "$ref": "#/$defs/key" },
|
||||||
|
{ "$ref": "#/$defs/cluster" },
|
||||||
|
{ "$ref": "#/$defs/component" },
|
||||||
|
{ "$ref": "#/$defs/variantGroup" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import Ajv2020 from "ajv/dist/2020";
|
||||||
|
import addFormats from "ajv-formats";
|
||||||
|
import phySchema from "./schemas/phy.schema.json";
|
||||||
|
import idxSchema from "./schemas/idx.schema.json";
|
||||||
|
import elcSchema from "./schemas/elc.schema.json";
|
||||||
|
import mapSchema from "./schemas/map.schema.json";
|
||||||
|
|
||||||
|
export type LayerName = "phy" | "idx" | "elc" | "map";
|
||||||
|
export type SchemaIssue = { path: string; message: string };
|
||||||
|
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||||
|
addFormats(ajv);
|
||||||
|
const validators = {
|
||||||
|
phy: ajv.compile(phySchema),
|
||||||
|
idx: ajv.compile(idxSchema),
|
||||||
|
elc: ajv.compile(elcSchema),
|
||||||
|
map: ajv.compile(mapSchema),
|
||||||
|
};
|
||||||
|
/** Validates one layer against its draft 2020-12 schema. */
|
||||||
|
export function validateSchema(layer: LayerName, value: unknown): SchemaIssue[] {
|
||||||
|
const valid = validators[layer](value);
|
||||||
|
return valid
|
||||||
|
? []
|
||||||
|
: (validators[layer].errors ?? []).map((e) => ({
|
||||||
|
path: `${e.instancePath || "/"}${e.params && "missingProperty" in e.params ? `/${e.params.missingProperty}` : ""}`,
|
||||||
|
message: e.message ?? "Schema validation failed",
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./ui/App.vue";
|
||||||
|
|
||||||
|
createApp(App).mount("#app");
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref, shallowRef } from "vue";
|
||||||
|
import { createProjectFromKle } from "../domain/project";
|
||||||
|
import type { ProjectFile } from "../domain/project";
|
||||||
|
import { ProjectHistory } from "../domain/history";
|
||||||
|
import { validateProject } from "../domain/validate";
|
||||||
|
import { saveProject, loadProject } from "../io/projectFile";
|
||||||
|
import { exportKle } from "../io/kleExport";
|
||||||
|
import { defaultLayout } from "./defaultLayout";
|
||||||
|
import type { Dispatch } from "./types";
|
||||||
|
import PhyEditor from "./phy/PhyEditor.vue";
|
||||||
|
import IdxEditor from "./idx/IdxEditor.vue";
|
||||||
|
import ElcPanel from "./panels/ElcPanel.vue";
|
||||||
|
import MapPanel from "./panels/MapPanel.vue";
|
||||||
|
import GeneratorPanel from "./panels/GeneratorPanel.vue";
|
||||||
|
const project = shallowRef<ProjectFile | null>(null),
|
||||||
|
activeTab = ref("PHY"),
|
||||||
|
errorText = ref("");
|
||||||
|
const importInput = ref<HTMLInputElement>(),
|
||||||
|
openInput = ref<HTMLInputElement>();
|
||||||
|
let history: ProjectHistory | null = null;
|
||||||
|
const epoch = ref(0);
|
||||||
|
const mismatch = computed(
|
||||||
|
() =>
|
||||||
|
!!project.value &&
|
||||||
|
new Set(project.value.phy.profiles.map((p) => `${p.unitSize.x},${p.unitSize.y}`)).size > 1,
|
||||||
|
);
|
||||||
|
const issues = computed(() => (project.value ? validateProject(project.value) : [])),
|
||||||
|
severity = ["blocksSave", "blocksOutput", "warning"] as const;
|
||||||
|
const canUndo = computed(() => {
|
||||||
|
epoch.value;
|
||||||
|
return !!history?.canUndo;
|
||||||
|
}),
|
||||||
|
canRedo = computed(() => {
|
||||||
|
epoch.value;
|
||||||
|
return !!history?.canRedo;
|
||||||
|
});
|
||||||
|
const dispatch: Dispatch = (label, update) => {
|
||||||
|
if (!project.value || mismatch.value) return;
|
||||||
|
const next = update(project.value);
|
||||||
|
project.value = next;
|
||||||
|
history?.push(next);
|
||||||
|
epoch.value++;
|
||||||
|
errorText.value = "";
|
||||||
|
};
|
||||||
|
function beginHistory() {
|
||||||
|
if (history) {
|
||||||
|
history.begin();
|
||||||
|
epoch.value++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function commitHistory() {
|
||||||
|
if (history && project.value) {
|
||||||
|
history.commit(project.value);
|
||||||
|
epoch.value++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function undo() {
|
||||||
|
if (history?.canUndo) {
|
||||||
|
project.value = history.undo();
|
||||||
|
epoch.value++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function redo() {
|
||||||
|
if (history?.canRedo) {
|
||||||
|
project.value = history.redo();
|
||||||
|
epoch.value++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function globalKey(e: KeyboardEvent) {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest("input,textarea,select,[contenteditable=true]")) return;
|
||||||
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
|
if (mod && e.key.toLowerCase() === "z") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.shiftKey ? redo() : undo();
|
||||||
|
} else if (mod && e.key.toLowerCase() === "y") {
|
||||||
|
e.preventDefault();
|
||||||
|
redo();
|
||||||
|
} else if (mod && e.key.toLowerCase() === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
void save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function initialize() {
|
||||||
|
const p = await createProjectFromKle(defaultLayout, { name: "New keyboard" });
|
||||||
|
project.value = p;
|
||||||
|
history = new ProjectHistory(p);
|
||||||
|
epoch.value++;
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
void initialize();
|
||||||
|
window.addEventListener("keydown", globalKey);
|
||||||
|
});
|
||||||
|
onUnmounted(() => window.removeEventListener("keydown", globalKey));
|
||||||
|
async function readFile(file: File, mode: "kle" | "project") {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(await file.text()) as unknown;
|
||||||
|
let p: ProjectFile;
|
||||||
|
if (mode === "kle") p = await createProjectFromKle(raw, { name: file.name });
|
||||||
|
else {
|
||||||
|
const r = await loadProject(JSON.stringify(raw));
|
||||||
|
if (!r.project) throw Error(r.errors.map((x) => x.message).join("; "));
|
||||||
|
p = r.project;
|
||||||
|
errorText.value = r.errors.map((x) => x.message).join("; ");
|
||||||
|
}
|
||||||
|
project.value = p;
|
||||||
|
history = new ProjectHistory(p);
|
||||||
|
epoch.value++;
|
||||||
|
} catch (e) {
|
||||||
|
errorText.value = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function download(name: string, text: string) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(new Blob([text], { type: "application/json" }));
|
||||||
|
a.download = name;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
if (!project.value) return;
|
||||||
|
const r = await saveProject(project.value, true);
|
||||||
|
if (r.json) download(`${project.value.phy.metadata.name || "project"}.bkle.json`, r.json);
|
||||||
|
else errorText.value = r.errors.map((x) => x.message).join("; ");
|
||||||
|
}
|
||||||
|
function exportSelected() {
|
||||||
|
if (project.value)
|
||||||
|
download(
|
||||||
|
"layout.kle.json",
|
||||||
|
JSON.stringify(exportKle(project.value.phy, project.value.output.variantSelection), null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<main v-if="project" class="app-shell" :class="{ locked: mismatch }">
|
||||||
|
<header class="toolbar">
|
||||||
|
<strong>Beyond KLE</strong><button @click="importInput?.click()">KLE JSONから新規</button
|
||||||
|
><input
|
||||||
|
ref="importInput"
|
||||||
|
hidden
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
@change="
|
||||||
|
(e) => {
|
||||||
|
const f = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (f) void readFile(f, 'kle');
|
||||||
|
(e.target as HTMLInputElement).value = '';
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/><button @click="openInput?.click()">プロジェクトを開く</button
|
||||||
|
><input
|
||||||
|
ref="openInput"
|
||||||
|
hidden
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
@change="
|
||||||
|
(e) => {
|
||||||
|
const f = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (f) void readFile(f, 'project');
|
||||||
|
(e.target as HTMLInputElement).value = '';
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/><button @click="save">プロジェクトを保存</button
|
||||||
|
><button @click="exportSelected">KLE JSONを出力</button
|
||||||
|
><button :disabled="!canUndo" @click="undo">Undo</button
|
||||||
|
><button :disabled="!canRedo" @click="redo">Redo</button
|
||||||
|
><span v-if="errorText" class="error">{{ errorText }}</span>
|
||||||
|
</header>
|
||||||
|
<nav class="tabs">
|
||||||
|
<button
|
||||||
|
v-for="t in ['PHY', 'IDX', 'ELC', 'MAP', 'Generator']"
|
||||||
|
:key="t"
|
||||||
|
:class="{ active: activeTab === t }"
|
||||||
|
@click="activeTab = t"
|
||||||
|
>
|
||||||
|
{{ t }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<PhyEditor
|
||||||
|
v-if="activeTab === 'PHY'"
|
||||||
|
:project="project"
|
||||||
|
:dispatch="dispatch"
|
||||||
|
:mismatch="mismatch"
|
||||||
|
:begin-history="beginHistory"
|
||||||
|
:commit-history="commitHistory"
|
||||||
|
@error="errorText = $event"
|
||||||
|
/>
|
||||||
|
<IdxEditor
|
||||||
|
v-else-if="activeTab === 'IDX'"
|
||||||
|
:project="project"
|
||||||
|
:dispatch="dispatch"
|
||||||
|
@error="errorText = $event"
|
||||||
|
/>
|
||||||
|
<section v-else class="other-panel">
|
||||||
|
<ElcPanel v-if="activeTab === 'ELC'" :project="project" :dispatch="dispatch" /><MapPanel
|
||||||
|
v-else-if="activeTab === 'MAP'"
|
||||||
|
:project="project"
|
||||||
|
:dispatch="dispatch"
|
||||||
|
:editable="!mismatch"
|
||||||
|
/><GeneratorPanel v-else :project="project" :dispatch="dispatch" />
|
||||||
|
</section>
|
||||||
|
<aside class="validation pane">
|
||||||
|
<h2>検証結果</h2>
|
||||||
|
<p v-if="issues.some((x) => x.severity === 'blocksSave')" class="warning" data-testid="save-blocked">
|
||||||
|
保存できない状態です。blocksSave の問題を解消してください。
|
||||||
|
</p>
|
||||||
|
<div class="issues">
|
||||||
|
<section v-for="s in severity" :key="s">
|
||||||
|
<h3>
|
||||||
|
{{ s }} <small>({{ issues.filter((x) => x.severity === s).length }})</small>
|
||||||
|
</h3>
|
||||||
|
<div v-for="(issue, i) in issues.filter((x) => x.severity === s)" :key="i" class="issue">
|
||||||
|
<b>{{ issue.code }}</b> {{ issue.message }} <small>{{ issue.path }}</small>
|
||||||
|
</div>
|
||||||
|
<p v-if="!issues.some((x) => x.severity === s)" class="muted">なし</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font:
|
||||||
|
14px system-ui,
|
||||||
|
sans-serif;
|
||||||
|
color: #20242b;
|
||||||
|
background: #edf0f4;
|
||||||
|
}
|
||||||
|
.app-shell {
|
||||||
|
height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 52px 42px minmax(300px, 1fr) 180px;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.toolbar,
|
||||||
|
.tabs,
|
||||||
|
.pane {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #cbd1da;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
.toolbar strong {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.tabs button.active {
|
||||||
|
background: #dce9ff;
|
||||||
|
}
|
||||||
|
.other-panel {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.validation {
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.validation h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.issues {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.issues section {
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
.issues h3 {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.issue {
|
||||||
|
margin: 3px 0;
|
||||||
|
background: #f4f6f9;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
.issue small {
|
||||||
|
display: block;
|
||||||
|
color: #667;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: #778;
|
||||||
|
}
|
||||||
|
.warning,
|
||||||
|
.error {
|
||||||
|
color: #9a4c00;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 選択構成の平坦な配置を読み取り専用で描く。IDX・ELC・MAP・Generatorのパネルで共有する。
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { flattenKeys } from "../../domain/geometry";
|
||||||
|
import type { FlatKey, PhyFile } from "../../domain/phy";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
phy: PhyFile;
|
||||||
|
selection: Record<string, string>;
|
||||||
|
/** キーの中央に出す文字列。省略時は先頭のラベル */
|
||||||
|
keyText?: (keyId: string) => string;
|
||||||
|
/** キーに付けるCSSクラス(状態の色分け) */
|
||||||
|
keyClass?: (keyId: string) => string | undefined;
|
||||||
|
selectedIds?: string[];
|
||||||
|
unit?: number;
|
||||||
|
}>(),
|
||||||
|
{ selectedIds: () => [], unit: 48 },
|
||||||
|
);
|
||||||
|
const emit = defineEmits<{ keyClick: [keyId: string, event: MouseEvent] }>();
|
||||||
|
|
||||||
|
const keys = computed<FlatKey[]>(() => flattenKeys(props.phy, props.selection));
|
||||||
|
|
||||||
|
// 回転を含めた四隅から表示範囲を求める
|
||||||
|
const viewBox = computed(() => {
|
||||||
|
let minX = Infinity,
|
||||||
|
minY = Infinity,
|
||||||
|
maxX = -Infinity,
|
||||||
|
maxY = -Infinity;
|
||||||
|
for (const k of keys.value) {
|
||||||
|
const r = (k.rotation * Math.PI) / 180,
|
||||||
|
c = Math.cos(r),
|
||||||
|
s = Math.sin(r);
|
||||||
|
const { w, h } = k.key.size;
|
||||||
|
for (const [lx, ly] of [
|
||||||
|
[0, 0],
|
||||||
|
[w, 0],
|
||||||
|
[0, h],
|
||||||
|
[w, h],
|
||||||
|
] as const) {
|
||||||
|
const x = k.x + lx * c - ly * s,
|
||||||
|
y = k.y + lx * s + ly * c;
|
||||||
|
minX = Math.min(minX, x);
|
||||||
|
minY = Math.min(minY, y);
|
||||||
|
maxX = Math.max(maxX, x);
|
||||||
|
maxY = Math.max(maxY, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isFinite(minX)) return "0 0 4 2";
|
||||||
|
const pad = 0.25;
|
||||||
|
return `${minX - pad} ${minY - pad} ${maxX - minX + pad * 2} ${maxY - minY + pad * 2}`;
|
||||||
|
});
|
||||||
|
const width = computed(() => {
|
||||||
|
const parts = viewBox.value.split(" ").map(Number);
|
||||||
|
return (parts[2] ?? 4) * props.unit;
|
||||||
|
});
|
||||||
|
|
||||||
|
function text(k: FlatKey): string {
|
||||||
|
return props.keyText ? props.keyText(k.key.id) : (k.key.labels?.find((l) => l) ?? "");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg class="keyboard-view" :viewBox="viewBox" :width="width" data-testid="keyboard-view">
|
||||||
|
<g
|
||||||
|
v-for="k in keys"
|
||||||
|
:key="k.key.id"
|
||||||
|
:transform="`translate(${k.x},${k.y}) rotate(${k.rotation})`"
|
||||||
|
:class="[
|
||||||
|
'kv-key',
|
||||||
|
keyClass?.(k.key.id),
|
||||||
|
{ selected: selectedIds.includes(k.key.id), decal: k.key.decal, ghost: k.key.ghost },
|
||||||
|
]"
|
||||||
|
:data-key-id="k.key.id"
|
||||||
|
@click="emit('keyClick', k.key.id, $event)"
|
||||||
|
>
|
||||||
|
<rect x="0.03" y="0.03" :width="k.key.size.w - 0.06" :height="k.key.size.h - 0.06" rx="0.08" />
|
||||||
|
<rect
|
||||||
|
v-if="k.key.size.w2 !== undefined || k.key.size.h2 !== undefined"
|
||||||
|
:x="(k.key.size.x2 ?? 0) + 0.03"
|
||||||
|
:y="(k.key.size.y2 ?? 0) + 0.03"
|
||||||
|
:width="(k.key.size.w2 ?? k.key.size.w) - 0.06"
|
||||||
|
:height="(k.key.size.h2 ?? k.key.size.h) - 0.06"
|
||||||
|
rx="0.08"
|
||||||
|
/>
|
||||||
|
<text :x="k.key.size.w / 2" :y="k.key.size.h / 2" text-anchor="middle" dominant-baseline="central">
|
||||||
|
{{ text(k) }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.keyboard-view {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.kv-key {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.kv-key rect {
|
||||||
|
fill: #f3f4f6;
|
||||||
|
stroke: #6b7280;
|
||||||
|
stroke-width: 0.02;
|
||||||
|
}
|
||||||
|
.kv-key text {
|
||||||
|
font-size: 0.22px;
|
||||||
|
fill: #111827;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.kv-key.selected rect {
|
||||||
|
stroke: #2563eb;
|
||||||
|
stroke-width: 0.06;
|
||||||
|
}
|
||||||
|
.kv-key.decal rect {
|
||||||
|
stroke-dasharray: 0.06 0.04;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
.kv-key.ghost {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.kv-key.warn rect {
|
||||||
|
fill: #fde68a;
|
||||||
|
}
|
||||||
|
.kv-key.error rect {
|
||||||
|
fill: #fecaca;
|
||||||
|
}
|
||||||
|
.kv-key.ok rect {
|
||||||
|
fill: #d1fae5;
|
||||||
|
}
|
||||||
|
.kv-key.muted rect {
|
||||||
|
fill: #e5e7eb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** Small 4x4 starter layout, kept in src/ui rather than the test fixtures. */
|
||||||
|
export const defaultLayout: unknown[] = Array.from({ length: 4 }, () => ["", "", "", ""]);
|
||||||
@@ -0,0 +1,657 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// IDX Editor。キャンバス上のドラッグとプリセットで order を並べ替える。T-31。
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||||
|
import type { ProjectFile } from "../../domain/project";
|
||||||
|
import { defaultVariantSelection } from "../../domain/project";
|
||||||
|
import { flattenKeys } from "../../domain/geometry";
|
||||||
|
import type { FlatKey, Node } from "../../domain/phy";
|
||||||
|
import { appendKeys, unregisteredKeys } from "../../domain/idx";
|
||||||
|
import { keyVariantGroups, renumber, type OrderContext } from "../../domain/idxOrder";
|
||||||
|
import { keyRect, matrixPositions, matrixSequence, scanKeys, type ScanResult } from "../../domain/idxScan";
|
||||||
|
import { matchPlacements, type Placement } from "../../domain/idxImport";
|
||||||
|
import { fitViewport, zoomAround } from "../phy/viewport";
|
||||||
|
import type { Dispatch } from "../types";
|
||||||
|
import IdxTree from "./IdxTree.vue";
|
||||||
|
import IdxPresets from "./IdxPresets.vue";
|
||||||
|
import IdxImport from "./IdxImport.vue";
|
||||||
|
import { applyDrop, keyCenter, outputNumbers, type DropAction } from "./logic";
|
||||||
|
import { defaultPresetSettings, lineColors, type PresetSettings } from "./presets";
|
||||||
|
import { useIdxPointer } from "./useIdxPointer";
|
||||||
|
|
||||||
|
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch }>();
|
||||||
|
const emit = defineEmits<{ error: [text: string] }>();
|
||||||
|
|
||||||
|
const variants = ref<Record<string, string>>({}),
|
||||||
|
selected = ref<string[]>([]),
|
||||||
|
zoom = ref(52),
|
||||||
|
pan = ref({ x: 50, y: 50 }),
|
||||||
|
size = ref({ w: 900, h: 600 }),
|
||||||
|
svg = ref<SVGSVGElement>(),
|
||||||
|
settings = ref<PresetSettings>(defaultPresetSettings()),
|
||||||
|
preview = ref<{ offsets: Map<number, number>; cross: number | null } | null>(null),
|
||||||
|
importMessage = ref("");
|
||||||
|
|
||||||
|
/** Displayed configuration: starts from output.variantSelection, never written back. T-31. */
|
||||||
|
function resetVariants() {
|
||||||
|
variants.value = {
|
||||||
|
...defaultVariantSelection(props.project.phy),
|
||||||
|
...props.project.output.variantSelection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
resetVariants();
|
||||||
|
watch(
|
||||||
|
() => props.project.projectId,
|
||||||
|
() => {
|
||||||
|
resetVariants();
|
||||||
|
selected.value = [];
|
||||||
|
preview.value = null;
|
||||||
|
importMessage.value = "";
|
||||||
|
fit();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => props.project.phy,
|
||||||
|
() => {
|
||||||
|
// groups made in PHY after opening get their default; removed keys leave the selection
|
||||||
|
variants.value = { ...defaultVariantSelection(props.project.phy), ...variants.value };
|
||||||
|
const shown = new Set(keys.value.map((k) => k.key.id));
|
||||||
|
selected.value = selected.value.filter((id) => shown.has(id));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const keys = computed<FlatKey[]>(() => {
|
||||||
|
try {
|
||||||
|
return flattenKeys(props.project.phy, variants.value).filter((k) => !k.key.decal);
|
||||||
|
} catch {
|
||||||
|
return flattenKeys(props.project.phy).filter((k) => !k.key.decal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const keyById = computed(() => new Map(keys.value.map((k) => [k.key.id, k])));
|
||||||
|
const visible = computed(() => new Set(keys.value.map((k) => k.key.id)));
|
||||||
|
const registered = computed(() => new Set(props.project.idx.order));
|
||||||
|
const allUnregistered = computed(() => new Set(unregisteredKeys(props.project.phy, props.project.idx)));
|
||||||
|
const ctx = computed<OrderContext>(() => ({
|
||||||
|
visible: visible.value,
|
||||||
|
groups: keyVariantGroups(props.project.phy),
|
||||||
|
}));
|
||||||
|
const positions = computed(() => matrixPositions(props.project));
|
||||||
|
|
||||||
|
// ---- preview of a preset ----
|
||||||
|
const targetKeys = computed(() =>
|
||||||
|
settings.value.target === "selection"
|
||||||
|
? keys.value.filter((k) => selected.value.includes(k.key.id))
|
||||||
|
: keys.value,
|
||||||
|
);
|
||||||
|
const scan = computed<ScanResult | null>(() => {
|
||||||
|
if (!preview.value) return null;
|
||||||
|
const target = targetKeys.value;
|
||||||
|
if (settings.value.kind === "matrix") {
|
||||||
|
const at = (id: string) => (registered.value.has(id) ? props.project.idx.order.indexOf(id) : Infinity);
|
||||||
|
const ids = target.map((k) => k.key.id).sort((a, b) => at(a) - at(b));
|
||||||
|
return { sequence: matrixSequence(ids, positions.value ?? new Map()), lines: [], crossLines: [] };
|
||||||
|
}
|
||||||
|
return scanKeys(target.map(keyRect), {
|
||||||
|
...settings.value,
|
||||||
|
kind: settings.value.kind,
|
||||||
|
offsets: preview.value.offsets,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const shownOrder = computed(() =>
|
||||||
|
scan.value ? renumber(props.project.idx.order, ctx.value, scan.value.sequence) : props.project.idx.order,
|
||||||
|
);
|
||||||
|
const numbers = computed(() => outputNumbers(shownOrder.value, visible.value));
|
||||||
|
const crossAxis = computed(() => (settings.value.kind === "columns" ? "row" : "column"));
|
||||||
|
const shiftHint = computed(() => {
|
||||||
|
if (!scan.value?.crossLines.length) return "";
|
||||||
|
if (preview.value?.cross == null)
|
||||||
|
return `キーをクリックして${crossAxis.value === "column" ? "列" : "行"}を選ぶと、${crossAxis.value === "column" ? "↑/↓で行" : "←/→で列"}の割り当てを1つずらせます。`;
|
||||||
|
return `選んだ${crossAxis.value === "column" ? "列" : "行"}: ${crossAxis.value === "column" ? "↑/↓" : "←/→"}でずらす(${preview.value.offsets.get(preview.value.cross) ?? 0})。Escで取り消し。`;
|
||||||
|
});
|
||||||
|
const previewCross = computed(() => {
|
||||||
|
const i = preview.value?.cross;
|
||||||
|
return new Set(i == null ? [] : (scan.value?.crossLines[i] ?? []));
|
||||||
|
});
|
||||||
|
watch(
|
||||||
|
settings,
|
||||||
|
() => {
|
||||||
|
if (preview.value) preview.value = { offsets: new Map(), cross: null };
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => [settings.value.target, selected.value.length] as const,
|
||||||
|
([target, count]) => {
|
||||||
|
if (target === "selection" && !count) settings.value = { ...settings.value, target: "all" };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
function startPreview() {
|
||||||
|
preview.value = { offsets: new Map(), cross: null };
|
||||||
|
}
|
||||||
|
function applyPreview() {
|
||||||
|
if (!scan.value) return;
|
||||||
|
const sequence = scan.value.sequence;
|
||||||
|
run("IDXプリセット適用", (p) => ({
|
||||||
|
...p,
|
||||||
|
idx: { ...p.idx, order: renumber(p.idx.order, ctx.value, sequence) },
|
||||||
|
}));
|
||||||
|
preview.value = null;
|
||||||
|
}
|
||||||
|
function shiftCross(delta: number) {
|
||||||
|
const p = preview.value;
|
||||||
|
if (!p || p.cross == null) return;
|
||||||
|
const offsets = new Map(p.offsets);
|
||||||
|
offsets.set(p.cross, (offsets.get(p.cross) ?? 0) + delta);
|
||||||
|
preview.value = { ...p, offsets };
|
||||||
|
}
|
||||||
|
function pickCross(id: string) {
|
||||||
|
const i = scan.value?.crossLines.findIndex((line) => line.includes(id)) ?? -1;
|
||||||
|
if (preview.value) preview.value = { ...preview.value, cross: i < 0 ? null : i };
|
||||||
|
}
|
||||||
|
function linePoints(ids: string[]): string {
|
||||||
|
return ids
|
||||||
|
.map((id) => keyById.value.get(id))
|
||||||
|
.filter((k): k is FlatKey => !!k)
|
||||||
|
.map((k) => {
|
||||||
|
const c = keyCenter(k);
|
||||||
|
return `${c.x},${c.y}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- editing ----
|
||||||
|
function run(label: string, update: (p: ProjectFile) => ProjectFile) {
|
||||||
|
try {
|
||||||
|
props.dispatch(label, update);
|
||||||
|
} catch (e) {
|
||||||
|
emit("error", String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function drop(source: string, action: DropAction) {
|
||||||
|
run(action.kind === "swap" ? "IDX入れ替え" : "IDX挿入", (p) => ({
|
||||||
|
...p,
|
||||||
|
idx: { ...p.idx, order: applyDrop(p.idx.order, ctx.value, source, action) },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function registerAll() {
|
||||||
|
run("IDX未登録キーを登録", (p) => ({ ...p, idx: appendKeys(p.idx, unregisteredKeys(p.phy, p.idx)) }));
|
||||||
|
}
|
||||||
|
function importPlacements(placements: Placement[], source: string) {
|
||||||
|
const { ids, unmatched } = matchPlacements(placements, keys.value);
|
||||||
|
if (ids.length)
|
||||||
|
run("IDX取り込み", (p) => ({ ...p, idx: { ...p.idx, order: renumber(p.idx.order, ctx.value, ids) } }));
|
||||||
|
const missing = keys.value.length - ids.length;
|
||||||
|
importMessage.value =
|
||||||
|
`${source}: ${ids.length}件を照合して並べ直しました。` +
|
||||||
|
(unmatched ? `定義側の${unmatched}件は表示中のキーと一致しませんでした。` : "") +
|
||||||
|
(missing ? `表示中の${missing}件は定義になく、今の順序のまま残しました。` : "");
|
||||||
|
}
|
||||||
|
function selectVariant(groupId: string, variantId: string) {
|
||||||
|
variants.value = { ...variants.value, [groupId]: variantId };
|
||||||
|
const shown = new Set(keys.value.map((k) => k.key.id));
|
||||||
|
selected.value = selected.value.filter((id) => shown.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { drag, band, spaceHeld, begin, move, end, cancel } = useIdxPointer({
|
||||||
|
svg,
|
||||||
|
zoom,
|
||||||
|
pan,
|
||||||
|
selected,
|
||||||
|
keys: () => keys.value,
|
||||||
|
registered: () => registered.value,
|
||||||
|
previewing: () => !!preview.value,
|
||||||
|
onDrop: drop,
|
||||||
|
onPreviewPick: pickCross,
|
||||||
|
});
|
||||||
|
const dropText = computed(() => {
|
||||||
|
const action = drag.value?.action;
|
||||||
|
if (!drag.value) return "";
|
||||||
|
if (!action) return "ここには置けません";
|
||||||
|
const n = numbers.value.get(action.target)!;
|
||||||
|
if (action.kind === "swap") return `${n} と入れ替え`;
|
||||||
|
return action.after
|
||||||
|
? `${n} と ${n + 1} の間に挿入`
|
||||||
|
: n === 0
|
||||||
|
? "先頭に挿入"
|
||||||
|
: `${n - 1} と ${n} の間に挿入`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- selection info ----
|
||||||
|
const info = computed(() => {
|
||||||
|
const id = selected.value.at(-1);
|
||||||
|
const k = id ? keyById.value.get(id) : undefined;
|
||||||
|
if (!k || !id) return null;
|
||||||
|
const names = new Map<string, string>();
|
||||||
|
const walk = (nodes: Node[]) =>
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
if (n.type === "cluster") walk(n.children);
|
||||||
|
if (n.type === "variantGroup")
|
||||||
|
n.children.forEach((v) => {
|
||||||
|
names.set(v.id, `${n.name} / ${v.name}`);
|
||||||
|
walk(v.children);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
walk(props.project.phy.root);
|
||||||
|
const path = k.path.flatMap((p) => (names.has(p) ? [names.get(p)!] : []));
|
||||||
|
const saved = props.project.idx.order.indexOf(id);
|
||||||
|
return {
|
||||||
|
label: k.key.labels?.find(Boolean) || "(ラベルなし)",
|
||||||
|
id,
|
||||||
|
saved,
|
||||||
|
number: numbers.value.get(id),
|
||||||
|
path: path.join(" › ") || "共通キー",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- viewport ----
|
||||||
|
function fit() {
|
||||||
|
const rects = keys.value.map(keyRect);
|
||||||
|
if (!rects.length) return;
|
||||||
|
const x = Math.min(...rects.map((r) => r.x)),
|
||||||
|
y = Math.min(...rects.map((r) => r.y));
|
||||||
|
const bounds = {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: Math.max(...rects.map((r) => r.x + r.w)) - x,
|
||||||
|
height: Math.max(...rects.map((r) => r.y + r.h)) - y,
|
||||||
|
};
|
||||||
|
const v = fitViewport(bounds, size.value.w, size.value.h);
|
||||||
|
zoom.value = v.zoom;
|
||||||
|
pan.value = v.pan;
|
||||||
|
}
|
||||||
|
function wheel(event: WheelEvent) {
|
||||||
|
const el = svg.value;
|
||||||
|
if (!el) return;
|
||||||
|
const local = new DOMPoint(event.clientX, event.clientY).matrixTransform(el.getScreenCTM()!.inverse());
|
||||||
|
const v = zoomAround(local, { zoom: zoom.value, pan: pan.value }, Math.exp(-event.deltaY * 0.001));
|
||||||
|
zoom.value = v.zoom;
|
||||||
|
pan.value = v.pan;
|
||||||
|
}
|
||||||
|
const bounds = computed(() => {
|
||||||
|
const rects = keys.value.map(keyRect);
|
||||||
|
return rects.length
|
||||||
|
? {
|
||||||
|
top: Math.min(...rects.map((r) => r.y)) - 0.4,
|
||||||
|
bottom: Math.max(...rects.map((r) => r.y + r.h)) + 0.4,
|
||||||
|
}
|
||||||
|
: { top: 0, bottom: 1 };
|
||||||
|
});
|
||||||
|
|
||||||
|
function keydown(e: KeyboardEvent) {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target?.closest("input,textarea,select,[contenteditable=true]")) return;
|
||||||
|
if (e.code === "Space") spaceHeld.value = true;
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
if (drag.value) cancel();
|
||||||
|
else if (preview.value) preview.value = null;
|
||||||
|
else selected.value = [];
|
||||||
|
} else if (preview.value?.cross != null && e.key.startsWith("Arrow")) {
|
||||||
|
const vertical = crossAxis.value === "column";
|
||||||
|
const delta = {
|
||||||
|
ArrowUp: vertical ? -1 : 0,
|
||||||
|
ArrowDown: vertical ? 1 : 0,
|
||||||
|
ArrowLeft: vertical ? 0 : -1,
|
||||||
|
ArrowRight: vertical ? 0 : 1,
|
||||||
|
}[e.key];
|
||||||
|
if (delta) {
|
||||||
|
e.preventDefault();
|
||||||
|
shiftCross(delta);
|
||||||
|
}
|
||||||
|
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
|
||||||
|
e.preventDefault();
|
||||||
|
selected.value = keys.value.map((k) => k.key.id);
|
||||||
|
} else if (e.key === "Enter" && preview.value) applyPreview();
|
||||||
|
}
|
||||||
|
function keyup(e: KeyboardEvent) {
|
||||||
|
if (e.code === "Space") spaceHeld.value = false;
|
||||||
|
}
|
||||||
|
let observer: ResizeObserver | null = null;
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("keydown", keydown);
|
||||||
|
window.addEventListener("keyup", keyup);
|
||||||
|
if (svg.value) {
|
||||||
|
observer = new ResizeObserver((entries) => {
|
||||||
|
const rect = entries[0]!.contentRect;
|
||||||
|
size.value = { w: rect.width, h: rect.height };
|
||||||
|
fit();
|
||||||
|
});
|
||||||
|
observer.observe(svg.value);
|
||||||
|
}
|
||||||
|
fit();
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("keydown", keydown);
|
||||||
|
window.removeEventListener("keyup", keyup);
|
||||||
|
observer?.disconnect();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="workspace">
|
||||||
|
<aside class="tree pane">
|
||||||
|
<h2>構成</h2>
|
||||||
|
<IdxTree
|
||||||
|
:nodes="project.phy.root"
|
||||||
|
:variants="variants"
|
||||||
|
:unregistered="allUnregistered"
|
||||||
|
@select="selectVariant"
|
||||||
|
/>
|
||||||
|
<p v-if="!Object.keys(variants).length" class="hint">バリアントグループはありません。</p>
|
||||||
|
</aside>
|
||||||
|
<section class="canvas-pane pane">
|
||||||
|
<div class="canvas-toolbar">
|
||||||
|
<button @click="fit">全体表示</button>
|
||||||
|
<span class="status" data-testid="idx-status">{{
|
||||||
|
drag
|
||||||
|
? dropText
|
||||||
|
: preview
|
||||||
|
? "プレビュー中(適用するまで保存されません)"
|
||||||
|
: "キーをドラッグして並べ替え。左右の端で挿入、中央で入れ替え"
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
ref="svg"
|
||||||
|
class="canvas"
|
||||||
|
:class="{ previewing: !!preview, panning: spaceHeld }"
|
||||||
|
data-testid="idx-canvas"
|
||||||
|
:viewBox="`0 0 ${size.w} ${size.h}`"
|
||||||
|
@wheel.prevent="wheel"
|
||||||
|
@pointerdown="begin($event)"
|
||||||
|
@pointermove="move"
|
||||||
|
@pointerup="end"
|
||||||
|
@pointercancel="cancel"
|
||||||
|
>
|
||||||
|
<g :transform="`translate(${pan.x} ${pan.y}) scale(${zoom})`">
|
||||||
|
<g
|
||||||
|
v-for="k in keys"
|
||||||
|
:key="k.key.id"
|
||||||
|
class="key"
|
||||||
|
:data-key-id="k.key.id"
|
||||||
|
:transform="`translate(${k.x} ${k.y}) rotate(${k.rotation})`"
|
||||||
|
:class="{
|
||||||
|
chosen: selected.includes(k.key.id),
|
||||||
|
unregistered: !registered.has(k.key.id),
|
||||||
|
ghost: k.key.ghost,
|
||||||
|
dragging: drag?.id === k.key.id,
|
||||||
|
cross: previewCross.has(k.key.id),
|
||||||
|
}"
|
||||||
|
@pointerdown.stop="begin($event, k.key.id)"
|
||||||
|
>
|
||||||
|
<rect :width="k.key.size.w" :height="k.key.size.h" rx=".06" class="face" />
|
||||||
|
<text class="label" x=".08" y=".22">{{ k.key.labels?.find(Boolean) }}</text>
|
||||||
|
<!-- 番号は回転したキーでも正立させる -->
|
||||||
|
<text
|
||||||
|
class="number"
|
||||||
|
data-testid="idx-number"
|
||||||
|
:x="k.key.size.w / 2"
|
||||||
|
:y="k.key.size.h / 2 + 0.12"
|
||||||
|
:transform="`rotate(${-k.rotation} ${k.key.size.w / 2} ${k.key.size.h / 2})`"
|
||||||
|
>
|
||||||
|
{{ numbers.has(k.key.id) ? numbers.get(k.key.id) : "未登録" }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
<template v-if="scan">
|
||||||
|
<polyline
|
||||||
|
v-for="(line, i) in scan.lines"
|
||||||
|
:key="i"
|
||||||
|
class="scan-line"
|
||||||
|
:points="linePoints(line)"
|
||||||
|
:stroke="lineColors[i % lineColors.length]"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
v-if="scan.boundary !== undefined"
|
||||||
|
class="boundary"
|
||||||
|
data-testid="idx-boundary"
|
||||||
|
:x1="scan.boundary"
|
||||||
|
:x2="scan.boundary"
|
||||||
|
:y1="bounds.top"
|
||||||
|
:y2="bounds.bottom"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<g
|
||||||
|
v-if="drag?.action && keyById.get(drag.action.target)"
|
||||||
|
:transform="`translate(${keyById.get(drag.action.target)!.x} ${keyById.get(drag.action.target)!.y}) rotate(${keyById.get(drag.action.target)!.rotation})`"
|
||||||
|
class="drop-mark"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
v-if="drag.action.kind === 'swap'"
|
||||||
|
class="swap-mark"
|
||||||
|
:width="keyById.get(drag.action.target)!.key.size.w"
|
||||||
|
:height="keyById.get(drag.action.target)!.key.size.h"
|
||||||
|
rx=".06"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
v-else
|
||||||
|
class="insert-mark"
|
||||||
|
:x1="drag.action.after ? keyById.get(drag.action.target)!.key.size.w : 0"
|
||||||
|
:x2="drag.action.after ? keyById.get(drag.action.target)!.key.size.w : 0"
|
||||||
|
y1="-0.1"
|
||||||
|
:y2="keyById.get(drag.action.target)!.key.size.h + 0.1"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g v-if="drag" class="drag-ghost" :transform="`translate(${drag.point.x} ${drag.point.y})`">
|
||||||
|
<circle r=".28" />
|
||||||
|
<text y=".1">{{ numbers.get(drag.id) ?? "+" }}</text>
|
||||||
|
</g>
|
||||||
|
<rect
|
||||||
|
v-if="band"
|
||||||
|
class="range-box"
|
||||||
|
:x="Math.min(band.start.x, band.end.x)"
|
||||||
|
:y="Math.min(band.start.y, band.end.y)"
|
||||||
|
:width="Math.abs(band.end.x - band.start.x)"
|
||||||
|
:height="Math.abs(band.end.y - band.start.y)"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</section>
|
||||||
|
<aside class="side pane">
|
||||||
|
<IdxPresets
|
||||||
|
:settings="settings"
|
||||||
|
:selected-count="selected.length"
|
||||||
|
:matrix-available="!!positions"
|
||||||
|
:previewing="!!preview"
|
||||||
|
:shift-hint="shiftHint"
|
||||||
|
@update="settings = $event"
|
||||||
|
@preview="startPreview"
|
||||||
|
@apply="applyPreview"
|
||||||
|
@cancel="preview = null"
|
||||||
|
/>
|
||||||
|
<IdxImport :message="importMessage" @apply="importPlacements" @error="emit('error', $event)" />
|
||||||
|
<section class="register">
|
||||||
|
<h3>未登録のキー</h3>
|
||||||
|
<p class="hint">
|
||||||
|
PHYに足したキーは、ここで末尾に登録するか、キャンバスでドラッグして位置を決めます。
|
||||||
|
</p>
|
||||||
|
<button data-testid="idx-register-all" :disabled="!allUnregistered.size" @click="registerAll">
|
||||||
|
未登録をすべて末尾に登録({{ allUnregistered.size }})
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
<section v-if="info" class="info" data-testid="idx-info">
|
||||||
|
<h3>選択中のキー</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>ラベル</dt>
|
||||||
|
<dd>{{ info.label }}</dd>
|
||||||
|
<dt>出力番号</dt>
|
||||||
|
<dd>{{ info.number ?? "未登録" }}</dd>
|
||||||
|
<dt>保存順</dt>
|
||||||
|
<dd>{{ info.saved < 0 ? "未登録" : info.saved }}</dd>
|
||||||
|
<dt>所属</dt>
|
||||||
|
<dd>{{ info.path }}</dd>
|
||||||
|
<dt>ID</dt>
|
||||||
|
<dd class="id">{{ info.id }}</dd>
|
||||||
|
</dl>
|
||||||
|
<p v-if="selected.length > 1" class="hint">ほか {{ selected.length - 1 }} 件を選択中</p>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.workspace {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px minmax(300px, 1fr) 270px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pane {
|
||||||
|
padding: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #cbd1da;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 15px;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #667;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
.canvas-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.canvas-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #445;
|
||||||
|
}
|
||||||
|
.canvas {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
background: #fbfcfe;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.canvas.panning {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.key {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.previewing .key {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.face {
|
||||||
|
fill: #f4f6f9;
|
||||||
|
stroke: #8d97a5;
|
||||||
|
stroke-width: 0.03;
|
||||||
|
}
|
||||||
|
.chosen .face {
|
||||||
|
fill: #dce9ff;
|
||||||
|
stroke: #1769d2;
|
||||||
|
stroke-width: 0.06;
|
||||||
|
}
|
||||||
|
.unregistered .face {
|
||||||
|
fill: #fff1d6;
|
||||||
|
stroke: #c07a12;
|
||||||
|
stroke-dasharray: 0.08 0.06;
|
||||||
|
}
|
||||||
|
.ghost .face {
|
||||||
|
fill-opacity: 0.5;
|
||||||
|
}
|
||||||
|
.dragging {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.cross .face {
|
||||||
|
stroke: #9b30d9;
|
||||||
|
stroke-width: 0.08;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 0.16px;
|
||||||
|
fill: #667;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.number {
|
||||||
|
font-size: 0.34px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-anchor: middle;
|
||||||
|
fill: #20242b;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.unregistered .number {
|
||||||
|
font-size: 0.18px;
|
||||||
|
fill: #9a4c00;
|
||||||
|
}
|
||||||
|
.scan-line {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 3px;
|
||||||
|
stroke-opacity: 0.7;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.boundary {
|
||||||
|
stroke: #9a4c00;
|
||||||
|
stroke-width: 2px;
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.drop-mark {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.insert-mark {
|
||||||
|
stroke: #d2142b;
|
||||||
|
stroke-width: 4px;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.swap-mark {
|
||||||
|
fill: #d2142b22;
|
||||||
|
stroke: #d2142b;
|
||||||
|
stroke-width: 3px;
|
||||||
|
stroke-dasharray: 6 3;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.drag-ghost {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.drag-ghost circle {
|
||||||
|
fill: #1769d2;
|
||||||
|
}
|
||||||
|
.drag-ghost text {
|
||||||
|
font-size: 0.26px;
|
||||||
|
fill: #fff;
|
||||||
|
text-anchor: middle;
|
||||||
|
}
|
||||||
|
.range-box {
|
||||||
|
fill: #4b8de822;
|
||||||
|
stroke: #1769d2;
|
||||||
|
stroke-width: 1px;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.register {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
.info dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 3px 10px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.info dt {
|
||||||
|
color: #667;
|
||||||
|
}
|
||||||
|
.info dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.info .id {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 既存の定義(QMK keyboard.json / VIA定義 / KLE JSON)からの取り込み。照合は親が表示中のキーで行う。T-31。
|
||||||
|
import { ref } from "vue";
|
||||||
|
import type { Placement } from "../../domain/idxImport";
|
||||||
|
import { readLayoutPlacements } from "../../io/layoutImport";
|
||||||
|
defineProps<{ message: string }>();
|
||||||
|
const emit = defineEmits<{ apply: [placements: Placement[], source: string]; error: [text: string] }>();
|
||||||
|
const input = ref<HTMLInputElement>();
|
||||||
|
const pending = ref<{ name: string; data: unknown; layouts: string[] } | null>(null);
|
||||||
|
const layout = ref("");
|
||||||
|
async function read(file: File) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await file.text()) as unknown;
|
||||||
|
const result = readLayoutPlacements(data);
|
||||||
|
if (result.layouts && !result.layout) {
|
||||||
|
pending.value = { name: file.name, data, layouts: result.layouts };
|
||||||
|
layout.value = result.layouts[0]!;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending.value = null;
|
||||||
|
emit("apply", result.placements, result.layout ? `${file.name} / ${result.layout}` : file.name);
|
||||||
|
} catch (e) {
|
||||||
|
emit("error", String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function applyLayout() {
|
||||||
|
if (!pending.value) return;
|
||||||
|
const { placements } = readLayoutPlacements(pending.value.data, layout.value);
|
||||||
|
emit("apply", placements, `${pending.value.name} / ${layout.value}`);
|
||||||
|
pending.value = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="import">
|
||||||
|
<h3>既存の定義から取り込む</h3>
|
||||||
|
<p class="hint">
|
||||||
|
QMK keyboard.json・VIA定義・KLE JSON のキー順を、位置が一致する表示中のキーに写します。
|
||||||
|
</p>
|
||||||
|
<button @click="input?.click()">ファイルを選ぶ…</button>
|
||||||
|
<input
|
||||||
|
ref="input"
|
||||||
|
hidden
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
data-testid="idx-import-file"
|
||||||
|
@change="
|
||||||
|
(e) => {
|
||||||
|
const f = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (f) void read(f);
|
||||||
|
(e.target as HTMLInputElement).value = '';
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div v-if="pending" class="layouts">
|
||||||
|
<label
|
||||||
|
>レイアウト
|
||||||
|
<select v-model="layout" aria-label="取り込むレイアウト">
|
||||||
|
<option v-for="name in pending.layouts" :key="name" :value="name">{{ name }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button @click="applyLayout">取り込む</button>
|
||||||
|
<button @click="pending = null">やめる</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="message" class="result" data-testid="idx-import-result">{{ message }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.import {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
.hint,
|
||||||
|
.result {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #667;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
.result {
|
||||||
|
color: #20242b;
|
||||||
|
}
|
||||||
|
.layouts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// プリセット(対象・走査・向き)の指定と、プレビュー・適用のボタン。T-31。
|
||||||
|
import type { PresetSettings } from "./presets";
|
||||||
|
const props = defineProps<{
|
||||||
|
settings: PresetSettings;
|
||||||
|
selectedCount: number;
|
||||||
|
matrixAvailable: boolean;
|
||||||
|
previewing: boolean;
|
||||||
|
shiftHint: string;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
update: [settings: PresetSettings];
|
||||||
|
preview: [];
|
||||||
|
apply: [];
|
||||||
|
cancel: [];
|
||||||
|
}>();
|
||||||
|
const scans = [
|
||||||
|
["rows", "行優先"],
|
||||||
|
["columns", "列優先"],
|
||||||
|
["halves", "半身ごと(左手側が先)"],
|
||||||
|
["alternating", "行ごとに左右交互"],
|
||||||
|
["matrix", "マトリクス順(row → col)"],
|
||||||
|
] as const;
|
||||||
|
function set<K extends keyof PresetSettings>(key: K, value: PresetSettings[K]) {
|
||||||
|
emit("update", { ...props.settings, [key]: value });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<fieldset class="presets">
|
||||||
|
<legend>一括の並べ替え</legend>
|
||||||
|
<div class="row" role="radiogroup" aria-label="対象">
|
||||||
|
<label
|
||||||
|
><input
|
||||||
|
type="radio"
|
||||||
|
name="idx-target"
|
||||||
|
:checked="settings.target === 'all'"
|
||||||
|
@change="set('target', 'all')"
|
||||||
|
/>全体</label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
><input
|
||||||
|
type="radio"
|
||||||
|
name="idx-target"
|
||||||
|
:checked="settings.target === 'selection'"
|
||||||
|
:disabled="!selectedCount"
|
||||||
|
@change="set('target', 'selection')"
|
||||||
|
/>選択範囲({{ selectedCount }})</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label class="field"
|
||||||
|
>走査
|
||||||
|
<select
|
||||||
|
aria-label="走査"
|
||||||
|
:value="settings.kind"
|
||||||
|
@change="set('kind', ($event.target as HTMLSelectElement).value as PresetSettings['kind'])"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="[value, label] in scans"
|
||||||
|
:key="value"
|
||||||
|
:value="value"
|
||||||
|
:disabled="value === 'matrix' && !matrixAvailable"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p v-if="!matrixAvailable" class="hint">マトリクス順には、出力設定の rowPins / colPins が必要です。</p>
|
||||||
|
<label v-if="settings.kind === 'rows' || settings.kind === 'columns'" class="field"
|
||||||
|
>向き
|
||||||
|
<select
|
||||||
|
aria-label="向き"
|
||||||
|
:value="settings.direction"
|
||||||
|
@change="set('direction', ($event.target as HTMLSelectElement).value as PresetSettings['direction'])"
|
||||||
|
>
|
||||||
|
<option value="normal">通常</option>
|
||||||
|
<option value="reversed">左右逆</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<template v-else-if="settings.kind !== 'matrix'">
|
||||||
|
<label class="field"
|
||||||
|
>左手側
|
||||||
|
<select
|
||||||
|
aria-label="左手側の向き"
|
||||||
|
:value="settings.left"
|
||||||
|
@change="set('left', ($event.target as HTMLSelectElement).value as PresetSettings['left'])"
|
||||||
|
>
|
||||||
|
<option value="normal">通常</option>
|
||||||
|
<option value="reversed">左右逆</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field"
|
||||||
|
>右手側
|
||||||
|
<select
|
||||||
|
aria-label="右手側の向き"
|
||||||
|
:value="settings.right"
|
||||||
|
@change="set('right', ($event.target as HTMLSelectElement).value as PresetSettings['right'])"
|
||||||
|
>
|
||||||
|
<option value="normal">通常</option>
|
||||||
|
<option value="reversed">左右逆</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
<div class="row">
|
||||||
|
<button v-if="!previewing" @click="emit('preview')">プレビュー</button>
|
||||||
|
<template v-else>
|
||||||
|
<button class="primary" @click="emit('apply')">適用</button>
|
||||||
|
<button @click="emit('cancel')">取り消し</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p v-if="previewing && shiftHint" class="hint">{{ shiftHint }}</p>
|
||||||
|
</fieldset>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.presets {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
legend {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
.field select {
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #667;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
background: #dce9ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// VariantGroupとバリアントのツリー。クラスターは出さず、その中のグループだけを拾う。T-31。
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { Node, VariantGroupNode } from "../../domain/phy";
|
||||||
|
defineOptions({ name: "IdxTree" });
|
||||||
|
const props = defineProps<{
|
||||||
|
nodes: Node[];
|
||||||
|
variants: Record<string, string>;
|
||||||
|
unregistered: ReadonlySet<string>;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ select: [groupId: string, variantId: string] }>();
|
||||||
|
function groupsIn(nodes: Node[]): VariantGroupNode[] {
|
||||||
|
return nodes.flatMap((n) =>
|
||||||
|
n.type === "cluster" ? groupsIn(n.children) : n.type === "variantGroup" ? [n] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function keysIn(nodes: Node[]): string[] {
|
||||||
|
return nodes.flatMap((n) =>
|
||||||
|
n.type === "key"
|
||||||
|
? n.decal
|
||||||
|
? []
|
||||||
|
: [n.id]
|
||||||
|
: n.type === "cluster"
|
||||||
|
? keysIn(n.children)
|
||||||
|
: n.type === "variantGroup"
|
||||||
|
? n.children.flatMap((v) => keysIn(v.children))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const groups = computed(() => groupsIn(props.nodes));
|
||||||
|
const chosen = (g: VariantGroupNode) => props.variants[g.id] ?? g.default;
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<ul v-if="groups.length" class="tree-list">
|
||||||
|
<li v-for="g in groups" :key="g.id">
|
||||||
|
<div class="tree-group" :data-tree-id="g.id">{{ g.name }}</div>
|
||||||
|
<ul class="tree-list">
|
||||||
|
<li v-for="v in g.children" :key="v.id">
|
||||||
|
<button
|
||||||
|
class="tree-node"
|
||||||
|
:data-tree-id="v.id"
|
||||||
|
:class="{ chosen: chosen(g) === v.id }"
|
||||||
|
:aria-pressed="chosen(g) === v.id"
|
||||||
|
@click="emit('select', g.id, v.id)"
|
||||||
|
>
|
||||||
|
{{ v.name }}
|
||||||
|
<small
|
||||||
|
>{{ g.default === v.id ? "既定 · " : "" }}{{ keysIn(v.children).length }}件<template
|
||||||
|
v-if="keysIn(v.children).some((id) => unregistered.has(id))"
|
||||||
|
>
|
||||||
|
· 未登録 {{ keysIn(v.children).filter((id) => unregistered.has(id)).length }}</template
|
||||||
|
></small
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<IdxTree
|
||||||
|
:nodes="v.children"
|
||||||
|
:variants="variants"
|
||||||
|
:unregistered="unregistered"
|
||||||
|
@select="(group, variant) => emit('select', group, variant)"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.tree-list {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 12px;
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
.tree-group {
|
||||||
|
padding: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tree-node {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tree-node small {
|
||||||
|
color: #667;
|
||||||
|
}
|
||||||
|
.chosen {
|
||||||
|
background: #dce9ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { FlatKey } from "../../domain/phy";
|
||||||
|
import { insertNextTo, swapKeys, type OrderContext } from "../../domain/idxOrder";
|
||||||
|
|
||||||
|
type Point = { x: number; y: number };
|
||||||
|
export type DropAction =
|
||||||
|
{ kind: "insert"; target: string; after: boolean } | { kind: "swap"; target: string };
|
||||||
|
|
||||||
|
/** World point in the key's own frame (origin at its top-left, x along its width). */
|
||||||
|
export function toKeyLocal(k: FlatKey, p: Point): Point {
|
||||||
|
const a = (-k.rotation * Math.PI) / 180,
|
||||||
|
dx = p.x - k.x,
|
||||||
|
dy = p.y - k.y;
|
||||||
|
return { x: dx * Math.cos(a) - dy * Math.sin(a), y: dx * Math.sin(a) + dy * Math.cos(a) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** World center of a key. */
|
||||||
|
export function keyCenter(k: FlatKey): Point {
|
||||||
|
const a = (k.rotation * Math.PI) / 180,
|
||||||
|
hw = k.key.size.w / 2,
|
||||||
|
hh = k.key.size.h / 2;
|
||||||
|
return { x: k.x + hw * Math.cos(a) - hh * Math.sin(a), y: k.y + hw * Math.sin(a) + hh * Math.cos(a) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Topmost key under a world point, with the point's position across the key width (0..1). */
|
||||||
|
export function hitKey(keys: readonly FlatKey[], p: Point): { id: string; fraction: number } | undefined {
|
||||||
|
for (let i = keys.length - 1; i >= 0; i--) {
|
||||||
|
const k = keys[i]!,
|
||||||
|
local = toKeyLocal(k, p);
|
||||||
|
if (local.x >= 0 && local.y >= 0 && local.x <= k.key.size.w && local.y <= k.key.size.h)
|
||||||
|
return { id: k.key.id, fraction: local.x / k.key.size.w };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop result of `source` at a hit: the outer quarters insert before or after the key, the middle
|
||||||
|
* swaps. Unregistered keys cannot swap, so their drop splits the key in halves. Unregistered targets
|
||||||
|
* and the source itself take no drop. T-31.
|
||||||
|
*/
|
||||||
|
export function dropAction(
|
||||||
|
source: string,
|
||||||
|
hit: { id: string; fraction: number } | undefined,
|
||||||
|
registered: ReadonlySet<string>,
|
||||||
|
): DropAction | undefined {
|
||||||
|
if (!hit || hit.id === source || !registered.has(hit.id)) return undefined;
|
||||||
|
if (!registered.has(source)) return { kind: "insert", target: hit.id, after: hit.fraction >= 0.5 };
|
||||||
|
if (hit.fraction < 0.25) return { kind: "insert", target: hit.id, after: false };
|
||||||
|
if (hit.fraction > 0.75) return { kind: "insert", target: hit.id, after: true };
|
||||||
|
return { kind: "swap", target: hit.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** New order after dropping `source`. */
|
||||||
|
export function applyDrop(
|
||||||
|
order: readonly string[],
|
||||||
|
ctx: OrderContext,
|
||||||
|
source: string,
|
||||||
|
action: DropAction,
|
||||||
|
): string[] {
|
||||||
|
return action.kind === "swap"
|
||||||
|
? swapKeys(order, source, action.target)
|
||||||
|
: insertNextTo(order, ctx, source, action.target, action.after);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output number of each shown key: its position after dropping keys outside the configuration. */
|
||||||
|
export function outputNumbers(order: readonly string[], visible: ReadonlySet<string>): Map<string, number> {
|
||||||
|
return new Map(order.filter((id) => visible.has(id)).map((id, i) => [id, i]));
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Direction, ScanKind } from "../../domain/idxScan";
|
||||||
|
|
||||||
|
/** Preset choices in the side pane. `matrix` sorts by ELC row → col instead of scanning. T-31. */
|
||||||
|
export type PresetSettings = {
|
||||||
|
target: "all" | "selection";
|
||||||
|
kind: ScanKind | "matrix";
|
||||||
|
direction: Direction;
|
||||||
|
left: Direction;
|
||||||
|
right: Direction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultPresetSettings = (): PresetSettings => ({
|
||||||
|
target: "all",
|
||||||
|
kind: "rows",
|
||||||
|
direction: "normal",
|
||||||
|
left: "normal",
|
||||||
|
right: "normal",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Line colors of the preview, cycled by line index. */
|
||||||
|
export const lineColors = ["#1769d2", "#d2691e", "#2e8b57", "#9b30d9", "#c0392b", "#00838f"];
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { ref, type Ref } from "vue";
|
||||||
|
import type { FlatKey } from "../../domain/phy";
|
||||||
|
import { dropAction, hitKey, keyCenter, type DropAction } from "./logic";
|
||||||
|
|
||||||
|
type Point = { x: number; y: number };
|
||||||
|
type Options = {
|
||||||
|
svg: Ref<SVGSVGElement | undefined>;
|
||||||
|
zoom: Ref<number>;
|
||||||
|
pan: Ref<Point>;
|
||||||
|
selected: Ref<string[]>;
|
||||||
|
keys: () => FlatKey[];
|
||||||
|
registered: () => ReadonlySet<string>;
|
||||||
|
previewing: () => boolean;
|
||||||
|
onDrop: (source: string, action: DropAction) => void;
|
||||||
|
onPreviewPick: (id: string) => void;
|
||||||
|
};
|
||||||
|
/** Pointer travel (px) before a press on a key becomes a drag. */
|
||||||
|
const DRAG_START = 4;
|
||||||
|
|
||||||
|
/** Canvas pointer handling: click/Shift+click selection, dragging a key to reorder, range selection, pan. T-31. */
|
||||||
|
export function useIdxPointer(o: Options) {
|
||||||
|
const drag = ref<{ id: string; point: Point; action?: DropAction } | null>(null);
|
||||||
|
const band = ref<{ start: Point; end: Point } | null>(null);
|
||||||
|
const spaceHeld = ref(false);
|
||||||
|
let press: { id?: string; client: Point; add: boolean; moved: boolean; pan?: Point } | null = null;
|
||||||
|
|
||||||
|
function world(event: PointerEvent): Point {
|
||||||
|
const svg = o.svg.value!;
|
||||||
|
const local = new DOMPoint(event.clientX, event.clientY).matrixTransform(svg.getScreenCTM()!.inverse());
|
||||||
|
return { x: (local.x - o.pan.value.x) / o.zoom.value, y: (local.y - o.pan.value.y) / o.zoom.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function begin(event: PointerEvent, id?: string) {
|
||||||
|
const add = event.shiftKey || event.ctrlKey || event.metaKey;
|
||||||
|
const panning = event.button === 1 || spaceHeld.value;
|
||||||
|
if (event.button !== 0 && !panning) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (!panning && id && o.previewing()) {
|
||||||
|
o.onPreviewPick(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
o.svg.value?.setPointerCapture?.(event.pointerId);
|
||||||
|
press = {
|
||||||
|
id: panning ? undefined : id,
|
||||||
|
client: { x: event.clientX, y: event.clientY },
|
||||||
|
add,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
if (panning) press.pan = { ...o.pan.value };
|
||||||
|
else if (!id && !o.previewing()) band.value = { start: world(event), end: world(event) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(event: PointerEvent) {
|
||||||
|
if (!press) return;
|
||||||
|
const dx = event.clientX - press.client.x,
|
||||||
|
dy = event.clientY - press.client.y;
|
||||||
|
if (!press.moved && Math.hypot(dx, dy) < DRAG_START) return;
|
||||||
|
press.moved = true;
|
||||||
|
if (press.pan) {
|
||||||
|
// viewBox matches the SVG's pixel size, so client deltas are viewBox deltas
|
||||||
|
o.pan.value = { x: press.pan.x + dx, y: press.pan.y + dy };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const point = world(event);
|
||||||
|
if (press.id) {
|
||||||
|
const action = dropAction(press.id, hitKey(o.keys(), point), o.registered());
|
||||||
|
drag.value = { id: press.id, point, action };
|
||||||
|
} else if (band.value) band.value = { ...band.value, end: point };
|
||||||
|
}
|
||||||
|
|
||||||
|
function end() {
|
||||||
|
if (!press) return;
|
||||||
|
const { id, add, moved } = press;
|
||||||
|
if (id && !moved) {
|
||||||
|
o.selected.value = add
|
||||||
|
? o.selected.value.includes(id)
|
||||||
|
? o.selected.value.filter((x) => x !== id)
|
||||||
|
: [...o.selected.value, id]
|
||||||
|
: [id];
|
||||||
|
} else if (id && drag.value?.action) o.onDrop(id, drag.value.action);
|
||||||
|
else if (!id && !press.pan) {
|
||||||
|
const b = band.value;
|
||||||
|
if (!moved || !b) {
|
||||||
|
if (!add) o.selected.value = [];
|
||||||
|
} else {
|
||||||
|
const [x0, x1] = [Math.min(b.start.x, b.end.x), Math.max(b.start.x, b.end.x)];
|
||||||
|
const [y0, y1] = [Math.min(b.start.y, b.end.y), Math.max(b.start.y, b.end.y)];
|
||||||
|
const inside = o
|
||||||
|
.keys()
|
||||||
|
.filter((k) => {
|
||||||
|
const c = keyCenter(k);
|
||||||
|
return c.x >= x0 && c.x <= x1 && c.y >= y0 && c.y <= y1;
|
||||||
|
})
|
||||||
|
.map((k) => k.key.id);
|
||||||
|
o.selected.value = add ? [...new Set([...o.selected.value, ...inside])] : inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
press = null;
|
||||||
|
drag.value = null;
|
||||||
|
band.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { drag, band, spaceHeld, begin, move, end, cancel };
|
||||||
|
}
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { flattenKeys } from "../../domain/geometry";
|
||||||
|
import {
|
||||||
|
mcuPinCandidates,
|
||||||
|
removeKeyWiring,
|
||||||
|
setKeyWiring,
|
||||||
|
setProvider,
|
||||||
|
removeProvider,
|
||||||
|
createLedChain,
|
||||||
|
setLedOrder,
|
||||||
|
} from "../../domain/elc";
|
||||||
|
import type { ContactWiring, McuProvider, AnalogMuxProvider, PinRef } from "../../domain/elc";
|
||||||
|
import { defaultVariantSelection, type ProjectFile } from "../../domain/project";
|
||||||
|
import type { Node } from "../../domain/phy";
|
||||||
|
import { validateProject } from "../../domain/validate";
|
||||||
|
import { wireMatrixKeys } from "../../domain/elcMatrix";
|
||||||
|
import type { Dispatch } from "../types";
|
||||||
|
import KeyboardView from "../common/KeyboardView.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch }>();
|
||||||
|
const variants = computed(() => ({
|
||||||
|
...defaultVariantSelection(props.project.phy),
|
||||||
|
...props.project.output.variantSelection,
|
||||||
|
}));
|
||||||
|
const keys = computed(() =>
|
||||||
|
flattenKeys(props.project.phy, variants.value)
|
||||||
|
.filter((x) => !x.key.decal)
|
||||||
|
.map((x) => x.key),
|
||||||
|
);
|
||||||
|
const selectedKeyId = ref("");
|
||||||
|
const selectedKey = computed(() => keys.value.find((x) => x.id === selectedKeyId.value));
|
||||||
|
const wired = computed(() => new Map(props.project.elc.keys.map((x) => [x.key, x])));
|
||||||
|
const collisionIds = computed(
|
||||||
|
() =>
|
||||||
|
new Set(
|
||||||
|
validateProject(props.project)
|
||||||
|
.filter((x) => x.code === "wiring-collision")
|
||||||
|
.flatMap((x) => x.ids ?? []),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const unwired = computed(() => keys.value.filter((k) => !wired.value.has(k.id)).map((k) => k.id));
|
||||||
|
const wiring = ref({ a: "", b: "", diode: "" as "" | "a-to-b" | "b-to-a" });
|
||||||
|
watch(selectedKeyId, (id) => {
|
||||||
|
const current = props.project.elc.keys.find((x) => x.key === id);
|
||||||
|
wiring.value =
|
||||||
|
current?.detect === "contact"
|
||||||
|
? { a: current.terminals.a, b: current.terminals.b, diode: current.diode ?? "" }
|
||||||
|
: { a: "", b: "", diode: "" };
|
||||||
|
});
|
||||||
|
const providerId = ref("MCU"),
|
||||||
|
providerPart = ref("RP2040"),
|
||||||
|
providerError = ref("");
|
||||||
|
const providerCandidates = computed(() => {
|
||||||
|
const p = { id: providerId.value, kind: "mcu" as const, part: providerPart.value };
|
||||||
|
return mcuPinCandidates(p).map((name) => `${p.id}.${name}`);
|
||||||
|
});
|
||||||
|
const pinRefs = computed(() => [
|
||||||
|
"VCC",
|
||||||
|
"GND",
|
||||||
|
...props.project.elc.providers.flatMap((p) =>
|
||||||
|
p.kind === "mcu"
|
||||||
|
? mcuPinCandidates(p).map((pin) => `${p.id}.${pin}`)
|
||||||
|
: Array.from({ length: p.channels }, (_, i) => `${p.id}.${i}`),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const providerRows = computed(() => props.project.elc.providers);
|
||||||
|
const mux = ref({ id: "MUX", channels: 8, select: "", common: "", enable: "" });
|
||||||
|
const chainId = ref("LED"),
|
||||||
|
chainData = ref(""),
|
||||||
|
selectedChainId = ref("");
|
||||||
|
const batchKeyIds = ref<string[]>([]),
|
||||||
|
batchRows = ref<string[]>([]),
|
||||||
|
batchCols = ref<string[]>([]);
|
||||||
|
const batchRow = ref(""),
|
||||||
|
batchCol = ref(""),
|
||||||
|
batchDiode = ref<"" | "a-to-b" | "b-to-a">("a-to-b");
|
||||||
|
const batchPreviewOpen = ref(false),
|
||||||
|
batchError = ref("");
|
||||||
|
const chains = computed(() => props.project.elc.ledChains ?? []);
|
||||||
|
const nodes = computed(() => {
|
||||||
|
const result: Array<{ id: string; label: string }> = [];
|
||||||
|
const visit = (ns: Node[]) =>
|
||||||
|
ns.forEach((n) => {
|
||||||
|
if (n.type === "key" && n.perKeyLED) result.push({ id: n.id, label: `${n.id} · per-key LED` });
|
||||||
|
else if (n.type === "component" && n.partGroup === "@led")
|
||||||
|
result.push({ id: n.id, label: `${n.id} · @led` });
|
||||||
|
else if (n.type === "cluster") visit(n.children);
|
||||||
|
else if (n.type === "variantGroup") n.children.forEach((v) => visit(v.children));
|
||||||
|
});
|
||||||
|
visit(props.project.phy.root);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const chain = computed(() => chains.value.find((x) => x.id === selectedChainId.value));
|
||||||
|
const chainSelection = ref<string[]>([]);
|
||||||
|
watch(selectedChainId, () => {
|
||||||
|
chainSelection.value = [];
|
||||||
|
});
|
||||||
|
function clickKey(id: string) {
|
||||||
|
selectedKeyId.value = id;
|
||||||
|
if (chain.value && nodes.value.some((n) => n.id === id) && !chain.value.order.includes(id))
|
||||||
|
setOrder([...chain.value.order, id]);
|
||||||
|
}
|
||||||
|
function keyClass(id: string) {
|
||||||
|
return collisionIds.value.has(id) ? "error" : !wired.value.has(id) ? "warn" : "ok";
|
||||||
|
}
|
||||||
|
function commitWiring() {
|
||||||
|
if (!selectedKeyId.value || !wiring.value.a || !wiring.value.b) return;
|
||||||
|
const next: ContactWiring = {
|
||||||
|
key: selectedKeyId.value,
|
||||||
|
detect: "contact",
|
||||||
|
terminals: { a: wiring.value.a, b: wiring.value.b },
|
||||||
|
...(wiring.value.diode ? { diode: wiring.value.diode } : {}),
|
||||||
|
};
|
||||||
|
props.dispatch("接点配線", (p) => ({ ...p, elc: setKeyWiring(p.elc, next) }));
|
||||||
|
}
|
||||||
|
function removeWiring() {
|
||||||
|
if (selectedKeyId.value)
|
||||||
|
props.dispatch("配線解除", (p) => ({ ...p, elc: removeKeyWiring(p.elc, selectedKeyId.value) }));
|
||||||
|
}
|
||||||
|
function addMcu() {
|
||||||
|
providerError.value = "";
|
||||||
|
if (!providerId.value || providerId.value.includes(".")) {
|
||||||
|
providerError.value = "IDは空欄にできず、'.'を含められません";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p: McuProvider = { id: providerId.value, kind: "mcu", part: providerPart.value };
|
||||||
|
props.dispatch("MCU追加・編集", (project) => ({ ...project, elc: setProvider(project.elc, p) }));
|
||||||
|
}
|
||||||
|
function editProvider(id: string) {
|
||||||
|
const p = props.project.elc.providers.find((x) => x.id === id);
|
||||||
|
if (p?.kind === "mcu") {
|
||||||
|
providerId.value = p.id;
|
||||||
|
providerPart.value = p.part;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function addMux() {
|
||||||
|
if (!mux.value.id || mux.value.id.includes(".")) {
|
||||||
|
providerError.value = "IDは空欄にできず、'.'を含められません";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p: AnalogMuxProvider = {
|
||||||
|
id: mux.value.id,
|
||||||
|
kind: "analog-mux",
|
||||||
|
channels: Number(mux.value.channels),
|
||||||
|
select: mux.value.select
|
||||||
|
.split(",")
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
common: mux.value.common,
|
||||||
|
...(mux.value.enable ? { enable: mux.value.enable } : {}),
|
||||||
|
};
|
||||||
|
props.dispatch("analog-mux追加・編集", (project) => ({ ...project, elc: setProvider(project.elc, p) }));
|
||||||
|
}
|
||||||
|
function removeProviderUi(id: string) {
|
||||||
|
props.dispatch("提供者削除", (p) => ({ ...p, elc: removeProvider(p.elc, id) }));
|
||||||
|
}
|
||||||
|
function makeChain() {
|
||||||
|
if (!chainId.value || !chainData.value) return;
|
||||||
|
try {
|
||||||
|
props.dispatch("LEDチェーン作成", (p) => ({
|
||||||
|
...p,
|
||||||
|
elc: createLedChain(p.elc, chainId.value, chainData.value),
|
||||||
|
}));
|
||||||
|
selectedChainId.value = chainId.value;
|
||||||
|
} catch (e) {
|
||||||
|
providerError.value = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setOrder(order: string[]) {
|
||||||
|
if (chain.value)
|
||||||
|
props.dispatch("LED順序", (p) => ({ ...p, elc: setLedOrder(p.elc, chain.value!.id, order) }));
|
||||||
|
}
|
||||||
|
function moveLed(index: number, direction: number) {
|
||||||
|
const order = [...(chain.value?.order ?? [])];
|
||||||
|
const to = index + direction;
|
||||||
|
if (to < 0 || to >= order.length) return;
|
||||||
|
[order[index], order[to]] = [order[to]!, order[index]!];
|
||||||
|
setOrder(order);
|
||||||
|
}
|
||||||
|
function removeLed(index: number) {
|
||||||
|
const order = [...(chain.value?.order ?? [])];
|
||||||
|
order.splice(index, 1);
|
||||||
|
setOrder(order);
|
||||||
|
}
|
||||||
|
function toggleBatchKey(id: string) {
|
||||||
|
batchKeyIds.value = batchKeyIds.value.includes(id)
|
||||||
|
? batchKeyIds.value.filter((x) => x !== id)
|
||||||
|
: [...batchKeyIds.value, id];
|
||||||
|
}
|
||||||
|
function moveBatchPin(kind: "row" | "col", index: number, delta: number) {
|
||||||
|
const list = kind === "row" ? batchRows.value : batchCols.value;
|
||||||
|
const nextIndex = index + delta;
|
||||||
|
if (nextIndex < 0 || nextIndex >= list.length) return;
|
||||||
|
[list[index], list[nextIndex]] = [list[nextIndex]!, list[index]!];
|
||||||
|
}
|
||||||
|
function batchWire() {
|
||||||
|
if (!batchKeyIds.value.length || !batchRows.value.length || !batchCols.value.length) return;
|
||||||
|
// Matrix assignment follows the visible PHY key order, regardless of click order.
|
||||||
|
const orderedKeys = keys.value.map((k) => k.id).filter((id) => batchKeyIds.value.includes(id));
|
||||||
|
try {
|
||||||
|
props.dispatch("一括配線", (p) => ({
|
||||||
|
...p,
|
||||||
|
elc: wireMatrixKeys(
|
||||||
|
p.elc,
|
||||||
|
orderedKeys,
|
||||||
|
batchRows.value,
|
||||||
|
batchCols.value,
|
||||||
|
batchDiode.value || undefined,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
batchError.value = "";
|
||||||
|
batchPreviewOpen.value = false;
|
||||||
|
} catch (e) {
|
||||||
|
batchError.value = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const batchOrderedKeys = computed(() =>
|
||||||
|
keys.value.map((k) => k.id).filter((id) => batchKeyIds.value.includes(id)),
|
||||||
|
);
|
||||||
|
const batchCapacityError = computed(
|
||||||
|
() => batchOrderedKeys.value.length > batchRows.value.length * batchCols.value.length,
|
||||||
|
);
|
||||||
|
const batchPreview = computed(() =>
|
||||||
|
batchOrderedKeys.value.map((key, i) => ({
|
||||||
|
key,
|
||||||
|
row: batchRows.value[Math.floor(i / batchCols.value.length)],
|
||||||
|
col: batchCols.value[i % batchCols.value.length],
|
||||||
|
overwrite: wired.value.has(key),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const ledCandidate = ref("");
|
||||||
|
function addLedCandidate() {
|
||||||
|
if (
|
||||||
|
chain.value &&
|
||||||
|
nodes.value.some((n) => n.id === ledCandidate.value) &&
|
||||||
|
!chain.value.order.includes(ledCandidate.value)
|
||||||
|
)
|
||||||
|
setOrder([...chain.value.order, ledCandidate.value]);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="elc-panel">
|
||||||
|
<h2>Wiring Editor</h2>
|
||||||
|
<div class="elc-grid">
|
||||||
|
<section class="box">
|
||||||
|
<h3>ピン提供者</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="p in providerRows" :key="p.id">
|
||||||
|
<button @click="editProvider(p.id)">
|
||||||
|
{{ p.id }} · {{ p.kind }} · {{ p.kind === "mcu" ? p.part : `${p.channels} channels` }}
|
||||||
|
</button>
|
||||||
|
<button @click="removeProviderUi(p.id)">削除</button>
|
||||||
|
<div v-if="p.kind === 'analog-mux'">
|
||||||
|
select {{ p.select.join(", ") }} / common {{ p.common
|
||||||
|
}}<span v-if="p.enable"> / enable {{ p.enable }}</span> (データ保持のみ)
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<label>ID <input v-model="providerId" data-testid="provider-id" /></label
|
||||||
|
><label
|
||||||
|
>MCU品番 <input v-model="providerPart" list="mcu-parts" /><datalist id="mcu-parts">
|
||||||
|
<option>RP2040</option>
|
||||||
|
</datalist></label
|
||||||
|
><button @click="addMcu">MCUを追加・更新</button>
|
||||||
|
<p v-if="providerCandidates.length">候補: {{ providerCandidates.join(", ") }}</p>
|
||||||
|
<p v-else>未知の品番はピン名を自由入力します。</p>
|
||||||
|
<hr />
|
||||||
|
<h4>analog-mux (保持のみ)</h4>
|
||||||
|
<label>ID <input v-model="mux.id" /></label
|
||||||
|
><label>channels <input type="number" min="1" v-model.number="mux.channels" /></label
|
||||||
|
><label>select (カンマ区切り) <input v-model="mux.select" /></label
|
||||||
|
><label>common <input v-model="mux.common" /></label
|
||||||
|
><label>enable <input v-model="mux.enable" /></label
|
||||||
|
><button @click="addMux">analog-muxを追加・更新</button>
|
||||||
|
<p v-if="providerError" class="error-text">{{ providerError }}</p>
|
||||||
|
</section>
|
||||||
|
<section class="box wiring-box">
|
||||||
|
<h3>選択構成のキー配線</h3>
|
||||||
|
<p>
|
||||||
|
未配線: <b data-testid="unwired-count">{{ unwired.length }}</b> / {{ keys.length }}
|
||||||
|
</p>
|
||||||
|
<KeyboardView
|
||||||
|
:phy="project.phy"
|
||||||
|
:selection="variants"
|
||||||
|
:selected-ids="selectedKeyId ? [selectedKeyId] : []"
|
||||||
|
:key-class="keyClass"
|
||||||
|
:key-text="
|
||||||
|
(id) => {
|
||||||
|
const w = wired.get(id);
|
||||||
|
return w?.detect === 'contact'
|
||||||
|
? `${w.terminals.a.split('.').at(-1)}/${w.terminals.b.split('.').at(-1)}`
|
||||||
|
: w
|
||||||
|
? 'analog'
|
||||||
|
: id.slice(-3);
|
||||||
|
}
|
||||||
|
"
|
||||||
|
@key-click="(id) => clickKey(id)"
|
||||||
|
/>
|
||||||
|
<p>
|
||||||
|
色: <span class="warn-text">黄 未配線</span> · <span class="error-text">赤 衝突</span> · 緑 配線済み
|
||||||
|
</p>
|
||||||
|
<div v-if="selectedKey" class="edit-form">
|
||||||
|
<h4>{{ selectedKey.id }} · 接点端子</h4>
|
||||||
|
<label
|
||||||
|
>a
|
||||||
|
<select v-model="wiring.a">
|
||||||
|
<option value="">選択…</option>
|
||||||
|
<option v-for="pin in pinRefs" :key="`a${pin}`" :value="pin">{{ pin }}</option></select
|
||||||
|
><input v-model="wiring.a" aria-label="a端子自由入力" placeholder="自由入力: provider.pin"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>b
|
||||||
|
<select v-model="wiring.b">
|
||||||
|
<option value="">選択…</option>
|
||||||
|
<option v-for="pin in pinRefs" :key="`b${pin}`" :value="pin">{{ pin }}</option></select
|
||||||
|
><input v-model="wiring.b" aria-label="b端子自由入力" placeholder="自由入力: provider.pin"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>ダイオード
|
||||||
|
<select v-model="wiring.diode">
|
||||||
|
<option value="">なし</option>
|
||||||
|
<option value="a-to-b">a-to-b</option>
|
||||||
|
<option value="b-to-a">b-to-a</option>
|
||||||
|
</select></label
|
||||||
|
><button @click="commitWiring">配線を保存</button><button @click="removeWiring">配線解除</button>
|
||||||
|
</div>
|
||||||
|
<p v-else>キーをクリックして端子を編集します。</p>
|
||||||
|
<h4>未配線・衝突</h4>
|
||||||
|
<p>未配線 ({{ unwired.length }}): {{ unwired.join(", ") || "なし" }}</p>
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="issue in validateProject(project).filter((x) => x.code === 'wiring-collision')"
|
||||||
|
:key="issue.ids?.join('-')"
|
||||||
|
>
|
||||||
|
{{ issue.code }}: {{ issue.ids?.join(" ↔ ") }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<hr />
|
||||||
|
<h4>一括配線</h4>
|
||||||
|
<p>
|
||||||
|
キーを選び、row/colピンの順序を指定して配線します。選択キーにはPHY表示順でrowを進め、各row内でcolを進めます。
|
||||||
|
</p>
|
||||||
|
<KeyboardView
|
||||||
|
:phy="project.phy"
|
||||||
|
:selection="variants"
|
||||||
|
:selected-ids="batchKeyIds"
|
||||||
|
:key-text="(id) => id.slice(-3)"
|
||||||
|
@key-click="(id) => toggleBatchKey(id)"
|
||||||
|
/>
|
||||||
|
<p>選択キー: {{ batchKeyIds.length }}</p>
|
||||||
|
<div class="batch-controls">
|
||||||
|
<label
|
||||||
|
>rowピン<select v-model="batchRow" data-testid="batch-row-select">
|
||||||
|
<option value="">選択…</option>
|
||||||
|
<option
|
||||||
|
v-for="pin in pinRefs.filter((x) => x !== 'VCC' && x !== 'GND')"
|
||||||
|
:key="`br-${pin}`"
|
||||||
|
:value="pin"
|
||||||
|
>
|
||||||
|
{{ pin }}
|
||||||
|
</option>
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
if (batchRow && !batchRows.includes(batchRow)) batchRows.push(batchRow);
|
||||||
|
batchRow = '';
|
||||||
|
"
|
||||||
|
>
|
||||||
|
rowへ追加
|
||||||
|
</button>
|
||||||
|
<ol>
|
||||||
|
<li v-for="(pin, i) in batchRows" :key="`br-${i}-${pin}`">
|
||||||
|
{{ i }}: {{ pin }} <button @click="moveBatchPin('row', i, -1)">↑</button
|
||||||
|
><button @click="moveBatchPin('row', i, 1)">↓</button
|
||||||
|
><button @click="batchRows.splice(i, 1)">除去</button>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<label
|
||||||
|
>colピン<select v-model="batchCol" data-testid="batch-col-select">
|
||||||
|
<option value="">選択…</option>
|
||||||
|
<option
|
||||||
|
v-for="pin in pinRefs.filter((x) => x !== 'VCC' && x !== 'GND')"
|
||||||
|
:key="`bc-${pin}`"
|
||||||
|
:value="pin"
|
||||||
|
>
|
||||||
|
{{ pin }}
|
||||||
|
</option>
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="
|
||||||
|
if (batchCol && !batchCols.includes(batchCol)) batchCols.push(batchCol);
|
||||||
|
batchCol = '';
|
||||||
|
"
|
||||||
|
>
|
||||||
|
colへ追加
|
||||||
|
</button>
|
||||||
|
<ol>
|
||||||
|
<li v-for="(pin, i) in batchCols" :key="`bc-${i}-${pin}`">
|
||||||
|
{{ i }}: {{ pin }} <button @click="moveBatchPin('col', i, -1)">↑</button
|
||||||
|
><button @click="moveBatchPin('col', i, 1)">↓</button
|
||||||
|
><button @click="batchCols.splice(i, 1)">除去</button>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<label
|
||||||
|
>ダイオード<select v-model="batchDiode">
|
||||||
|
<option value="">なし</option>
|
||||||
|
<option value="a-to-b">a-to-b</option>
|
||||||
|
<option value="b-to-a">b-to-a</option>
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
data-testid="batch-wire"
|
||||||
|
:disabled="!batchKeyIds.length || !batchRows.length || !batchCols.length"
|
||||||
|
@click="
|
||||||
|
batchPreviewOpen = true;
|
||||||
|
batchError = '';
|
||||||
|
"
|
||||||
|
>
|
||||||
|
割当プレビュー
|
||||||
|
</button>
|
||||||
|
<p v-if="batchCapacityError" role="alert" data-testid="batch-capacity-error">
|
||||||
|
容量不足: {{ batchOrderedKeys.length }} keys / {{ batchRows.length * batchCols.length }} positions
|
||||||
|
</p>
|
||||||
|
<p v-if="batchError" role="alert">{{ batchError }}</p>
|
||||||
|
<section v-if="batchPreviewOpen" data-testid="batch-preview">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>キー</th>
|
||||||
|
<th>rowピン</th>
|
||||||
|
<th>colピン</th>
|
||||||
|
<th>既存配線</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in batchPreview" :key="item.key">
|
||||||
|
<td>{{ item.key }}</td>
|
||||||
|
<td>{{ item.row }}</td>
|
||||||
|
<td>{{ item.col }}</td>
|
||||||
|
<td>{{ item.overwrite ? "上書き" : "新規" }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button data-testid="batch-confirm" :disabled="batchCapacityError" @click="batchWire">
|
||||||
|
この内容で確定
|
||||||
|
</button>
|
||||||
|
<button @click="batchPreviewOpen = false">キャンセル</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="box">
|
||||||
|
<h3>LEDチェーン</h3>
|
||||||
|
<label>チェーンID <input v-model="chainId" /></label
|
||||||
|
><label>データピン <input v-model="chainData" list="pin-options" placeholder="provider.pin" /></label
|
||||||
|
><datalist id="pin-options">
|
||||||
|
<option v-for="pin in pinRefs" :key="pin">{{ pin }}</option></datalist
|
||||||
|
><button @click="makeChain">作成</button>
|
||||||
|
<label
|
||||||
|
>編集するチェーン
|
||||||
|
<select v-model="selectedChainId">
|
||||||
|
<option value="">選択…</option>
|
||||||
|
<option v-for="c in chains" :key="c.id" :value="c.id">{{ c.id }}</option>
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<p>対象は全バリアントのper-key LEDキーと@led部品ノードです。</p>
|
||||||
|
<label
|
||||||
|
>追加するLED
|
||||||
|
<select v-model="ledCandidate" data-testid="led-candidate">
|
||||||
|
<option value="">選択…</option>
|
||||||
|
<option v-for="n in nodes" :key="n.id" :value="n.id">{{ n.label }}</option>
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<button data-testid="led-add" :disabled="!ledCandidate || !chain" @click="addLedCandidate">
|
||||||
|
チェーン末尾へ追加
|
||||||
|
</button>
|
||||||
|
<KeyboardView
|
||||||
|
v-if="chain"
|
||||||
|
:phy="project.phy"
|
||||||
|
:selection="variants"
|
||||||
|
:selected-ids="chain.order"
|
||||||
|
:key-text="(id) => String(chain!.order.indexOf(id) + 1 || '')"
|
||||||
|
@key-click="
|
||||||
|
(id) => {
|
||||||
|
if (nodes.some((n) => n.id === id) && !chain!.order.includes(id))
|
||||||
|
setOrder([...chain!.order, id]);
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<ol>
|
||||||
|
<li v-for="(id, i) in chain?.order ?? []" :key="`${id}-${i}`">
|
||||||
|
{{ i }} · {{ id }} <button @click="moveLed(i, -1)">↑</button
|
||||||
|
><button @click="moveLed(i, 1)">↓</button><button @click="removeLed(i)">除去</button>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.elc-panel {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #cbd1da;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.elc-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(230px, 1fr) minmax(320px, 1.5fr) minmax(230px, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.box {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #d2d7df;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.box label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
.box input,
|
||||||
|
.box select {
|
||||||
|
max-width: 170px;
|
||||||
|
}
|
||||||
|
.wiring-box {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.edit-form {
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
.edit-form label {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.batch-controls {
|
||||||
|
padding: 8px;
|
||||||
|
background: #f6f8fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.batch-controls ol {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.warn-text {
|
||||||
|
color: #985c00;
|
||||||
|
}
|
||||||
|
.error-text {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
.warn :deep(rect) {
|
||||||
|
fill: #fde68a;
|
||||||
|
}
|
||||||
|
.error :deep(rect) {
|
||||||
|
fill: #fecaca;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { defaultVariantSelection, type ProjectFile } from "../../domain/project";
|
||||||
|
import type { Node, VariantGroupNode } from "../../domain/phy";
|
||||||
|
import { flattenKeys } from "../../domain/geometry";
|
||||||
|
import { mcuPinCandidates } from "../../domain/elc";
|
||||||
|
import { generateMockTarget } from "../../domain/generator";
|
||||||
|
import type { Dispatch } from "../types";
|
||||||
|
import KeyboardView from "../common/KeyboardView.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch }>();
|
||||||
|
const selection = computed(() => ({
|
||||||
|
...defaultVariantSelection(props.project.phy),
|
||||||
|
...props.project.output.variantSelection,
|
||||||
|
}));
|
||||||
|
const groups = computed(() => {
|
||||||
|
const all: VariantGroupNode[] = [];
|
||||||
|
const visit = (ns: Node[]) =>
|
||||||
|
ns.forEach((n) => {
|
||||||
|
if (n.type === "cluster") visit(n.children);
|
||||||
|
else if (n.type === "variantGroup") {
|
||||||
|
all.push(n);
|
||||||
|
n.children.forEach((v) => visit(v.children));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
visit(props.project.phy.root);
|
||||||
|
return all;
|
||||||
|
});
|
||||||
|
const pins = computed(() => [
|
||||||
|
...new Set(
|
||||||
|
props.project.elc.providers.flatMap((p) =>
|
||||||
|
p.kind === "mcu"
|
||||||
|
? mcuPinCandidates(p).map((x) => `${p.id}.${x}`)
|
||||||
|
: Array.from({ length: p.channels }, (_, i) => `${p.id}.${i}`),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const rowAdd = ref(""),
|
||||||
|
colAdd = ref("");
|
||||||
|
const result = ref<ReturnType<typeof generateMockTarget> | null>(null);
|
||||||
|
watch(
|
||||||
|
() => props.project,
|
||||||
|
() => {
|
||||||
|
result.value = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const rows = computed(() => props.project.output.matrix?.rowPins ?? []),
|
||||||
|
cols = computed(() => props.project.output.matrix?.colPins ?? []);
|
||||||
|
const flatKeys = computed(() => flattenKeys(props.project.phy, selection.value).filter((x) => !x.key.decal));
|
||||||
|
const mappingById = computed(() => new Map((result.value?.target?.mapping ?? []).map((m) => [m.keyId, m])));
|
||||||
|
function setVariant(groupId: string, variantId: string) {
|
||||||
|
props.dispatch("出力構成", (p) => ({
|
||||||
|
...p,
|
||||||
|
output: { ...p.output, variantSelection: { ...selection.value, [groupId]: variantId } },
|
||||||
|
}));
|
||||||
|
result.value = null;
|
||||||
|
}
|
||||||
|
function setPins(kind: "rowPins" | "colPins", value: string[]) {
|
||||||
|
props.dispatch(kind === "rowPins" ? "rowピン順序" : "colピン順序", (p) => ({
|
||||||
|
...p,
|
||||||
|
output: {
|
||||||
|
...p.output,
|
||||||
|
matrix: {
|
||||||
|
...p.output.matrix,
|
||||||
|
kind: "matrix",
|
||||||
|
rowPins: [...(p.output.matrix?.rowPins ?? [])],
|
||||||
|
colPins: [...(p.output.matrix?.colPins ?? [])],
|
||||||
|
[kind]: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
result.value = null;
|
||||||
|
}
|
||||||
|
function addPin(kind: "rowPins" | "colPins", pin: string) {
|
||||||
|
if (!pin) return;
|
||||||
|
const current = kind === "rowPins" ? rows.value : cols.value;
|
||||||
|
if (current.includes(pin)) return;
|
||||||
|
setPins(kind, [...current, pin]);
|
||||||
|
if (kind === "rowPins") rowAdd.value = "";
|
||||||
|
else colAdd.value = "";
|
||||||
|
}
|
||||||
|
function movePin(kind: "rowPins" | "colPins", index: number, d: number) {
|
||||||
|
const next = [...(kind === "rowPins" ? rows.value : cols.value)],
|
||||||
|
to = index + d;
|
||||||
|
if (to < 0 || to >= next.length) return;
|
||||||
|
[next[index], next[to]] = [next[to]!, next[index]!];
|
||||||
|
setPins(kind, next);
|
||||||
|
}
|
||||||
|
function removePin(kind: "rowPins" | "colPins", index: number) {
|
||||||
|
const next = [...(kind === "rowPins" ? rows.value : cols.value)];
|
||||||
|
next.splice(index, 1);
|
||||||
|
setPins(kind, next);
|
||||||
|
}
|
||||||
|
function generate() {
|
||||||
|
result.value = generateMockTarget(props.project);
|
||||||
|
}
|
||||||
|
function download() {
|
||||||
|
if (!result.value?.target) return;
|
||||||
|
const blob = new Blob([JSON.stringify(result.value.target, null, 2)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "mock-target.json";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
function keyText(id: string) {
|
||||||
|
const m = mappingById.value.get(id);
|
||||||
|
return m ? `${m.number}\nr${m.row} c${m.col}` : id.slice(-3);
|
||||||
|
}
|
||||||
|
function keyClass(id: string) {
|
||||||
|
return mappingById.value.has(id) ? "ok" : "muted";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="generator-panel">
|
||||||
|
<h2>Generator · プレビュー</h2>
|
||||||
|
<p class="notice">
|
||||||
|
これは検証用の <code>mock-target.json</code> です。QMKにそのまま渡せる生成物ではありません。
|
||||||
|
</p>
|
||||||
|
<div class="settings">
|
||||||
|
<section class="box">
|
||||||
|
<h3>出力構成</h3>
|
||||||
|
<label v-for="g in groups" :key="g.id"
|
||||||
|
>{{ g.name }}
|
||||||
|
<select
|
||||||
|
:value="selection[g.id] ?? g.default"
|
||||||
|
@change="setVariant(g.id, ($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option v-for="v in g.children" :key="v.id" :value="v.id">{{ v.name }}</option>
|
||||||
|
</select></label
|
||||||
|
>
|
||||||
|
<p v-if="!groups.length">バリアントグループなし</p>
|
||||||
|
</section>
|
||||||
|
<section class="box">
|
||||||
|
<h3>Matrix row / col</h3>
|
||||||
|
<p>ELCで使うピン候補から順序を設定します。番号はリスト順です。</p>
|
||||||
|
<h4>rowPins</h4>
|
||||||
|
<ol>
|
||||||
|
<li v-for="(pin, i) in rows" :key="`${pin}-${i}`">
|
||||||
|
{{ i }}: {{ pin }} <button @click="movePin('rowPins', i, -1)">↑</button
|
||||||
|
><button @click="movePin('rowPins', i, 1)">↓</button
|
||||||
|
><button @click="removePin('rowPins', i)">削除</button>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<select v-model="rowAdd" data-testid="row-pin-select">
|
||||||
|
<option value="">ピンを選択…</option>
|
||||||
|
<option v-for="pin in pins" :key="`r${pin}`">{{ pin }}</option></select
|
||||||
|
><button @click="addPin('rowPins', rowAdd)">row追加</button>
|
||||||
|
<h4>colPins</h4>
|
||||||
|
<ol>
|
||||||
|
<li v-for="(pin, i) in cols" :key="`${pin}-${i}`">
|
||||||
|
{{ i }}: {{ pin }} <button @click="movePin('colPins', i, -1)">↑</button
|
||||||
|
><button @click="movePin('colPins', i, 1)">↓</button
|
||||||
|
><button @click="removePin('colPins', i)">削除</button>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<select v-model="colAdd" data-testid="col-pin-select">
|
||||||
|
<option value="">ピンを選択…</option>
|
||||||
|
<option v-for="pin in pins" :key="`c${pin}`">{{ pin }}</option></select
|
||||||
|
><button @click="addPin('colPins', colAdd)">col追加</button>
|
||||||
|
<p v-if="!pins.length">ELCのピン提供者を先に追加してください。</p>
|
||||||
|
</section>
|
||||||
|
<section class="box preview">
|
||||||
|
<h3>生成結果</h3>
|
||||||
|
<button data-testid="generate-button" @click="generate">生成</button
|
||||||
|
><span v-if="result?.target" data-testid="mapping-count"> {{ result.target.mapping.length }}行</span>
|
||||||
|
<div v-if="result && !result.target" class="errors">
|
||||||
|
<h4>生成を止めた理由 ({{ result.errors.length }})</h4>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(e, i) in result.errors" :key="`${e.code}-${i}`">
|
||||||
|
<b>{{ e.code }}</b> {{ e.message }} <small>{{ e.ids?.join(", ") }}</small>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<template v-if="result?.target"
|
||||||
|
><button data-testid="download-target" @click="download">mock-target.jsonをダウンロード</button>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>出力番号</th>
|
||||||
|
<th>keyId</th>
|
||||||
|
<th>row</th>
|
||||||
|
<th>col</th>
|
||||||
|
<th>ラベル</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="m in result.target.mapping" :key="m.keyId">
|
||||||
|
<td>{{ m.number }}</td>
|
||||||
|
<td>{{ m.keyId }}</td>
|
||||||
|
<td>{{ m.row }}</td>
|
||||||
|
<td>{{ m.col }}</td>
|
||||||
|
<td>{{ m.label }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h4>レイヤー配列</h4>
|
||||||
|
<section v-for="layer in result.target.keymaps" :key="layer.name">
|
||||||
|
<b>{{ layer.name }}</b
|
||||||
|
><code class="array">{{ JSON.stringify(layer.keycodes) }}</code>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<section class="layout-box">
|
||||||
|
<h3>選択構成 · 番号 / row,col</h3>
|
||||||
|
<KeyboardView :phy="project.phy" :selection="selection" :key-text="keyText" :key-class="keyClass" />
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.generator-panel {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #cbd1da;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.notice {
|
||||||
|
padding: 8px;
|
||||||
|
background: #fff4d6;
|
||||||
|
}
|
||||||
|
.settings {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 0.8fr) minmax(260px, 1fr) minmax(380px, 1.5fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.box,
|
||||||
|
.layout-box {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #d2d7df;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.box label {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.box h3,
|
||||||
|
.layout-box h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.preview table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.preview th,
|
||||||
|
.preview td {
|
||||||
|
border: 1px solid #d8dde5;
|
||||||
|
padding: 3px 5px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.errors {
|
||||||
|
color: #9b1c1c;
|
||||||
|
}
|
||||||
|
.array {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
margin: 5px 0 10px;
|
||||||
|
}
|
||||||
|
.layout-box {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.layout-box :deep(.kv-key text) {
|
||||||
|
font-size: 0.16px;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { flattenKeys } from "../../domain/geometry";
|
||||||
|
import {
|
||||||
|
assignKey,
|
||||||
|
deleteLayer,
|
||||||
|
isEditableKeycode,
|
||||||
|
parseKeycode,
|
||||||
|
reorderLayer,
|
||||||
|
renameLayer,
|
||||||
|
unassignKey,
|
||||||
|
unassignedKeys,
|
||||||
|
addLayer,
|
||||||
|
} from "../../domain/map";
|
||||||
|
import { defaultVariantSelection, type ProjectFile } from "../../domain/project";
|
||||||
|
import { getKeycodeLabel } from "../../domain/keycodeLabels";
|
||||||
|
import { qmkBasicKeycodes } from "../../domain/qmkBasicKeycodes";
|
||||||
|
import type { Dispatch } from "../types";
|
||||||
|
import KeyboardView from "../common/KeyboardView.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch; editable?: boolean }>();
|
||||||
|
const selectedLayer = ref("Base"),
|
||||||
|
selectedKey = ref(""),
|
||||||
|
draft = ref(""),
|
||||||
|
newLayerName = ref(""),
|
||||||
|
renameValue = ref(""),
|
||||||
|
error = ref("");
|
||||||
|
const selection = computed(() => ({
|
||||||
|
...defaultVariantSelection(props.project.phy),
|
||||||
|
...props.project.output.variantSelection,
|
||||||
|
}));
|
||||||
|
const keys = computed(() => flattenKeys(props.project.phy, selection.value).filter((k) => !k.key.decal));
|
||||||
|
const layer = computed(
|
||||||
|
() => props.project.map.layers.find((x) => x.name === selectedLayer.value) ?? props.project.map.layers[0],
|
||||||
|
);
|
||||||
|
const keyLabels = computed(
|
||||||
|
() => new Map(keys.value.map((x) => [x.key.id, x.key.labels?.find(Boolean) || x.key.id])),
|
||||||
|
);
|
||||||
|
const assignments = computed(() => new Map((layer.value?.keymap ?? []).map((a) => [a.keyId, a.keycode])));
|
||||||
|
const selectedCode = computed(() => assignments.value.get(selectedKey.value) ?? "");
|
||||||
|
const selectedCodeEditable = computed(() => !selectedCode.value || isEditableKeycode(selectedCode.value));
|
||||||
|
const missing = computed(() =>
|
||||||
|
unassignedKeys(
|
||||||
|
props.project.map,
|
||||||
|
layer.value?.name ?? "",
|
||||||
|
keys.value.map((x) => x.key.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const candidates = computed(() => {
|
||||||
|
const keycodes = qmkBasicKeycodes
|
||||||
|
.filter((entry) => entry.group === "basic" || entry.group === "modifiers" || entry.group === "internal")
|
||||||
|
.map((entry) => entry.key);
|
||||||
|
const modifierFunctions = ["LCTL", "LSFT", "LALT", "LGUI", "RCTL", "RSFT", "RALT", "RGUI"];
|
||||||
|
const out = [...keycodes];
|
||||||
|
for (const l of props.project.map.layers)
|
||||||
|
for (const code of ["MO", "TG", "TO", "TT", "DF", "OSL"]) out.push(`${code}(${l.name})`);
|
||||||
|
for (const l of props.project.map.layers) for (const kc of keycodes) out.push(`LT(${l.name}, ${kc})`);
|
||||||
|
for (const mod of modifierFunctions) for (const kc of keycodes) out.push(`${mod}(${kc})`);
|
||||||
|
return [...new Set(out)];
|
||||||
|
});
|
||||||
|
function fail(e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
function pickLayer(name: string) {
|
||||||
|
selectedLayer.value = name;
|
||||||
|
selectedKey.value = "";
|
||||||
|
error.value = "";
|
||||||
|
}
|
||||||
|
function chooseKey(id: string) {
|
||||||
|
selectedKey.value = id;
|
||||||
|
draft.value = assignments.value.get(id) ?? "";
|
||||||
|
error.value = "";
|
||||||
|
}
|
||||||
|
function saveAssignment() {
|
||||||
|
if (props.editable === false || !selectedKey.value || !layer.value || !selectedCodeEditable.value) return;
|
||||||
|
if (draft.value.trim() === selectedCode.value) return;
|
||||||
|
try {
|
||||||
|
props.dispatch("MAPキー割り当て", (p) => ({
|
||||||
|
...p,
|
||||||
|
map: assignKey(p.map, layer.value!.name, selectedKey.value, draft.value.trim()),
|
||||||
|
}));
|
||||||
|
error.value = "";
|
||||||
|
} catch (e) {
|
||||||
|
fail(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function add() {
|
||||||
|
if (props.editable === false) return;
|
||||||
|
if (!newLayerName.value.trim()) {
|
||||||
|
error.value = "レイヤー名を入力してください。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
props.dispatch("MAPレイヤー追加", (p) => ({ ...p, map: addLayer(p.map, newLayerName.value.trim()) }));
|
||||||
|
selectedLayer.value = newLayerName.value.trim();
|
||||||
|
newLayerName.value = "";
|
||||||
|
error.value = "";
|
||||||
|
} catch (e) {
|
||||||
|
fail(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function rename() {
|
||||||
|
if (props.editable === false) return;
|
||||||
|
if (!layer.value) return;
|
||||||
|
if (!renameValue.value.trim()) {
|
||||||
|
error.value = "新しいレイヤー名を入力してください。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
props.dispatch("MAPレイヤー改名", (p) => ({
|
||||||
|
...p,
|
||||||
|
map: renameLayer(p.map, layer.value!.name, renameValue.value.trim()),
|
||||||
|
}));
|
||||||
|
selectedLayer.value = renameValue.value.trim();
|
||||||
|
renameValue.value = "";
|
||||||
|
error.value = "";
|
||||||
|
} catch (e) {
|
||||||
|
fail(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function remove() {
|
||||||
|
if (props.editable === false) return;
|
||||||
|
if (!layer.value) return;
|
||||||
|
try {
|
||||||
|
props.dispatch("MAPレイヤー削除", (p) => ({ ...p, map: deleteLayer(p.map, layer.value!.name) }));
|
||||||
|
selectedLayer.value = props.project.map.layers[0]?.name ?? "";
|
||||||
|
error.value = "";
|
||||||
|
} catch (e) {
|
||||||
|
fail(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function move(delta: number) {
|
||||||
|
if (props.editable === false) return;
|
||||||
|
if (!layer.value) return;
|
||||||
|
const i = props.project.map.layers.findIndex((x) => x.name === layer.value!.name),
|
||||||
|
j = i + delta;
|
||||||
|
if (j < 0 || j >= props.project.map.layers.length) return;
|
||||||
|
props.dispatch("MAPレイヤー順序変更", (p) => ({ ...p, map: reorderLayer(p.map, layer.value!.name, j) }));
|
||||||
|
}
|
||||||
|
function unassign() {
|
||||||
|
if (props.editable === false || !layer.value || !selectedKey.value || !selectedCodeEditable.value) return;
|
||||||
|
props.dispatch("MAP割り当て解除", (p) => ({
|
||||||
|
...p,
|
||||||
|
map: unassignKey(p.map, layer.value!.name, selectedKey.value),
|
||||||
|
}));
|
||||||
|
draft.value = "";
|
||||||
|
error.value = "";
|
||||||
|
}
|
||||||
|
function keyText(id: string) {
|
||||||
|
const code = assignments.value.get(id);
|
||||||
|
if (!code) return "";
|
||||||
|
const parsed = parseKeycode(code);
|
||||||
|
const label = parsed.kind === "basic" ? getKeycodeLabel(code) : undefined;
|
||||||
|
return label ? `${code}\n${label}` : code;
|
||||||
|
}
|
||||||
|
function keyClass(id: string) {
|
||||||
|
const code = assignments.value.get(id);
|
||||||
|
if (!code) return "warn";
|
||||||
|
return isEditableKeycode(code) ? undefined : "muted";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="map-panel">
|
||||||
|
<h2>Keymap Designer</h2>
|
||||||
|
<div class="map-columns">
|
||||||
|
<aside class="map-layers">
|
||||||
|
<h3>レイヤー</h3>
|
||||||
|
<div class="layer-tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
v-for="(item, i) in project.map.layers"
|
||||||
|
:key="item.name"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="selectedLayer === item.name"
|
||||||
|
:class="{ active: selectedLayer === item.name }"
|
||||||
|
@click="pickLayer(item.name)"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
>追加する名前
|
||||||
|
<input
|
||||||
|
v-model="newLayerName"
|
||||||
|
aria-label="追加するレイヤー名"
|
||||||
|
:disabled="editable === false" /></label
|
||||||
|
><button data-testid="map-add-layer" :disabled="editable === false" @click="add">追加</button>
|
||||||
|
<label
|
||||||
|
>新しい名前
|
||||||
|
<input
|
||||||
|
v-model="renameValue"
|
||||||
|
aria-label="新しい名前"
|
||||||
|
:placeholder="layer?.name"
|
||||||
|
:disabled="editable === false" /></label
|
||||||
|
><button data-testid="map-rename-layer" :disabled="editable === false" @click="rename">改名</button>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
aria-label="レイヤーを上へ"
|
||||||
|
:disabled="editable === false || !layer || project.map.layers[0]?.name === layer.name"
|
||||||
|
@click="move(-1)"
|
||||||
|
>
|
||||||
|
↑</button
|
||||||
|
><button
|
||||||
|
aria-label="レイヤーを下へ"
|
||||||
|
:disabled="editable === false || !layer || project.map.layers.at(-1)?.name === layer.name"
|
||||||
|
@click="move(1)"
|
||||||
|
>
|
||||||
|
↓</button
|
||||||
|
><button data-testid="map-delete-layer" :disabled="editable === false" @click="remove">削除</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" role="alert" class="map-error">{{ error }}</p>
|
||||||
|
</aside>
|
||||||
|
<main class="map-editor">
|
||||||
|
<h3>{{ layer?.name }} · キーマップ</h3>
|
||||||
|
<KeyboardView
|
||||||
|
:phy="project.phy"
|
||||||
|
:selection="selection"
|
||||||
|
:selected-ids="selectedKey ? [selectedKey] : []"
|
||||||
|
:key-text="keyText"
|
||||||
|
:key-class="keyClass"
|
||||||
|
@key-click="(id) => chooseKey(id)"
|
||||||
|
/>
|
||||||
|
<div v-if="selectedKey" class="map-assignment">
|
||||||
|
<strong>{{ keyLabels.get(selectedKey) }} · {{ selectedKey }}</strong>
|
||||||
|
<label
|
||||||
|
>キーコード
|
||||||
|
<input
|
||||||
|
v-model="draft"
|
||||||
|
list="map-keycode-candidates"
|
||||||
|
aria-label="キーコード"
|
||||||
|
@keydown.enter.prevent="saveAssignment"
|
||||||
|
:disabled="editable === false || !selectedCodeEditable"
|
||||||
|
/></label>
|
||||||
|
<datalist id="map-keycode-candidates">
|
||||||
|
<option v-for="value in candidates" :key="value" :value="value" />
|
||||||
|
</datalist>
|
||||||
|
<button @click="saveAssignment" :disabled="editable === false || !selectedCodeEditable">
|
||||||
|
割り当て</button
|
||||||
|
><button @click="unassign" :disabled="editable === false || !selectedCodeEditable">
|
||||||
|
割り当て解除
|
||||||
|
</button>
|
||||||
|
<p v-if="selectedCode && !selectedCodeEditable" role="status">
|
||||||
|
未知のキーコードは保持し、表示のみです。
|
||||||
|
</p>
|
||||||
|
<p v-if="draft && !isEditableKeycode(draft.trim())" class="map-error">
|
||||||
|
このキーコードは編集対象外です。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<section class="map-missing">
|
||||||
|
<h3>未割り当て · {{ missing.length }} keys</h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="id in missing" :key="id">
|
||||||
|
<button @click="chooseKey(id)">{{ keyLabels.get(id) }} · {{ id }}</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.map-panel {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #cbd1da;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.map-panel h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.map-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px minmax(400px, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.map-layers {
|
||||||
|
border-right: 1px solid #d5dae2;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
.map-layers h3,
|
||||||
|
.map-editor h3 {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.map-layers label,
|
||||||
|
.map-assignment label {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 9px 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.map-layers input {
|
||||||
|
min-width: 0;
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
.layer-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.layer-tabs button.active {
|
||||||
|
background: #dce9ff;
|
||||||
|
}
|
||||||
|
.map-editor {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.map-assignment {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 8px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
.map-assignment input {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
.map-missing ul {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.map-error {
|
||||||
|
color: #a21d16;
|
||||||
|
background: #fff0ef;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
.map-panel :deep(.keyboard-view) {
|
||||||
|
max-height: 48vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Node } from "../../domain/phy";
|
||||||
|
import { keyPath } from "./logic";
|
||||||
|
defineOptions({ name: "NodeSvg" });
|
||||||
|
defineProps<{
|
||||||
|
nodes: Node[];
|
||||||
|
selected: string[];
|
||||||
|
variants: Record<string, string>;
|
||||||
|
cut: string[];
|
||||||
|
dim: boolean;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
down: [e: PointerEvent, n: Node, el: SVGGElement];
|
||||||
|
focus: [id: string];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<g
|
||||||
|
v-for="n in nodes"
|
||||||
|
:key="n.id"
|
||||||
|
:data-node-type="n.type"
|
||||||
|
:data-node-id="n.id"
|
||||||
|
:transform="`translate(${n.transform.x} ${n.transform.y}) rotate(${n.transform.rotation})`"
|
||||||
|
:class="{ dim: dim, cut: cut.includes(n.id) }"
|
||||||
|
@dblclick.stop="emit('focus', n.id)"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
v-if="n.type === 'key'"
|
||||||
|
class="node"
|
||||||
|
@pointerdown.stop="emit('down', $event, n, $event.currentTarget as SVGGElement)"
|
||||||
|
><path
|
||||||
|
v-if="keyPath(n.size)"
|
||||||
|
:d="keyPath(n.size)"
|
||||||
|
class="key-face"
|
||||||
|
:class="{ chosen: selected.includes(n.id), ghost: n.ghost, decal: n.decal }"
|
||||||
|
/><rect
|
||||||
|
v-else
|
||||||
|
:width="n.size.w"
|
||||||
|
:height="n.size.h"
|
||||||
|
rx=".06"
|
||||||
|
class="key-face"
|
||||||
|
:class="{ chosen: selected.includes(n.id), ghost: n.ghost, decal: n.decal }"
|
||||||
|
/><text class="key-label" x=".08" y=".24">{{ n.labels?.find(Boolean) }}</text></g
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
v-else-if="n.type === 'component'"
|
||||||
|
class="node"
|
||||||
|
@pointerdown.stop="emit('down', $event, n, $event.currentTarget as SVGGElement)"
|
||||||
|
><rect
|
||||||
|
v-if="n.partGroup === '@led'"
|
||||||
|
x="-.14"
|
||||||
|
y="-.14"
|
||||||
|
width=".28"
|
||||||
|
height=".28"
|
||||||
|
transform="rotate(45)"
|
||||||
|
class="part led"
|
||||||
|
:class="{ chosen: selected.includes(n.id) }"
|
||||||
|
/><g v-else-if="n.partGroup === '@encoder'" class="part" :class="{ chosen: selected.includes(n.id) }"
|
||||||
|
><circle r=".16" /><path d="M -.1 0 H .1 M 0 -.1 V .1" /></g
|
||||||
|
><path
|
||||||
|
v-else-if="n.partGroup === '@screw'"
|
||||||
|
d="M -.14 -.14 L .14 .14 M .14 -.14 L -.14 .14"
|
||||||
|
class="part screw"
|
||||||
|
:class="{ chosen: selected.includes(n.id) }"
|
||||||
|
/><rect
|
||||||
|
v-else-if="n.partGroup === '@oled'"
|
||||||
|
x="-.2"
|
||||||
|
y="-.12"
|
||||||
|
width=".4"
|
||||||
|
height=".24"
|
||||||
|
rx=".025"
|
||||||
|
class="part oled"
|
||||||
|
:class="{ chosen: selected.includes(n.id) }"
|
||||||
|
/><circle v-else r=".14" class="part" :class="{ chosen: selected.includes(n.id) }" /><text
|
||||||
|
x=".2"
|
||||||
|
y=".04"
|
||||||
|
class="part-label"
|
||||||
|
>{{ n.partGroup || "part" }}</text
|
||||||
|
></g
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
v-else-if="n.type === 'cluster'"
|
||||||
|
class="group"
|
||||||
|
@pointerdown.self="emit('down', $event, n, $event.currentTarget as SVGGElement)"
|
||||||
|
><NodeSvg
|
||||||
|
:nodes="n.children"
|
||||||
|
:selected="selected"
|
||||||
|
:variants="variants"
|
||||||
|
:cut="cut"
|
||||||
|
:dim="dim"
|
||||||
|
@down="(e, c, el) => emit('down', e, c, el)"
|
||||||
|
@focus="(id) => emit('focus', id)"
|
||||||
|
/><text x="0" y="-.12" class="group-label">{{ n.name }}</text></g
|
||||||
|
>
|
||||||
|
<g v-else class="group"
|
||||||
|
><NodeSvg
|
||||||
|
v-for="v in n.children.filter((v) => v.id === (variants[n.id] || n.default))"
|
||||||
|
:key="v.id"
|
||||||
|
:nodes="v.children"
|
||||||
|
:selected="selected"
|
||||||
|
:variants="variants"
|
||||||
|
:cut="cut"
|
||||||
|
:dim="dim"
|
||||||
|
@down="(e, c, el) => emit('down', e, c, el)"
|
||||||
|
@focus="(id) => emit('focus', id)"
|
||||||
|
/></g>
|
||||||
|
</g>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.node {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.key-face {
|
||||||
|
fill: #f8cc78;
|
||||||
|
stroke: #5b4a2b;
|
||||||
|
stroke-width: 0.03;
|
||||||
|
}
|
||||||
|
.key-face.ghost {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.key-face.decal {
|
||||||
|
fill: #fff;
|
||||||
|
stroke-dasharray: 0.08 0.06;
|
||||||
|
}
|
||||||
|
.key-label,
|
||||||
|
.part-label,
|
||||||
|
.group-label {
|
||||||
|
font-size: 0.14px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.part {
|
||||||
|
fill: #b4d5ef;
|
||||||
|
stroke: #315d7a;
|
||||||
|
stroke-width: 0.03;
|
||||||
|
}
|
||||||
|
.part.chosen {
|
||||||
|
stroke: #1769d2;
|
||||||
|
stroke-width: 2px;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.part.screw {
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
.part path {
|
||||||
|
fill: none;
|
||||||
|
stroke: #315d7a;
|
||||||
|
stroke-width: 0.03;
|
||||||
|
}
|
||||||
|
.group {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
.cut {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.group-label {
|
||||||
|
fill: #222;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { Node } from "../../domain/phy";
|
||||||
|
import type { Affine } from "../../domain/geometry";
|
||||||
|
import { variantBadges } from "./logic";
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
nodes: Node[];
|
||||||
|
variants: Record<string, string>;
|
||||||
|
zoom: number;
|
||||||
|
dim?: boolean;
|
||||||
|
parent?: Affine;
|
||||||
|
}>(),
|
||||||
|
{ dim: false, parent: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) },
|
||||||
|
);
|
||||||
|
const emit = defineEmits<{ badge: [g: Node, e: MouseEvent] }>();
|
||||||
|
// ノードより後に描き、隣のキーの下に隠れないようにする
|
||||||
|
const badges = computed(() => variantBadges(props.nodes, props.variants, props.zoom, props.parent));
|
||||||
|
/** バッジは名前を書かず、表示中のバリアント名はツールチップで示す。T-30。 */
|
||||||
|
function badgeTitle(n: Extract<Node, { type: "variantGroup" }>) {
|
||||||
|
const shown = props.variants[n.id] || n.default;
|
||||||
|
const name = n.children.find((v) => v.id === shown)?.name ?? "";
|
||||||
|
return `${n.name}: ${name}${shown === n.default ? "(既定)" : ""}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<g class="badge-layer" :class="{ dim }">
|
||||||
|
<g
|
||||||
|
v-for="badge in badges"
|
||||||
|
:key="badge.group.id"
|
||||||
|
:data-badge-id="badge.group.id"
|
||||||
|
class="badge"
|
||||||
|
@click.stop="!dim && emit('badge', badge.group, $event)"
|
||||||
|
><rect :x="badge.x" :y="badge.y" :width="badge.size" :height="badge.size" :rx="3 / zoom" /><text
|
||||||
|
:x="badge.x + badge.size / 2"
|
||||||
|
:y="badge.y + badge.size / 2"
|
||||||
|
:style="{ fontSize: `${12 / zoom}px` }"
|
||||||
|
>⇄</text
|
||||||
|
><title>{{ badgeTitle(badge.group) }}</title></g
|
||||||
|
>
|
||||||
|
</g>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.badge {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
.dim {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
.dim .badge {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.badge rect {
|
||||||
|
fill: #fff3c2;
|
||||||
|
stroke: #8a691b;
|
||||||
|
stroke-width: 1px;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.badge text {
|
||||||
|
fill: #302709;
|
||||||
|
text-anchor: middle;
|
||||||
|
dominant-baseline: central;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||||
|
import type { ProjectFile } from "../../domain/project";
|
||||||
|
import type { Node } from "../../domain/phy";
|
||||||
|
import type { Dispatch } from "../types";
|
||||||
|
import { reframe } from "../../domain/phyCommands";
|
||||||
|
import { listDeleteReferences } from "../../domain/deleteCascade";
|
||||||
|
import PhyTree from "./PhyTree.vue";
|
||||||
|
import NodeSvg from "./NodeSvg.vue";
|
||||||
|
import PhySelectionLayer from "./PhySelectionLayer.vue";
|
||||||
|
import PhyBadgeLayer from "./PhyBadgeLayer.vue";
|
||||||
|
import PhyToolbar from "./PhyToolbar.vue";
|
||||||
|
import PhyProperties from "./PhyProperties.vue";
|
||||||
|
import PhyHoldOverlay from "./PhyHoldOverlay.vue";
|
||||||
|
import PhyMountLayer from "./PhyMountLayer.vue";
|
||||||
|
import {
|
||||||
|
normalizeSelection,
|
||||||
|
nextGrid,
|
||||||
|
insertionContainer,
|
||||||
|
structuralNodes,
|
||||||
|
visibleNodeIds,
|
||||||
|
stabilizerSelections,
|
||||||
|
selectedStabilizers,
|
||||||
|
withStabilizers,
|
||||||
|
} from "./logic";
|
||||||
|
import { stabilizerCandidates } from "../../domain/stabilizers";
|
||||||
|
import { usePhyClipboard } from "./usePhyClipboard";
|
||||||
|
import { usePhyViewport } from "./usePhyViewport";
|
||||||
|
import { usePhyCanvasPointer } from "./usePhyCanvasPointer";
|
||||||
|
import { HOLD_DELAY, HOLD_INTERVAL, type HoldMode } from "./usePhyLongPress";
|
||||||
|
import { usePhyKeyboard } from "./usePhyKeyboard";
|
||||||
|
import { usePhyCommands } from "./usePhyCommands";
|
||||||
|
import { usePhyCanvasView } from "./usePhyCanvasView";
|
||||||
|
import { usePhyTreeModel } from "./usePhyTreeModel";
|
||||||
|
const props = defineProps<{
|
||||||
|
project: ProjectFile;
|
||||||
|
dispatch: Dispatch;
|
||||||
|
mismatch: boolean;
|
||||||
|
beginHistory: () => void;
|
||||||
|
commitHistory: () => void;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ error: [text: string] }>();
|
||||||
|
const selected = ref<string[]>([]),
|
||||||
|
variants = ref<Record<string, string>>({}),
|
||||||
|
focus = ref<string | null>(null),
|
||||||
|
grid = ref<number>(0.25),
|
||||||
|
zoom = ref(52),
|
||||||
|
pan = ref({ x: 50, y: 50 }),
|
||||||
|
pane = ref<HTMLElement>(),
|
||||||
|
svg = ref<SVGSVGElement>(),
|
||||||
|
treeOpen = ref(true),
|
||||||
|
showHelp = ref(false),
|
||||||
|
popover = ref<string | null>(null),
|
||||||
|
cut = ref<string[]>([]),
|
||||||
|
confirm = ref<string | null>(null),
|
||||||
|
confirmIds = ref<string[]>([]),
|
||||||
|
confirmRefs = ref<ReturnType<typeof listDeleteReferences>>([]),
|
||||||
|
confirmPos = ref({ left: 12, top: 44 }),
|
||||||
|
popoverPos = ref({ left: 12, top: 44 }),
|
||||||
|
size = ref({ w: 900, h: 600 }),
|
||||||
|
longHint = ref("");
|
||||||
|
watch(
|
||||||
|
() => props.project,
|
||||||
|
(p) => {
|
||||||
|
for (const g of structuralNodes(p.phy.root))
|
||||||
|
if (g.type === "variantGroup" && !variants.value[g.id]) variants.value[g.id] = g.default;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => props.project.projectId,
|
||||||
|
async () => {
|
||||||
|
selected.value = [];
|
||||||
|
focus.value = null;
|
||||||
|
variants.value = {};
|
||||||
|
await nextTick();
|
||||||
|
fit();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
allNodes,
|
||||||
|
node,
|
||||||
|
parentId,
|
||||||
|
isAncestor,
|
||||||
|
selectedContainer,
|
||||||
|
groupForVariant,
|
||||||
|
parentLabel,
|
||||||
|
pressTargets,
|
||||||
|
} = usePhyTreeModel(() => props.project, focus);
|
||||||
|
const { fit, fitSelected, localPoint, wheel } = usePhyViewport({
|
||||||
|
project: () => props.project,
|
||||||
|
svg,
|
||||||
|
size,
|
||||||
|
zoom,
|
||||||
|
pan,
|
||||||
|
selected,
|
||||||
|
variants,
|
||||||
|
node,
|
||||||
|
});
|
||||||
|
function setSel(id: string, add = false) {
|
||||||
|
selected.value = normalizeSelection(
|
||||||
|
props.project.phy,
|
||||||
|
add
|
||||||
|
? selected.value.includes(id)
|
||||||
|
? selected.value.filter((x) => x !== id)
|
||||||
|
: [...selected.value, id]
|
||||||
|
: [id],
|
||||||
|
);
|
||||||
|
pane.value?.focus();
|
||||||
|
}
|
||||||
|
const canvasView = usePhyCanvasView({
|
||||||
|
project: () => props.project,
|
||||||
|
size,
|
||||||
|
pan,
|
||||||
|
zoom,
|
||||||
|
focus,
|
||||||
|
variants,
|
||||||
|
grid,
|
||||||
|
selected,
|
||||||
|
node,
|
||||||
|
parentId,
|
||||||
|
parentLabel,
|
||||||
|
selectedContainer,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
keys,
|
||||||
|
groupsAll,
|
||||||
|
outsideNodes,
|
||||||
|
gridLines,
|
||||||
|
variantGroups,
|
||||||
|
focusGridLines,
|
||||||
|
moveTargets,
|
||||||
|
parentMatrix: focusParentMatrix,
|
||||||
|
affineText,
|
||||||
|
focusCrumbs,
|
||||||
|
propertyNode: prop,
|
||||||
|
} = canvasView;
|
||||||
|
function focusIn(id: string) {
|
||||||
|
focus.value = id;
|
||||||
|
selected.value = [id];
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("keydown", keyHandler);
|
||||||
|
window.addEventListener("keyup", spaceUp);
|
||||||
|
window.addEventListener("paste", pasteEvent);
|
||||||
|
window.addEventListener("copy", copyEvent);
|
||||||
|
window.addEventListener("cut", copyEvent);
|
||||||
|
fit();
|
||||||
|
});
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("keydown", keyHandler);
|
||||||
|
window.removeEventListener("keyup", spaceUp);
|
||||||
|
window.removeEventListener("paste", pasteEvent);
|
||||||
|
window.removeEventListener("copy", copyEvent);
|
||||||
|
window.removeEventListener("cut", copyEvent);
|
||||||
|
});
|
||||||
|
function dispatch(label: string, fn: (p: ProjectFile) => ProjectFile) {
|
||||||
|
try {
|
||||||
|
props.dispatch(label, fn);
|
||||||
|
} catch (e) {
|
||||||
|
emit("error", String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function update(id: string, fn: (n: Node) => void, label = "プロパティ変更") {
|
||||||
|
dispatch(label, (p) => {
|
||||||
|
const q = structuredClone(p);
|
||||||
|
const n = allNodes(q.phy.root).find((x) => x.id === id);
|
||||||
|
if (n) fn(n);
|
||||||
|
return q;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const commands = usePhyCommands({
|
||||||
|
project: () => props.project,
|
||||||
|
selected,
|
||||||
|
variants,
|
||||||
|
focus,
|
||||||
|
grid,
|
||||||
|
zoom,
|
||||||
|
pan,
|
||||||
|
size,
|
||||||
|
svg,
|
||||||
|
pane,
|
||||||
|
popover,
|
||||||
|
confirm,
|
||||||
|
confirmIds,
|
||||||
|
confirmRefs,
|
||||||
|
confirmPos,
|
||||||
|
node,
|
||||||
|
allNodes,
|
||||||
|
parentId,
|
||||||
|
selectedContainer,
|
||||||
|
groupForVariant,
|
||||||
|
variantGroups: () => variantGroups.value,
|
||||||
|
dispatch,
|
||||||
|
emitError: (message) => emit("error", message),
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
parentMatrix,
|
||||||
|
nodesInContainer,
|
||||||
|
directChildren,
|
||||||
|
tabItem,
|
||||||
|
chooseVariant,
|
||||||
|
keyAdd,
|
||||||
|
presetAdd,
|
||||||
|
partAdd,
|
||||||
|
makeCluster,
|
||||||
|
ungroup,
|
||||||
|
variantGroup,
|
||||||
|
duplicate,
|
||||||
|
moveSelectionTo,
|
||||||
|
askDelete,
|
||||||
|
deleteConfirmed,
|
||||||
|
setDefault,
|
||||||
|
addVariant: addVariantToGroup,
|
||||||
|
} = commands;
|
||||||
|
const { copyEvent, pasteEvent } = usePhyClipboard({
|
||||||
|
project: () => props.project,
|
||||||
|
mismatch: () => props.mismatch,
|
||||||
|
selected,
|
||||||
|
cut,
|
||||||
|
grid,
|
||||||
|
node,
|
||||||
|
selectedContainer,
|
||||||
|
insertionParent: () => insertionContainer(props.project.phy.root, focus.value, variants.value),
|
||||||
|
parentMatrix,
|
||||||
|
dispatch,
|
||||||
|
setSelected: (ids) => (selected.value = ids),
|
||||||
|
});
|
||||||
|
const priorChild = new Map<string, string>();
|
||||||
|
function visibleIds(): Set<string> {
|
||||||
|
if (!focus.value) return new Set(allNodes().map((n) => n.id));
|
||||||
|
return visibleNodeIds(node(focus.value), variants.value);
|
||||||
|
}
|
||||||
|
const holdMode = ref<HoldMode>("loop");
|
||||||
|
const rangeBox = ref<{ x: number; y: number; width: number; height: number } | null>(null);
|
||||||
|
const pointer = usePhyCanvasPointer({
|
||||||
|
project: () => props.project,
|
||||||
|
svg,
|
||||||
|
pane,
|
||||||
|
pan,
|
||||||
|
zoom,
|
||||||
|
grid,
|
||||||
|
selected,
|
||||||
|
focus,
|
||||||
|
rangeBox,
|
||||||
|
allNodes,
|
||||||
|
keys: () => keys.value,
|
||||||
|
localPoint,
|
||||||
|
visibleIds,
|
||||||
|
mismatch: () => props.mismatch,
|
||||||
|
isAncestor,
|
||||||
|
parentLabel,
|
||||||
|
pressTargets,
|
||||||
|
holdMode,
|
||||||
|
setSelection: setSel,
|
||||||
|
beginHistory: props.beginHistory,
|
||||||
|
commitHistory: props.commitHistory,
|
||||||
|
dispatch,
|
||||||
|
});
|
||||||
|
const { canvasDown, down, spaceUp, hold, spaceHeld } = pointer;
|
||||||
|
const keyHandler = usePhyKeyboard({
|
||||||
|
project: () => props.project,
|
||||||
|
mismatch: () => props.mismatch,
|
||||||
|
pane,
|
||||||
|
selected,
|
||||||
|
focus,
|
||||||
|
grid,
|
||||||
|
zoom,
|
||||||
|
showHelp,
|
||||||
|
popover,
|
||||||
|
confirm,
|
||||||
|
spaceHeld,
|
||||||
|
variants,
|
||||||
|
priorChild,
|
||||||
|
node,
|
||||||
|
allNodes,
|
||||||
|
parentId,
|
||||||
|
selectedContainer,
|
||||||
|
insertionParent: () => insertionContainer(props.project.phy.root, focus.value, variants.value),
|
||||||
|
nodesInContainer,
|
||||||
|
directChildren,
|
||||||
|
tabItem,
|
||||||
|
dispatch,
|
||||||
|
fit,
|
||||||
|
fitSelected,
|
||||||
|
focusIn,
|
||||||
|
makeCluster,
|
||||||
|
ungroup,
|
||||||
|
duplicate,
|
||||||
|
keyAdd,
|
||||||
|
askDelete,
|
||||||
|
deleteConfirmed,
|
||||||
|
chooseVariant,
|
||||||
|
});
|
||||||
|
function overlayPoint(clientX: number, clientY: number, width = 380, height = 240) {
|
||||||
|
const r = pane.value?.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
left: Math.max(4, Math.min(clientX - (r?.left ?? 0), (r?.width ?? width) - width - 4)),
|
||||||
|
top: Math.max(4, Math.min(clientY - (r?.top ?? 0), (r?.height ?? height) - height - 4)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function openBadge(g: Node, e: PointerEvent | MouseEvent) {
|
||||||
|
if (focus.value && !visibleIds().has(g.id)) return;
|
||||||
|
popover.value = g.id;
|
||||||
|
const r = (e.currentTarget as Element).getBoundingClientRect();
|
||||||
|
const paneRect = pane.value?.getBoundingClientRect();
|
||||||
|
const left = r.right + 210 < (paneRect?.right ?? Infinity) ? r.left : r.left - 210;
|
||||||
|
const top = r.bottom + 150 < (paneRect?.bottom ?? Infinity) ? r.bottom + 4 : r.top - 150;
|
||||||
|
popoverPos.value = overlayPoint(left, top, 210, 150);
|
||||||
|
}
|
||||||
|
const showMounts = ref(false);
|
||||||
|
const stabilizerChoice = computed(() => {
|
||||||
|
const item = prop.value;
|
||||||
|
if (item?.type !== "key") return null;
|
||||||
|
return {
|
||||||
|
candidates: stabilizerCandidates(item.size),
|
||||||
|
selected: selectedStabilizers(stabilizerSelections(props.project), item),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
function onFocus(_id: string) {
|
||||||
|
if (
|
||||||
|
pointer.priorSelection().length === 1 &&
|
||||||
|
["cluster", "variantGroup"].includes(node(pointer.priorSelection()[0]!)?.type || "")
|
||||||
|
)
|
||||||
|
focusIn(pointer.priorSelection()[0]!);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="workspace" :class="{ locked: mismatch, 'tree-closed': !treeOpen }">
|
||||||
|
<aside v-if="treeOpen" class="tree pane">
|
||||||
|
<PhyTree :nodes="project.phy.root" :selected="selected" @select="setSel" />
|
||||||
|
</aside>
|
||||||
|
<section ref="pane" class="canvas-pane pane" tabindex="0">
|
||||||
|
<PhyToolbar
|
||||||
|
:mismatch="mismatch"
|
||||||
|
:selected-count="selected.length"
|
||||||
|
:grid="grid"
|
||||||
|
:move-targets="moveTargets"
|
||||||
|
:focus="focus"
|
||||||
|
:crumbs="focus ? focusCrumbs(focus) : []"
|
||||||
|
@key-add="keyAdd()"
|
||||||
|
@preset-add="presetAdd"
|
||||||
|
@part-add="partAdd"
|
||||||
|
@cluster="makeCluster"
|
||||||
|
@ungroup="ungroup"
|
||||||
|
@variant-group="variantGroup"
|
||||||
|
@move-to="moveSelectionTo"
|
||||||
|
@duplicate="duplicate"
|
||||||
|
@delete="(event) => askDelete(undefined, event)"
|
||||||
|
@grid="grid = nextGrid(grid, 1)"
|
||||||
|
@fit="fit"
|
||||||
|
@fit-selected="fitSelected"
|
||||||
|
@zoom="(direction) => (zoom *= direction > 0 ? 1.15 : 1 / 1.15)"
|
||||||
|
@tree="treeOpen = !treeOpen"
|
||||||
|
@help="showHelp = true"
|
||||||
|
@focus="focus = $event"
|
||||||
|
:hold-mode="holdMode"
|
||||||
|
@hold-mode="holdMode = holdMode === 'loop' ? 'list' : 'loop'"
|
||||||
|
:show-mounts="showMounts"
|
||||||
|
@mounts="showMounts = !showMounts"
|
||||||
|
/>
|
||||||
|
<PhyHoldOverlay
|
||||||
|
v-if="hold.open.value"
|
||||||
|
:mode="hold.mode.value"
|
||||||
|
:pos="hold.pos.value"
|
||||||
|
:names="hold.names.value"
|
||||||
|
:index="hold.index.value"
|
||||||
|
:step="hold.step.value"
|
||||||
|
:pointing="hold.pointing.value"
|
||||||
|
/>
|
||||||
|
<div v-if="mismatch" class="warning">unitSize が一致しないため編集できません。</div>
|
||||||
|
<svg
|
||||||
|
ref="svg"
|
||||||
|
class="canvas"
|
||||||
|
:viewBox="`0 0 ${size.w} ${size.h}`"
|
||||||
|
@wheel.prevent="wheel"
|
||||||
|
@pointerdown="canvasDown"
|
||||||
|
>
|
||||||
|
<g :transform="`translate(${pan.x} ${pan.y}) scale(${zoom})`">
|
||||||
|
<path
|
||||||
|
v-if="!focus"
|
||||||
|
v-for="(line, i) in gridLines"
|
||||||
|
:key="i"
|
||||||
|
:d="line.d"
|
||||||
|
:class="{ major: line.major }"
|
||||||
|
/>
|
||||||
|
<NodeSvg
|
||||||
|
v-if="focus && outsideNodes.length"
|
||||||
|
:nodes="outsideNodes"
|
||||||
|
:selected="[]"
|
||||||
|
:variants="variants"
|
||||||
|
:cut="cut"
|
||||||
|
:dim="true"
|
||||||
|
/>
|
||||||
|
<g v-if="focus" :transform="affineText(focusParentMatrix(focus))">
|
||||||
|
<g
|
||||||
|
:transform="`translate(${node(focus)!.transform.x} ${node(focus)!.transform.y}) rotate(${node(focus)!.transform.rotation})`"
|
||||||
|
>
|
||||||
|
<path v-for="(line, i) in focusGridLines" :key="i" :d="line.d" :class="{ major: line.major }" />
|
||||||
|
</g>
|
||||||
|
<NodeSvg
|
||||||
|
:nodes="[node(focus)!]"
|
||||||
|
:selected="selected"
|
||||||
|
:variants="variants"
|
||||||
|
:cut="cut"
|
||||||
|
:dim="false"
|
||||||
|
@down="down"
|
||||||
|
@focus="onFocus"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<NodeSvg
|
||||||
|
v-else
|
||||||
|
:nodes="project.phy.root"
|
||||||
|
:selected="selected"
|
||||||
|
:variants="variants"
|
||||||
|
:cut="cut"
|
||||||
|
:dim="false"
|
||||||
|
@down="down"
|
||||||
|
@focus="onFocus"
|
||||||
|
/>
|
||||||
|
<PhyMountLayer
|
||||||
|
v-if="showMounts"
|
||||||
|
:nodes="focus ? [node(focus)!] : project.phy.root"
|
||||||
|
:variants="variants"
|
||||||
|
:selections="stabilizerSelections(project)"
|
||||||
|
:zoom="zoom"
|
||||||
|
:parent="focus ? focusParentMatrix(focus) : undefined"
|
||||||
|
/>
|
||||||
|
<PhySelectionLayer
|
||||||
|
:nodes="focus ? [node(focus)!] : project.phy.root"
|
||||||
|
:selected="selected"
|
||||||
|
:variants="variants"
|
||||||
|
:zoom="zoom"
|
||||||
|
:candidate="hold.open.value ? (hold.candidates.value[hold.index.value] ?? null) : null"
|
||||||
|
:parent="focus ? focusParentMatrix(focus) : undefined"
|
||||||
|
/>
|
||||||
|
<PhyBadgeLayer
|
||||||
|
v-if="focus && outsideNodes.length"
|
||||||
|
:nodes="outsideNodes"
|
||||||
|
:variants="variants"
|
||||||
|
:zoom="zoom"
|
||||||
|
:dim="true"
|
||||||
|
/>
|
||||||
|
<PhyBadgeLayer
|
||||||
|
:nodes="focus ? [node(focus)!] : project.phy.root"
|
||||||
|
:variants="variants"
|
||||||
|
:zoom="zoom"
|
||||||
|
:parent="focus ? focusParentMatrix(focus) : undefined"
|
||||||
|
@badge="openBadge"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<rect
|
||||||
|
v-if="rangeBox"
|
||||||
|
class="range-box"
|
||||||
|
:x="rangeBox.x"
|
||||||
|
:y="rangeBox.y"
|
||||||
|
:width="rangeBox.width"
|
||||||
|
:height="rangeBox.height"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
v-if="confirm"
|
||||||
|
class="confirm-float"
|
||||||
|
:style="{ left: confirmPos.left + 'px', top: confirmPos.top + 'px' }"
|
||||||
|
>
|
||||||
|
<b>削除の確認</b>
|
||||||
|
<p>参照 {{ confirmRefs.length }}件</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(r, i) in confirmRefs" :key="i">{{ r.layer }} · {{ r.id }} · {{ r.location }}</li>
|
||||||
|
</ul>
|
||||||
|
<button @click="deleteConfirmed">削除</button><button @click="confirm = null">取消</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="popover"
|
||||||
|
class="popover"
|
||||||
|
:style="{ left: popoverPos.left + 'px', top: popoverPos.top + 'px' }"
|
||||||
|
>
|
||||||
|
<template v-for="g in variantGroups.filter((n) => n.id === popover)" :key="g.id"
|
||||||
|
><button v-for="v in g.children" :key="v.id" @click="chooseVariant(g, v.id)">
|
||||||
|
{{ v.name }}<span v-if="g.default === v.id" class="default-tag">既定</span>
|
||||||
|
</button></template
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div v-if="showHelp" class="overlay" @click.self="showHelp = false">
|
||||||
|
<article>
|
||||||
|
<button @click="showHelp = false">閉じる</button>
|
||||||
|
<h2>ショートカット</h2>
|
||||||
|
<p>Esc 選択解除・階層を戻る · Alt+↑/↓ 親・子 · Tab/Shift+Tab 兄弟 · Ctrl+A 同じ親を選択</p>
|
||||||
|
<p>矢印 移動 · Shift+矢印 サイズ · PgUp/PgDn 回転 · R/Shift+R キーの向き</p>
|
||||||
|
<p>
|
||||||
|
Ctrl+G クラスター · Ctrl+Shift+G 解除 · Ctrl+C/X/V コピー・カット・貼り付け · Ctrl+D 複製 · Ins
|
||||||
|
追加 · Delete 削除
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Shift+1 全体表示 · Shift+2 選択表示 · +/- ズーム · G/Shift+G 刻み · V/Shift+V バリアント · Enter
|
||||||
|
フォーカス · F2 プロパティ
|
||||||
|
</p>
|
||||||
|
<p>Ctrl+Z/Y/S Undo・Redo・保存 · ? 一覧</p>
|
||||||
|
<p>
|
||||||
|
長押し: キー上で{{ HOLD_DELAY }}ms待つと選択先候補が{{
|
||||||
|
HOLD_INTERVAL
|
||||||
|
}}msごとに進み、離すと確定します。
|
||||||
|
ループ方式では、そのまま動かすと選んだ先をドラッグします。リスト方式では、リストに入ると巡回が止まり、指している行で離すと確定します。
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<PhyProperties
|
||||||
|
:selected-count="selected.length"
|
||||||
|
:selected-id="selected[0] ?? null"
|
||||||
|
:item="prop ?? null"
|
||||||
|
:mismatch="mismatch"
|
||||||
|
@update="update"
|
||||||
|
@apply-frame="(id, frame) => dispatch('座標枠変更', (p) => ({ ...p, phy: reframe(p.phy, id, frame) }))"
|
||||||
|
@set-default="(groupId, variantId) => setDefault(groupId, variantId)"
|
||||||
|
@change-variant="chooseVariant"
|
||||||
|
@delete-node="(id) => askDelete(id)"
|
||||||
|
@add-variant="(groupId, name) => addVariantToGroup(groupId, name)"
|
||||||
|
:stabilizers="stabilizerChoice"
|
||||||
|
@stabilizers="(keyId, ids) => dispatch('スタビ位置', (p) => withStabilizers(p, keyId, ids))"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.workspace {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px minmax(300px, 1fr) 250px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.workspace.tree-closed {
|
||||||
|
grid-template-columns: minmax(300px, 1fr) 250px;
|
||||||
|
}
|
||||||
|
.pane {
|
||||||
|
padding: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #cbd1da;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.canvas-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
background: #fbfcfe;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.canvas path {
|
||||||
|
stroke: #e6eaf0;
|
||||||
|
stroke-width: 0.015;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
.canvas path.major {
|
||||||
|
stroke: #ccd3de;
|
||||||
|
stroke-width: 0.03;
|
||||||
|
}
|
||||||
|
.range-box {
|
||||||
|
fill: #4b8de822;
|
||||||
|
stroke: #1769d2;
|
||||||
|
stroke-width: 1px;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.properties fieldset {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.properties label {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.properties input {
|
||||||
|
width: 110px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.popover,
|
||||||
|
.confirm-float,
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #99a3b2;
|
||||||
|
box-shadow: 0 4px 15px #0003;
|
||||||
|
}
|
||||||
|
.confirm-float {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 6;
|
||||||
|
width: min(380px, calc(100% - 16px));
|
||||||
|
max-height: min(240px, calc(100% - 16px));
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #20242a;
|
||||||
|
}
|
||||||
|
.confirm-float ul {
|
||||||
|
max-height: 120px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
inset: 20%;
|
||||||
|
}
|
||||||
|
.tree-list {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
.confirm-float rect {
|
||||||
|
fill: #fff2dc;
|
||||||
|
stroke: #db9d39;
|
||||||
|
}
|
||||||
|
.confirm-float foreignObject div {
|
||||||
|
font: 1.5px sans-serif;
|
||||||
|
}
|
||||||
|
.confirm-float ul {
|
||||||
|
max-height: 1px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.workspace.locked .properties {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.warning {
|
||||||
|
color: #9a4c00;
|
||||||
|
}
|
||||||
|
.default-tag {
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 1px solid #8a691b;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b520f;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { HOLD_INTERVAL, type HoldMode } from "./usePhyLongPress";
|
||||||
|
defineProps<{
|
||||||
|
mode: HoldMode;
|
||||||
|
pos: { x: number; y: number };
|
||||||
|
names: string[];
|
||||||
|
index: number;
|
||||||
|
step: number;
|
||||||
|
pointing: boolean;
|
||||||
|
}>();
|
||||||
|
// 円の周長。インジケータは次の切り替えまでに一周する
|
||||||
|
const ring = 2 * Math.PI * 14;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
v-if="!pointing"
|
||||||
|
:key="step"
|
||||||
|
class="hold-ring"
|
||||||
|
width="36"
|
||||||
|
height="36"
|
||||||
|
:style="{ left: pos.x - 18 + 'px', top: pos.y - 18 + 'px' }"
|
||||||
|
>
|
||||||
|
<circle cx="18" cy="18" r="14" class="track" />
|
||||||
|
<circle
|
||||||
|
cx="18"
|
||||||
|
cy="18"
|
||||||
|
r="14"
|
||||||
|
class="progress"
|
||||||
|
:stroke-dasharray="ring"
|
||||||
|
:style="{ '--ring': ring, animationDuration: HOLD_INTERVAL + 'ms' }"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<ol
|
||||||
|
class="hold-list"
|
||||||
|
:class="mode"
|
||||||
|
:data-hold-mode="mode"
|
||||||
|
:style="{ left: pos.x + 22 + 'px', top: pos.y + 10 + 'px' }"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(name, i) in names"
|
||||||
|
:key="i"
|
||||||
|
class="hold-row"
|
||||||
|
:class="{ active: i === index }"
|
||||||
|
:data-hold-index="i"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hold-ring {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.track,
|
||||||
|
.progress {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
.track {
|
||||||
|
stroke: rgb(200 125 19 / 25%);
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
stroke: #c87d13;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
transform-origin: 18px 18px;
|
||||||
|
animation: hold-ring linear forwards;
|
||||||
|
}
|
||||||
|
@keyframes hold-ring {
|
||||||
|
from {
|
||||||
|
stroke-dashoffset: var(--ring);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.hold-list {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3;
|
||||||
|
margin: 0;
|
||||||
|
padding: 3px 0;
|
||||||
|
list-style: none;
|
||||||
|
background: #fff3d8;
|
||||||
|
border: 1px solid #c87d13;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.hold-list.loop {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.hold-row {
|
||||||
|
padding: 3px 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hold-row.active {
|
||||||
|
background: #c87d13;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { Node } from "../../domain/phy";
|
||||||
|
import type { Affine } from "../../domain/geometry";
|
||||||
|
import { mountMarks, type StabilizerSelections } from "./logic";
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
nodes: Node[];
|
||||||
|
variants: Record<string, string>;
|
||||||
|
selections: StabilizerSelections;
|
||||||
|
zoom: number;
|
||||||
|
parent?: Affine;
|
||||||
|
}>(),
|
||||||
|
{ parent: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) },
|
||||||
|
);
|
||||||
|
const marks = computed(() => mountMarks(props.nodes, props.variants, props.selections, props.parent));
|
||||||
|
// 十字の大きさは画面上で固定する。
|
||||||
|
const cross = (p: { x: number; y: number }) => {
|
||||||
|
const r = 6 / props.zoom;
|
||||||
|
return `M ${p.x - r} ${p.y} H ${p.x + r} M ${p.x} ${p.y - r} V ${p.y + r}`;
|
||||||
|
};
|
||||||
|
const affineText = (m: Affine) => `matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m.f})`;
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<g class="mount-layer" pointer-events="none">
|
||||||
|
<g v-for="mark in marks" :key="mark.id" :data-mount-id="mark.id" :transform="affineText(mark.transform)">
|
||||||
|
<path class="switch" :d="cross(mark.switchAt)" />
|
||||||
|
<path v-for="(p, i) in mark.stabs" :key="i" class="stab" :d="cross(p)" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
path {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2px;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.switch {
|
||||||
|
stroke: #c2185b;
|
||||||
|
}
|
||||||
|
.stab {
|
||||||
|
stroke: #00897b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import type { Node, Transform } from "../../domain/phy";
|
||||||
|
import type { StabilizerCandidate } from "../../domain/stabilizers";
|
||||||
|
import { hasSecondRect } from "../../domain/keyShape";
|
||||||
|
const props = defineProps<{
|
||||||
|
selectedCount: number;
|
||||||
|
selectedId: string | null;
|
||||||
|
item: Node | null;
|
||||||
|
mismatch: boolean;
|
||||||
|
stabilizers: { candidates: StabilizerCandidate[]; selected: string[] } | null;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
update: [id: string, change: (node: Node) => void, label?: string];
|
||||||
|
applyFrame: [id: string, frame: Transform];
|
||||||
|
setDefault: [groupId: string, variantId: string];
|
||||||
|
changeVariant: [group: Node, variantId: string];
|
||||||
|
deleteNode: [id: string];
|
||||||
|
addVariant: [groupId: string, name: string];
|
||||||
|
stabilizers: [keyId: string, ids: string[]];
|
||||||
|
}>();
|
||||||
|
function setSwitchOffset(axis: "x" | "y", value: number) {
|
||||||
|
emit("update", props.item!.id, (n) => {
|
||||||
|
if (n.type !== "key") return;
|
||||||
|
const offset = { x: n.switchOffset?.x ?? 0, y: n.switchOffset?.y ?? 0, [axis]: value };
|
||||||
|
if (offset.x === 0 && offset.y === 0) delete n.switchOffset;
|
||||||
|
else n.switchOffset = offset;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 異形キーは2つ目の矩形(x2, y2, w2, h2)を持つ。オンにしたときはKLEの既定(0, 0, w, h)から始める。T-32。
|
||||||
|
const irregular = computed(() => props.item?.type === "key" && hasSecondRect(props.item.size));
|
||||||
|
function setIrregular(on: boolean) {
|
||||||
|
emit("update", props.item!.id, (n) => {
|
||||||
|
if (n.type !== "key") return;
|
||||||
|
if (on) n.size = { ...n.size, x2: 0, y2: 0, w2: n.size.w, h2: n.size.h };
|
||||||
|
else n.size = { w: n.size.w, h: n.size.h };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setSecondRect(field: "x2" | "y2" | "w2" | "h2", value: number) {
|
||||||
|
emit("update", props.item!.id, (n) => {
|
||||||
|
if (n.type === "key") n.size[field] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function toggleStabilizer(id: string, on: boolean) {
|
||||||
|
const current = props.stabilizers?.selected ?? [];
|
||||||
|
emit(
|
||||||
|
"stabilizers",
|
||||||
|
props.item!.id,
|
||||||
|
on ? [...current.filter((x) => x !== id), id] : current.filter((x) => x !== id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const group = computed(() => (props.item?.type === "variantGroup" ? props.item : null));
|
||||||
|
const frameX = ref(0);
|
||||||
|
const frameY = ref(0);
|
||||||
|
const frameR = ref(0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="properties pane">
|
||||||
|
<fieldset :disabled="mismatch">
|
||||||
|
<p v-if="selectedCount > 1">{{ selectedCount }}件を選択中</p>
|
||||||
|
<template v-else-if="item">
|
||||||
|
<h2>{{ item.type }}</h2>
|
||||||
|
<label
|
||||||
|
>X
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:value="item.transform.x"
|
||||||
|
@change="
|
||||||
|
emit(
|
||||||
|
'update',
|
||||||
|
item.id,
|
||||||
|
(n) => (n.transform.x = Number(($event.target as HTMLInputElement).value)),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>Y
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:value="item.transform.y"
|
||||||
|
@change="
|
||||||
|
emit(
|
||||||
|
'update',
|
||||||
|
item.id,
|
||||||
|
(n) => (n.transform.y = Number(($event.target as HTMLInputElement).value)),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>Rotation
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:value="item.transform.rotation"
|
||||||
|
@change="
|
||||||
|
emit(
|
||||||
|
'update',
|
||||||
|
item.id,
|
||||||
|
(n) => (n.transform.rotation = Number(($event.target as HTMLInputElement).value)),
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/></label>
|
||||||
|
<template v-if="item.type === 'key'">
|
||||||
|
<label
|
||||||
|
>Width
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:value="item.size.w"
|
||||||
|
@change="
|
||||||
|
emit('update', item.id, (n) => {
|
||||||
|
if (n.type === 'key') n.size.w = Number(($event.target as HTMLInputElement).value);
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>Height
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:value="item.size.h"
|
||||||
|
@change="
|
||||||
|
emit('update', item.id, (n) => {
|
||||||
|
if (n.type === 'key') n.size.h = Number(($event.target as HTMLInputElement).value);
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/></label>
|
||||||
|
<label class="check"
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="irregular"
|
||||||
|
@change="setIrregular(($event.target as HTMLInputElement).checked)"
|
||||||
|
/>異形キー</label
|
||||||
|
>
|
||||||
|
<template v-if="irregular && item.type === 'key'">
|
||||||
|
<label
|
||||||
|
>X2
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:value="item.size.x2 ?? 0"
|
||||||
|
@change="setSecondRect('x2', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>Y2
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:value="item.size.y2 ?? 0"
|
||||||
|
@change="setSecondRect('y2', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>Width2
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:value="item.size.w2 ?? item.size.w"
|
||||||
|
@change="setSecondRect('w2', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>Height2
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:value="item.size.h2 ?? item.size.h"
|
||||||
|
@change="setSecondRect('h2', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
/></label>
|
||||||
|
</template>
|
||||||
|
<label
|
||||||
|
>switchOffset X
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:value="item.switchOffset?.x ?? 0"
|
||||||
|
@change="setSwitchOffset('x', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
/></label>
|
||||||
|
<label
|
||||||
|
>switchOffset Y
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
:value="item.switchOffset?.y ?? 0"
|
||||||
|
@change="setSwitchOffset('y', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
/></label>
|
||||||
|
<div class="stabilizers">
|
||||||
|
スタビ
|
||||||
|
<span v-if="!stabilizers?.candidates.length" class="none">候補なし</span>
|
||||||
|
<label v-for="c in stabilizers?.candidates ?? []" :key="c.id" class="check"
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="stabilizers!.selected.includes(c.id)"
|
||||||
|
@change="toggleStabilizer(c.id, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>{{ c.label }}</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
>Label
|
||||||
|
<input
|
||||||
|
:value="item.labels?.[0]"
|
||||||
|
@change="
|
||||||
|
emit('update', item.id, (n) => {
|
||||||
|
if (n.type === 'key') n.labels = [($event.target as HTMLInputElement).value];
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/></label>
|
||||||
|
</template>
|
||||||
|
<template v-if="item.type === 'cluster'">
|
||||||
|
<label
|
||||||
|
>名前
|
||||||
|
<input
|
||||||
|
:value="item.name"
|
||||||
|
@change="
|
||||||
|
emit('update', item.id, (n) => {
|
||||||
|
if (n.type === 'cluster') n.name = ($event.target as HTMLInputElement).value;
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/></label>
|
||||||
|
<h3>座標枠変更</h3>
|
||||||
|
<label>原点X <input type="number" v-model.number="frameX" /></label>
|
||||||
|
<label>原点Y <input type="number" v-model.number="frameY" /></label>
|
||||||
|
<label>角度 <input type="number" v-model.number="frameR" /></label>
|
||||||
|
<button @click="emit('applyFrame', item.id, { x: frameX, y: frameY, rotation: frameR })">
|
||||||
|
適用
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-if="group">
|
||||||
|
<label
|
||||||
|
>名前
|
||||||
|
<input
|
||||||
|
:value="group.name"
|
||||||
|
@change="
|
||||||
|
emit('update', group.id, (n) => {
|
||||||
|
if (n.type === 'variantGroup') n.name = ($event.target as HTMLInputElement).value;
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/></label>
|
||||||
|
<div v-for="v in group.children" :key="v.id">
|
||||||
|
{{ v.name }} {{ v.id === group.default ? "✓" : "" }}
|
||||||
|
<button @click="emit('setDefault', group.id, v.id)">既定</button>
|
||||||
|
<button @click="emit('changeVariant', group, v.id)">表示</button>
|
||||||
|
<button :disabled="group.children.length <= 2" @click="emit('deleteNode', v.id)">削除</button>
|
||||||
|
</div>
|
||||||
|
<button @click="emit('addVariant', group.id, 'Option ' + (group.children.length + 1))">追加</button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<p v-else-if="selectedCount === 0">ノードを選択してください</p>
|
||||||
|
</fieldset>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pane {
|
||||||
|
padding: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #cbd1da;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.properties fieldset {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.properties label {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.properties input {
|
||||||
|
width: 110px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.properties label.check {
|
||||||
|
margin: 4px 0 4px 12px;
|
||||||
|
}
|
||||||
|
.properties label.check input {
|
||||||
|
width: auto;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.stabilizers .none {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: #6b7380;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { Node } from "../../domain/phy";
|
||||||
|
import type { Affine } from "../../domain/geometry";
|
||||||
|
import { selectionFrames } from "./logic";
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
nodes: Node[];
|
||||||
|
selected: string[];
|
||||||
|
variants: Record<string, string>;
|
||||||
|
zoom: number;
|
||||||
|
candidate?: string | null;
|
||||||
|
parent?: Affine;
|
||||||
|
}>(),
|
||||||
|
{ candidate: null, parent: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) },
|
||||||
|
);
|
||||||
|
const frames = computed(() =>
|
||||||
|
selectionFrames(props.nodes, props.selected, props.variants, props.candidate, props.parent),
|
||||||
|
);
|
||||||
|
// 原点の丸は選択中のクラスター(VariantGroupを含む)にだけ出す。T-30。
|
||||||
|
const origins = computed(() => frames.value.filter((frame) => frame.selected && frame.type !== "key"));
|
||||||
|
const affineText = (m: Affine) => `matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m.f})`;
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<g class="selection-layer" pointer-events="none">
|
||||||
|
<template v-for="frame in frames" :key="frame.id">
|
||||||
|
<path
|
||||||
|
v-if="frame.path"
|
||||||
|
:data-sel-id="frame.id"
|
||||||
|
:class="['sel', { candidate: frame.candidate }]"
|
||||||
|
:transform="affineText(frame.transform)"
|
||||||
|
:d="frame.path"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
v-else
|
||||||
|
:data-sel-id="frame.id"
|
||||||
|
:class="[frame.type === 'key' ? 'sel' : 'group-sel', { candidate: frame.candidate }]"
|
||||||
|
:transform="affineText(frame.transform)"
|
||||||
|
:x="frame.box.x"
|
||||||
|
:y="frame.box.y"
|
||||||
|
:width="frame.box.width"
|
||||||
|
:height="frame.box.height"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<circle
|
||||||
|
v-for="frame in origins"
|
||||||
|
:key="`origin-${frame.id}`"
|
||||||
|
:data-origin-id="frame.id"
|
||||||
|
class="origin"
|
||||||
|
:cx="frame.transform.e"
|
||||||
|
:cy="frame.transform.f"
|
||||||
|
:r="5 / zoom"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.sel,
|
||||||
|
.group-sel {
|
||||||
|
fill: none;
|
||||||
|
stroke: #1769d2;
|
||||||
|
stroke-width: 3.5px;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.group-sel {
|
||||||
|
stroke-dasharray: 5 3;
|
||||||
|
}
|
||||||
|
.candidate {
|
||||||
|
stroke: #e87912;
|
||||||
|
stroke-width: 3.5px;
|
||||||
|
}
|
||||||
|
.origin {
|
||||||
|
fill: #fff;
|
||||||
|
stroke: #1769d2;
|
||||||
|
stroke-width: 2px;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { IRREGULAR_KEY_PRESETS } from "../../domain/keyShape";
|
||||||
|
defineProps<{
|
||||||
|
mismatch: boolean;
|
||||||
|
selectedCount: number;
|
||||||
|
grid: number;
|
||||||
|
moveTargets: { id: string; label: string }[];
|
||||||
|
focus: string | null;
|
||||||
|
crumbs: { id: string | null; label: string }[];
|
||||||
|
holdMode: "loop" | "list";
|
||||||
|
showMounts: boolean;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
keyAdd: [];
|
||||||
|
presetAdd: [presetId: string];
|
||||||
|
partAdd: [group: string];
|
||||||
|
cluster: [];
|
||||||
|
ungroup: [];
|
||||||
|
variantGroup: [];
|
||||||
|
moveTo: [id: string];
|
||||||
|
duplicate: [];
|
||||||
|
delete: [event: MouseEvent];
|
||||||
|
grid: [];
|
||||||
|
fit: [];
|
||||||
|
fitSelected: [];
|
||||||
|
zoom: [direction: 1 | -1];
|
||||||
|
tree: [];
|
||||||
|
help: [];
|
||||||
|
focus: [id: string | null];
|
||||||
|
holdMode: [];
|
||||||
|
mounts: [];
|
||||||
|
}>();
|
||||||
|
function addPreset(e: Event) {
|
||||||
|
const select = e.target as HTMLSelectElement;
|
||||||
|
emit("presetAdd", select.value);
|
||||||
|
select.value = "";
|
||||||
|
}
|
||||||
|
function addPart(e: Event) {
|
||||||
|
const select = e.target as HTMLSelectElement;
|
||||||
|
emit("partAdd", select.value);
|
||||||
|
select.value = "";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tools">
|
||||||
|
<button title="キーを足す" aria-label="キーを足す" :disabled="mismatch" @click="emit('keyAdd')">
|
||||||
|
+⌨
|
||||||
|
</button>
|
||||||
|
<select aria-label="異形キーを足す" :disabled="mismatch" @change="addPreset">
|
||||||
|
<option value="">異形キーを足す</option>
|
||||||
|
<option v-for="preset in IRREGULAR_KEY_PRESETS" :key="preset.id" :value="preset.id">
|
||||||
|
{{ preset.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<select aria-label="部品を足す" :disabled="mismatch" @change="addPart">
|
||||||
|
<option value="">部品を足す</option>
|
||||||
|
<option>@led</option>
|
||||||
|
<option>@encoder</option>
|
||||||
|
<option>@screw</option>
|
||||||
|
<option>@oled</option>
|
||||||
|
<option>その他</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
title="クラスターにする"
|
||||||
|
aria-label="クラスターにする"
|
||||||
|
:disabled="mismatch || !selectedCount"
|
||||||
|
@click="emit('cluster')"
|
||||||
|
>
|
||||||
|
▱
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title="クラスターを解除"
|
||||||
|
aria-label="クラスターを解除"
|
||||||
|
:disabled="mismatch"
|
||||||
|
@click="emit('ungroup')"
|
||||||
|
>
|
||||||
|
⊟
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title="選択からバリアントグループ"
|
||||||
|
aria-label="選択からバリアントグループ"
|
||||||
|
:disabled="mismatch || !selectedCount"
|
||||||
|
@click="emit('variantGroup')"
|
||||||
|
>
|
||||||
|
◇
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
class="move-to"
|
||||||
|
aria-label="移し先…"
|
||||||
|
:disabled="mismatch || !selectedCount"
|
||||||
|
@change="emit('moveTo', ($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option value="">移し先…</option>
|
||||||
|
<option v-for="target in moveTargets" :key="target.id" :value="target.id">{{ target.label }}</option>
|
||||||
|
</select>
|
||||||
|
<button title="複製" aria-label="複製" :disabled="mismatch || !selectedCount" @click="emit('duplicate')">
|
||||||
|
⧉
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title="削除"
|
||||||
|
aria-label="削除"
|
||||||
|
:disabled="mismatch || !selectedCount"
|
||||||
|
@click="emit('delete', $event)"
|
||||||
|
>
|
||||||
|
⌫
|
||||||
|
</button>
|
||||||
|
<button @click="emit('grid')">Grid {{ grid }}U</button>
|
||||||
|
<button title="全体表示" aria-label="全体表示" @click="emit('fit')">⛶</button>
|
||||||
|
<button title="選択範囲にズーム" aria-label="選択範囲にズーム" @click="emit('fitSelected')">⌕</button>
|
||||||
|
<button title="ズームイン" aria-label="ズームイン" @click="emit('zoom', 1)">+</button>
|
||||||
|
<button title="ズームアウト" aria-label="ズームアウト" @click="emit('zoom', -1)">−</button>
|
||||||
|
<button title="階層" aria-label="階層" @click="emit('tree')">☷</button>
|
||||||
|
<button
|
||||||
|
:title="`長押しの方式: ${holdMode === 'loop' ? 'ループ' : 'リスト'}(押すと切り替え)`"
|
||||||
|
:aria-label="`長押し: ${holdMode === 'loop' ? 'ループ' : 'リスト'}`"
|
||||||
|
@click="emit('holdMode')"
|
||||||
|
>
|
||||||
|
{{ holdMode === "loop" ? "⟳" : "☰" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title="スイッチ・スタビライザーの位置"
|
||||||
|
aria-label="スイッチ・スタビライザーの位置"
|
||||||
|
:aria-pressed="showMounts"
|
||||||
|
:class="{ on: showMounts }"
|
||||||
|
@click="emit('mounts')"
|
||||||
|
>
|
||||||
|
✛
|
||||||
|
</button>
|
||||||
|
<button title="ショートカット一覧" aria-label="ショートカット一覧" @click="emit('help')">?</button>
|
||||||
|
<span v-if="focus">
|
||||||
|
<template v-for="(crumb, i) in crumbs" :key="i">
|
||||||
|
<button @click="emit('focus', crumb.id)">{{ i ? " › " : "" }}{{ crumb.label }}</button>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tools {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.tools button,
|
||||||
|
.tools select {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.tools button.on {
|
||||||
|
background: #dbe8fb;
|
||||||
|
border-color: #1769d2;
|
||||||
|
}
|
||||||
|
/* 候補の名前でセレクトの幅が変わると、ツールバーが折り返してキャンバスの大きさが変わる */
|
||||||
|
.tools select.move-to {
|
||||||
|
width: 6em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { Node } from "../../domain/phy";
|
||||||
|
defineOptions({ name: "PhyTree" });
|
||||||
|
const props = defineProps<{ nodes: Node[]; selected: string[] }>();
|
||||||
|
const emit = defineEmits<{ select: [id: string, add: boolean] }>();
|
||||||
|
const structures = computed(() =>
|
||||||
|
props.nodes.filter((n) => n.type === "cluster" || n.type === "variantGroup"),
|
||||||
|
);
|
||||||
|
const leaves = computed(() => props.nodes.filter((n) => n.type === "key" || n.type === "component"));
|
||||||
|
function countLeaves(nodes: Node[]): number {
|
||||||
|
return nodes.reduce(
|
||||||
|
(count, n) =>
|
||||||
|
count +
|
||||||
|
(n.type === "key" || n.type === "component"
|
||||||
|
? 1
|
||||||
|
: n.type === "cluster"
|
||||||
|
? countLeaves(n.children)
|
||||||
|
: n.type === "variantGroup"
|
||||||
|
? n.children.reduce((sum, v) => sum + countLeaves(v.children), 0)
|
||||||
|
: 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<ul class="tree-list">
|
||||||
|
<li v-for="n in structures" :key="n.id">
|
||||||
|
<button
|
||||||
|
class="tree-node"
|
||||||
|
:data-tree-id="n.id"
|
||||||
|
:class="{ chosen: selected.includes(n.id) }"
|
||||||
|
@click="emit('select', n.id, $event.shiftKey)"
|
||||||
|
>
|
||||||
|
{{ n.type === "cluster" ? "クラスター" : n.name }}
|
||||||
|
<small>{{ n.type === "cluster" ? `${countLeaves(n.children)}件` : `${n.children.length}件` }}</small>
|
||||||
|
</button>
|
||||||
|
<PhyTree
|
||||||
|
v-if="n.type === 'cluster'"
|
||||||
|
:nodes="n.children"
|
||||||
|
:selected="selected"
|
||||||
|
@select="(id, add) => emit('select', id, add)"
|
||||||
|
/>
|
||||||
|
<div v-else class="variants">
|
||||||
|
<div v-for="v in n.children" :key="v.id">
|
||||||
|
<button
|
||||||
|
class="tree-node variant"
|
||||||
|
:data-tree-id="n.id"
|
||||||
|
:class="{ chosen: selected.includes(n.id) }"
|
||||||
|
@click="emit('select', n.id, $event.shiftKey)"
|
||||||
|
>
|
||||||
|
{{ v.name }}
|
||||||
|
<small>{{ n.default === v.id ? "既定 · " : "" }}{{ countLeaves(v.children) }}件</small>
|
||||||
|
</button>
|
||||||
|
<details>
|
||||||
|
<summary>キー・部品 {{ countLeaves(v.children) }}件</summary>
|
||||||
|
<PhyTree
|
||||||
|
:nodes="v.children"
|
||||||
|
:selected="selected"
|
||||||
|
@select="(id, add) => emit('select', id, add)"
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li v-if="leaves.length">
|
||||||
|
<details>
|
||||||
|
<summary>キー・部品 {{ leaves.length }}件</summary>
|
||||||
|
<ul class="tree-list">
|
||||||
|
<li v-for="n in leaves" :key="n.id">
|
||||||
|
<button
|
||||||
|
class="tree-node leaf"
|
||||||
|
:data-tree-id="n.id"
|
||||||
|
:class="{ chosen: selected.includes(n.id) }"
|
||||||
|
@click="emit('select', n.id, $event.shiftKey)"
|
||||||
|
>
|
||||||
|
{{ n.type === "key" ? "キー" : "部品" }}
|
||||||
|
<small>{{ n.type === "key" ? n.labels?.find(Boolean) : n.partGroup }}</small>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.tree-list {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 12px;
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
.tree-node {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.leaf {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.chosen {
|
||||||
|
background: #dce9ff;
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #667;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.variants {
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import type { KeyNode, Node, PhyFile, Transform } from "../../domain/phy";
|
||||||
|
import type { ProjectFile } from "../../domain/project";
|
||||||
|
import { stabilizerCandidates, stabilizerPoints, switchPoint } from "../../domain/stabilizers";
|
||||||
|
import { apply, compose, inverse, matrix, type Affine } from "../../domain/geometry";
|
||||||
|
import { hasSecondRect, keyCorners, keyOutline } from "../../domain/keyShape";
|
||||||
|
|
||||||
|
export function normalizeSelection(file: PhyFile, ids: string[]): string[] {
|
||||||
|
const parent = new Map<string, string | null>(),
|
||||||
|
nodes = new Map<string, Node>();
|
||||||
|
const walk = (items: Node[], p: string | null) =>
|
||||||
|
items.forEach((n) => {
|
||||||
|
parent.set(n.id, p);
|
||||||
|
nodes.set(n.id, n);
|
||||||
|
if (n.type === "cluster") walk(n.children, n.id);
|
||||||
|
if (n.type === "variantGroup")
|
||||||
|
n.children.forEach((v) => {
|
||||||
|
parent.set(v.id, n.id);
|
||||||
|
walk(v.children, v.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
walk(file.root, null);
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
if (!nodes.has(id) || out.includes(id)) continue;
|
||||||
|
const ancestors = new Set<string>();
|
||||||
|
let p = parent.get(id) ?? null;
|
||||||
|
while (p) {
|
||||||
|
ancestors.add(p);
|
||||||
|
p = parent.get(p) ?? null;
|
||||||
|
}
|
||||||
|
const keep = out.filter((x) => !ancestors.has(x) && !isDescendant(parent, x, id));
|
||||||
|
out.splice(0, out.length, ...keep, id);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function isDescendant(parent: Map<string, string | null>, candidate: string, ancestor: string): boolean {
|
||||||
|
let p = parent.get(candidate) ?? null;
|
||||||
|
while (p) {
|
||||||
|
if (p === ancestor) return true;
|
||||||
|
p = parent.get(p) ?? null;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
export function longPressColumn(keyId: string, ancestorsNearestFirst: string[], focusId?: string): string[] {
|
||||||
|
const list = [keyId, ...ancestorsNearestFirst];
|
||||||
|
if (focusId) {
|
||||||
|
const i = list.indexOf(focusId);
|
||||||
|
if (i >= 0) list.splice(i);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
export function tabOrder<T extends { id: string; x: number; y: number }>(items: T[]): T[] {
|
||||||
|
const sorted = [...items].sort((a, b) => a.y - b.y),
|
||||||
|
rows: T[][] = [];
|
||||||
|
for (const item of sorted) {
|
||||||
|
const row = rows.at(-1);
|
||||||
|
if (!row || item.y - row[0]!.y >= 0.5) rows.push([item]);
|
||||||
|
else row.push(item);
|
||||||
|
}
|
||||||
|
return rows.flatMap((row) => row.sort((a, b) => a.x - b.x));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1つの走査結果からノードの親と挿入先の器を引けるようにする。 */
|
||||||
|
export function treeIndex(roots: Node[]) {
|
||||||
|
const nodes = new Map<string, Node>();
|
||||||
|
const parents = new Map<string, string | null>();
|
||||||
|
const containers = new Map<string, string | null>();
|
||||||
|
const variants = new Map<string, string>();
|
||||||
|
const visit = (items: Node[], parent: string | null, container: string | null) => {
|
||||||
|
for (const n of items) {
|
||||||
|
nodes.set(n.id, n);
|
||||||
|
parents.set(n.id, parent);
|
||||||
|
containers.set(n.id, container);
|
||||||
|
if (n.type === "cluster") visit(n.children, n.id, n.id);
|
||||||
|
if (n.type === "variantGroup") {
|
||||||
|
for (const v of n.children) {
|
||||||
|
variants.set(v.id, n.id);
|
||||||
|
visit(v.children, n.id, v.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(roots, null, null);
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
parents,
|
||||||
|
containers,
|
||||||
|
variants,
|
||||||
|
selectableParent(id: string) {
|
||||||
|
return parents.get(id) ?? null;
|
||||||
|
},
|
||||||
|
insertionContainer(id: string) {
|
||||||
|
return containers.get(id) ?? null;
|
||||||
|
},
|
||||||
|
groupForVariant(id: string) {
|
||||||
|
return variants.get(id) ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function treeNodes(roots: Node[]): Node[] {
|
||||||
|
return [...treeIndex(roots).nodes.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function structuralNodes(roots: Node[]): Extract<Node, { type: "cluster" | "variantGroup" }>[] {
|
||||||
|
return treeNodes(roots).filter(
|
||||||
|
(node): node is Extract<Node, { type: "cluster" | "variantGroup" }> =>
|
||||||
|
node.type === "cluster" || node.type === "variantGroup",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeFocusedSubtree(nodes: Node[], focusedId: string): Node[] {
|
||||||
|
const result: Node[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.id === focusedId) continue;
|
||||||
|
if (node.type === "cluster") {
|
||||||
|
const children = removeFocusedSubtree(node.children, focusedId);
|
||||||
|
result.push({ ...node, children });
|
||||||
|
} else if (node.type === "variantGroup") {
|
||||||
|
result.push({
|
||||||
|
...node,
|
||||||
|
children: node.children.map((variant) => ({
|
||||||
|
...variant,
|
||||||
|
children: removeFocusedSubtree(variant.children, focusedId),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleNodeIds(root: Node | undefined, shownVariants: Record<string, string>): Set<string> {
|
||||||
|
const result = new Set<string>();
|
||||||
|
const visit = (node: Node) => {
|
||||||
|
result.add(node.id);
|
||||||
|
if (node.type === "cluster") node.children.forEach(visit);
|
||||||
|
if (node.type === "variantGroup") {
|
||||||
|
const variant = node.children.find((item) => item.id === (shownVariants[node.id] ?? node.default));
|
||||||
|
variant?.children.forEach(visit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (root) visit(root);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectableParent(roots: Node[], id: string): string | null {
|
||||||
|
return treeIndex(roots).selectableParent(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertionContainer(
|
||||||
|
roots: Node[],
|
||||||
|
parentId: string | null,
|
||||||
|
shownVariants: Record<string, string> = {},
|
||||||
|
): string | null {
|
||||||
|
if (parentId === null) return null;
|
||||||
|
const index = treeIndex(roots);
|
||||||
|
const node = index.nodes.get(parentId);
|
||||||
|
if (node?.type === "cluster") return node.id;
|
||||||
|
if (node?.type === "variantGroup") return shownVariants[node.id] ?? node.default;
|
||||||
|
return index.variants.get(parentId) === undefined ? null : parentId;
|
||||||
|
}
|
||||||
|
export function snap(value: number, step: number): number {
|
||||||
|
return Math.round(value / step) * step;
|
||||||
|
}
|
||||||
|
export const GRID_STEPS = [1, 0.25, 0.125, 1 / 12] as const;
|
||||||
|
export function nextGrid(step: number, direction: 1 | -1): number {
|
||||||
|
const i = GRID_STEPS.findIndex((x) => Math.abs(x - step) < 1e-9);
|
||||||
|
return GRID_STEPS[(i + direction + GRID_STEPS.length) % GRID_STEPS.length]!;
|
||||||
|
}
|
||||||
|
export function resolveKey(
|
||||||
|
e: Pick<KeyboardEvent, "key" | "code" | "ctrlKey" | "metaKey" | "shiftKey" | "altKey">,
|
||||||
|
): string | null {
|
||||||
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
|
if (e.code === "Digit1" && e.shiftKey) return "fit";
|
||||||
|
if (e.code === "Digit2" && e.shiftKey) return "selection-fit";
|
||||||
|
if (mod && e.key.toLowerCase() === "z") return e.shiftKey ? "redo" : "undo";
|
||||||
|
if (mod && e.key.toLowerCase() === "y") return "redo";
|
||||||
|
if (mod && e.key.toLowerCase() === "s") return "save";
|
||||||
|
if (mod && e.key.toLowerCase() === "g") return e.shiftKey ? "ungroup" : "group";
|
||||||
|
if (mod && ["a", "c", "x", "v", "d"].includes(e.key.toLowerCase())) return e.key.toLowerCase();
|
||||||
|
return e.key;
|
||||||
|
}
|
||||||
|
export type Box = { x: number; y: number; width: number; height: number };
|
||||||
|
export function obbAabb(points: { x: number; y: number }[]): Box {
|
||||||
|
if (!points.length) return { x: 0, y: 0, width: 0, height: 0 };
|
||||||
|
const xs = points.map((p) => p.x),
|
||||||
|
ys = points.map((p) => p.y),
|
||||||
|
x = Math.min(...xs),
|
||||||
|
y = Math.min(...ys);
|
||||||
|
return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
|
||||||
|
}
|
||||||
|
export function pasteParent(selectedParents: (string | null)[], focus: string | null): string | null {
|
||||||
|
return selectedParents.length ? selectedParents[0]! : focus;
|
||||||
|
}
|
||||||
|
/** 子のローカル形状を親座標でまとめる。回転親に追従する選択枠用。 */
|
||||||
|
export function localBounds(nodes: Node[], variants: Record<string, string> = {}): Box {
|
||||||
|
const points: { x: number; y: number }[] = [];
|
||||||
|
const visit = (items: Node[], parent: Affine) =>
|
||||||
|
items.forEach((n) => {
|
||||||
|
const w = compose(parent, matrix(n.transform));
|
||||||
|
if (n.type === "key") {
|
||||||
|
points.push(...keyCorners(n.size).map((p) => apply(w, p)));
|
||||||
|
} else if (n.type === "component") {
|
||||||
|
const c = apply(w, { x: 0, y: 0 });
|
||||||
|
points.push({ x: c.x - 0.16, y: c.y - 0.16 }, { x: c.x + 0.16, y: c.y + 0.16 });
|
||||||
|
} else if (n.type === "cluster") visit(n.children, w);
|
||||||
|
else {
|
||||||
|
const v = n.children.find((x) => x.id === (variants[n.id] ?? n.default));
|
||||||
|
if (v) visit(v.children, w);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
visit(nodes, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 });
|
||||||
|
return obbAabb(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SVG path of a non-rectangular key's outline; undefined for plain keys, which are drawn as rounded rects. */
|
||||||
|
export function keyPath(size: KeyNode["size"]): string | undefined {
|
||||||
|
if (!hasSecondRect(size)) return undefined;
|
||||||
|
return keyOutline(size)
|
||||||
|
.map((loop) => `M ${loop.map((p) => `${p.x} ${p.y}`).join(" L ")} Z`)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SelectionFrame = {
|
||||||
|
id: string;
|
||||||
|
type: Node["type"];
|
||||||
|
transform: Affine;
|
||||||
|
box: Box;
|
||||||
|
/** Outline of a non-rectangular key, drawn instead of `box`. */
|
||||||
|
path?: string;
|
||||||
|
selected: boolean;
|
||||||
|
candidate: boolean;
|
||||||
|
};
|
||||||
|
/** 可視ノードの選択枠をワールド行列とローカル矩形で返す。 */
|
||||||
|
export function selectionFrames(
|
||||||
|
roots: Node[],
|
||||||
|
selected: string[],
|
||||||
|
variants: Record<string, string>,
|
||||||
|
candidate: string | null,
|
||||||
|
parent: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
|
||||||
|
): SelectionFrame[] {
|
||||||
|
const frames: SelectionFrame[] = [];
|
||||||
|
const visit = (nodes: Node[], ancestor: Affine) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
const transform = compose(ancestor, matrix(node.transform));
|
||||||
|
if (node.type === "key" && (selected.includes(node.id) || candidate === node.id)) {
|
||||||
|
frames.push({
|
||||||
|
id: node.id,
|
||||||
|
type: node.type,
|
||||||
|
transform,
|
||||||
|
box: { x: 0, y: 0, width: node.size.w, height: node.size.h },
|
||||||
|
path: keyPath(node.size),
|
||||||
|
selected: selected.includes(node.id),
|
||||||
|
candidate: candidate === node.id,
|
||||||
|
});
|
||||||
|
} else if (node.type === "cluster") {
|
||||||
|
if (selected.includes(node.id) || candidate === node.id)
|
||||||
|
frames.push({
|
||||||
|
id: node.id,
|
||||||
|
type: node.type,
|
||||||
|
transform,
|
||||||
|
box: localBounds(node.children, variants),
|
||||||
|
selected: selected.includes(node.id),
|
||||||
|
candidate: candidate === node.id,
|
||||||
|
});
|
||||||
|
visit(node.children, transform);
|
||||||
|
} else if (node.type === "variantGroup") {
|
||||||
|
const variant = node.children.find((item) => item.id === (variants[node.id] ?? node.default));
|
||||||
|
if (selected.includes(node.id) || candidate === node.id)
|
||||||
|
frames.push({
|
||||||
|
id: node.id,
|
||||||
|
type: node.type,
|
||||||
|
transform,
|
||||||
|
box: localBounds(variant?.children ?? [], variants),
|
||||||
|
selected: selected.includes(node.id),
|
||||||
|
candidate: candidate === node.id,
|
||||||
|
});
|
||||||
|
if (variant) visit(variant.children, transform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(roots, parent);
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
export type VariantBadge = {
|
||||||
|
group: Extract<Node, { type: "variantGroup" }>;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
/** 表示中のVariantGroupのバッジを、OBBの右上に正立で置く。大きさは画面上で固定する。 */
|
||||||
|
export function variantBadges(
|
||||||
|
roots: Node[],
|
||||||
|
variants: Record<string, string>,
|
||||||
|
zoom: number,
|
||||||
|
parent: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
|
||||||
|
): VariantBadge[] {
|
||||||
|
const badges: VariantBadge[] = [];
|
||||||
|
const size = 20 / zoom;
|
||||||
|
const visit = (nodes: Node[], ancestor: Affine) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
const transform = compose(ancestor, matrix(node.transform));
|
||||||
|
if (node.type === "cluster") visit(node.children, transform);
|
||||||
|
else if (node.type === "variantGroup") {
|
||||||
|
const variant = node.children.find((item) => item.id === (variants[node.id] || node.default));
|
||||||
|
const box = localBounds(variant?.children ?? [], variants);
|
||||||
|
const corner = apply(transform, { x: box.x + box.width + 4 / zoom, y: box.y - size - 4 / zoom });
|
||||||
|
badges.push({ group: node, x: corner.x, y: corner.y, size });
|
||||||
|
if (variant) visit(variant.children, transform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(roots, parent);
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
/** ワールド位置を貼り先の親ローカルへ戻し、親軸で1刻みずらす。T-11/T-27。 */
|
||||||
|
export function pasteLocalTransform(sourceWorld: Affine, parentWorld: Affine, step: number): Transform {
|
||||||
|
const local = compose(inverse(parentWorld), sourceWorld);
|
||||||
|
return { x: local.e + step, y: local.f + step, rotation: (Math.atan2(local.b, local.a) * 180) / Math.PI };
|
||||||
|
}
|
||||||
|
/** スタビ位置の選択は仕様の層に入れず、ProjectFile.ui.phy.stabilizers にキーIDごとに持つ。T-10/T-30。 */
|
||||||
|
export type StabilizerSelections = Record<string, string[]>;
|
||||||
|
export function stabilizerSelections(project: ProjectFile): StabilizerSelections {
|
||||||
|
const ui = project.ui as { phy?: { stabilizers?: StabilizerSelections } } | undefined;
|
||||||
|
return ui?.phy?.stabilizers ?? {};
|
||||||
|
}
|
||||||
|
/** 記録がなければ何も選ばない。サイズが変わって候補にない記録は捨てて読む。 */
|
||||||
|
export function selectedStabilizers(selections: StabilizerSelections, key: KeyNode): string[] {
|
||||||
|
const ids = selections[key.id] ?? [];
|
||||||
|
const known = new Set(stabilizerCandidates(key.size).map((c) => c.id));
|
||||||
|
return ids.filter((id) => known.has(id));
|
||||||
|
}
|
||||||
|
export function withStabilizers(project: ProjectFile, keyId: string, ids: string[]): ProjectFile {
|
||||||
|
const ui = (project.ui ?? {}) as Record<string, unknown>,
|
||||||
|
phy = (ui.phy ?? {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
ui: { ...ui, phy: { ...phy, stabilizers: { ...stabilizerSelections(project), [keyId]: ids } } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type MountMark = {
|
||||||
|
id: string;
|
||||||
|
transform: Affine;
|
||||||
|
switchAt: { x: number; y: number };
|
||||||
|
stabs: { x: number; y: number }[];
|
||||||
|
};
|
||||||
|
/** 表示中のキーごとに、スイッチ位置とスタビ位置をキーのローカル座標で返す。 */
|
||||||
|
export function mountMarks(
|
||||||
|
roots: Node[],
|
||||||
|
variants: Record<string, string>,
|
||||||
|
selections: StabilizerSelections,
|
||||||
|
parent: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 },
|
||||||
|
): MountMark[] {
|
||||||
|
const marks: MountMark[] = [];
|
||||||
|
const visit = (nodes: Node[], ancestor: Affine) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
const transform = compose(ancestor, matrix(node.transform));
|
||||||
|
if (node.type === "key") {
|
||||||
|
if (node.decal) continue;
|
||||||
|
marks.push({
|
||||||
|
id: node.id,
|
||||||
|
transform,
|
||||||
|
switchAt: switchPoint(node),
|
||||||
|
stabs: stabilizerPoints(node.size, selectedStabilizers(selections, node)),
|
||||||
|
});
|
||||||
|
} else if (node.type === "cluster") visit(node.children, transform);
|
||||||
|
else if (node.type === "variantGroup") {
|
||||||
|
const variant = node.children.find((item) => item.id === (variants[node.id] ?? node.default));
|
||||||
|
if (variant) visit(variant.children, transform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(roots, parent);
|
||||||
|
return marks;
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import { nextTick, onUnmounted, ref, type Ref } from "vue";
|
||||||
|
import type { ProjectFile } from "../../domain/project";
|
||||||
|
import type { KeyNode, Node } from "../../domain/phy";
|
||||||
|
import { apply, worldTransform } from "../../domain/geometry";
|
||||||
|
import { keyCorners } from "../../domain/keyShape";
|
||||||
|
import { moveNode } from "../../domain/phyCommands";
|
||||||
|
import { normalizeSelection, snap } from "./logic";
|
||||||
|
import { usePhyLongPress, type HoldMode } from "./usePhyLongPress";
|
||||||
|
import { autoPanVelocity } from "./viewport";
|
||||||
|
|
||||||
|
type Point = { x: number; y: number };
|
||||||
|
type Box = Point & { width: number; height: number };
|
||||||
|
type Options = {
|
||||||
|
project: () => ProjectFile;
|
||||||
|
svg: Ref<SVGSVGElement | undefined>;
|
||||||
|
pane: Ref<HTMLElement | undefined>;
|
||||||
|
pan: Ref<Point>;
|
||||||
|
zoom: Ref<number>;
|
||||||
|
grid: Ref<number>;
|
||||||
|
selected: Ref<string[]>;
|
||||||
|
focus: Ref<string | null>;
|
||||||
|
rangeBox: Ref<Box | null>;
|
||||||
|
allNodes: () => Node[];
|
||||||
|
keys: () => { key: KeyNode }[];
|
||||||
|
localPoint: (element: SVGSVGElement | SVGGElement, x: number, y: number) => Point;
|
||||||
|
visibleIds: () => Set<string>;
|
||||||
|
mismatch: () => boolean;
|
||||||
|
isAncestor: (candidate: string, id: string) => boolean;
|
||||||
|
parentLabel: (id: string | null) => string;
|
||||||
|
pressTargets: (id: string) => string[];
|
||||||
|
holdMode: Ref<HoldMode>;
|
||||||
|
setSelection: (id: string, add?: boolean) => void;
|
||||||
|
beginHistory: () => void;
|
||||||
|
commitHistory: () => void;
|
||||||
|
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePhyCanvasPointer(options: Options) {
|
||||||
|
const longPress = usePhyLongPress({ ...options, mode: () => options.holdMode.value });
|
||||||
|
const spaceHeld = ref(false);
|
||||||
|
let preClickSelection: string[] = [];
|
||||||
|
let preClickTarget: string | null = null;
|
||||||
|
let preClickAt = 0;
|
||||||
|
let lastPointer: Point | null = null;
|
||||||
|
let autoPanFrame: number | undefined;
|
||||||
|
let lastAutoPanAt = 0;
|
||||||
|
let panDrag: { start: Point; origin: Point } | null = null;
|
||||||
|
let rangeDrag: { start: Point; shift: boolean; moved: boolean } | null = null;
|
||||||
|
let dragState: {
|
||||||
|
id: string;
|
||||||
|
start: Point;
|
||||||
|
base: ProjectFile;
|
||||||
|
ids: string[];
|
||||||
|
shift: boolean;
|
||||||
|
parents: Map<string, SVGGElement>;
|
||||||
|
starts: Map<string, Point>;
|
||||||
|
moved: boolean;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
function stopAutoPan() {
|
||||||
|
if (autoPanFrame !== undefined) window.cancelAnimationFrame(autoPanFrame);
|
||||||
|
autoPanFrame = undefined;
|
||||||
|
lastAutoPanAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDraggedPosition(clientX: number, clientY: number) {
|
||||||
|
const state = dragState;
|
||||||
|
if (!state?.moved) return;
|
||||||
|
let phy = state.base.phy;
|
||||||
|
for (const id of state.ids) {
|
||||||
|
const parent = state.parents.get(id);
|
||||||
|
const start = state.starts.get(id);
|
||||||
|
if (!parent || !start) continue;
|
||||||
|
const current = options.localPoint(parent, clientX, clientY);
|
||||||
|
phy = moveNode(
|
||||||
|
phy,
|
||||||
|
id,
|
||||||
|
snap(current.x - start.x, options.grid.value),
|
||||||
|
snap(current.y - start.y, options.grid.value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
options.dispatch("ドラッグ", (project) => ({ ...project, phy }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoPanTick(time: number) {
|
||||||
|
autoPanFrame = undefined;
|
||||||
|
const svg = options.svg.value;
|
||||||
|
const pointer = lastPointer;
|
||||||
|
if (!svg || !pointer || !dragState?.moved || longPress.cycling()) return;
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const velocity = autoPanVelocity(
|
||||||
|
{ x: pointer.x, y: pointer.y },
|
||||||
|
{ x: rect.left, y: rect.top, width: rect.width, height: rect.height },
|
||||||
|
);
|
||||||
|
if (!velocity.x && !velocity.y) {
|
||||||
|
lastAutoPanAt = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elapsed = lastAutoPanAt ? Math.min((time - lastAutoPanAt) / 1000, 0.05) : 0;
|
||||||
|
lastAutoPanAt = time;
|
||||||
|
const transform = svg.getScreenCTM();
|
||||||
|
if (elapsed && transform) {
|
||||||
|
options.pan.value = {
|
||||||
|
x: options.pan.value.x - (velocity.x * elapsed) / transform.a,
|
||||||
|
y: options.pan.value.y - (velocity.y * elapsed) / transform.d,
|
||||||
|
};
|
||||||
|
void nextTick(() => {
|
||||||
|
if (dragState?.moved && lastPointer) updateDraggedPosition(lastPointer.x, lastPointer.y);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
autoPanFrame = window.requestAnimationFrame(autoPanTick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAutoPan() {
|
||||||
|
const svg = options.svg.value;
|
||||||
|
if (!svg || !lastPointer || !dragState?.moved || longPress.cycling()) {
|
||||||
|
stopAutoPan();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = svg.getBoundingClientRect();
|
||||||
|
const velocity = autoPanVelocity(
|
||||||
|
{ x: lastPointer.x, y: lastPointer.y },
|
||||||
|
{ x: rect.left, y: rect.top, width: rect.width, height: rect.height },
|
||||||
|
);
|
||||||
|
if (!velocity.x && !velocity.y) {
|
||||||
|
stopAutoPan();
|
||||||
|
} else if (autoPanFrame === undefined) {
|
||||||
|
autoPanFrame = window.requestAnimationFrame(autoPanTick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorSelection() {
|
||||||
|
return preClickSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentElementFor(id: string, fallback?: SVGGElement) {
|
||||||
|
const elements = options.svg.value?.querySelectorAll(`[data-node-id="${id}"]`);
|
||||||
|
return (
|
||||||
|
(elements?.item((elements.length || 1) - 1)?.parentElement as SVGGElement | undefined) ||
|
||||||
|
(fallback?.parentElement?.parentElement as SVGGElement | undefined) ||
|
||||||
|
options.svg.value!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginEmptyPointer(event: PointerEvent, point?: Point) {
|
||||||
|
const svg = options.svg.value;
|
||||||
|
const start = point ?? (svg ? options.localPoint(svg, event.clientX, event.clientY) : { x: 0, y: 0 });
|
||||||
|
rangeDrag = { start, shift: event.shiftKey, moved: false };
|
||||||
|
options.rangeBox.value = { ...start, width: 0, height: 0 };
|
||||||
|
window.addEventListener("pointermove", move, true);
|
||||||
|
window.addEventListener("pointerup", up, true);
|
||||||
|
svg?.setPointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasDown(event: PointerEvent) {
|
||||||
|
const svg = options.svg.value;
|
||||||
|
if (event.target !== svg || !svg) return;
|
||||||
|
const point = options.localPoint(svg, event.clientX, event.clientY);
|
||||||
|
if (event.button === 1 || spaceHeld.value) {
|
||||||
|
panDrag = { start: point, origin: { ...options.pan.value } };
|
||||||
|
window.addEventListener("pointermove", move, true);
|
||||||
|
window.addEventListener("pointerup", up, true);
|
||||||
|
svg.setPointerCapture(event.pointerId);
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
beginEmptyPointer(event, point);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRange(event: PointerEvent) {
|
||||||
|
if (!rangeDrag || !options.svg.value) return;
|
||||||
|
const point = options.localPoint(options.svg.value, event.clientX, event.clientY);
|
||||||
|
const start = rangeDrag.start;
|
||||||
|
rangeDrag.moved = Math.hypot(point.x - start.x, point.y - start.y) >= 4;
|
||||||
|
options.rangeBox.value = {
|
||||||
|
x: Math.min(start.x, point.x),
|
||||||
|
y: Math.min(start.y, point.y),
|
||||||
|
width: Math.abs(point.x - start.x),
|
||||||
|
height: Math.abs(point.y - start.y),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function down(event: PointerEvent, node: Node, _element: SVGGElement) {
|
||||||
|
const svg = options.svg.value;
|
||||||
|
if (event.button === 1 || spaceHeld.value) {
|
||||||
|
const point = svg ? options.localPoint(svg, event.clientX, event.clientY) : { x: 0, y: 0 };
|
||||||
|
panDrag = { start: point, origin: { ...options.pan.value } };
|
||||||
|
window.addEventListener("pointermove", move, true);
|
||||||
|
window.addEventListener("pointerup", up, true);
|
||||||
|
svg?.setPointerCapture(event.pointerId);
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.altKey) return;
|
||||||
|
options.pane.value?.focus();
|
||||||
|
if (options.focus.value && !options.visibleIds().has(node.id)) {
|
||||||
|
beginEmptyPointer(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.mismatch()) {
|
||||||
|
options.setSelection(node.id, event.shiftKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (preClickTarget !== node.id || Date.now() - preClickAt > 600) {
|
||||||
|
preClickSelection = [...options.selected.value];
|
||||||
|
}
|
||||||
|
preClickTarget = node.id;
|
||||||
|
preClickAt = Date.now();
|
||||||
|
longPress.reset();
|
||||||
|
dragState = {
|
||||||
|
id: node.id,
|
||||||
|
start: { x: event.clientX, y: event.clientY },
|
||||||
|
base: options.project(),
|
||||||
|
ids: [...options.selected.value],
|
||||||
|
shift: event.shiftKey,
|
||||||
|
parents: new Map(),
|
||||||
|
starts: new Map(),
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
window.addEventListener("pointermove", move, true);
|
||||||
|
window.addEventListener("pointerup", up, true);
|
||||||
|
if (node.type === "key") longPress.start(event, node.id);
|
||||||
|
(event.currentTarget as Element).setPointerCapture?.(event.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(event: PointerEvent) {
|
||||||
|
const svg = options.svg.value;
|
||||||
|
if (!svg) return;
|
||||||
|
const point = options.localPoint(svg, event.clientX, event.clientY);
|
||||||
|
if (panDrag) {
|
||||||
|
options.pan.value = {
|
||||||
|
x: panDrag.origin.x + point.x - panDrag.start.x,
|
||||||
|
y: panDrag.origin.y + point.y - panDrag.start.y,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rangeDrag) {
|
||||||
|
updateRange(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!dragState) return;
|
||||||
|
lastPointer = { x: event.clientX, y: event.clientY };
|
||||||
|
if (longPress.picking()) {
|
||||||
|
longPress.point(event.clientX, event.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = dragState;
|
||||||
|
if (Math.hypot(event.clientX - state.start.x, event.clientY - state.start.y) < 4) return;
|
||||||
|
longPress.cancelPending();
|
||||||
|
if (longPress.cycling()) {
|
||||||
|
const { id, shift } = longPress.take();
|
||||||
|
options.selected.value = normalizeSelection(
|
||||||
|
options.project().phy,
|
||||||
|
shift ? [...preClickSelection, id] : [id],
|
||||||
|
);
|
||||||
|
state.ids = [id];
|
||||||
|
state.base = options.project();
|
||||||
|
state.parents = new Map([[id, parentElementFor(id)]]);
|
||||||
|
state.starts = new Map([
|
||||||
|
[id, options.localPoint(state.parents.get(id)!, state.start.x, state.start.y)],
|
||||||
|
]);
|
||||||
|
options.beginHistory();
|
||||||
|
} else if (!state.moved) {
|
||||||
|
const includesAncestor = preClickSelection.some(
|
||||||
|
(id) => id === state.id || options.isAncestor(id, state.id),
|
||||||
|
);
|
||||||
|
const ids = includesAncestor ? preClickSelection : [state.id];
|
||||||
|
if (!includesAncestor) options.selected.value = [state.id];
|
||||||
|
state.ids = ids;
|
||||||
|
state.base = options.project();
|
||||||
|
state.parents = new Map(ids.map((id) => [id, parentElementFor(id)]));
|
||||||
|
state.starts = new Map(
|
||||||
|
[...state.parents].map(([id, parent]) => [
|
||||||
|
id,
|
||||||
|
options.localPoint(parent, state.start.x, state.start.y),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
options.beginHistory();
|
||||||
|
}
|
||||||
|
state.moved = true;
|
||||||
|
updateDraggedPosition(event.clientX, event.clientY);
|
||||||
|
scheduleAutoPan();
|
||||||
|
}
|
||||||
|
|
||||||
|
function up() {
|
||||||
|
stopAutoPan();
|
||||||
|
window.removeEventListener("pointermove", move, true);
|
||||||
|
window.removeEventListener("pointerup", up, true);
|
||||||
|
if (panDrag) {
|
||||||
|
panDrag = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rangeDrag) {
|
||||||
|
if (rangeDrag.moved && options.rangeBox.value) {
|
||||||
|
const box = options.rangeBox.value;
|
||||||
|
const worldBox = {
|
||||||
|
x: (box.x - options.pan.value.x) / options.zoom.value,
|
||||||
|
y: (box.y - options.pan.value.y) / options.zoom.value,
|
||||||
|
width: box.width / options.zoom.value,
|
||||||
|
height: box.height / options.zoom.value,
|
||||||
|
};
|
||||||
|
const scope = options.visibleIds();
|
||||||
|
const hits: string[] = [];
|
||||||
|
for (const item of options.keys()) {
|
||||||
|
if (!scope.has(item.key.id)) continue;
|
||||||
|
const transform = worldTransform(options.project().phy, item.key.id);
|
||||||
|
const corners = keyCorners(item.key.size).map((point) => apply(transform, point));
|
||||||
|
const xs = corners.map((point) => point.x);
|
||||||
|
const ys = corners.map((point) => point.y);
|
||||||
|
if (
|
||||||
|
intersects(worldBox, {
|
||||||
|
x: Math.min(...xs),
|
||||||
|
y: Math.min(...ys),
|
||||||
|
width: Math.max(...xs) - Math.min(...xs),
|
||||||
|
height: Math.max(...ys) - Math.min(...ys),
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
hits.push(item.key.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const node of options
|
||||||
|
.allNodes()
|
||||||
|
.filter((item) => item.type === "component" && scope.has(item.id))) {
|
||||||
|
const transform = worldTransform(options.project().phy, node.id);
|
||||||
|
if (
|
||||||
|
intersects(worldBox, { x: transform.e - 0.16, y: transform.f - 0.16, width: 0.32, height: 0.32 })
|
||||||
|
) {
|
||||||
|
hits.push(node.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options.selected.value = normalizeSelection(
|
||||||
|
options.project().phy,
|
||||||
|
rangeDrag.shift ? [...options.selected.value, ...hits] : hits,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
options.selected.value = [];
|
||||||
|
}
|
||||||
|
rangeDrag = null;
|
||||||
|
options.rangeBox.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pick = longPress.finish();
|
||||||
|
if (pick) {
|
||||||
|
options.selected.value = normalizeSelection(
|
||||||
|
options.project().phy,
|
||||||
|
pick.shift ? [...options.selected.value, pick.id] : [pick.id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const moved = dragState?.moved ?? false;
|
||||||
|
if (dragState?.moved) options.commitHistory();
|
||||||
|
else if (dragState && !longPress.wasActivated()) {
|
||||||
|
const id = dragState.id;
|
||||||
|
const next = dragState.shift
|
||||||
|
? options.selected.value.includes(id)
|
||||||
|
? options.selected.value.filter((selectedId) => selectedId !== id)
|
||||||
|
: [...options.selected.value, id]
|
||||||
|
: [id];
|
||||||
|
options.selected.value = normalizeSelection(options.project().phy, next);
|
||||||
|
}
|
||||||
|
if (longPress.wasActivated() || moved) preClickTarget = null;
|
||||||
|
dragState = null;
|
||||||
|
lastPointer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function spaceUp(event: KeyboardEvent) {
|
||||||
|
if (event.code === "Space") spaceHeld.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(stopAutoPan);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canvasDown,
|
||||||
|
down,
|
||||||
|
move,
|
||||||
|
up,
|
||||||
|
spaceUp,
|
||||||
|
hold: {
|
||||||
|
open: longPress.open,
|
||||||
|
mode: longPress.mode,
|
||||||
|
pos: longPress.pos,
|
||||||
|
candidates: longPress.candidates,
|
||||||
|
names: longPress.names,
|
||||||
|
index: longPress.index,
|
||||||
|
step: longPress.step,
|
||||||
|
pointing: longPress.pointing,
|
||||||
|
},
|
||||||
|
spaceHeld,
|
||||||
|
priorSelection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersects(a: Box, b: Box) {
|
||||||
|
return a.x <= b.x + b.width && a.x + a.width >= b.x && a.y <= b.y + b.height && a.y + a.height >= b.y;
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { computed, type Ref } from "vue";
|
||||||
|
import type { ProjectFile } from "../../domain/project";
|
||||||
|
import type { Node, VariantGroupNode } from "../../domain/phy";
|
||||||
|
import {
|
||||||
|
apply,
|
||||||
|
compose,
|
||||||
|
flattenKeys,
|
||||||
|
inverse,
|
||||||
|
matrix,
|
||||||
|
worldTransform,
|
||||||
|
type Affine,
|
||||||
|
} from "../../domain/geometry";
|
||||||
|
import { removeFocusedSubtree, structuralNodes } from "./logic";
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
project: () => ProjectFile;
|
||||||
|
size: Ref<{ w: number; h: number }>;
|
||||||
|
pan: Ref<{ x: number; y: number }>;
|
||||||
|
zoom: Ref<number>;
|
||||||
|
focus: Ref<string | null>;
|
||||||
|
variants: Ref<Record<string, string>>;
|
||||||
|
grid: Ref<number>;
|
||||||
|
selected: Ref<string[]>;
|
||||||
|
node: (id: string) => Node | undefined;
|
||||||
|
parentId: (id: string) => string | null;
|
||||||
|
parentLabel: (id: string | null) => string;
|
||||||
|
selectedContainer: (id: string) => string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePhyCanvasView(options: Options) {
|
||||||
|
const keys = computed(() => flattenKeys(options.project().phy, options.variants.value));
|
||||||
|
const groupsAll = computed(() => structuralNodes(options.project().phy.root));
|
||||||
|
const outsideNodes = computed(() =>
|
||||||
|
options.focus.value ? removeFocusedSubtree(options.project().phy.root, options.focus.value) : [],
|
||||||
|
);
|
||||||
|
const gridLines = computed(() => {
|
||||||
|
const step = options.grid.value;
|
||||||
|
const xMin = Math.floor(-options.pan.value.x / options.zoom.value / step);
|
||||||
|
const xMax = Math.ceil((options.size.value.w - options.pan.value.x) / options.zoom.value / step);
|
||||||
|
const yMin = Math.floor(-options.pan.value.y / options.zoom.value / step);
|
||||||
|
const yMax = Math.ceil((options.size.value.h - options.pan.value.y) / options.zoom.value / step);
|
||||||
|
const lines: { d: string; major: boolean }[] = [];
|
||||||
|
for (let index = xMin; index <= xMax; index++) {
|
||||||
|
lines.push({ d: `M ${index * step} -100 V 100`, major: Math.abs((index * step) % 1) < 1e-8 });
|
||||||
|
}
|
||||||
|
for (let index = yMin; index <= yMax; index++) {
|
||||||
|
lines.push({ d: `M -100 ${index * step} H 100`, major: Math.abs((index * step) % 1) < 1e-8 });
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
});
|
||||||
|
const variantGroups = computed(() =>
|
||||||
|
groupsAll.value.filter((node): node is VariantGroupNode => node.type === "variantGroup"),
|
||||||
|
);
|
||||||
|
const focusGridLines = computed(() => {
|
||||||
|
const focusId = options.focus.value;
|
||||||
|
if (!focusId || !options.node(focusId)) return [];
|
||||||
|
const screen = compose(
|
||||||
|
{
|
||||||
|
a: options.zoom.value,
|
||||||
|
b: 0,
|
||||||
|
c: 0,
|
||||||
|
d: options.zoom.value,
|
||||||
|
e: options.pan.value.x,
|
||||||
|
f: options.pan.value.y,
|
||||||
|
},
|
||||||
|
worldTransform(options.project().phy, focusId),
|
||||||
|
);
|
||||||
|
const toLocal = inverse(screen);
|
||||||
|
const corners = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: options.size.value.w, y: 0 },
|
||||||
|
{ x: options.size.value.w, y: options.size.value.h },
|
||||||
|
{ x: 0, y: options.size.value.h },
|
||||||
|
].map((point) => apply(toLocal, point));
|
||||||
|
const xs = corners.map((point) => point.x);
|
||||||
|
const ys = corners.map((point) => point.y);
|
||||||
|
const step = options.grid.value;
|
||||||
|
const lines: { d: string; major: boolean }[] = [];
|
||||||
|
for (
|
||||||
|
let index = Math.floor(Math.min(...xs) / step);
|
||||||
|
index <= Math.ceil(Math.max(...xs) / step);
|
||||||
|
index++
|
||||||
|
) {
|
||||||
|
lines.push({ d: `M ${index * step} -100 V 100`, major: Math.abs((index * step) % 1) < 1e-8 });
|
||||||
|
}
|
||||||
|
for (
|
||||||
|
let index = Math.floor(Math.min(...ys) / step);
|
||||||
|
index <= Math.ceil(Math.max(...ys) / step);
|
||||||
|
index++
|
||||||
|
) {
|
||||||
|
lines.push({ d: `M -100 ${index * step} H 100`, major: Math.abs((index * step) % 1) < 1e-8 });
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
});
|
||||||
|
const moveTargets = computed(() => {
|
||||||
|
const excluded = new Set<string>();
|
||||||
|
const add = (node: Node) => {
|
||||||
|
excluded.add(node.id);
|
||||||
|
if (node.type === "cluster") node.children.forEach(add);
|
||||||
|
if (node.type === "variantGroup") {
|
||||||
|
node.children.forEach((variant) => {
|
||||||
|
excluded.add(variant.id);
|
||||||
|
variant.children.forEach(add);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
options.selected.value.forEach((id) => {
|
||||||
|
const node = options.node(id);
|
||||||
|
if (node) add(node);
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
{ id: "__root__", label: "root" },
|
||||||
|
...groupsAll.value
|
||||||
|
.filter((node) => node.type === "cluster" && !excluded.has(node.id))
|
||||||
|
.map((node) => ({ id: node.id, label: node.name || "クラスター" })),
|
||||||
|
...variantGroups.value.flatMap((group) =>
|
||||||
|
group.children
|
||||||
|
.filter((variant) => !excluded.has(variant.id))
|
||||||
|
.map((variant) => ({ id: variant.id, label: `${group.name} / ${variant.name}` })),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
function parentMatrix(id: string): Affine {
|
||||||
|
const node = options.node(id);
|
||||||
|
return node
|
||||||
|
? compose(worldTransform(options.project().phy, id), inverse(matrix(node.transform)))
|
||||||
|
: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusAngle(id: string) {
|
||||||
|
const transform = parentMatrix(id);
|
||||||
|
return (Math.atan2(transform.b, transform.a) * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
function affineText(transform: Affine) {
|
||||||
|
return `matrix(${transform.a} ${transform.b} ${transform.c} ${transform.d} ${transform.e} ${transform.f})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusCrumbs(id: string): { id: string | null; label: string }[] {
|
||||||
|
const ids = [id];
|
||||||
|
let parent = options.parentId(id);
|
||||||
|
while (parent) {
|
||||||
|
ids.unshift(parent);
|
||||||
|
parent = options.parentId(parent);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ id: null, label: "root" },
|
||||||
|
...ids.map((item) => ({
|
||||||
|
id:
|
||||||
|
groupsAll.value.find(
|
||||||
|
(node) => node.type === "variantGroup" && node.children.some((variant) => variant.id === item),
|
||||||
|
)?.id || item,
|
||||||
|
label: options.parentLabel(item),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const propertyNode = computed(() =>
|
||||||
|
options.selected.value.length === 1 ? options.node(options.selected.value[0]!) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
keys,
|
||||||
|
groupsAll,
|
||||||
|
outsideNodes,
|
||||||
|
gridLines,
|
||||||
|
variantGroups,
|
||||||
|
focusGridLines,
|
||||||
|
moveTargets,
|
||||||
|
parentMatrix,
|
||||||
|
focusAngle,
|
||||||
|
affineText,
|
||||||
|
focusCrumbs,
|
||||||
|
propertyNode,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { Ref } from "vue";
|
||||||
|
import type { ProjectFile } from "../../domain/project";
|
||||||
|
import type { KeyNode, Node } from "../../domain/phy";
|
||||||
|
import { cloneNodesWithNewIds, insertNodes, reparent, reparentToVariant } from "../../domain/phyCommands";
|
||||||
|
import { worldTransform, type Affine } from "../../domain/geometry";
|
||||||
|
import { importKle } from "../../io/kleImport";
|
||||||
|
import { treeIndex, pasteLocalTransform } from "./logic";
|
||||||
|
|
||||||
|
type ClipboardNode = { node: Node; world: Affine };
|
||||||
|
type Options = {
|
||||||
|
project: () => ProjectFile;
|
||||||
|
mismatch: () => boolean;
|
||||||
|
selected: Ref<string[]>;
|
||||||
|
cut: Ref<string[]>;
|
||||||
|
grid: Ref<number>;
|
||||||
|
node: (id: string) => Node | undefined;
|
||||||
|
selectedContainer: (id: string) => string | null;
|
||||||
|
insertionParent: () => string | null;
|
||||||
|
parentMatrix: (project: ProjectFile, parent: string | null) => Affine;
|
||||||
|
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
|
||||||
|
setSelected: (ids: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePhyClipboard(options: Options) {
|
||||||
|
let clipboard: ClipboardNode[] = [];
|
||||||
|
let marker = "";
|
||||||
|
|
||||||
|
function copyEvent(event: ClipboardEvent) {
|
||||||
|
if (!options.selected.value.length || !event.clipboardData) return;
|
||||||
|
const project = options.project();
|
||||||
|
clipboard = options.selected.value.map((id) => ({
|
||||||
|
node: structuredClone(options.node(id)!),
|
||||||
|
world: worldTransform(project.phy, id),
|
||||||
|
}));
|
||||||
|
marker = `beyond-kle-clipboard:${crypto.randomUUID()}`;
|
||||||
|
event.clipboardData.setData("text/plain", marker);
|
||||||
|
if (event.type === "cut") options.cut.value = [...options.selected.value];
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasteEvent(event: ClipboardEvent) {
|
||||||
|
if (options.mismatch()) return;
|
||||||
|
const text = event.clipboardData?.getData("text/plain") || "";
|
||||||
|
if (text === marker && clipboard.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
const parent = options.selected.value.length
|
||||||
|
? options.selectedContainer(options.selected.value[0]!)
|
||||||
|
: options.insertionParent();
|
||||||
|
if (options.cut.value.length) {
|
||||||
|
const ids = [...options.cut.value];
|
||||||
|
options.dispatch("カット貼り付け", (project) => ({
|
||||||
|
...ids.reduce((next, id) => {
|
||||||
|
const isVariant = parent !== null && treeIndex(next.phy.root).groupForVariant(parent) !== null;
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
phy: isVariant ? reparentToVariant(next.phy, id, parent!) : reparent(next.phy, id, parent),
|
||||||
|
};
|
||||||
|
}, project),
|
||||||
|
}));
|
||||||
|
options.setSelected(ids);
|
||||||
|
options.cut.value = [];
|
||||||
|
} else {
|
||||||
|
let ids: string[] = [];
|
||||||
|
options.dispatch("貼り付け", (project) => {
|
||||||
|
const copies = cloneNodesWithNewIds(clipboard.map((item) => item.node));
|
||||||
|
const parentWorld = options.parentMatrix(project, parent);
|
||||||
|
copies.forEach((node, index) => {
|
||||||
|
node.transform = pasteLocalTransform(clipboard[index]!.world, parentWorld, options.grid.value);
|
||||||
|
ids.push(node.id);
|
||||||
|
});
|
||||||
|
return { ...project, phy: insertNodes(project.phy, parent, copies) };
|
||||||
|
});
|
||||||
|
options.setSelected(ids);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { phy, keyOrder } = importKle(JSON.parse(text));
|
||||||
|
if (!event.clipboardData) return;
|
||||||
|
event.preventDefault();
|
||||||
|
let ids: string[] = [];
|
||||||
|
options.dispatch("KLE貼り付け", (project) => {
|
||||||
|
const nodes = phy.root
|
||||||
|
.filter((node): node is KeyNode => node.type === "key")
|
||||||
|
.map((key) => ({ ...key, profile: project.phy.profiles[0]?.id || key.profile }));
|
||||||
|
ids = nodes.map((node) => node.id);
|
||||||
|
const parent = options.selected.value.length
|
||||||
|
? options.selectedContainer(options.selected.value[0]!)
|
||||||
|
: options.insertionParent();
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
phy: insertNodes(project.phy, parent, nodes),
|
||||||
|
idx: { ...project.idx, order: [...project.idx.order, ...keyOrder] },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
options.setSelected(ids);
|
||||||
|
} catch {
|
||||||
|
// 外部クリップボードがKLE JSONでなければ無視する。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { copyEvent, pasteEvent };
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
import type { Ref } from "vue";
|
||||||
|
import type { ProjectFile } from "../../domain/project";
|
||||||
|
import type { KeyNode, Node, Size, VariantGroupNode } from "../../domain/phy";
|
||||||
|
import {
|
||||||
|
addComponentToParent,
|
||||||
|
addVariant as addVariantCommand,
|
||||||
|
addKeyToParent,
|
||||||
|
clusterNodes,
|
||||||
|
cloneNodesWithNewIds,
|
||||||
|
createVariantGroupInParent,
|
||||||
|
insertNodes,
|
||||||
|
reparent,
|
||||||
|
reparentToVariant,
|
||||||
|
setDefaultVariant,
|
||||||
|
uncluster,
|
||||||
|
} from "../../domain/phyCommands";
|
||||||
|
import { deleteNodeCascade, listDeleteReferences } from "../../domain/deleteCascade";
|
||||||
|
import { apply, inverse, matrix, worldTransform, type Affine } from "../../domain/geometry";
|
||||||
|
import { newRowPosition, rowEndPosition } from "../../domain/rows";
|
||||||
|
import { IRREGULAR_KEY_PRESETS, keyOrigin } from "../../domain/keyShape";
|
||||||
|
import { pasteLocalTransform, localBounds, treeIndex } from "./logic";
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
project: () => ProjectFile;
|
||||||
|
selected: Ref<string[]>;
|
||||||
|
variants: Ref<Record<string, string>>;
|
||||||
|
focus: Ref<string | null>;
|
||||||
|
grid: Ref<number>;
|
||||||
|
zoom: Ref<number>;
|
||||||
|
pan: Ref<{ x: number; y: number }>;
|
||||||
|
size: Ref<{ w: number; h: number }>;
|
||||||
|
svg: Ref<SVGSVGElement | undefined>;
|
||||||
|
pane: Ref<HTMLElement | undefined>;
|
||||||
|
popover: Ref<string | null>;
|
||||||
|
confirm: Ref<string | null>;
|
||||||
|
confirmIds: Ref<string[]>;
|
||||||
|
confirmRefs: Ref<ReturnType<typeof listDeleteReferences>>;
|
||||||
|
confirmPos: Ref<{ left: number; top: number }>;
|
||||||
|
node: (id: string) => Node | undefined;
|
||||||
|
allNodes: (roots?: Node[]) => Node[];
|
||||||
|
parentId: (id: string) => string | null;
|
||||||
|
selectedContainer: (id: string) => string | null;
|
||||||
|
groupForVariant: (id: string) => string | null;
|
||||||
|
variantGroups: () => VariantGroupNode[];
|
||||||
|
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
|
||||||
|
emitError: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePhyCommands(options: Options) {
|
||||||
|
function parentMatrix(project: ProjectFile, parent: string | null): Affine {
|
||||||
|
if (!parent) return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
|
||||||
|
if (treeIndex(project.phy.root).nodes.get(parent)?.type === "cluster")
|
||||||
|
return worldTransform(project.phy, parent);
|
||||||
|
const groupId = treeIndex(project.phy.root).groupForVariant(parent);
|
||||||
|
if (groupId) return worldTransform(project.phy, groupId);
|
||||||
|
throw new Error(`Unknown parent: ${parent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodesInContainer(container: string | null): Node[] {
|
||||||
|
if (container === null) return options.project().phy.root;
|
||||||
|
const node = options.node(container);
|
||||||
|
if (node?.type === "cluster") return node.children;
|
||||||
|
if (node?.type === "variantGroup") {
|
||||||
|
const variant = node.children.find(
|
||||||
|
(item) => item.id === (options.variants.value[node.id] || node.default),
|
||||||
|
);
|
||||||
|
return variant?.children ?? [];
|
||||||
|
}
|
||||||
|
const groupId = options.groupForVariant(container);
|
||||||
|
const group = groupId ? options.node(groupId) : null;
|
||||||
|
if (group?.type === "variantGroup")
|
||||||
|
return group.children.find((variant) => variant.id === container)?.children ?? [];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function directChildren(id: string): Node[] {
|
||||||
|
const node = options.node(id);
|
||||||
|
if (node?.type === "cluster") return node.children;
|
||||||
|
if (node?.type === "variantGroup") {
|
||||||
|
const variant = node.children.find(
|
||||||
|
(item) => item.id === (options.variants.value[node.id] || node.default),
|
||||||
|
);
|
||||||
|
return variant?.children ?? [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabItem(node: Node) {
|
||||||
|
if (node.type === "key") {
|
||||||
|
const center = apply(matrix(node.transform), { x: node.size.w / 2, y: node.size.h / 2 });
|
||||||
|
return { id: node.id, x: center.x, y: center.y };
|
||||||
|
}
|
||||||
|
if (node.type === "component") return { id: node.id, x: node.transform.x, y: node.transform.y };
|
||||||
|
const bounds =
|
||||||
|
node.type === "cluster"
|
||||||
|
? localBounds(node.children, options.variants.value)
|
||||||
|
: localBounds(directChildren(node.id), options.variants.value);
|
||||||
|
const center = apply(matrix(node.transform), {
|
||||||
|
x: bounds.x + bounds.width / 2,
|
||||||
|
y: bounds.y + bounds.height / 2,
|
||||||
|
});
|
||||||
|
return { id: node.id, x: center.x, y: center.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerInParent(parent: string | null) {
|
||||||
|
const world = {
|
||||||
|
x: (options.size.value.w / 2 - options.pan.value.x) / options.zoom.value,
|
||||||
|
y: (options.size.value.h / 2 - options.pan.value.y) / options.zoom.value,
|
||||||
|
};
|
||||||
|
return apply(inverse(parentMatrix(options.project(), parent)), world);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseVariant(group: Node, id: string) {
|
||||||
|
if (group.type !== "variantGroup") return;
|
||||||
|
options.variants.value = { ...options.variants.value, [group.id]: id };
|
||||||
|
const valid = new Set(
|
||||||
|
group.children.find((variant) => variant.id === id)?.children.map((item) => item.id) || [],
|
||||||
|
);
|
||||||
|
if (options.selected.value.some((selectedId) => !valid.has(selectedId)))
|
||||||
|
options.selected.value = [group.id];
|
||||||
|
options.popover.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The node id present in `after` but not in `before`; the tree order does not tell where it went. */
|
||||||
|
function addedId(before: ProjectFile["phy"], after: ProjectFile["phy"]): string {
|
||||||
|
const existing = new Set(options.allNodes(before.root).map((node) => node.id));
|
||||||
|
return options.allNodes(after.root).find((node) => !existing.has(node.id))?.id || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyAdd(size: Size = { w: 1, h: 1 }) {
|
||||||
|
const chosen = options.node(options.selected.value.at(-1) || "");
|
||||||
|
// A selected cluster, or the shown variant of a selected group, receives a new row inside it,
|
||||||
|
// like an empty selection inside the focus.
|
||||||
|
const parent =
|
||||||
|
chosen?.type === "cluster"
|
||||||
|
? chosen.id
|
||||||
|
: chosen?.type === "variantGroup"
|
||||||
|
? options.variants.value[chosen.id] || chosen.default
|
||||||
|
: chosen
|
||||||
|
? options.selectedContainer(chosen.id)
|
||||||
|
: insertionParent();
|
||||||
|
const keys = nodesInContainer(parent).flatMap((node) =>
|
||||||
|
node.type === "key" ? [{ id: node.id, x: node.transform.x, y: node.transform.y, ...node.size }] : [],
|
||||||
|
);
|
||||||
|
const snap = (value: number) => Math.round(value / options.grid.value) * options.grid.value;
|
||||||
|
const center = centerInParent(parent);
|
||||||
|
const { x, y } =
|
||||||
|
chosen?.type === "key"
|
||||||
|
? rowEndPosition(keys, chosen.id)!
|
||||||
|
: chosen?.type === "component"
|
||||||
|
? { x: snap(chosen.transform.x + 1), y: snap(chosen.transform.y) }
|
||||||
|
: (newRowPosition(keys) ?? { x: snap(center.x), y: snap(center.y) });
|
||||||
|
// The key's bounding box, not its origin, goes to the found position (T-32).
|
||||||
|
const offset = keyOrigin(size);
|
||||||
|
let id = "";
|
||||||
|
options.dispatch("キー追加", (project) => {
|
||||||
|
const at = { x: x - offset.x, y: y - offset.y, rotation: 0 };
|
||||||
|
const phy = addKeyToParent(project.phy, parent, at, size);
|
||||||
|
id = addedId(project.phy, phy);
|
||||||
|
return { ...project, phy, idx: { ...project.idx, order: [...project.idx.order, id] } };
|
||||||
|
});
|
||||||
|
if (id) options.selected.value = [id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function presetAdd(presetId: string) {
|
||||||
|
const preset = IRREGULAR_KEY_PRESETS.find((item) => item.id === presetId);
|
||||||
|
if (preset) keyAdd(preset.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertionParent() {
|
||||||
|
const focusId = options.focus.value;
|
||||||
|
if (!focusId) return null;
|
||||||
|
const focused = options.node(focusId);
|
||||||
|
if (focused?.type === "cluster") return focused.id;
|
||||||
|
if (focused?.type === "variantGroup") return options.variants.value[focused.id] ?? focused.default;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function partAdd(group: string) {
|
||||||
|
const selected = options.node(options.selected.value.at(-1) || "");
|
||||||
|
const parent = selected ? options.selectedContainer(selected.id) : insertionParent();
|
||||||
|
const center = centerInParent(parent);
|
||||||
|
const x =
|
||||||
|
Math.round(
|
||||||
|
(selected ? selected.transform.x + (selected.type === "key" ? selected.size.w : 1) : center.x) /
|
||||||
|
options.grid.value,
|
||||||
|
) * options.grid.value;
|
||||||
|
const y =
|
||||||
|
Math.round((selected ? selected.transform.y : center.y) / options.grid.value) * options.grid.value;
|
||||||
|
let id = "";
|
||||||
|
options.dispatch("部品追加", (project) => {
|
||||||
|
const phy = addComponentToParent(
|
||||||
|
project.phy,
|
||||||
|
parent,
|
||||||
|
{ x, y, rotation: 0 },
|
||||||
|
group === "その他" ? undefined : group,
|
||||||
|
);
|
||||||
|
id = addedId(project.phy, phy);
|
||||||
|
return { ...project, phy };
|
||||||
|
});
|
||||||
|
if (id) options.selected.value = [id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCluster() {
|
||||||
|
try {
|
||||||
|
let id = "";
|
||||||
|
options.dispatch("クラスター化", (project) => {
|
||||||
|
const phy = clusterNodes(project.phy, options.selected.value);
|
||||||
|
id =
|
||||||
|
options
|
||||||
|
.allNodes(phy.root)
|
||||||
|
.find(
|
||||||
|
(node) =>
|
||||||
|
node.type === "cluster" &&
|
||||||
|
!options.allNodes(project.phy.root).some((old) => old.id === node.id),
|
||||||
|
)?.id || "";
|
||||||
|
return { ...project, phy };
|
||||||
|
});
|
||||||
|
if (id) options.selected.value = [id];
|
||||||
|
} catch (error) {
|
||||||
|
options.emitError(String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ungroup() {
|
||||||
|
const ids = options.selected.value.filter((id) => options.node(id)?.type === "cluster");
|
||||||
|
if (ids.length)
|
||||||
|
options.dispatch("クラスター解除", (project) => ({
|
||||||
|
...project,
|
||||||
|
phy: ids.reduce((phy, id) => uncluster(phy, id), project.phy),
|
||||||
|
}));
|
||||||
|
options.selected.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function variantGroup() {
|
||||||
|
if (!options.selected.value.length) return;
|
||||||
|
const parent = options.selectedContainer(options.selected.value[0]!);
|
||||||
|
let id = "";
|
||||||
|
options.dispatch("バリアントグループ作成", (project) => {
|
||||||
|
let phy = createVariantGroupInParent(
|
||||||
|
project.phy,
|
||||||
|
parent,
|
||||||
|
"Variant Group",
|
||||||
|
{ x: 0, y: 0, rotation: 0 },
|
||||||
|
[{ name: "Option A" }, { name: "Option B" }],
|
||||||
|
);
|
||||||
|
const group = options
|
||||||
|
.allNodes(phy.root)
|
||||||
|
.find(
|
||||||
|
(node) =>
|
||||||
|
node.type === "variantGroup" &&
|
||||||
|
!options.allNodes(project.phy.root).some((old) => old.id === node.id),
|
||||||
|
);
|
||||||
|
if (group?.type !== "variantGroup") return { ...project, phy };
|
||||||
|
id = group.id;
|
||||||
|
for (const child of options.selected.value) phy = reparentToVariant(phy, child, group.children[0]!.id);
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
phy,
|
||||||
|
output: {
|
||||||
|
...project.output,
|
||||||
|
variantSelection: { ...project.output.variantSelection, [group.id]: group.default },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (id) options.selected.value = [id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicate() {
|
||||||
|
if (!options.selected.value.length) return;
|
||||||
|
const originals = options.selected.value
|
||||||
|
.map((id) => options.node(id))
|
||||||
|
.filter((node): node is Node => !!node);
|
||||||
|
const parent = options.selectedContainer(options.selected.value[0]!);
|
||||||
|
const worlds = originals.map((node) => worldTransform(options.project().phy, node.id));
|
||||||
|
let ids: string[] = [];
|
||||||
|
options.dispatch("複製", (project) => {
|
||||||
|
const copies = cloneNodesWithNewIds(originals);
|
||||||
|
const parentWorld = parentMatrix(project, parent);
|
||||||
|
copies.forEach((node, index) => {
|
||||||
|
node.transform = pasteLocalTransform(worlds[index]!, parentWorld, options.grid.value);
|
||||||
|
ids.push(node.id);
|
||||||
|
});
|
||||||
|
return { ...project, phy: insertNodes(project.phy, parent, copies) };
|
||||||
|
});
|
||||||
|
options.selected.value = ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSelectionTo(target: string) {
|
||||||
|
const destination = target === "__root__" ? null : target;
|
||||||
|
try {
|
||||||
|
options.dispatch("reparent", (project) => ({
|
||||||
|
...project,
|
||||||
|
phy: options.selected.value.reduce(
|
||||||
|
(phy, id) =>
|
||||||
|
destination && options.groupForVariant(destination)
|
||||||
|
? reparentToVariant(phy, id, destination)
|
||||||
|
: reparent(phy, id, destination),
|
||||||
|
project.phy,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
options.emitError(String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askDelete(id = options.selected.value[0], event?: MouseEvent) {
|
||||||
|
const ids = id ? (options.selected.value.includes(id) ? options.selected.value : [id]) : [];
|
||||||
|
if (!ids.length) return;
|
||||||
|
options.confirmIds.value = ids;
|
||||||
|
options.confirm.value = id!;
|
||||||
|
options.confirmRefs.value = ids.flatMap((selectedId) =>
|
||||||
|
listDeleteReferences(options.project(), selectedId),
|
||||||
|
);
|
||||||
|
if (event) {
|
||||||
|
const rect = (event.currentTarget as Element).getBoundingClientRect();
|
||||||
|
const pane = options.pane.value?.getBoundingClientRect();
|
||||||
|
const left = rect.right + 380 < (pane?.right ?? Infinity) ? rect.left : rect.left - 380;
|
||||||
|
const top = rect.bottom + 240 < (pane?.bottom ?? Infinity) ? rect.bottom + 4 : rect.top - 240;
|
||||||
|
options.confirmPos.value = overlayPoint(left, top, 380, 240);
|
||||||
|
} else {
|
||||||
|
const rects = ids.flatMap((selectedId) =>
|
||||||
|
[...(options.svg.value?.querySelectorAll(`[data-node-id="${selectedId}"]`) ?? [])].map((element) =>
|
||||||
|
element.getBoundingClientRect(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (rects.length) {
|
||||||
|
const right = Math.max(...rects.map((rect) => rect.right));
|
||||||
|
const bottom = Math.max(...rects.map((rect) => rect.bottom));
|
||||||
|
const pane = options.pane.value?.getBoundingClientRect();
|
||||||
|
const left = right + 388 < (pane?.right ?? Infinity) ? right + 8 : right - 388;
|
||||||
|
const top = bottom + 248 < (pane?.bottom ?? Infinity) ? bottom + 8 : bottom - 248;
|
||||||
|
options.confirmPos.value = overlayPoint(left, top, 380, 240);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlayPoint(clientX: number, clientY: number, width: number, height: number) {
|
||||||
|
const rect = options.pane.value?.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
left: Math.max(4, Math.min(clientX - (rect?.left ?? 0), (rect?.width ?? width) - width - 4)),
|
||||||
|
top: Math.max(4, Math.min(clientY - (rect?.top ?? 0), (rect?.height ?? height) - height - 4)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteConfirmed() {
|
||||||
|
if (!options.confirm.value) return;
|
||||||
|
const ids = [...options.confirmIds.value];
|
||||||
|
const shownGroups = ids
|
||||||
|
.map(
|
||||||
|
(id) =>
|
||||||
|
options
|
||||||
|
.variantGroups()
|
||||||
|
.find(
|
||||||
|
(group) =>
|
||||||
|
group.children.some((variant) => variant.id === id) &&
|
||||||
|
options.variants.value[group.id] === id,
|
||||||
|
)?.id,
|
||||||
|
)
|
||||||
|
.filter((id): id is string => !!id);
|
||||||
|
options.dispatch("削除", (project) => ids.reduce((next, id) => deleteNodeCascade(next, id), project));
|
||||||
|
const next = { ...options.variants.value };
|
||||||
|
for (const id of shownGroups) {
|
||||||
|
const group = options.variantGroups().find((item) => item.id === id);
|
||||||
|
if (group) next[id] = group.default;
|
||||||
|
else delete next[id];
|
||||||
|
}
|
||||||
|
options.variants.value = next;
|
||||||
|
options.selected.value = [];
|
||||||
|
options.confirm.value = null;
|
||||||
|
options.confirmIds.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDefault(groupId: string, variantId: string) {
|
||||||
|
options.dispatch("既定変更", (project) => ({
|
||||||
|
...project,
|
||||||
|
phy: setDefaultVariant(project.phy, groupId, variantId),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addVariant(groupId: string, name: string) {
|
||||||
|
options.dispatch("バリアント追加", (project) => ({
|
||||||
|
...project,
|
||||||
|
phy: addVariantCommand(project.phy, groupId, name),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
parentMatrix,
|
||||||
|
nodesInContainer,
|
||||||
|
directChildren,
|
||||||
|
tabItem,
|
||||||
|
chooseVariant,
|
||||||
|
keyAdd,
|
||||||
|
presetAdd,
|
||||||
|
partAdd,
|
||||||
|
makeCluster,
|
||||||
|
ungroup,
|
||||||
|
variantGroup,
|
||||||
|
duplicate,
|
||||||
|
moveSelectionTo,
|
||||||
|
askDelete,
|
||||||
|
deleteConfirmed,
|
||||||
|
setDefault,
|
||||||
|
addVariant,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import type { Ref } from "vue";
|
||||||
|
import type { ProjectFile } from "../../domain/project";
|
||||||
|
import type { KeyNode, Node } from "../../domain/phy";
|
||||||
|
import { moveNode, resizeKey, rotateNode } from "../../domain/phyCommands";
|
||||||
|
import { nextGrid, resolveKey, tabOrder } from "./logic";
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
project: () => ProjectFile;
|
||||||
|
mismatch: () => boolean;
|
||||||
|
pane: Ref<HTMLElement | undefined>;
|
||||||
|
selected: Ref<string[]>;
|
||||||
|
focus: Ref<string | null>;
|
||||||
|
grid: Ref<number>;
|
||||||
|
zoom: Ref<number>;
|
||||||
|
showHelp: Ref<boolean>;
|
||||||
|
popover: Ref<string | null>;
|
||||||
|
confirm: Ref<string | null>;
|
||||||
|
spaceHeld: Ref<boolean>;
|
||||||
|
variants: Ref<Record<string, string>>;
|
||||||
|
priorChild: Map<string, string>;
|
||||||
|
node: (id: string) => Node | undefined;
|
||||||
|
allNodes: (roots?: Node[]) => Node[];
|
||||||
|
parentId: (id: string) => string | null;
|
||||||
|
selectedContainer: (id: string) => string | null;
|
||||||
|
insertionParent: () => string | null;
|
||||||
|
nodesInContainer: (container: string | null) => Node[];
|
||||||
|
directChildren: (id: string) => Node[];
|
||||||
|
tabItem: (node: Node) => { id: string; x: number; y: number };
|
||||||
|
dispatch: (label: string, update: (project: ProjectFile) => ProjectFile) => void;
|
||||||
|
fit: () => void;
|
||||||
|
fitSelected: () => void;
|
||||||
|
focusIn: (id: string) => void;
|
||||||
|
makeCluster: () => void;
|
||||||
|
ungroup: () => void;
|
||||||
|
duplicate: () => void;
|
||||||
|
keyAdd: () => void;
|
||||||
|
askDelete: () => void;
|
||||||
|
deleteConfirmed: () => void;
|
||||||
|
chooseVariant: (group: Node, id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePhyKeyboard(options: Options) {
|
||||||
|
return function keyHandler(event: KeyboardEvent) {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (
|
||||||
|
!options.pane.value?.contains(document.activeElement) ||
|
||||||
|
target?.closest("input,select,textarea,[contenteditable=true]")
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.code === "Space") {
|
||||||
|
options.spaceHeld.value = true;
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action = resolveKey(event);
|
||||||
|
const control = event.ctrlKey || event.metaKey;
|
||||||
|
if (["undo", "redo", "save"].includes(action || "")) return;
|
||||||
|
if (action === "?" || event.key === "?") {
|
||||||
|
options.showHelp.value = true;
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
if (options.showHelp.value || options.popover.value || options.confirm.value) {
|
||||||
|
options.showHelp.value = false;
|
||||||
|
options.popover.value = null;
|
||||||
|
options.confirm.value = null;
|
||||||
|
} else if (options.selected.value.length) {
|
||||||
|
options.selected.value = [];
|
||||||
|
} else if (options.focus.value) {
|
||||||
|
options.focus.value = options.parentId(options.focus.value);
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" && options.confirm.value) {
|
||||||
|
options.deleteConfirmed();
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
event.key === "Enter" &&
|
||||||
|
options.selected.value.length === 1 &&
|
||||||
|
["cluster", "variantGroup"].includes(options.node(options.selected.value[0]!)?.type || "")
|
||||||
|
) {
|
||||||
|
options.focusIn(options.selected.value[0]!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "fit") {
|
||||||
|
options.fit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "selection-fit") {
|
||||||
|
options.fitSelected();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options.mismatch()) return;
|
||||||
|
if (action === "group" && control) {
|
||||||
|
event.preventDefault();
|
||||||
|
options.makeCluster();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "ungroup" && control) {
|
||||||
|
event.preventDefault();
|
||||||
|
options.ungroup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "d" && control) {
|
||||||
|
event.preventDefault();
|
||||||
|
options.duplicate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === "a" && control) {
|
||||||
|
event.preventDefault();
|
||||||
|
const container = options.selected.value.length
|
||||||
|
? options.selectedContainer(options.selected.value[0]!)
|
||||||
|
: options.insertionParent();
|
||||||
|
options.selected.value = options.nodesInContainer(container).map((node) => node.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Insert") {
|
||||||
|
event.preventDefault();
|
||||||
|
options.keyAdd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Delete" || event.key === "Backspace") {
|
||||||
|
event.preventDefault();
|
||||||
|
options.askDelete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "g" || event.key === "G") {
|
||||||
|
options.grid.value = nextGrid(options.grid.value, event.shiftKey ? -1 : 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "+" || event.key === "=") {
|
||||||
|
options.zoom.value *= 1.15;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "-") {
|
||||||
|
options.zoom.value /= 1.15;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
const container = options.selected.value.length
|
||||||
|
? options.selectedContainer(options.selected.value[0]!)
|
||||||
|
: options.insertionParent();
|
||||||
|
const siblings = options.nodesInContainer(container);
|
||||||
|
const ordered = tabOrder(siblings.map(options.tabItem));
|
||||||
|
const index = ordered.findIndex((node) => node.id === options.selected.value[0]);
|
||||||
|
const next = (index + (event.shiftKey ? -1 : 1) + ordered.length) % ordered.length;
|
||||||
|
if (ordered[next]) options.selected.value = [ordered[next]!.id];
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.altKey && event.key === "ArrowUp") {
|
||||||
|
const id = options.selected.value[0];
|
||||||
|
const parent = id ? options.parentId(id) : null;
|
||||||
|
if (id && parent) {
|
||||||
|
options.priorChild.set(parent, id);
|
||||||
|
options.selected.value = [parent];
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.altKey && event.key === "ArrowDown") {
|
||||||
|
const id = options.selected.value[0];
|
||||||
|
const children = id ? options.directChildren(id) : [];
|
||||||
|
const back = id ? options.priorChild.get(id) : undefined;
|
||||||
|
const next = children.some((child) => child.id === back)
|
||||||
|
? back
|
||||||
|
: tabOrder(children.map(options.tabItem))[0]?.id;
|
||||||
|
if (next) options.selected.value = [next];
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key.startsWith("Arrow")) {
|
||||||
|
const ids = options.selected.value;
|
||||||
|
if (!ids.length) return;
|
||||||
|
event.preventDefault();
|
||||||
|
let phy = options.project().phy;
|
||||||
|
for (const id of ids) {
|
||||||
|
const node = options.node(id);
|
||||||
|
if (!node) continue;
|
||||||
|
if (event.shiftKey) {
|
||||||
|
if (node.type !== "key") continue;
|
||||||
|
const size = { ...node.size };
|
||||||
|
if (event.key === "ArrowRight") size.w = Math.max(0.25, size.w + 0.25);
|
||||||
|
if (event.key === "ArrowDown") size.h = Math.max(0.25, size.h + 0.25);
|
||||||
|
if (event.key === "ArrowLeft") size.w = Math.max(0.25, size.w - 0.25);
|
||||||
|
if (event.key === "ArrowUp") size.h = Math.max(0.25, size.h - 0.25);
|
||||||
|
phy = resizeKey(phy, id, size);
|
||||||
|
} else {
|
||||||
|
const delta =
|
||||||
|
event.key === "ArrowLeft"
|
||||||
|
? [-options.grid.value, 0]
|
||||||
|
: event.key === "ArrowRight"
|
||||||
|
? [options.grid.value, 0]
|
||||||
|
: event.key === "ArrowUp"
|
||||||
|
? [0, -options.grid.value]
|
||||||
|
: [0, options.grid.value];
|
||||||
|
phy = moveNode(phy, id, delta[0]!, delta[1]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options.dispatch(event.shiftKey ? "サイズ" : "移動", (project) => ({ ...project, phy }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "PageUp" || event.key === "PageDown") {
|
||||||
|
const ids = options.selected.value;
|
||||||
|
if (ids.length) {
|
||||||
|
const delta = event.key === "PageUp" ? 15 : -15;
|
||||||
|
options.dispatch("回転", (project) => ({
|
||||||
|
...project,
|
||||||
|
phy: ids.reduce((phy, id) => rotateNode(phy, id, delta), project.phy),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key.toLowerCase() === "r") {
|
||||||
|
const ids = options.selected.value.filter((id) => options.node(id)?.type === "key");
|
||||||
|
if (ids.length) {
|
||||||
|
const delta = event.shiftKey ? -90 : 90;
|
||||||
|
options.dispatch("向き変更", (project) => {
|
||||||
|
const copy = structuredClone(project);
|
||||||
|
for (const id of ids) {
|
||||||
|
const node = options.allNodes(copy.phy.root).find((item) => item.id === id);
|
||||||
|
if (node?.type === "key") {
|
||||||
|
node.orientation = (((node.orientation || 0) + delta + 360) % 360) as KeyNode["orientation"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key.toLowerCase() === "v" && options.selected.value.length === 1) {
|
||||||
|
const group = options.node(options.selected.value[0]!);
|
||||||
|
if (group?.type === "variantGroup") {
|
||||||
|
const index = group.children.findIndex(
|
||||||
|
(variant) => variant.id === (options.variants.value[group.id] || group.default),
|
||||||
|
);
|
||||||
|
const variant =
|
||||||
|
group.children[(index + (event.shiftKey ? -1 : 1) + group.children.length) % group.children.length];
|
||||||
|
if (variant) options.chooseVariant(group, variant.id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "F2") {
|
||||||
|
(document.querySelector(".properties input") as HTMLInputElement | null)?.focus();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
export type HoldMode = "loop" | "list";
|
||||||
|
|
||||||
|
// feedback「長押しでの選択の切り替え」で 500ms / 450ms は早いとされたため延ばす。
|
||||||
|
export const HOLD_DELAY = 600;
|
||||||
|
export const HOLD_INTERVAL = 1000;
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
parentLabel: (id: string | null) => string;
|
||||||
|
pressTargets: (id: string) => string[];
|
||||||
|
mode: () => HoldMode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LongPressPick = { id: string; shift: boolean };
|
||||||
|
|
||||||
|
// キーの長押しで、キー → 親 → … の順に選択先を巡回する。
|
||||||
|
// loop: 巡回だけで選び、長押し後に動かすと選んだ先をドラッグする。
|
||||||
|
// list: 巡回は同じだが、ポインタがリストに入ると巡回をやめ、指している行を離した時点で選ぶ。ドラッグはしない。
|
||||||
|
// ポインタの扱い(ドラッグ・クリック)は呼び出し側が持ち、ここは候補と表示だけを持つ。
|
||||||
|
export function usePhyLongPress(options: Options) {
|
||||||
|
const open = ref(false);
|
||||||
|
const mode = ref<HoldMode>("loop");
|
||||||
|
const pos = ref({ x: 0, y: 0 });
|
||||||
|
const candidates = ref<string[]>([]);
|
||||||
|
const names = ref<string[]>([]);
|
||||||
|
const index = ref(0);
|
||||||
|
// 巡回が進むたびに増やし、インジケータのアニメーションをやり直させる
|
||||||
|
const step = ref(0);
|
||||||
|
// list 方式で、ポインタがリストに入ったあと
|
||||||
|
const pointing = ref(false);
|
||||||
|
let timer: number | undefined;
|
||||||
|
let cycle: number | undefined;
|
||||||
|
let shift = false;
|
||||||
|
let activated = false;
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
activated = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCycle() {
|
||||||
|
if (cycle) window.clearInterval(cycle);
|
||||||
|
cycle = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(event: PointerEvent, keyId: string) {
|
||||||
|
candidates.value = options.pressTargets(keyId);
|
||||||
|
index.value = 0;
|
||||||
|
shift = event.shiftKey;
|
||||||
|
timer = window.setTimeout(() => {
|
||||||
|
timer = undefined;
|
||||||
|
if (candidates.value.length <= 1) return;
|
||||||
|
activated = true;
|
||||||
|
mode.value = options.mode();
|
||||||
|
names.value = candidates.value.map((id, i) => (i === 0 ? "キー" : options.parentLabel(id)));
|
||||||
|
index.value = 1;
|
||||||
|
step.value++;
|
||||||
|
pointing.value = false;
|
||||||
|
pos.value = { x: event.clientX, y: event.clientY };
|
||||||
|
open.value = true;
|
||||||
|
cycle = window.setInterval(() => {
|
||||||
|
index.value = (index.value + 1) % candidates.value.length;
|
||||||
|
step.value++;
|
||||||
|
}, HOLD_INTERVAL);
|
||||||
|
}, HOLD_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycling() {
|
||||||
|
return open.value && mode.value === "loop";
|
||||||
|
}
|
||||||
|
|
||||||
|
function picking() {
|
||||||
|
return open.value && mode.value === "list";
|
||||||
|
}
|
||||||
|
|
||||||
|
// list 方式: ポインタの下にあるリストの行を指す。リストの外では直前の状態を保つ。
|
||||||
|
function point(clientX: number, clientY: number) {
|
||||||
|
const row = document
|
||||||
|
.elementsFromPoint(clientX, clientY)
|
||||||
|
.find((element) => element instanceof HTMLElement && element.dataset.holdIndex !== undefined);
|
||||||
|
if (!(row instanceof HTMLElement)) return;
|
||||||
|
stopCycle();
|
||||||
|
pointing.value = true;
|
||||||
|
index.value = Number(row.dataset.holdIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPending() {
|
||||||
|
if (timer && !open.value) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function take(): LongPressPick {
|
||||||
|
const id = candidates.value[index.value]!;
|
||||||
|
stopCycle();
|
||||||
|
open.value = false;
|
||||||
|
return { id, shift };
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(): LongPressPick | null {
|
||||||
|
if (timer) window.clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
stopCycle();
|
||||||
|
const id = open.value ? candidates.value[index.value] : undefined;
|
||||||
|
open.value = false;
|
||||||
|
return id ? { id, shift } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wasActivated() {
|
||||||
|
return activated;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
mode,
|
||||||
|
pos,
|
||||||
|
candidates,
|
||||||
|
names,
|
||||||
|
index,
|
||||||
|
step,
|
||||||
|
pointing,
|
||||||
|
reset,
|
||||||
|
start,
|
||||||
|
cycling,
|
||||||
|
picking,
|
||||||
|
point,
|
||||||
|
cancelPending,
|
||||||
|
take,
|
||||||
|
finish,
|
||||||
|
wasActivated,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { Ref } from "vue";
|
||||||
|
import type { ProjectFile } from "../../domain/project";
|
||||||
|
import type { Node } from "../../domain/phy";
|
||||||
|
import { longPressColumn, treeIndex, treeNodes } from "./logic";
|
||||||
|
|
||||||
|
export function usePhyTreeModel(project: () => ProjectFile, focus: Ref<string | null>) {
|
||||||
|
function allNodes(nodes = project().phy.root): Node[] {
|
||||||
|
return treeNodes(nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function node(id: string): Node | undefined {
|
||||||
|
return treeIndex(project().phy.root).nodes.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentId(id: string): string | null {
|
||||||
|
return treeIndex(project().phy.root).selectableParent(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAncestor(candidate: string, id: string): boolean {
|
||||||
|
let parent = parentId(id);
|
||||||
|
while (parent) {
|
||||||
|
if (parent === candidate) return true;
|
||||||
|
parent = parentId(parent);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedContainer(id: string): string | null {
|
||||||
|
return treeIndex(project().phy.root).insertionContainer(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupForVariant(id: string): string | null {
|
||||||
|
return treeIndex(project().phy.root).groupForVariant(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentLabel(id: string | null): string {
|
||||||
|
if (!id) return "root";
|
||||||
|
const item = node(id);
|
||||||
|
if (item?.type === "variantGroup") return item.name;
|
||||||
|
if (item?.type === "cluster") return item.name || "クラスター";
|
||||||
|
const variant = allNodes()
|
||||||
|
.flatMap((item) => (item.type === "variantGroup" ? item.children : []))
|
||||||
|
.find((item) => item.id === id);
|
||||||
|
return variant?.name || id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pressTargets(id: string): string[] {
|
||||||
|
const ancestors: string[] = [];
|
||||||
|
let parent = parentId(id);
|
||||||
|
while (parent) {
|
||||||
|
const group = allNodes().find(
|
||||||
|
(item) => item.type === "variantGroup" && item.children.some((variant) => variant.id === parent),
|
||||||
|
);
|
||||||
|
if (group) {
|
||||||
|
ancestors.push(group.id);
|
||||||
|
parent = parentId(group.id);
|
||||||
|
} else {
|
||||||
|
ancestors.push(parent);
|
||||||
|
parent = parentId(parent);
|
||||||
|
}
|
||||||
|
if (ancestors.at(-1) === focus.value) break;
|
||||||
|
}
|
||||||
|
return longPressColumn(id, ancestors, focus.value || undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allNodes,
|
||||||
|
node,
|
||||||
|
parentId,
|
||||||
|
isAncestor,
|
||||||
|
selectedContainer,
|
||||||
|
groupForVariant,
|
||||||
|
parentLabel,
|
||||||
|
pressTargets,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { onMounted, onUnmounted, type Ref } from "vue";
|
||||||
|
import type { ProjectFile } from "../../domain/project";
|
||||||
|
import type { Node } from "../../domain/phy";
|
||||||
|
import { fitViewport, zoomAround } from "./viewport";
|
||||||
|
import { localBounds } from "./logic";
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
project: () => ProjectFile;
|
||||||
|
svg: Ref<SVGSVGElement | undefined>;
|
||||||
|
size: Ref<{ w: number; h: number }>;
|
||||||
|
zoom: Ref<number>;
|
||||||
|
pan: Ref<{ x: number; y: number }>;
|
||||||
|
selected: Ref<string[]>;
|
||||||
|
variants: Ref<Record<string, string>>;
|
||||||
|
node: (id: string) => Node | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePhyViewport(options: Options) {
|
||||||
|
let observer: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
function fit() {
|
||||||
|
const bounds = localBounds(options.project().phy.root, options.variants.value);
|
||||||
|
const viewport = fitViewport(bounds, options.size.value.w, options.size.value.h);
|
||||||
|
options.zoom.value = viewport.zoom;
|
||||||
|
options.pan.value = viewport.pan;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitSelected() {
|
||||||
|
const nodes = options.selected.value
|
||||||
|
.map((id) => options.node(id))
|
||||||
|
.filter((node): node is Node => node !== undefined);
|
||||||
|
if (!nodes.length) return;
|
||||||
|
const bounds = localBounds(nodes, options.variants.value);
|
||||||
|
const viewport = fitViewport(bounds, options.size.value.w, options.size.value.h);
|
||||||
|
options.zoom.value = viewport.zoom;
|
||||||
|
options.pan.value = viewport.pan;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localPoint(element: SVGSVGElement | SVGGElement, x: number, y: number) {
|
||||||
|
const transform = element.getScreenCTM();
|
||||||
|
if (!transform) return { x: 0, y: 0 };
|
||||||
|
const point = new DOMPoint(x, y).matrixTransform(transform.inverse());
|
||||||
|
return { x: point.x, y: point.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function wheel(event: WheelEvent) {
|
||||||
|
const svg = options.svg.value;
|
||||||
|
if (!svg) return;
|
||||||
|
const point = localPoint(svg, event.clientX, event.clientY);
|
||||||
|
const viewport = zoomAround(
|
||||||
|
point,
|
||||||
|
{ zoom: options.zoom.value, pan: options.pan.value },
|
||||||
|
Math.exp(-event.deltaY * 0.001),
|
||||||
|
);
|
||||||
|
options.zoom.value = viewport.zoom;
|
||||||
|
options.pan.value = viewport.pan;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!options.svg.value) return;
|
||||||
|
// viewBox はSVG自身の大きさに合わせる。ペインにはツールバーも入り、折り返すと高さがずれる
|
||||||
|
observer = new ResizeObserver((entries) => {
|
||||||
|
const rect = entries[0]!.contentRect;
|
||||||
|
options.size.value = { w: rect.width, h: rect.height };
|
||||||
|
fit();
|
||||||
|
});
|
||||||
|
observer.observe(options.svg.value);
|
||||||
|
fit();
|
||||||
|
});
|
||||||
|
onUnmounted(() => observer?.disconnect());
|
||||||
|
|
||||||
|
return { fit, fitSelected, localPoint, wheel };
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export type Viewport = { zoom: number; pan: { x: number; y: number } };
|
||||||
|
export type ViewBounds = { x: number; y: number; width: number; height: number };
|
||||||
|
|
||||||
|
/** SVG画面端に近いほど強く、端の外では上限に達する自動パン速度(px/秒)。 */
|
||||||
|
export function autoPanVelocity(
|
||||||
|
point: { x: number; y: number },
|
||||||
|
bounds: ViewBounds,
|
||||||
|
edge = 32,
|
||||||
|
maxSpeed = 480,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const axis = (position: number, start: number, length: number) => {
|
||||||
|
const fromStart = position - start;
|
||||||
|
const fromEnd = start + length - position;
|
||||||
|
if (fromStart < edge) return -maxSpeed * Math.min(1, (edge - fromStart) / edge);
|
||||||
|
if (fromEnd < edge) return maxSpeed * Math.min(1, (edge - fromEnd) / edge);
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
x: axis(point.x, bounds.x, bounds.width),
|
||||||
|
y: axis(point.y, bounds.y, bounds.height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 境界を余白付きで中央へ収める。座標・倍率だけを扱う純粋関数。 */
|
||||||
|
export function fitViewport(
|
||||||
|
bounds: ViewBounds,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
padding = 40,
|
||||||
|
minZoom = 8,
|
||||||
|
maxZoom = 160,
|
||||||
|
): Viewport {
|
||||||
|
const zoom = Math.max(
|
||||||
|
minZoom,
|
||||||
|
Math.min(
|
||||||
|
maxZoom,
|
||||||
|
Math.min(
|
||||||
|
(width - 2 * padding) / Math.max(bounds.width, 1),
|
||||||
|
(height - 2 * padding) / Math.max(bounds.height, 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
zoom,
|
||||||
|
pan: {
|
||||||
|
x: (width - (2 * bounds.x + bounds.width) * zoom) / 2,
|
||||||
|
y: (height - (2 * bounds.y + bounds.height) * zoom) / 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 指定した画面座標の下にある点を保ったまま倍率を変える。 */
|
||||||
|
export function zoomAround(
|
||||||
|
point: { x: number; y: number },
|
||||||
|
viewport: Viewport,
|
||||||
|
factor: number,
|
||||||
|
minZoom = 5,
|
||||||
|
maxZoom = 200,
|
||||||
|
): Viewport {
|
||||||
|
const zoom = Math.max(minZoom, Math.min(maxZoom, viewport.zoom * factor));
|
||||||
|
const world = {
|
||||||
|
x: (point.x - viewport.pan.x) / viewport.zoom,
|
||||||
|
y: (point.y - viewport.pan.y) / viewport.zoom,
|
||||||
|
};
|
||||||
|
return { zoom, pan: { x: point.x - world.x * zoom, y: point.y - world.y * zoom } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import type { ProjectFile } from "../domain/project";
|
||||||
|
export type Dispatch = (label: string, update: (project: ProjectFile) => ProjectFile) => void;
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { createProjectFromKle } from "../src/domain/project";
|
||||||
|
import { saveProject, loadProject } from "../src/io/projectFile";
|
||||||
|
import { validateSchema } from "../src/io/validateSchema";
|
||||||
|
import { appendKeys, unregisteredKeys } from "../src/domain/idx";
|
||||||
|
import { assignKey, isEditableKeycode, parseKeycode, renameLayer, deleteLayer } from "../src/domain/map";
|
||||||
|
import { getKeycodeLabel } from "../src/domain/keycodeLabels";
|
||||||
|
import { qmkBasicKeycodes } from "../src/domain/qmkBasicKeycodes";
|
||||||
|
import { generateMockTarget } from "../src/domain/generator";
|
||||||
|
import { deleteNodeCascade } from "../src/domain/deleteCascade";
|
||||||
|
import { ProjectHistory } from "../src/domain/history";
|
||||||
|
import { validateProject } from "../src/domain/validate";
|
||||||
|
import { mcuPinCandidates } from "../src/domain/elc";
|
||||||
|
const kle = JSON.parse(
|
||||||
|
readFileSync(new URL("../../samples/kle/macropad-4x4.json", import.meta.url), "utf8"),
|
||||||
|
) as unknown;
|
||||||
|
const gen = (() => {
|
||||||
|
let i = 0;
|
||||||
|
return () => `${String(++i).padStart(8, "0")}-0000-4000-8000-000000000000`;
|
||||||
|
})();
|
||||||
|
describe("domain project flow", () => {
|
||||||
|
it("supports QMK basic keycode names and aliases while preserving entered spelling", async () => {
|
||||||
|
const editable = [
|
||||||
|
"KC_ENT",
|
||||||
|
"KC_ENTER",
|
||||||
|
"KC_ESCAPE",
|
||||||
|
"KC_ESC",
|
||||||
|
"KC_P0",
|
||||||
|
"KC_KP_0",
|
||||||
|
"KC_LCTL",
|
||||||
|
"KC_LEFT_CTRL",
|
||||||
|
"KC_INT1",
|
||||||
|
"KC_LNG1",
|
||||||
|
];
|
||||||
|
for (const code of editable) expect(isEditableKeycode(code), code).toBe(true);
|
||||||
|
|
||||||
|
const formalNames = qmkBasicKeycodes.filter((entry) => entry.group !== "internal");
|
||||||
|
expect(formalNames.length).toBeGreaterThanOrEqual(100);
|
||||||
|
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||||
|
const keyId = p.idx.order[0]!;
|
||||||
|
for (const code of editable) {
|
||||||
|
p.map = assignKey(p.map, "Base", keyId, code);
|
||||||
|
expect(p.map.layers[0]!.keymap[0]!.keycode).toBe(code);
|
||||||
|
}
|
||||||
|
p.map = assignKey(p.map, "Base", keyId, "LCTL(KC_ENT)");
|
||||||
|
expect(isEditableKeycode("LCTL(KC_ENT)")).toBe(true);
|
||||||
|
p.map.layers.push({ name: "Nav", keymap: [] });
|
||||||
|
p.map = assignKey(p.map, "Base", keyId, "LT(Nav, KC_LNG1)");
|
||||||
|
expect(isEditableKeycode("LT(Nav, KC_LNG1)")).toBe(true);
|
||||||
|
p.map = assignKey(p.map, "Base", keyId, "KC_P0");
|
||||||
|
const roundTrip = await loadProject((await saveProject(p)).json!);
|
||||||
|
expect(roundTrip.project!.map.layers[0]!.keymap[0]!.keycode).toBe("KC_P0");
|
||||||
|
|
||||||
|
for (const code of ["KC_NO", "XXXXXXX", "KC_TRANSPARENT", "KC_TRNS", "_______"]) {
|
||||||
|
expect(isEditableKeycode(code), code).toBe(true);
|
||||||
|
p.map = assignKey(p.map, "Base", keyId, code);
|
||||||
|
expect(p.map.layers[0]!.keymap[0]!.keycode).toBe(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const code of ["KC_FOO", "QK_BOOT"]) {
|
||||||
|
expect(parseKeycode(code).kind).toBe("unknown");
|
||||||
|
expect(isEditableKeycode(code)).toBe(false);
|
||||||
|
expect(() => assignKey(p.map, "Base", keyId, code)).toThrow();
|
||||||
|
}
|
||||||
|
p.map.layers[0]!.keymap[0]!.keycode = "KC_FOO";
|
||||||
|
expect(p.map.layers[0]!.keymap[0]!.keycode).toBe("KC_FOO");
|
||||||
|
|
||||||
|
expect(getKeycodeLabel("KC_A")).toBe("A");
|
||||||
|
expect(getKeycodeLabel("KC_ENTER")).toBe("Enter");
|
||||||
|
expect(getKeycodeLabel("KC_ENT")).toBe("Enter");
|
||||||
|
expect(getKeycodeLabel("KC_ESCAPE")).toBe("Escape");
|
||||||
|
expect(getKeycodeLabel("KC_ESC")).toBe("Escape");
|
||||||
|
});
|
||||||
|
it("imports KLE, saves, reloads and validates schemas", async () => {
|
||||||
|
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||||
|
const saved = await saveProject(p);
|
||||||
|
expect(saved.errors.some((issue) => issue.severity === "blocksSave")).toBe(false);
|
||||||
|
const loaded = await loadProject(saved.json!);
|
||||||
|
expect(loaded.errors.filter((x) => x.severity === "blocksSave")).toEqual([]);
|
||||||
|
expect(JSON.stringify(loaded.project)).toBe(JSON.stringify(p));
|
||||||
|
for (const l of ["phy", "idx", "elc", "map"] as const) expect(validateSchema(l, p[l])).toEqual([]);
|
||||||
|
});
|
||||||
|
it("preserves unknown data and IDX order", async () => {
|
||||||
|
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||||
|
p.extra = { x: 1 };
|
||||||
|
(p.phy as typeof p.phy & { extraPhy: boolean }).extraPhy = true;
|
||||||
|
p.idx.extraIdx = true;
|
||||||
|
p.elc.extraElc = true;
|
||||||
|
p.map.extraMap = true;
|
||||||
|
const q = (await loadProject((await saveProject(p)).json!)).project!;
|
||||||
|
expect(q.extra).toEqual({ x: 1 });
|
||||||
|
expect((q.phy as typeof q.phy & { extraPhy: boolean }).extraPhy).toBe(true);
|
||||||
|
expect(q.idx.extraIdx).toBe(true);
|
||||||
|
expect(q.elc.extraElc).toBe(true);
|
||||||
|
expect(q.map.extraMap).toBe(true);
|
||||||
|
expect(unregisteredKeys(q.phy, appendKeys(q.idx, []))).toEqual([]);
|
||||||
|
});
|
||||||
|
it("supports MAP layer reference changes, duplicate checks and safe parsing", async () => {
|
||||||
|
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||||
|
p.map.layers.push({ name: "Nav", keymap: [] });
|
||||||
|
p.map = assignKey(p.map, "Base", p.idx.order[0]!, "MO(Nav)");
|
||||||
|
p.map = assignKey(p.map, "Base", p.idx.order[1]!, "LT(Nav, KC_SPC)");
|
||||||
|
p.map = renameLayer(p.map, "Nav", "Fn");
|
||||||
|
expect(p.map.layers[0]!.keymap.map((x) => x.keycode)).toEqual(["MO(Fn)", "LT(Fn, KC_SPC)"]);
|
||||||
|
expect(parseKeycode("MO(Fn)").layer).toBe("Fn");
|
||||||
|
expect(() => deleteLayer(p.map, "Fn")).toThrow();
|
||||||
|
p.map.layers[0]!.keymap.push({ ...p.map.layers[0]!.keymap[0]! });
|
||||||
|
expect(validateProject(p).some((x) => x.code === "duplicate-map-key")).toBe(true);
|
||||||
|
});
|
||||||
|
it("generates ordered matrix/keymap previews and cascades deletion", async () => {
|
||||||
|
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||||
|
const ids = p.idx.order;
|
||||||
|
p.output.matrix = {
|
||||||
|
rowPins: ["m.GP0", "m.GP1", "m.GP2", "m.GP3"],
|
||||||
|
colPins: ["m.GP4", "m.GP5", "m.GP6", "m.GP7"],
|
||||||
|
};
|
||||||
|
p.elc.providers.push({ id: "m", kind: "mcu", part: "RP2040" });
|
||||||
|
p.elc.ledChains = [{ id: "chain", data: "m.GP8", order: [ids[0]!, ids[1]!] }];
|
||||||
|
p.map.layers[0]!.keymap = ids.map((keyId, i) => ({ keyId, keycode: `KC_${i % 10}` }));
|
||||||
|
p.elc.keys = ids.map((key, i) => ({
|
||||||
|
key,
|
||||||
|
detect: "contact",
|
||||||
|
terminals: { a: `m.GP${Math.floor(i / 4)}`, b: `m.GP${4 + (i % 4)}` },
|
||||||
|
diode: "b-to-a",
|
||||||
|
}));
|
||||||
|
const out = generateMockTarget(p);
|
||||||
|
expect(out.errors).toEqual([]);
|
||||||
|
expect(out.target?.idx).toEqual(ids);
|
||||||
|
expect(out.target?.matrix.rows.slice(0, 5)).toEqual([0, 0, 0, 0, 1]);
|
||||||
|
expect(out.target?.matrix.cols.slice(0, 5)).toEqual([0, 1, 2, 3, 0]);
|
||||||
|
expect(out.target?.keymaps[0]!.keycodes).toHaveLength(16);
|
||||||
|
const q = deleteNodeCascade(p, ids[0]!);
|
||||||
|
expect(q.idx.order).not.toContain(ids[0]);
|
||||||
|
expect(q.elc.keys.some((x) => x.key === ids[0])).toBe(false);
|
||||||
|
expect(q.elc.ledChains?.[0]?.order).not.toContain(ids[0]);
|
||||||
|
expect(q.map.layers[0]!.keymap.some((x) => x.keyId === ids[0])).toBe(false);
|
||||||
|
});
|
||||||
|
it("groups history edits", async () => {
|
||||||
|
const p = await createProjectFromKle(kle, { idGenerator: gen }),
|
||||||
|
h = new ProjectHistory(p);
|
||||||
|
const q = structuredClone(p);
|
||||||
|
q.phy.metadata.name = "drag";
|
||||||
|
h.begin();
|
||||||
|
h.push(q);
|
||||||
|
h.replaceTop(q);
|
||||||
|
h.commit(q);
|
||||||
|
expect(h.canUndo).toBe(true);
|
||||||
|
expect(h.undo().phy.metadata.name).toBe(p.phy.metadata.name);
|
||||||
|
expect(h.redo().phy.metadata.name).toBe("drag");
|
||||||
|
});
|
||||||
|
it("does not add a history step for an unchanged snapshot", async () => {
|
||||||
|
const p = await createProjectFromKle(kle, { idGenerator: gen }),
|
||||||
|
h = new ProjectHistory(p);
|
||||||
|
const q = structuredClone(p);
|
||||||
|
q.phy.metadata.name = "edit";
|
||||||
|
h.push(q);
|
||||||
|
h.push(structuredClone(q));
|
||||||
|
expect(h.undo().phy.metadata.name).toBe(p.phy.metadata.name);
|
||||||
|
expect(h.canUndo).toBe(false);
|
||||||
|
});
|
||||||
|
it("imports the VIA geometry sample", async () => {
|
||||||
|
const via = JSON.parse(
|
||||||
|
readFileSync(new URL("../../samples/via/cr0wn60.json", import.meta.url), "utf8"),
|
||||||
|
) as unknown;
|
||||||
|
const p = await createProjectFromKle(via, { idGenerator: gen });
|
||||||
|
expect(validateSchema("phy", p.phy)).toEqual([]);
|
||||||
|
});
|
||||||
|
it("detects T-25 collisions and permits reversed diode pairs and exclusive variants", async () => {
|
||||||
|
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||||
|
const ids = p.idx.order.slice(0, 2);
|
||||||
|
p.phy.root = p.phy.root.filter((n) => n.type === "key" && ids.includes(n.id));
|
||||||
|
p.idx.order = ids;
|
||||||
|
p.elc.providers = [{ id: "m", kind: "mcu", part: "RP2040" }];
|
||||||
|
p.elc.keys = [
|
||||||
|
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" } },
|
||||||
|
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP1", b: "m.GP0" } },
|
||||||
|
];
|
||||||
|
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(true);
|
||||||
|
p.elc.keys = [
|
||||||
|
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" }, diode: "b-to-a" },
|
||||||
|
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP1", b: "m.GP0" }, diode: "b-to-a" },
|
||||||
|
];
|
||||||
|
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(false);
|
||||||
|
p.elc.keys = [
|
||||||
|
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" } },
|
||||||
|
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" } },
|
||||||
|
];
|
||||||
|
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(true);
|
||||||
|
p.elc.keys = [
|
||||||
|
{ key: ids[0]!, detect: "contact", terminals: { a: "m.GP0", b: "m.GP1" }, diode: "a-to-b" },
|
||||||
|
{ key: ids[1]!, detect: "contact", terminals: { a: "m.GP1", b: "m.GP0" } },
|
||||||
|
];
|
||||||
|
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(true);
|
||||||
|
const a = p.phy.root[0]!,
|
||||||
|
b = p.phy.root[1]!;
|
||||||
|
p.phy.root = [
|
||||||
|
{
|
||||||
|
type: "variantGroup",
|
||||||
|
id: "vg",
|
||||||
|
name: "Choice",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
default: "va",
|
||||||
|
children: [
|
||||||
|
{ type: "variant", id: "va", name: "A", children: [a] },
|
||||||
|
{ type: "variant", id: "vb", name: "B", children: [b] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
expect(validateProject(p).some((x) => x.code === "wiring-collision")).toBe(false);
|
||||||
|
expect(mcuPinCandidates(p.elc.providers[0] as { id: string; kind: "mcu"; part: string })).toHaveLength(
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("filters variant output without rewriting stored IDX order", async () => {
|
||||||
|
const p = await createProjectFromKle(kle, { idGenerator: gen });
|
||||||
|
const ids = p.idx.order.slice(0, 2);
|
||||||
|
p.phy.root = [
|
||||||
|
{
|
||||||
|
type: "variantGroup",
|
||||||
|
id: "vgx",
|
||||||
|
name: "Choice",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
default: "vax",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "variant",
|
||||||
|
id: "vax",
|
||||||
|
name: "A",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
id: ids[0]!,
|
||||||
|
profile: "default",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
size: { w: 1, h: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "variant",
|
||||||
|
id: "vbx",
|
||||||
|
name: "B",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
id: ids[1]!,
|
||||||
|
profile: "default",
|
||||||
|
transform: { x: 1, y: 0, rotation: 0 },
|
||||||
|
size: { w: 1, h: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
p.output.variantSelection = { vgx: "vax" };
|
||||||
|
p.idx.order = ids;
|
||||||
|
p.elc.providers = [{ id: "m", kind: "mcu", part: "RP2040" }];
|
||||||
|
p.output.matrix = { rowPins: ["m.GP0"], colPins: ["m.GP1"] };
|
||||||
|
p.elc.keys = ids.map((key) => ({
|
||||||
|
key,
|
||||||
|
detect: "contact",
|
||||||
|
terminals: { a: "m.GP0", b: "m.GP1" },
|
||||||
|
diode: "b-to-a",
|
||||||
|
}));
|
||||||
|
p.map.layers[0]!.keymap = ids.map((keyId) => ({ keyId, keycode: "KC_A" }));
|
||||||
|
let g = generateMockTarget(p);
|
||||||
|
expect(g.target?.idx).toEqual([ids[0]]);
|
||||||
|
p.output.variantSelection = { vgx: "vbx" };
|
||||||
|
g = generateMockTarget(p);
|
||||||
|
expect(g.target?.idx).toEqual([ids[1]]);
|
||||||
|
expect(p.idx.order).toEqual(ids);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createElc } from "../src/domain/elc";
|
||||||
|
import { wireMatrixKeys } from "../src/domain/elcMatrix";
|
||||||
|
|
||||||
|
describe("wireMatrixKeys", () => {
|
||||||
|
it("assigns row-major pins and diode without mutating source", () => {
|
||||||
|
const source = createElc({ source: "test", checksum: "" });
|
||||||
|
const next = wireMatrixKeys(
|
||||||
|
source,
|
||||||
|
["k1", "k2", "k3"],
|
||||||
|
["MCU.GP0", "MCU.GP1"],
|
||||||
|
["MCU.GP2", "MCU.GP3"],
|
||||||
|
"a-to-b",
|
||||||
|
);
|
||||||
|
expect(next.keys.map((w) => w.detect === "contact" && [w.terminals.a, w.terminals.b, w.diode])).toEqual([
|
||||||
|
["MCU.GP0", "MCU.GP2", "a-to-b"],
|
||||||
|
["MCU.GP0", "MCU.GP3", "a-to-b"],
|
||||||
|
["MCU.GP1", "MCU.GP2", "a-to-b"],
|
||||||
|
]);
|
||||||
|
expect(source.keys).toEqual([]);
|
||||||
|
});
|
||||||
|
it("rejects an undersized matrix", () => {
|
||||||
|
expect(() =>
|
||||||
|
wireMatrixKeys(createElc({ source: "test", checksum: "" }), ["a", "b"], ["R"], ["C"]),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
it("preserves extension fields when updating existing wiring", () => {
|
||||||
|
const source = {
|
||||||
|
...createElc({ source: "test", checksum: "" }),
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
key: "k1",
|
||||||
|
detect: "contact" as const,
|
||||||
|
terminals: { a: "OLD.R", b: "OLD.C" },
|
||||||
|
extension: "keep",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const next = wireMatrixKeys(source, ["k1"], ["R"], ["C"]);
|
||||||
|
expect(next.keys[0]).toMatchObject({ extension: "keep", terminals: { a: "R", b: "C" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+5967
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,322 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import {
|
||||||
|
insertAtGap,
|
||||||
|
insertNextTo,
|
||||||
|
keyVariantGroups,
|
||||||
|
orderSegments,
|
||||||
|
renumber,
|
||||||
|
swapKeys,
|
||||||
|
} from "../src/domain/idxOrder";
|
||||||
|
import type { OrderContext } from "../src/domain/idxOrder";
|
||||||
|
import {
|
||||||
|
assignLines,
|
||||||
|
keyRect,
|
||||||
|
matrixSequence,
|
||||||
|
scanKeys,
|
||||||
|
splitHalves,
|
||||||
|
type ScanOptions,
|
||||||
|
} from "../src/domain/idxScan";
|
||||||
|
import { matchPlacements } from "../src/domain/idxImport";
|
||||||
|
import { readLayoutPlacements } from "../src/io/layoutImport";
|
||||||
|
import { importKle } from "../src/io/kleImport";
|
||||||
|
import { flattenKeys } from "../src/domain/geometry";
|
||||||
|
import type { KeyRect } from "../src/domain/rows";
|
||||||
|
import type { FlatKey, PhyFile } from "../src/domain/phy";
|
||||||
|
|
||||||
|
const sample = (path: string) =>
|
||||||
|
JSON.parse(readFileSync(new URL(`../../samples/${path}`, import.meta.url), "utf8")) as unknown;
|
||||||
|
const gen = () => {
|
||||||
|
let i = 0;
|
||||||
|
return () => `${String(++i).padStart(8, "0")}-0000-4000-8000-000000000000`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Context where `hidden` keys are in group g (with every `grouped` visible key) and the rest are common. */
|
||||||
|
const ctx = (visible: string[], grouped: string[] = []): OrderContext => ({
|
||||||
|
visible: new Set(visible),
|
||||||
|
groups: new Map(grouped.map((id) => [id, ["g"]])),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("IDX order editing", () => {
|
||||||
|
it("keeps the ISO Enter next to the ANSI Enter when inserting after it", () => {
|
||||||
|
// a = ANSI Enter (shown), h = ISO Enter (hidden), b = common key
|
||||||
|
const c = ctx(["a", "b", "x"], ["a", "h"]);
|
||||||
|
expect(orderSegments(["a", "h", "b"], c)).toEqual([["a", "h"], ["b"]]);
|
||||||
|
expect(insertNextTo(["a", "h", "b", "x"], c, "x", "a", true)).toEqual(["a", "h", "x", "b"]);
|
||||||
|
expect(insertNextTo(["a", "h", "b", "x"], c, "x", "b", false)).toEqual(["a", "h", "x", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches hidden keys forward only to a key sharing their group", () => {
|
||||||
|
const c = ctx(["p", "q"], ["h1", "h2", "q"]);
|
||||||
|
expect(orderSegments(["p", "h1", "h2", "q"], c)).toEqual([["p"], ["h1", "h2", "q"]]);
|
||||||
|
// shares with neither: stays with the key before
|
||||||
|
expect(orderSegments(["p", "z", "q"], ctx(["p", "q"]))).toEqual([["p", "z"], ["q"]]);
|
||||||
|
// shares with both: stays with the key before
|
||||||
|
expect(orderSegments(["p", "h", "q"], ctx(["p", "q"], ["p", "h", "q"]))).toEqual([["p", "h"], ["q"]]);
|
||||||
|
// once a hidden key goes forward, the rest of the run follows to keep the order
|
||||||
|
const mixed = {
|
||||||
|
visible: new Set(["p", "q"]),
|
||||||
|
groups: new Map([
|
||||||
|
["h1", ["g"]],
|
||||||
|
["q", ["g"]],
|
||||||
|
["h2", ["k"]],
|
||||||
|
["p", ["k"]],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
expect(orderSegments(["p", "h1", "h2", "q"], mixed)).toEqual([["p"], ["h1", "h2", "q"]]);
|
||||||
|
// leading hidden keys attach to the first visible key, trailing ones to the last
|
||||||
|
expect(orderSegments(["z", "p", "q", "y"], ctx(["p", "q"]))).toEqual([
|
||||||
|
["z", "p"],
|
||||||
|
["q", "y"],
|
||||||
|
]);
|
||||||
|
expect(orderSegments(["z"], ctx([]))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a key between two others like a list move", () => {
|
||||||
|
const c = ctx(["k0", "k1", "k2", "k3", "k4", "k5"]);
|
||||||
|
const order = ["k0", "k1", "k2", "k3", "k4", "k5"];
|
||||||
|
// n = 4 dropped between i = 1 and i + 1 = 2: lands on 2, 2..3 shift back
|
||||||
|
expect(insertNextTo(order, c, "k4", "k1", true)).toEqual(["k0", "k1", "k4", "k2", "k3", "k5"]);
|
||||||
|
expect(insertNextTo(order, c, "k4", "k2", false)).toEqual(["k0", "k1", "k4", "k2", "k3", "k5"]);
|
||||||
|
// n = 1 dropped between i = 3 and 4: lands on 3, 2..3 shift forward
|
||||||
|
expect(insertNextTo(order, c, "k1", "k3", true)).toEqual(["k0", "k2", "k3", "k1", "k4", "k5"]);
|
||||||
|
expect(insertNextTo(order, c, "k1", "k4", false)).toEqual(["k0", "k2", "k3", "k1", "k4", "k5"]);
|
||||||
|
expect(insertNextTo(order, c, "k1", "k1", true)).toEqual(order);
|
||||||
|
expect(insertAtGap(order, c, "k0", 99)).toEqual(["k1", "k2", "k3", "k4", "k5", "k0"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves hidden keys in place when their visible key moves away", () => {
|
||||||
|
const c = ctx(["a", "b", "c"], ["a", "h"]);
|
||||||
|
expect(insertNextTo(["a", "h", "b", "c"], c, "a", "c", true)).toEqual(["h", "b", "c", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers an unregistered key at the drop position", () => {
|
||||||
|
const c = ctx(["a", "b", "new"]);
|
||||||
|
expect(insertNextTo(["a", "b"], c, "new", "b", false)).toEqual(["a", "new", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swaps only the two keys", () => {
|
||||||
|
expect(swapKeys(["a", "h", "b", "c"], "a", "c")).toEqual(["c", "h", "b", "a"]);
|
||||||
|
expect(() => swapKeys(["a"], "a", "zz")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renumbers a sequence from the position of its first key, moving attached hidden keys along", () => {
|
||||||
|
const c = ctx(["a", "b", "c", "d"], ["b", "h"]);
|
||||||
|
const order = ["a", "b", "h", "c", "d"];
|
||||||
|
expect(renumber(order, c, ["d", "b"])).toEqual(["a", "c", "d", "b", "h"]);
|
||||||
|
expect(renumber(order, c, ["b", "a"])).toEqual(["b", "h", "a", "c", "d"]);
|
||||||
|
expect(renumber(order, c, ["c", "a"])).toEqual(["b", "h", "c", "a", "d"]);
|
||||||
|
// unregistered keys are registered inside the block; an all-new sequence goes to the end
|
||||||
|
expect(renumber(["a", "b"], ctx(["a", "b", "n"]), ["n", "b"])).toEqual(["a", "n", "b"]);
|
||||||
|
expect(renumber(["a"], ctx(["a", "n"]), ["n"])).toEqual(["a", "n"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never duplicates or drops keys", () => {
|
||||||
|
const c = ctx(["a", "b", "c", "d"], ["a", "h1", "c", "h2"]);
|
||||||
|
const order = ["h1", "a", "b", "h2", "c", "d", "z"];
|
||||||
|
const hiddenOf = (o: string[]) => o.filter((id) => !c.visible.has(id));
|
||||||
|
const moved = renumber(order, c, ["d", "c", "b", "a"]);
|
||||||
|
expect([...moved].sort()).toEqual([...order].sort());
|
||||||
|
// hidden keys travel with their visible key here
|
||||||
|
expect(moved).toEqual(["d", "z", "h2", "c", "b", "h1", "a"]);
|
||||||
|
for (const r of [
|
||||||
|
insertNextTo(order, c, "a", "d", true),
|
||||||
|
insertNextTo(order, c, "d", "a", false),
|
||||||
|
swapKeys(order, "b", "d"),
|
||||||
|
]) {
|
||||||
|
expect([...r].sort()).toEqual([...order].sort());
|
||||||
|
expect(hiddenOf(r)).toEqual(hiddenOf(order));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists VariantGroup ancestors including nested groups", () => {
|
||||||
|
const { phy } = importKle(sample("kle/macropad-4x4.json"), { idGenerator: gen() });
|
||||||
|
const [a, b] = phy.root as Extract<PhyFile["root"][number], { type: "key" }>[];
|
||||||
|
const nested: PhyFile = {
|
||||||
|
...phy,
|
||||||
|
root: [
|
||||||
|
{
|
||||||
|
type: "variantGroup",
|
||||||
|
id: "outer",
|
||||||
|
name: "o",
|
||||||
|
default: "v1",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "variant",
|
||||||
|
id: "v1",
|
||||||
|
name: "1",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "variantGroup",
|
||||||
|
id: "inner",
|
||||||
|
name: "i",
|
||||||
|
default: "v2",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
children: [{ type: "variant", id: "v2", name: "2", children: [a!] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
b!,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const groups = keyVariantGroups(nested);
|
||||||
|
expect(groups.get(a!.id)).toEqual(["outer", "inner"]);
|
||||||
|
expect(groups.get(b!.id)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const r = (id: string, x: number, y: number, w = 1, h = 1): KeyRect => ({ id, x, y, w, h });
|
||||||
|
const options = (o: Partial<ScanOptions>): ScanOptions => ({
|
||||||
|
kind: "rows",
|
||||||
|
direction: "normal",
|
||||||
|
left: "normal",
|
||||||
|
right: "normal",
|
||||||
|
offsets: new Map(),
|
||||||
|
...o,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("IDX preset scans", () => {
|
||||||
|
// Two 2x2 halves with a 2U gap
|
||||||
|
const split = [r("l00", 0, 0), r("l01", 1, 0), r("l10", 0, 1), r("l11", 1, 1)].concat([
|
||||||
|
r("r00", 4, 0),
|
||||||
|
r("r01", 5, 0),
|
||||||
|
r("r10", 4, 1),
|
||||||
|
r("r11", 5, 1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("scans rows, columns, halves and alternating halves", () => {
|
||||||
|
expect(scanKeys(split, options({})).sequence).toEqual([
|
||||||
|
"l00",
|
||||||
|
"l01",
|
||||||
|
"r00",
|
||||||
|
"r01",
|
||||||
|
"l10",
|
||||||
|
"l11",
|
||||||
|
"r10",
|
||||||
|
"r11",
|
||||||
|
]);
|
||||||
|
expect(scanKeys(split, options({ direction: "reversed" })).sequence.slice(0, 4)).toEqual([
|
||||||
|
"r01",
|
||||||
|
"r00",
|
||||||
|
"l01",
|
||||||
|
"l00",
|
||||||
|
]);
|
||||||
|
expect(scanKeys(split, options({ kind: "columns" })).sequence.slice(0, 4)).toEqual([
|
||||||
|
"l00",
|
||||||
|
"l10",
|
||||||
|
"l01",
|
||||||
|
"l11",
|
||||||
|
]);
|
||||||
|
expect(scanKeys(split, options({ kind: "columns", direction: "reversed" })).sequence.slice(0, 2)).toEqual(
|
||||||
|
["r01", "r11"],
|
||||||
|
);
|
||||||
|
const halves = scanKeys(split, options({ kind: "halves", right: "reversed" }));
|
||||||
|
expect(halves.sequence).toEqual(["l00", "l01", "l10", "l11", "r01", "r00", "r11", "r10"]);
|
||||||
|
expect(halves.boundary).toBe(3);
|
||||||
|
expect(scanKeys(split, options({ kind: "alternating" })).sequence).toEqual([
|
||||||
|
"l00",
|
||||||
|
"l01",
|
||||||
|
"r00",
|
||||||
|
"r01",
|
||||||
|
"l10",
|
||||||
|
"l11",
|
||||||
|
"r10",
|
||||||
|
"r11",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits a board without a gap at the middle of its x range", () => {
|
||||||
|
const halves = splitHalves([r("a", 0, 0), r("b", 1, 0), r("c", 2, 0), r("d", 3, 0, 2)]);
|
||||||
|
expect(halves.boundary).toBe(2.5);
|
||||||
|
expect([...halves.left]).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shifts the row assignment of one column", () => {
|
||||||
|
// column-staggered: the outer column sits a full row lower and is detected one row off
|
||||||
|
const keys = [r("o0", 0, 1), r("o1", 0, 2), r("a0", 1, 0), r("a1", 1, 1), r("a2", 1, 2)];
|
||||||
|
const before = assignLines(keys, "row", new Map());
|
||||||
|
expect(before.lines).toEqual([["a0"], ["o0", "a1"], ["o1", "a2"]]);
|
||||||
|
const outer = before.crossLines.findIndex((col) => col.includes("o0"));
|
||||||
|
const after = assignLines(keys, "row", new Map([[outer, -1]]));
|
||||||
|
expect(after.lines).toEqual([["o0", "a0"], ["o1", "a1"], ["a2"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the bounding box of rotated keys", () => {
|
||||||
|
const rotated = keyRect({
|
||||||
|
key: {
|
||||||
|
type: "key",
|
||||||
|
id: "k",
|
||||||
|
profile: "p",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
size: { w: 2, h: 1 },
|
||||||
|
},
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
rotation: 90,
|
||||||
|
path: [],
|
||||||
|
});
|
||||||
|
expect(rotated.x).toBeCloseTo(-1);
|
||||||
|
expect(rotated.w).toBeCloseTo(1);
|
||||||
|
expect(rotated.h).toBeCloseTo(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders by matrix row then col and leaves unwired keys at the end", () => {
|
||||||
|
const positions = new Map([
|
||||||
|
["a", { row: 1, col: 0 }],
|
||||||
|
["b", { row: 0, col: 2 }],
|
||||||
|
["c", { row: 0, col: 1 }],
|
||||||
|
]);
|
||||||
|
expect(matrixSequence(["x", "a", "b", "y", "c"], positions)).toEqual(["c", "b", "a", "x", "y"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("IDX import from existing definitions", () => {
|
||||||
|
const shown = (phy: PhyFile): FlatKey[] => flattenKeys(phy).filter((k) => !k.key.decal);
|
||||||
|
|
||||||
|
it("matches every key of the KLE the project came from, in KLE order", () => {
|
||||||
|
for (const file of ["kle/macropad-4x4.json", "kle/cr0wn60.json"]) {
|
||||||
|
const { phy, keyOrder } = importKle(sample(file), { idGenerator: gen() });
|
||||||
|
const result = matchPlacements(readLayoutPlacements(sample(file)).placements, shown(phy));
|
||||||
|
expect(result.unmatched, file).toBe(0);
|
||||||
|
expect(result.ids).toEqual(keyOrder.filter((id) => shown(phy).some((k) => k.key.id === id)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the VIA definition of cr0wn60 against its KLE", () => {
|
||||||
|
const { phy } = importKle(sample("kle/cr0wn60.json"), { idGenerator: gen() });
|
||||||
|
const keys = shown(phy);
|
||||||
|
const result = matchPlacements(readLayoutPlacements(sample("via/cr0wn60.json")).placements, keys);
|
||||||
|
expect(result.ids.length).toBe(keys.length);
|
||||||
|
expect(new Set(result.ids).size).toBe(result.ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads QMK layouts, asks for a name when there are several, and applies rotation", () => {
|
||||||
|
const kle = [
|
||||||
|
[{ r: 30, rx: 2, ry: 1, x: 0.5 }, "A"],
|
||||||
|
[{ x: -1 }, "B"],
|
||||||
|
];
|
||||||
|
const { phy } = importKle(kle, { idGenerator: gen() });
|
||||||
|
const keys = shown(phy);
|
||||||
|
const qmk = {
|
||||||
|
layouts: {
|
||||||
|
LAYOUT_a: {
|
||||||
|
layout: [
|
||||||
|
{ matrix: [0, 1], x: 1, y: 2, r: 30, rx: 2, ry: 1 },
|
||||||
|
{ matrix: [0, 0], x: 2.5, y: 1, r: 30, rx: 2, ry: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
LAYOUT_b: { layout: [{ matrix: [0, 0], x: 0, y: 0 }] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(readLayoutPlacements(qmk)).toEqual({ placements: [], layouts: ["LAYOUT_a", "LAYOUT_b"] });
|
||||||
|
const read = readLayoutPlacements(qmk, "LAYOUT_a");
|
||||||
|
const result = matchPlacements(read.placements, keys);
|
||||||
|
expect(result.unmatched).toBe(0);
|
||||||
|
const label = (id: string) => keys.find((k) => k.key.id === id)!.key.labels?.find(Boolean);
|
||||||
|
expect(result.ids.map(label)).toEqual(["B", "A"]);
|
||||||
|
expect(matchPlacements(readLayoutPlacements(qmk, "LAYOUT_b").placements, keys).unmatched).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
IRREGULAR_KEY_PRESETS,
|
||||||
|
hasSecondRect,
|
||||||
|
keyOrigin,
|
||||||
|
keyOutline,
|
||||||
|
keyRects,
|
||||||
|
} from "../src/domain/keyShape";
|
||||||
|
import { keyPath } from "../src/ui/phy/logic";
|
||||||
|
|
||||||
|
const isoEnter = IRREGULAR_KEY_PRESETS.find((p) => p.id === "iso-enter")!.size;
|
||||||
|
const asSet = (loop: { x: number; y: number }[]) => loop.map((p) => `${p.x},${p.y}`).sort();
|
||||||
|
|
||||||
|
describe("key shape", () => {
|
||||||
|
it("fills missing second-rectangle fields with KLE's defaults", () => {
|
||||||
|
expect(hasSecondRect({ w: 1, h: 1 })).toBe(false);
|
||||||
|
expect(keyRects({ w: 1, h: 1 })).toEqual([{ x: 0, y: 0, w: 1, h: 1 }]);
|
||||||
|
expect(keyRects({ w: 1.25, h: 1, w2: 1.75 })).toEqual([
|
||||||
|
{ x: 0, y: 0, w: 1.25, h: 1 },
|
||||||
|
{ x: 0, y: 0, w: 1.75, h: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("traces the ISO Enter as one clockwise loop of six corners", () => {
|
||||||
|
const loops = keyOutline(isoEnter);
|
||||||
|
expect(loops).toHaveLength(1);
|
||||||
|
expect(asSet(loops[0]!)).toEqual(
|
||||||
|
asSet([
|
||||||
|
{ x: -0.25, y: 0 },
|
||||||
|
{ x: 1.25, y: 0 },
|
||||||
|
{ x: 1.25, y: 2 },
|
||||||
|
{ x: 0, y: 2 },
|
||||||
|
{ x: 0, y: 1 },
|
||||||
|
{ x: -0.25, y: 1 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
// Shoelace area is positive for a clockwise loop on a y-down screen.
|
||||||
|
const loop = loops[0]!;
|
||||||
|
const area = loop.reduce((sum, p, i) => {
|
||||||
|
const q = loop[(i + 1) % loop.length]!;
|
||||||
|
return sum + (p.x * q.y - q.x * p.y) / 2;
|
||||||
|
}, 0);
|
||||||
|
expect(area).toBeCloseTo(1.25 * 2 + 0.25);
|
||||||
|
});
|
||||||
|
it("traces a rectangle-in-rectangle as the outer rectangle", () => {
|
||||||
|
expect(keyOutline({ w: 1, h: 1, w2: 2 }).map(asSet)).toEqual([
|
||||||
|
asSet([
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 2, y: 0 },
|
||||||
|
{ x: 2, y: 1 },
|
||||||
|
{ x: 0, y: 1 },
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it("puts the bounding box's top-left where the key is placed", () => {
|
||||||
|
expect(keyOrigin(isoEnter)).toEqual({ x: -0.25, y: 0 });
|
||||||
|
expect(keyOrigin({ w: 1, h: 1 })).toEqual({ x: 0, y: 0 });
|
||||||
|
});
|
||||||
|
it("draws plain keys as rects and irregular ones as a path", () => {
|
||||||
|
expect(keyPath({ w: 1, h: 1 })).toBeUndefined();
|
||||||
|
expect(keyPath(isoEnter)).toMatch(/^M .* Z$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { KeyNode, PhyFile } from "../src/domain/phy";
|
||||||
|
import { flattenKeys } from "../src/domain/geometry";
|
||||||
|
import { clusterNodes, reframe, reparent } from "../src/domain/phyCommands";
|
||||||
|
import { importKle } from "../src/io/kleImport";
|
||||||
|
import { exportKle } from "../src/io/kleExport";
|
||||||
|
import { createIdx } from "../src/domain/idx";
|
||||||
|
|
||||||
|
type OracleKey = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
x2: number;
|
||||||
|
y2: number;
|
||||||
|
width2: number;
|
||||||
|
height2: number;
|
||||||
|
rotation_angle: number;
|
||||||
|
rotation_x: number;
|
||||||
|
rotation_y: number;
|
||||||
|
labels: (string | null)[];
|
||||||
|
textColor: (string | null)[];
|
||||||
|
textSize: (number | null)[];
|
||||||
|
default: { textColor: string; textSize: number };
|
||||||
|
color: string;
|
||||||
|
profile: string;
|
||||||
|
nub: boolean;
|
||||||
|
stepped: boolean;
|
||||||
|
decal: boolean;
|
||||||
|
ghost: boolean;
|
||||||
|
sm: string;
|
||||||
|
sb: string;
|
||||||
|
st: string;
|
||||||
|
};
|
||||||
|
type Case = { input: unknown[]; meta: Record<string, unknown>; keys: OracleKey[] };
|
||||||
|
const oracle = JSON.parse(
|
||||||
|
readFileSync(resolve(process.cwd(), "tests/fixtures/kle-oracle.json"), "utf8"),
|
||||||
|
) as Record<string, Case>;
|
||||||
|
// Independent expected lookup derived from the frozen serial.js output fixture.
|
||||||
|
const labelMap: readonly (readonly (number | null)[])[] = [
|
||||||
|
[0, 8, 2, 6, 9, 7, 1, 10, 3, 4, 11, 5],
|
||||||
|
[null, 0, null, null, 6, null, null, 1, null, 4, 11, 5],
|
||||||
|
[null, null, null, 0, 8, 2, null, null, null, 4, 11, 5],
|
||||||
|
[null, null, null, null, 0, null, null, null, null, 4, 11, 5],
|
||||||
|
[0, 8, 2, 6, 9, 7, 1, 10, 3, null, 4, null],
|
||||||
|
[null, 0, null, null, 6, null, null, 1, null, null, 4, null],
|
||||||
|
[null, null, null, 0, 8, 2, null, null, null, null, 4, null],
|
||||||
|
[null, null, null, null, 0, null, null, null, null, null, 4, null],
|
||||||
|
];
|
||||||
|
const uuid = (() => {
|
||||||
|
let i = 0;
|
||||||
|
return () => `12345678-90ab-4cde-8f01-${String(++i).padStart(12, "0")}`;
|
||||||
|
})();
|
||||||
|
const angleRad = (degrees: number) => (degrees * Math.PI) / 180;
|
||||||
|
const near = (actual: number, expected: number, tolerance = 1e-9) =>
|
||||||
|
expect(Math.abs(actual - expected), `${actual} differs from ${expected}`).toBeLessThanOrEqual(tolerance);
|
||||||
|
function canonicalLabels(labels: (string | null)[]): string[] {
|
||||||
|
const result = labels.map((label) => label ?? "");
|
||||||
|
while (result.at(-1) === "") result.pop();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function oracleOrigin(k: OracleKey): { x: number; y: number } {
|
||||||
|
const r = angleRad(k.rotation_angle),
|
||||||
|
dx = k.x - k.rotation_x,
|
||||||
|
dy = k.y - k.rotation_y;
|
||||||
|
return {
|
||||||
|
x: k.rotation_x + Math.cos(r) * dx - Math.sin(r) * dy,
|
||||||
|
y: k.rotation_y + Math.sin(r) * dx + Math.cos(r) * dy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function expectedCorners(k: OracleKey, second = false): { x: number; y: number }[] {
|
||||||
|
const r = angleRad(k.rotation_angle),
|
||||||
|
ox = k.x + (second ? k.x2 : 0),
|
||||||
|
oy = k.y + (second ? k.y2 : 0);
|
||||||
|
const w = second ? k.width2 : k.width,
|
||||||
|
h = second ? k.height2 : k.height;
|
||||||
|
return [
|
||||||
|
[0, 0],
|
||||||
|
[w, 0],
|
||||||
|
[w, h],
|
||||||
|
[0, h],
|
||||||
|
].map(([x, y]) => {
|
||||||
|
const px = ox + x!,
|
||||||
|
py = oy + y!,
|
||||||
|
dx = px - k.rotation_x,
|
||||||
|
dy = py - k.rotation_y;
|
||||||
|
return {
|
||||||
|
x: k.rotation_x + Math.cos(r) * dx - Math.sin(r) * dy,
|
||||||
|
y: k.rotation_y + Math.sin(r) * dx + Math.cos(r) * dy,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function keyCorners(k: KeyNode, second = false): { x: number; y: number }[] {
|
||||||
|
const r = angleRad(k.transform.rotation),
|
||||||
|
ox = second ? (k.size.x2 ?? 0) : 0,
|
||||||
|
oy = second ? (k.size.y2 ?? 0) : 0;
|
||||||
|
const w = second ? (k.size.w2 ?? k.size.w) : k.size.w,
|
||||||
|
h = second ? (k.size.h2 ?? k.size.h) : k.size.h;
|
||||||
|
return [
|
||||||
|
[0, 0],
|
||||||
|
[w, 0],
|
||||||
|
[w, h],
|
||||||
|
[0, h],
|
||||||
|
].map(([x, y]) => ({
|
||||||
|
x: k.transform.x + Math.cos(r) * (ox + x!) - Math.sin(r) * (oy + y!),
|
||||||
|
y: k.transform.y + Math.sin(r) * (ox + x!) + Math.cos(r) * (oy + y!),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function expectedStyleArrays(k: OracleKey, key: KeyNode): void {
|
||||||
|
const compat = key.kleCompatibility ?? {},
|
||||||
|
a = typeof compat.a === "number" ? compat.a : 4;
|
||||||
|
const slots = labelMap[a] ?? labelMap[4]!;
|
||||||
|
const t = typeof compat.t === "string" ? compat.t.split("\n") : [];
|
||||||
|
expect(compat.c).toBe(k.color);
|
||||||
|
expect(compat.p).toBe(k.profile);
|
||||||
|
expect(compat.sm).toBe(k.sm);
|
||||||
|
expect(compat.sb).toBe(k.sb);
|
||||||
|
expect(compat.st).toBe(k.st);
|
||||||
|
expect(compat.l === true).toBe(k.stepped);
|
||||||
|
expect(compat.n === true).toBe(k.nub);
|
||||||
|
expect(key.decal === true).toBe(k.decal);
|
||||||
|
expect(key.ghost === true).toBe(k.ghost);
|
||||||
|
expect(compat.f).toBe(k.default.textSize);
|
||||||
|
const defaultTextColor = t[0] ?? "#000000";
|
||||||
|
expect(k.default.textColor).toBe(defaultTextColor);
|
||||||
|
const expectedColors = Array<string | null>(12).fill(null),
|
||||||
|
expectedSizes = Array<number | null>(12).fill(null);
|
||||||
|
slots.forEach((source, slot) => {
|
||||||
|
const hasLabel = (key.labels?.[slot] ?? "") !== "";
|
||||||
|
if (hasLabel && source !== null && source > 0 && source < t.length) expectedColors[slot] = t[source]!;
|
||||||
|
if (hasLabel && source !== null && source >= 0) {
|
||||||
|
if (Array.isArray(compat.fa) && source < compat.fa.length && typeof compat.fa[source] === "number")
|
||||||
|
expectedSizes[slot] = compat.fa[source] as number;
|
||||||
|
else if (source > 0 && typeof compat.f2 === "number") expectedSizes[slot] = compat.f2;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const oracleColors = Array.from({ length: 12 }, (_, i) => k.textColor[i] ?? null);
|
||||||
|
const oracleSizes = Array.from({ length: 12 }, (_, i) => k.textSize[i] ?? null);
|
||||||
|
expect(expectedColors).toEqual(oracleColors);
|
||||||
|
expect(expectedSizes).toEqual(oracleSizes);
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
expect(expectedColors[i]).toBe(oracleColors[i]);
|
||||||
|
expect(expectedSizes[i]).toBe(oracleSizes[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function verify(phy: PhyFile, expected: Case, tolerance = 1e-9): void {
|
||||||
|
const keys = flattenKeys(phy).map((x) => x.key);
|
||||||
|
expect(keys).toHaveLength(expected.keys.length);
|
||||||
|
expect(phy.kleCompatibility).toEqual(expected.meta);
|
||||||
|
const unmatched = [...keys];
|
||||||
|
for (const want of expected.keys) {
|
||||||
|
const origin = oracleOrigin(want),
|
||||||
|
labels = canonicalLabels(want.labels);
|
||||||
|
const index = unmatched.findIndex(
|
||||||
|
(candidate) =>
|
||||||
|
JSON.stringify(candidate.labels ?? []) === JSON.stringify(labels) &&
|
||||||
|
Math.abs(candidate.transform.x - origin.x) < 1e-6 &&
|
||||||
|
Math.abs(candidate.transform.y - origin.y) < 1e-6,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
index,
|
||||||
|
`missing oracle key labels=${JSON.stringify(labels)} at ${origin.x},${origin.y}`,
|
||||||
|
).toBeGreaterThanOrEqual(0);
|
||||||
|
const actual = unmatched.splice(index, 1)[0]!;
|
||||||
|
near(actual.transform.x, origin.x, tolerance);
|
||||||
|
near(actual.transform.y, origin.y, tolerance);
|
||||||
|
near(actual.transform.rotation, want.rotation_angle, tolerance);
|
||||||
|
expect(actual.labels ?? []).toEqual(labels);
|
||||||
|
expect(actual.size.w).toBe(want.width);
|
||||||
|
expect(actual.size.h).toBe(want.height);
|
||||||
|
expect(actual.size.x2 ?? 0).toBe(want.x2);
|
||||||
|
expect(actual.size.y2 ?? 0).toBe(want.y2);
|
||||||
|
expect(actual.size.w2 ?? actual.size.w).toBe(want.width2);
|
||||||
|
expect(actual.size.h2 ?? actual.size.h).toBe(want.height2);
|
||||||
|
const actualMain = keyCorners(actual),
|
||||||
|
expectedMain = expectedCorners(want);
|
||||||
|
const actualSecond = keyCorners(actual, true),
|
||||||
|
expectedSecond = expectedCorners(want, true);
|
||||||
|
[...actualMain, ...actualSecond].forEach((point, i) => {
|
||||||
|
const target = [...expectedMain, ...expectedSecond][i]!;
|
||||||
|
near(point.x, target.x, tolerance);
|
||||||
|
near(point.y, target.y, tolerance);
|
||||||
|
});
|
||||||
|
expectedStyleArrays(want, actual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("KLE importer against frozen serial.js oracle", () => {
|
||||||
|
for (const [name, testCase] of Object.entries(oracle)) {
|
||||||
|
it(`${name}: import and export/import match oracle`, () => {
|
||||||
|
const first = importKle(testCase.input, { idGenerator: uuid });
|
||||||
|
verify(first.phy, testCase);
|
||||||
|
const eligible = first.phy.root.filter((node): node is KeyNode => node.type === "key" && !node.decal);
|
||||||
|
expect(first.keyOrder).toEqual(eligible.map((key) => key.id));
|
||||||
|
const second = importKle(exportKle(first.phy), { idGenerator: uuid });
|
||||||
|
verify(second.phy, testCase, 1e-6);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PHY regression cases from Sol review", () => {
|
||||||
|
const base = (root: PhyFile["root"]): PhyFile => ({
|
||||||
|
version: "0.1.0",
|
||||||
|
metadata: {},
|
||||||
|
profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }],
|
||||||
|
root,
|
||||||
|
});
|
||||||
|
it("reparent rejects self and descendants regardless of moved node kind", () => {
|
||||||
|
const phy = base([
|
||||||
|
{
|
||||||
|
type: "cluster",
|
||||||
|
id: "parent",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "cluster",
|
||||||
|
id: "child",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
children: [
|
||||||
|
{ type: "cluster", id: "grandchild", transform: { x: 0, y: 0, rotation: 0 }, children: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "variantGroup",
|
||||||
|
id: "group",
|
||||||
|
name: "G",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
default: "v",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "variant",
|
||||||
|
id: "v",
|
||||||
|
name: "V",
|
||||||
|
children: [
|
||||||
|
{ type: "cluster", id: "inside", transform: { x: 0, y: 0, rotation: 0 }, children: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "variant", id: "v2", name: "V2", children: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(() => reparent(phy, "parent", "parent")).toThrow();
|
||||||
|
expect(() => reparent(phy, "parent", "child")).toThrow();
|
||||||
|
expect(() => reparent(phy, "child", "child")).toThrow();
|
||||||
|
expect(() => reparent(phy, "child", "grandchild")).toThrow();
|
||||||
|
expect(() => reparent(phy, "group", "inside")).toThrow();
|
||||||
|
});
|
||||||
|
it("cluster frame starts at the rotated shape bounds, including second rectangles", () => {
|
||||||
|
const phy = base([
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
id: "wide",
|
||||||
|
profile: "default",
|
||||||
|
transform: { x: 2, y: 2, rotation: 90 },
|
||||||
|
size: { w: 2, h: 1, x2: -1, y2: -0.5, w2: 1, h2: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
id: "other",
|
||||||
|
profile: "default",
|
||||||
|
transform: { x: 5, y: 2, rotation: 90 },
|
||||||
|
size: { w: 1, h: 1 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const grouped = clusterNodes(
|
||||||
|
phy,
|
||||||
|
["wide", "other"],
|
||||||
|
"block",
|
||||||
|
() => "12345678-90ab-4cde-8f01-000000000001",
|
||||||
|
);
|
||||||
|
const cluster = grouped.root[0]!;
|
||||||
|
expect(cluster.type).toBe("cluster");
|
||||||
|
if (cluster.type !== "cluster") throw new Error("Expected cluster");
|
||||||
|
// In the cluster's 90-degree coordinate frame the second rectangle extends left/up.
|
||||||
|
near(cluster.transform.rotation, 90);
|
||||||
|
near(cluster.transform.x, 5);
|
||||||
|
near(cluster.transform.y, 1);
|
||||||
|
});
|
||||||
|
it("flattening propagates raw ancestor rotations without normalizing", () => {
|
||||||
|
const phy = base([
|
||||||
|
{
|
||||||
|
type: "cluster",
|
||||||
|
id: "a",
|
||||||
|
transform: { x: 0, y: 0, rotation: 450 },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "cluster",
|
||||||
|
id: "b",
|
||||||
|
transform: { x: 0, y: 0, rotation: 400 },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
id: "k",
|
||||||
|
profile: "default",
|
||||||
|
transform: { x: 0, y: 0, rotation: -20 },
|
||||||
|
size: { w: 1, h: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(flattenKeys(phy)[0]!.rotation).toBe(830);
|
||||||
|
});
|
||||||
|
it("cluster, uncluster, reparent, and reframe retain raw world angles", () => {
|
||||||
|
let phy = base([
|
||||||
|
{
|
||||||
|
type: "cluster",
|
||||||
|
id: "outer",
|
||||||
|
transform: { x: 1, y: 2, rotation: 400 },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
id: "a",
|
||||||
|
profile: "default",
|
||||||
|
transform: { x: 0, y: 0, rotation: 50 },
|
||||||
|
size: { w: 1, h: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
id: "b",
|
||||||
|
profile: "default",
|
||||||
|
transform: { x: 2, y: 0, rotation: 50 },
|
||||||
|
size: { w: 1, h: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "cluster", id: "target", transform: { x: -1, y: 4, rotation: 720 }, children: [] },
|
||||||
|
]);
|
||||||
|
const rotations = () =>
|
||||||
|
Object.fromEntries(flattenKeys(phy).map(({ key, rotation }) => [key.id, rotation]));
|
||||||
|
expect(rotations()).toEqual({ a: 450, b: 450 });
|
||||||
|
phy = clusterNodes(phy, ["a", "b"], "nested", uuid);
|
||||||
|
expect(rotations()).toEqual({ a: 450, b: 450 });
|
||||||
|
const outer = phy.root[0]!;
|
||||||
|
if (outer.type !== "cluster") throw new Error("Expected outer cluster");
|
||||||
|
const nested = outer.children[0]!;
|
||||||
|
if (nested.type !== "cluster") throw new Error("Expected nested cluster");
|
||||||
|
phy = reframe(phy, nested.id, { x: 3, y: -2, rotation: 810 });
|
||||||
|
expect(rotations()).toEqual({ a: 450, b: 450 });
|
||||||
|
phy = reparent(phy, "a", "target");
|
||||||
|
expect(rotations()).toEqual({ a: 450, b: 450 });
|
||||||
|
phy = clusterNodes(phy, ["b"], "single", uuid);
|
||||||
|
expect(rotations()).toEqual({ a: 450, b: 450 });
|
||||||
|
});
|
||||||
|
it("T-18 initial order filters decal and retains ghost", () => {
|
||||||
|
const phy = base([
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
id: "decal",
|
||||||
|
profile: "default",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
size: { w: 1, h: 1 },
|
||||||
|
decal: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
id: "ghost",
|
||||||
|
profile: "default",
|
||||||
|
transform: { x: 1, y: 0, rotation: 0 },
|
||||||
|
size: { w: 1, h: 1 },
|
||||||
|
ghost: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const imported = importKle([[{ d: true }, "D", { g: true }, "G"]], { idGenerator: uuid });
|
||||||
|
expect(imported.keyOrder).toHaveLength(1);
|
||||||
|
const idx = createIdx(phy, ["decal", "ghost"], { source: "x", checksum: "sha256-jcs:x" });
|
||||||
|
expect(idx.order).toEqual(["ghost"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import type { PhyFile, KeyNode } from "../src/domain/phy";
|
||||||
|
import { flattenKeys } from "../src/domain/geometry";
|
||||||
|
import { importKle } from "../src/io/kleImport";
|
||||||
|
import { exportKle } from "../src/io/kleExport";
|
||||||
|
import { canonicalize, sha256Jcs } from "../src/io/json";
|
||||||
|
import {
|
||||||
|
addVariant,
|
||||||
|
clusterNodes,
|
||||||
|
createVariantGroup,
|
||||||
|
deleteVariant,
|
||||||
|
reframe,
|
||||||
|
reparent,
|
||||||
|
uncluster,
|
||||||
|
} from "../src/domain/phyCommands";
|
||||||
|
|
||||||
|
const gen = (() => {
|
||||||
|
let i = 0;
|
||||||
|
return () => {
|
||||||
|
const s = String(++i).padStart(12, "0");
|
||||||
|
return `${s.slice(0, 8)}-${s.slice(8)}-4000-8000-000000000000`;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
function sample(path: string): unknown {
|
||||||
|
return JSON.parse(readFileSync(resolve(process.cwd(), "../samples/kle", path), "utf8")) as unknown;
|
||||||
|
}
|
||||||
|
function byShape(p: PhyFile) {
|
||||||
|
return flattenKeys(p)
|
||||||
|
.map((k) => ({
|
||||||
|
x: k.x,
|
||||||
|
y: k.y,
|
||||||
|
r: k.rotation,
|
||||||
|
w: k.key.size.w,
|
||||||
|
h: k.key.size.h,
|
||||||
|
labels: k.key.labels,
|
||||||
|
compat: k.key.kleCompatibility,
|
||||||
|
}))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Math.round(a.r * 1e6) - Math.round(b.r * 1e6) ||
|
||||||
|
Math.round(a.y * 1e6) - Math.round(b.y * 1e6) ||
|
||||||
|
Math.round(a.x * 1e6) - Math.round(b.x * 1e6),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
describe("KLE round trip", () => {
|
||||||
|
for (const file of ["macropad-4x4.json", "cr0wn60.json"])
|
||||||
|
it(file, () => {
|
||||||
|
const a = importKle(sample(file), { idGenerator: gen }).phy;
|
||||||
|
const b = importKle(exportKle(a), { idGenerator: gen }).phy;
|
||||||
|
const aa = byShape(a),
|
||||||
|
bb = byShape(b);
|
||||||
|
expect(bb).toHaveLength(aa.length);
|
||||||
|
for (let i = 0; i < aa.length; i++) {
|
||||||
|
const x = aa[i]!,
|
||||||
|
y = bb[i]!;
|
||||||
|
expect(
|
||||||
|
Math.abs(x.x - y.x),
|
||||||
|
`${file} item ${i}: x ${x.x} vs ${y.x}; y=${x.y}/${y.y}; rot=${x.r}/${y.r}; labels=${JSON.stringify(x.labels)}/${JSON.stringify(y.labels)}`,
|
||||||
|
).toBeLessThanOrEqual(1e-6);
|
||||||
|
expect(Math.abs(x.y - y.y), `${file} item ${i}: y ${x.y} vs ${y.y}`).toBeLessThanOrEqual(1e-6);
|
||||||
|
expect(Math.abs(x.r - y.r)).toBeLessThanOrEqual(1e-6);
|
||||||
|
expect(x.w).toBe(y.w);
|
||||||
|
expect(x.h).toBe(y.h);
|
||||||
|
expect(x.labels).toEqual(y.labels);
|
||||||
|
expect(x.compat).toEqual(y.compat);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("rotated KLE cursor, mid-row rotation and pivot changes", () => {
|
||||||
|
const { phy } = importKle([[{ r: 90, rx: 1, ry: 1 }, "A", "B", { r: 180, rx: 2, ry: 1 }, "C"]], {
|
||||||
|
idGenerator: gen,
|
||||||
|
});
|
||||||
|
const k = phy.root as KeyNode[];
|
||||||
|
expect(k.map((x) => [x.transform.x, x.transform.y, x.transform.rotation])).toEqual([
|
||||||
|
[1, 1, 90],
|
||||||
|
[1, 2, 90],
|
||||||
|
[2, 1, 180],
|
||||||
|
]);
|
||||||
|
const corners = (n: KeyNode) =>
|
||||||
|
[
|
||||||
|
[0, 0],
|
||||||
|
[n.size.w, 0],
|
||||||
|
[n.size.w, n.size.h],
|
||||||
|
[0, n.size.h],
|
||||||
|
].map(([x, y]) => {
|
||||||
|
const a = (n.transform.rotation * Math.PI) / 180;
|
||||||
|
return [
|
||||||
|
n.transform.x + Math.cos(a) * x! - Math.sin(a) * y!,
|
||||||
|
n.transform.y + Math.sin(a) * x! + Math.cos(a) * y!,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const check = (actual: number[][], expected: number[][]) =>
|
||||||
|
actual.forEach((p, i) =>
|
||||||
|
p.forEach((v, j) => expect(Math.abs(v - expected[i]![j]!)).toBeLessThanOrEqual(1e-12)),
|
||||||
|
);
|
||||||
|
check(corners(k[0]!), [
|
||||||
|
[1, 1],
|
||||||
|
[1, 2],
|
||||||
|
[0, 2],
|
||||||
|
[0, 1],
|
||||||
|
]);
|
||||||
|
check(corners(k[2]!), [
|
||||||
|
[2, 1],
|
||||||
|
[1, 1],
|
||||||
|
[1, 0],
|
||||||
|
[2, 0],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
function fixture(): PhyFile {
|
||||||
|
return {
|
||||||
|
version: "0.1.0",
|
||||||
|
metadata: {},
|
||||||
|
profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }],
|
||||||
|
root: [
|
||||||
|
{
|
||||||
|
type: "cluster",
|
||||||
|
id: "outer",
|
||||||
|
name: "Outer",
|
||||||
|
transform: { x: 4, y: 2, rotation: 30 },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "cluster",
|
||||||
|
id: "inner",
|
||||||
|
name: "Inner",
|
||||||
|
transform: { x: 1, y: 2, rotation: 20 },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
id: "a",
|
||||||
|
profile: "default",
|
||||||
|
transform: { x: 0.2, y: 0.4, rotation: 15 },
|
||||||
|
size: { w: 1, h: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
id: "b",
|
||||||
|
profile: "default",
|
||||||
|
transform: { x: 1.4, y: -0.2, rotation: -5 },
|
||||||
|
size: { w: 1, h: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "cluster",
|
||||||
|
id: "other",
|
||||||
|
name: "Other",
|
||||||
|
transform: { x: -3, y: 6, rotation: -12 },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const pose = (p: PhyFile) =>
|
||||||
|
Object.fromEntries(flattenKeys(p).map((k) => [k.key.id, [k.x, k.y, k.rotation]]));
|
||||||
|
const closePose = (a: Record<string, number[]>, b: Record<string, number[]>) => {
|
||||||
|
for (const id of Object.keys(a)) {
|
||||||
|
a[id]!.forEach((v, i) =>
|
||||||
|
expect(Math.abs(v - b[id]![i]!), `pose ${id} component ${i}: ${v} vs ${b[id]![i]}`).toBeLessThanOrEqual(
|
||||||
|
1e-9,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
describe("PHY commands and variants", () => {
|
||||||
|
it("preserves nested rotated key world placement through cluster, uncluster, reparent, reframe", () => {
|
||||||
|
let p = fixture();
|
||||||
|
const before = pose(p);
|
||||||
|
p = clusterNodes(p, ["a", "b"], "picked", gen);
|
||||||
|
closePose(before, pose(p));
|
||||||
|
const outer = p.root[0]!;
|
||||||
|
if (outer.type !== "cluster") throw Error();
|
||||||
|
const inner = outer.children[0]!;
|
||||||
|
if (inner.type !== "cluster") throw Error();
|
||||||
|
const created = inner.children[0]!;
|
||||||
|
if (created.type !== "cluster") throw Error();
|
||||||
|
p = uncluster(p, created.id);
|
||||||
|
closePose(before, pose(p));
|
||||||
|
p = reparent(p, "a", "other");
|
||||||
|
closePose(before, pose(p));
|
||||||
|
p = reframe(p, "outer", { x: 8, y: -4, rotation: -20 });
|
||||||
|
closePose(before, pose(p));
|
||||||
|
});
|
||||||
|
it("flattens variant selection, exports only the selected branch, and applies deletion rules", () => {
|
||||||
|
let p: PhyFile = {
|
||||||
|
version: "0.1.0",
|
||||||
|
metadata: {},
|
||||||
|
profiles: [{ id: "default", name: "Default", unitSize: { x: 19.05, y: 19.05 } }],
|
||||||
|
root: [],
|
||||||
|
};
|
||||||
|
p = createVariantGroup(
|
||||||
|
p,
|
||||||
|
"Layout",
|
||||||
|
{ x: 2, y: 0, rotation: 0 },
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: "ANSI",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
id: "ansi",
|
||||||
|
profile: "default",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
size: { w: 1, h: 1 },
|
||||||
|
labels: ["Enter"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ISO",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
id: "iso",
|
||||||
|
profile: "default",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
size: { w: 1, h: 1 },
|
||||||
|
labels: ["Enter"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
gen,
|
||||||
|
);
|
||||||
|
const group = p.root[0]!;
|
||||||
|
if (group.type !== "variantGroup") throw Error();
|
||||||
|
expect(flattenKeys(p).map((x) => x.key.id)).toEqual(["ansi"]);
|
||||||
|
const choice = group.children[1]!.id;
|
||||||
|
expect(flattenKeys(p, { [group.id]: choice }).map((x) => x.key.id)).toEqual(["iso"]);
|
||||||
|
const out = JSON.stringify(exportKle(p, { [group.id]: choice }));
|
||||||
|
expect(out).not.toContain('"ansi"');
|
||||||
|
expect(() => deleteVariant(p, group.id, group.children[0]!.id)).toThrow();
|
||||||
|
p = addVariant(p, group.id, "JIS", gen);
|
||||||
|
const grown = p.root[0]!;
|
||||||
|
if (grown.type !== "variantGroup") throw Error();
|
||||||
|
p = deleteVariant(p, group.id, grown.default);
|
||||||
|
const reduced = p.root[0]!;
|
||||||
|
if (reduced.type !== "variantGroup") throw Error();
|
||||||
|
expect(reduced.default).toBe(reduced.children[0]!.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("JCS", () => {
|
||||||
|
it("sorts keys and uses ECMAScript number formatting", () => {
|
||||||
|
expect(canonicalize({ z: 1, a: 1e-6, b: 1e21, c: -0 })).toBe('{"a":0.000001,"b":1e+21,"c":0,"z":1}');
|
||||||
|
expect(canonicalize([333333333.33333329, 1e30, 4.5, 2e-3, 1e-27])).toBe(
|
||||||
|
"[333333333.3333333,1e+30,4.5,0.002,1e-27]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("hashes canonical UTF-8 with SHA-256", async () => {
|
||||||
|
expect(await sha256Jcs({ a: 1 })).toBe(
|
||||||
|
"sha256-jcs:015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { PhyFile } from "../src/domain/phy";
|
||||||
|
import {
|
||||||
|
addComponentToParent,
|
||||||
|
addKeyToParent,
|
||||||
|
cloneNodesWithNewIds,
|
||||||
|
createVariantGroupInParent,
|
||||||
|
duplicateNode,
|
||||||
|
insertNodes,
|
||||||
|
} from "../src/domain/phyCommands";
|
||||||
|
const ids = (() => {
|
||||||
|
let n = 0;
|
||||||
|
return () => `0000000${++n}-0000-4000-8000-000000000000`;
|
||||||
|
})();
|
||||||
|
const file: PhyFile = {
|
||||||
|
version: "0.1.0",
|
||||||
|
metadata: {},
|
||||||
|
profiles: [{ id: "p", name: "P", unitSize: { x: 1, y: 1 } }],
|
||||||
|
root: [
|
||||||
|
{ type: "cluster", id: "cl-parent", name: "P", transform: { x: 0, y: 0, rotation: 0 }, children: [] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
describe("PHY parent insertion and duplication", () => {
|
||||||
|
it("adds keys and components to root, clusters, or variants", () => {
|
||||||
|
let p = addKeyToParent(file, "cl-parent", { x: 2, y: 3, rotation: 0 }, { w: 1, h: 1 }, "p", ids);
|
||||||
|
expect(
|
||||||
|
p.root[0]!.type === "cluster" &&
|
||||||
|
(p.root[0]! as import("../src/domain/phy").ClusterNode).children[0]?.type,
|
||||||
|
).toBe("key");
|
||||||
|
p = addComponentToParent(p, "cl-parent", { x: 0, y: 0, rotation: 0 }, "@led", ids);
|
||||||
|
expect(
|
||||||
|
p.root[0]!.type === "cluster" &&
|
||||||
|
(p.root[0]! as import("../src/domain/phy").ClusterNode).children[1]?.type,
|
||||||
|
).toBe("component");
|
||||||
|
});
|
||||||
|
it("inserts a copied node batch into a selected container", () => {
|
||||||
|
const node = {
|
||||||
|
type: "key" as const,
|
||||||
|
id: "key-source",
|
||||||
|
profile: "p",
|
||||||
|
transform: { x: 1, y: 1, rotation: 0 },
|
||||||
|
size: { w: 1, h: 1 },
|
||||||
|
};
|
||||||
|
const p = insertNodes(file, "cl-parent", [node]);
|
||||||
|
expect(
|
||||||
|
p.root[0]!.type === "cluster" && (p.root[0]! as import("../src/domain/phy").ClusterNode).children,
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
it("renews IDs and remaps a duplicated group's internal default", () => {
|
||||||
|
const base: PhyFile = {
|
||||||
|
...file,
|
||||||
|
root: [
|
||||||
|
{
|
||||||
|
type: "variantGroup",
|
||||||
|
id: "vg-source",
|
||||||
|
name: "VG",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
default: "v-one",
|
||||||
|
children: [
|
||||||
|
{ type: "variant", id: "v-one", name: "One", children: [] },
|
||||||
|
{ type: "variant", id: "v-two", name: "Two", children: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const q = duplicateNode(base, "vg-source", ids);
|
||||||
|
const copy = q.root[1];
|
||||||
|
expect(copy?.type).toBe("variantGroup");
|
||||||
|
if (copy?.type === "variantGroup") {
|
||||||
|
expect(copy.id).not.toBe("vg-source");
|
||||||
|
expect(copy.default).not.toBe("v-one");
|
||||||
|
expect(copy.children.map((v) => v.id)).not.toContain("v-one");
|
||||||
|
expect(copy.default).toBe(copy.children[0]!.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("remaps defaults for variant groups nested inside a copied cluster", () => {
|
||||||
|
const nested: PhyFile["root"][number] = {
|
||||||
|
type: "cluster",
|
||||||
|
id: "outer",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "variantGroup",
|
||||||
|
id: "vg-inner",
|
||||||
|
name: "Inner",
|
||||||
|
transform: { x: 1, y: 2, rotation: 0 },
|
||||||
|
default: "v-inner-a",
|
||||||
|
children: [
|
||||||
|
{ type: "variant", id: "v-inner-a", name: "A", children: [] },
|
||||||
|
{ type: "variant", id: "v-inner-b", name: "B", children: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const copy = cloneNodesWithNewIds([nested], ids)[0];
|
||||||
|
expect(copy?.type).toBe("cluster");
|
||||||
|
if (copy?.type === "cluster") {
|
||||||
|
const group = copy.children[0];
|
||||||
|
expect(group?.type).toBe("variantGroup");
|
||||||
|
if (group?.type === "variantGroup") {
|
||||||
|
expect(group.default).toBe(group.children[0]!.id);
|
||||||
|
expect(group.default).not.toBe("v-inner-a");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("creates a variant group inside a cluster or a variant", () => {
|
||||||
|
const variants: Parameters<typeof createVariantGroupInParent>[4] = [{ name: "A" }, { name: "B" }];
|
||||||
|
let p = createVariantGroupInParent(file, "cl-parent", "G", { x: 0, y: 0, rotation: 0 }, variants, ids);
|
||||||
|
const parent = p.root[0] as import("../src/domain/phy").ClusterNode;
|
||||||
|
expect(p.root).toHaveLength(1);
|
||||||
|
expect(parent.children.map((n) => n.type)).toEqual(["variantGroup"]);
|
||||||
|
const group = parent.children[0] as import("../src/domain/phy").VariantGroupNode;
|
||||||
|
p = createVariantGroupInParent(
|
||||||
|
p,
|
||||||
|
group.children[1]!.id,
|
||||||
|
"Inner",
|
||||||
|
{ x: 0, y: 0, rotation: 0 },
|
||||||
|
variants,
|
||||||
|
ids,
|
||||||
|
);
|
||||||
|
const updated = (p.root[0] as import("../src/domain/phy").ClusterNode).children[0] as typeof group;
|
||||||
|
expect(updated.children[1]!.children.map((n) => n.type)).toEqual(["variantGroup"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
GRID_STEPS,
|
||||||
|
localBounds,
|
||||||
|
insertionContainer,
|
||||||
|
longPressColumn,
|
||||||
|
nextGrid,
|
||||||
|
normalizeSelection,
|
||||||
|
obbAabb,
|
||||||
|
removeFocusedSubtree,
|
||||||
|
pasteLocalTransform,
|
||||||
|
pasteParent,
|
||||||
|
resolveKey,
|
||||||
|
snap,
|
||||||
|
selectionFrames,
|
||||||
|
variantBadges,
|
||||||
|
tabOrder,
|
||||||
|
treeIndex,
|
||||||
|
visibleNodeIds,
|
||||||
|
} from "../src/ui/phy/logic";
|
||||||
|
import { autoPanVelocity, fitViewport, zoomAround } from "../src/ui/phy/viewport";
|
||||||
|
import type { PhyFile, Node as PhyNode } from "../src/domain/phy";
|
||||||
|
const phy: PhyFile = {
|
||||||
|
version: "0.1.0",
|
||||||
|
metadata: {},
|
||||||
|
profiles: [],
|
||||||
|
root: [
|
||||||
|
{
|
||||||
|
type: "cluster",
|
||||||
|
id: "c",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
children: [
|
||||||
|
{ type: "key", id: "k", profile: "p", transform: { x: 0, y: 0, rotation: 0 }, size: { w: 1, h: 1 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
describe("PHY pure interaction rules", () => {
|
||||||
|
it("computes selected frame world transforms through nested rotations", () => {
|
||||||
|
const roots: PhyNode[] = [
|
||||||
|
{
|
||||||
|
type: "cluster",
|
||||||
|
id: "outer",
|
||||||
|
transform: { x: 10, y: 20, rotation: 90 },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "cluster",
|
||||||
|
id: "inner",
|
||||||
|
transform: { x: 2, y: 3, rotation: -90 },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
id: "target",
|
||||||
|
profile: "p",
|
||||||
|
transform: { x: 4, y: 5, rotation: 30 },
|
||||||
|
size: { w: 2, h: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const frame = selectionFrames(roots, ["target"], {}, null)[0]!;
|
||||||
|
expect(frame.id).toBe("target");
|
||||||
|
expect(frame.transform.e).toBeCloseTo(11);
|
||||||
|
expect(frame.transform.f).toBeCloseTo(27);
|
||||||
|
expect(frame.transform.a).toBeCloseTo(Math.cos(Math.PI / 6));
|
||||||
|
expect(frame.transform.b).toBeCloseTo(Math.sin(Math.PI / 6));
|
||||||
|
expect(frame.selected).toBe(true);
|
||||||
|
const candidateOnly = selectionFrames(roots, [], {}, "target")[0]!;
|
||||||
|
expect([candidateOnly.selected, candidateOnly.candidate]).toEqual([false, true]);
|
||||||
|
});
|
||||||
|
it("places variant badges at the rotated group's top-right corner, upright", () => {
|
||||||
|
const key = (id: string): PhyNode => ({
|
||||||
|
type: "key",
|
||||||
|
id,
|
||||||
|
profile: "p",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
size: { w: 2, h: 1 },
|
||||||
|
});
|
||||||
|
const roots: PhyNode[] = [
|
||||||
|
{
|
||||||
|
type: "variantGroup",
|
||||||
|
id: "g",
|
||||||
|
name: "G",
|
||||||
|
transform: { x: 1, y: 1, rotation: 90 },
|
||||||
|
default: "a",
|
||||||
|
children: [
|
||||||
|
{ type: "variant", id: "a", name: "A", children: [key("ka")] },
|
||||||
|
{ type: "variant", id: "b", name: "B", children: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const [badge] = variantBadges(roots, {}, 10);
|
||||||
|
// ローカル (2 + 0.4, -2 - 0.4) を90度回して (1, 1) だけ動かす
|
||||||
|
expect(badge!.group.id).toBe("g");
|
||||||
|
expect(badge!.x).toBeCloseTo(1 + 2.4);
|
||||||
|
expect(badge!.y).toBeCloseTo(1 + 2.4);
|
||||||
|
expect(badge!.size).toBeCloseTo(2);
|
||||||
|
});
|
||||||
|
it("removes ancestors and descendants from mixed selections", () => {
|
||||||
|
expect(normalizeSelection(phy, ["c", "k"])).toEqual(["k"]);
|
||||||
|
expect(normalizeSelection(phy, ["k", "c"])).toEqual(["c"]);
|
||||||
|
const outside = removeFocusedSubtree(phy.root, "k");
|
||||||
|
expect(treeIndex(outside).nodes.has("c")).toBe(true);
|
||||||
|
expect(treeIndex(outside).nodes.has("k")).toBe(false);
|
||||||
|
expect(outside[0]?.type === "cluster" ? outside[0].children : []).toEqual([]);
|
||||||
|
});
|
||||||
|
it("cycles long press candidates and stops before the focus parent", () => {
|
||||||
|
expect(longPressColumn("k", ["c", "root"], "c")).toEqual(["k"]);
|
||||||
|
expect(longPressColumn("k", ["c", "root"])).toEqual(["k", "c", "root"]);
|
||||||
|
});
|
||||||
|
it("orders rows with a half unit tolerance", () => {
|
||||||
|
expect(
|
||||||
|
tabOrder([
|
||||||
|
{ id: "b", x: 2, y: 0.4 },
|
||||||
|
{ id: "a", x: 1, y: 0 },
|
||||||
|
{ id: "c", x: 0, y: 1 },
|
||||||
|
]).map((x) => x.id),
|
||||||
|
).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
it("computes bounds and paste target", () => {
|
||||||
|
expect(
|
||||||
|
obbAabb([
|
||||||
|
{ x: 2, y: 3 },
|
||||||
|
{ x: -1, y: 5 },
|
||||||
|
]),
|
||||||
|
).toEqual({ x: -1, y: 3, width: 3, height: 2 });
|
||||||
|
expect(pasteParent([], "c")).toBe("c");
|
||||||
|
expect(pasteParent(["p"], null)).toBe("p");
|
||||||
|
expect(pasteParent([null], "c")).toBeNull();
|
||||||
|
});
|
||||||
|
it("computes child bounds in parent axes and converts paste world position", () => {
|
||||||
|
const group: PhyNode = {
|
||||||
|
type: "cluster",
|
||||||
|
id: "g",
|
||||||
|
transform: { x: 10, y: 20, rotation: 90 },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
id: "key2",
|
||||||
|
profile: "p",
|
||||||
|
transform: { x: 2, y: 3, rotation: 0 },
|
||||||
|
size: { w: 1, h: 2 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(localBounds(group.type === "cluster" ? group.children : [])).toEqual({
|
||||||
|
x: 2,
|
||||||
|
y: 3,
|
||||||
|
width: 1,
|
||||||
|
height: 2,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
pasteLocalTransform(
|
||||||
|
{ a: 0, b: 1, c: -1, d: 0, e: 10, f: 20 },
|
||||||
|
{ a: 0, b: 1, c: -1, d: 0, e: 8, f: 18 },
|
||||||
|
0.25,
|
||||||
|
),
|
||||||
|
).toEqual({ x: 2.25, y: -1.75, rotation: 0 });
|
||||||
|
});
|
||||||
|
it("distinguishes selectable parents from variant insertion containers without ID prefixes", () => {
|
||||||
|
const roots: PhyNode[] = [
|
||||||
|
{
|
||||||
|
type: "variantGroup",
|
||||||
|
id: "group-any-id",
|
||||||
|
name: "Layout",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
default: "option-any-id",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "variant",
|
||||||
|
id: "option-any-id",
|
||||||
|
name: "Main",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "key",
|
||||||
|
id: "key-any-id",
|
||||||
|
profile: "p",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
size: { w: 1, h: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const index = treeIndex(roots);
|
||||||
|
expect(index.selectableParent("key-any-id")).toBe("group-any-id");
|
||||||
|
expect(index.insertionContainer("key-any-id")).toBe("option-any-id");
|
||||||
|
expect(index.groupForVariant("option-any-id")).toBe("group-any-id");
|
||||||
|
expect(insertionContainer(roots, "group-any-id", { "group-any-id": "option-any-id" })).toBe(
|
||||||
|
"option-any-id",
|
||||||
|
);
|
||||||
|
expect(visibleNodeIds(roots[0], {}).has("key-any-id")).toBe(true);
|
||||||
|
const outside = removeFocusedSubtree(roots, "key-any-id");
|
||||||
|
expect(treeIndex(outside).nodes.has("key-any-id")).toBe(false);
|
||||||
|
expect(treeIndex(outside).nodes.has("group-any-id")).toBe(true);
|
||||||
|
});
|
||||||
|
it("snaps without changing off-grid direct numeric values and cycles steps", () => {
|
||||||
|
expect(snap(0.37, 0.25)).toBe(0.25);
|
||||||
|
expect(nextGrid(0.25, 1)).toBe(0.125);
|
||||||
|
expect(GRID_STEPS).toHaveLength(4);
|
||||||
|
});
|
||||||
|
it("resolves keyboard layout independent digit shortcuts and modifiers", () => {
|
||||||
|
expect(
|
||||||
|
resolveKey({ key: "@", code: "Digit1", shiftKey: true, ctrlKey: false, metaKey: false, altKey: false }),
|
||||||
|
).toBe("fit");
|
||||||
|
expect(
|
||||||
|
resolveKey({ key: "z", code: "KeyZ", shiftKey: true, ctrlKey: true, metaKey: false, altKey: false }),
|
||||||
|
).toBe("redo");
|
||||||
|
});
|
||||||
|
it("fits bounds centrally with padding and zooms around the cursor", () => {
|
||||||
|
const fitted = fitViewport({ x: 10, y: 20, width: 4, height: 2 }, 400, 300, 20);
|
||||||
|
expect(fitted.zoom).toBe(90);
|
||||||
|
expect(fitted.pan.x + (10 + 2) * fitted.zoom).toBeCloseTo(200);
|
||||||
|
expect(fitted.pan.y + (20 + 1) * fitted.zoom).toBeCloseTo(150);
|
||||||
|
const cursor = { x: 140, y: 160 },
|
||||||
|
world = { x: (cursor.x - fitted.pan.x) / fitted.zoom, y: (cursor.y - fitted.pan.y) / fitted.zoom },
|
||||||
|
zoomed = zoomAround(cursor, fitted, 1.5);
|
||||||
|
expect((cursor.x - zoomed.pan.x) / zoomed.zoom).toBeCloseTo(world.x);
|
||||||
|
expect((cursor.y - zoomed.pan.y) / zoomed.zoom).toBeCloseTo(world.y);
|
||||||
|
});
|
||||||
|
it("scales auto-pan speed near edges, outside the canvas, and at corners", () => {
|
||||||
|
const bounds = { x: 10, y: 20, width: 200, height: 100 };
|
||||||
|
expect(autoPanVelocity({ x: 110, y: 70 }, bounds)).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(autoPanVelocity({ x: 194, y: 70 }, bounds)).toEqual({ x: 240, y: 0 });
|
||||||
|
expect(autoPanVelocity({ x: 210, y: 70 }, bounds)).toEqual({ x: 480, y: 0 });
|
||||||
|
expect(autoPanVelocity({ x: 220, y: 70 }, bounds)).toEqual({ x: 480, y: 0 });
|
||||||
|
expect(autoPanVelocity({ x: 210, y: 10 }, bounds)).toEqual({ x: 480, y: -480 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createProject } from "../src/domain/project";
|
||||||
|
import type { PhyFile, KeyNode, VariantGroupNode } from "../src/domain/phy";
|
||||||
|
import { generateMockTarget } from "../src/domain/generator";
|
||||||
|
import { validateProject } from "../src/domain/validate";
|
||||||
|
import { assignKey, type MapFile } from "../src/domain/map";
|
||||||
|
import { setKeyWiring, setProvider, type ContactWiring, type ElcFile } from "../src/domain/elc";
|
||||||
|
import { listDeleteReferences, deleteNodeCascade } from "../src/domain/deleteCascade";
|
||||||
|
import { loadProject, saveProject } from "../src/io/projectFile";
|
||||||
|
import { makePhyReference } from "../src/domain/phyReference";
|
||||||
|
import { ProjectHistory } from "../src/domain/history";
|
||||||
|
|
||||||
|
const emptyPhy = (): PhyFile => ({
|
||||||
|
version: "0.1.0",
|
||||||
|
metadata: {},
|
||||||
|
profiles: [{ id: "d", name: "D", unitSize: { x: 1, y: 1 } }],
|
||||||
|
root: [],
|
||||||
|
});
|
||||||
|
const key = (id: string, decal = false): KeyNode => ({
|
||||||
|
type: "key",
|
||||||
|
id,
|
||||||
|
profile: "d",
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
size: { w: 1, h: 1 },
|
||||||
|
...(decal ? { decal: true } : {}),
|
||||||
|
});
|
||||||
|
const variantGroup = (
|
||||||
|
id: string,
|
||||||
|
defaultId: string,
|
||||||
|
variants: Array<[string, string, (KeyNode | VariantGroupNode)[]]>,
|
||||||
|
): VariantGroupNode => ({
|
||||||
|
type: "variantGroup",
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
transform: { x: 0, y: 0, rotation: 0 },
|
||||||
|
default: defaultId,
|
||||||
|
children: variants.map(([variantId, name, children]) => ({
|
||||||
|
type: "variant",
|
||||||
|
id: variantId,
|
||||||
|
name,
|
||||||
|
children,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("review regressions", () => {
|
||||||
|
it("keeps sibling Variant references when deleting a Variant and moves the selection", async () => {
|
||||||
|
const phy = emptyPhy();
|
||||||
|
phy.root = [
|
||||||
|
variantGroup("vg", "va", [
|
||||||
|
["va", "A", [key("ka")]],
|
||||||
|
["vb", "B", [key("kb")]],
|
||||||
|
["vc", "C", [key("kc")]],
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
const p = await createProject(phy);
|
||||||
|
p.idx.order = ["ka", "kb", "kc"];
|
||||||
|
p.output.variantSelection = { vg: "va" };
|
||||||
|
p.elc.keys = ["ka", "kb", "kc"].map((id) => ({
|
||||||
|
key: id,
|
||||||
|
detect: "contact",
|
||||||
|
terminals: { a: "VCC", b: "GND" },
|
||||||
|
}));
|
||||||
|
p.elc.ledChains = [{ id: "chain", data: "VCC", order: ["ka", "kb", "kc"] }];
|
||||||
|
p.map.layers[0]!.keymap = ["ka", "kb", "kc"].map((keyId) => ({ keyId, keycode: "KC_A" }));
|
||||||
|
expect(listDeleteReferences(p, "va").map((ref) => ref.id)).toEqual(["ka", "ka", "ka", "ka"]);
|
||||||
|
const result = deleteNodeCascade(p, "va");
|
||||||
|
expect(
|
||||||
|
result.phy.root[0]!.type === "variantGroup" && result.phy.root[0]!.children.map((v) => v.id),
|
||||||
|
).toEqual(["vb", "vc"]);
|
||||||
|
expect(result.phy.root[0]!.type === "variantGroup" && result.phy.root[0]!.default).toBe("vb");
|
||||||
|
expect(result.output.variantSelection.vg).toBe("vb");
|
||||||
|
expect(result.idx.order).toEqual(["kb", "kc"]);
|
||||||
|
expect(result.elc.keys.map((w) => w.key)).toEqual(["kb", "kc"]);
|
||||||
|
expect(result.elc.ledChains?.[0]?.order).toEqual(["kb", "kc"]);
|
||||||
|
expect(result.map.layers[0]!.keymap.map((assignment) => assignment.keyId)).toEqual(["kb", "kc"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns schema issues without running semantic validation on malformed input", async () => {
|
||||||
|
const p = await createProject(emptyPhy());
|
||||||
|
const badOutput = await loadProject(JSON.stringify({ ...p, output: {} }));
|
||||||
|
expect(badOutput.errors.some((issue) => issue.path === "/output/variantSelection")).toBe(true);
|
||||||
|
expect(badOutput.errors.some((issue) => issue.code === "positive-unit-size")).toBe(false);
|
||||||
|
const badPhy = await loadProject(
|
||||||
|
JSON.stringify({ ...p, phy: { version: "0.1.0", profiles: null, root: [] } }),
|
||||||
|
);
|
||||||
|
expect(badPhy.errors.every((issue) => issue.code === "schema")).toBe(true);
|
||||||
|
expect(badPhy.errors.length).toBeGreaterThan(0);
|
||||||
|
const blockedSave = await saveProject({ ...p, phy: { ...p.phy, profiles: [] } });
|
||||||
|
expect(blockedSave.json).toBeUndefined();
|
||||||
|
expect(blockedSave.errors.some((issue) => issue.severity === "blocksSave")).toBe(true);
|
||||||
|
const semanticBlock = await saveProject({
|
||||||
|
...p,
|
||||||
|
phy: { ...p.phy, profiles: [{ ...p.phy.profiles[0]!, unitSize: { x: 0, y: 1 } }] },
|
||||||
|
});
|
||||||
|
expect(semanticBlock.json).toBeUndefined();
|
||||||
|
expect(semanticBlock.errors.some((issue) => issue.code === "positive-unit-size")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsupported project versions and allows other blocksSave projects to load", async () => {
|
||||||
|
const p = await createProject(emptyPhy());
|
||||||
|
const unsupported = await loadProject(JSON.stringify({ ...p, version: "0.2.0" }));
|
||||||
|
expect(unsupported.project).toBeUndefined();
|
||||||
|
expect(unsupported.errors.some((x) => x.code === "version")).toBe(true);
|
||||||
|
p.idx.order = ["missing"];
|
||||||
|
const blocked = await loadProject(JSON.stringify(p));
|
||||||
|
expect(blocked.project).toBeDefined();
|
||||||
|
expect(blocked.errors.some((x) => x.severity === "blocksSave")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks saving duplicate IDX keys", async () => {
|
||||||
|
const phy = emptyPhy();
|
||||||
|
phy.root = [key("k")];
|
||||||
|
const p = await createProject(phy);
|
||||||
|
p.idx.order = ["k", "k"];
|
||||||
|
expect(validateProject(p).some((x) => x.code === "schema" && x.severity === "blocksSave")).toBe(true);
|
||||||
|
const result = await saveProject(p);
|
||||||
|
expect(result.json).toBeUndefined();
|
||||||
|
expect(result.errors.some((x) => x.code === "schema")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retain a no-op grouped history entry", async () => {
|
||||||
|
const p = await createProject(emptyPhy());
|
||||||
|
const history = new ProjectHistory(p);
|
||||||
|
history.begin();
|
||||||
|
history.commit(p);
|
||||||
|
expect(history.canUndo).toBe(false);
|
||||||
|
const changed = { ...p, projectId: "changed" };
|
||||||
|
history.begin();
|
||||||
|
history.commit(changed);
|
||||||
|
expect(history.canUndo).toBe(true);
|
||||||
|
expect(history.undo().projectId).toBe(p.projectId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores decals when requiring index registration, wiring, and keymap assignments", async () => {
|
||||||
|
const phy = emptyPhy();
|
||||||
|
phy.root = [key("decal", true)];
|
||||||
|
const p = await createProject(phy);
|
||||||
|
const relevant = validateProject(p).filter((issue) => issue.ids?.includes("decal"));
|
||||||
|
expect(
|
||||||
|
relevant.filter((issue) => ["idx-unregistered", "unwired-key", "unassigned-key"].includes(issue.code)),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves extension fields while editing existing MAP and ELC entries", async () => {
|
||||||
|
const p = await createProject(emptyPhy());
|
||||||
|
const map = p.map as MapFile;
|
||||||
|
map.layers[0]!.keymap = [{ keyId: "k", keycode: "KC_A", vendorAssignment: { keep: true } }];
|
||||||
|
const editedMap = assignKey(map, "Base", "k", "KC_B");
|
||||||
|
expect(editedMap.layers[0]!.keymap[0]).toEqual({
|
||||||
|
keyId: "k",
|
||||||
|
keycode: "KC_B",
|
||||||
|
vendorAssignment: { keep: true },
|
||||||
|
});
|
||||||
|
expect(() => assignKey(map, "Base", "k", "VENDOR_ACTION")).toThrow();
|
||||||
|
expect(() => assignKey(map, "Base", "k", "MO(Missing)")).toThrow();
|
||||||
|
|
||||||
|
const elc = p.elc as ElcFile;
|
||||||
|
elc.providers = [{ id: "m", kind: "mcu", part: "RP2040", vendorProvider: 9 }];
|
||||||
|
const editedProvider = setProvider(elc, { id: "m", kind: "mcu", part: "RP2040B" });
|
||||||
|
expect(editedProvider.providers[0]).toMatchObject({ part: "RP2040B", vendorProvider: 9 });
|
||||||
|
elc.keys = [
|
||||||
|
{
|
||||||
|
key: "k",
|
||||||
|
detect: "contact",
|
||||||
|
terminals: { a: "m.GP0", b: "m.GP1", vendorTerminal: "x" },
|
||||||
|
vendorWiring: true,
|
||||||
|
} as ContactWiring,
|
||||||
|
];
|
||||||
|
const editedElc = setKeyWiring(elc, {
|
||||||
|
key: "k",
|
||||||
|
detect: "contact",
|
||||||
|
terminals: { a: "m.GP2", b: "m.GP3" },
|
||||||
|
});
|
||||||
|
expect(editedElc.keys[0]).toMatchObject({
|
||||||
|
vendorWiring: true,
|
||||||
|
terminals: { a: "m.GP2", b: "m.GP3", vendorTerminal: "x" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates pin ownership, provider pin ranges, duplicate ELC IDs and MAP layer references", async () => {
|
||||||
|
const phy = emptyPhy();
|
||||||
|
phy.root = [key("k")];
|
||||||
|
const p = await createProject(phy);
|
||||||
|
p.elc.providers = [
|
||||||
|
{ id: "m", kind: "mcu", part: "RP2040" },
|
||||||
|
{ id: "m", kind: "mcu", part: "RP2040" },
|
||||||
|
{ id: "mux", kind: "analog-mux", channels: 2, select: ["missing.X"], common: "m.GP30" },
|
||||||
|
];
|
||||||
|
p.elc.keys = [
|
||||||
|
{ key: "k", detect: "contact", terminals: { a: "mux.2", b: "missing.P" } },
|
||||||
|
{ key: "k", detect: "contact", terminals: { a: "VCC", b: "GND" } },
|
||||||
|
];
|
||||||
|
p.elc.ledChains = [
|
||||||
|
{ id: "chain", data: "m.GP0", order: [] },
|
||||||
|
{ id: "chain", data: "m.GP1", order: [] },
|
||||||
|
];
|
||||||
|
p.map.layers[0]!.keymap = [{ keyId: "k", keycode: "MO(Missing)" }];
|
||||||
|
const codes = new Set(validateProject(p).map((issue) => issue.code));
|
||||||
|
for (const code of [
|
||||||
|
"duplicate-provider-id",
|
||||||
|
"duplicate-key-wiring",
|
||||||
|
"duplicate-led-chain-id",
|
||||||
|
"missing-pin-provider",
|
||||||
|
"unknown-provider-pin",
|
||||||
|
"missing-map-layer-reference",
|
||||||
|
])
|
||||||
|
expect(codes.has(code), code).toBe(true);
|
||||||
|
const unknownPart = validateProject({
|
||||||
|
...p,
|
||||||
|
elc: {
|
||||||
|
...p.elc,
|
||||||
|
providers: [{ id: "custom", kind: "mcu", part: "UnknownPart" }],
|
||||||
|
keys: [{ key: "k", detect: "contact", terminals: { a: "custom.any_pin", b: "VCC" } }],
|
||||||
|
ledChains: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(unknownPart.some((issue) => issue.code === "unknown-provider-pin")).toBe(false);
|
||||||
|
expect(() => setProvider(p.elc, { id: "bad.id", kind: "mcu", part: "RP2040" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects nested variants and treats sibling alternatives as exclusive", async () => {
|
||||||
|
const phy = emptyPhy();
|
||||||
|
phy.root = [
|
||||||
|
variantGroup("outer", "outer-a", [
|
||||||
|
[
|
||||||
|
"outer-a",
|
||||||
|
"A",
|
||||||
|
[
|
||||||
|
key("common"),
|
||||||
|
variantGroup("inner", "inner-a", [
|
||||||
|
["inner-a", "IA", [key("ia")]],
|
||||||
|
["inner-b", "IB", [key("ib")]],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
["outer-b", "B", [key("other")]],
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
const p = await createProject(phy);
|
||||||
|
p.idx.order = ["common", "ia", "ib", "other"];
|
||||||
|
p.output.variantSelection = { outer: "outer-a", inner: "inner-b" };
|
||||||
|
p.elc.providers = [{ id: "m", kind: "mcu", part: "RP2040" }];
|
||||||
|
p.output.matrix = { rowPins: ["m.GP0", "m.GP2"], colPins: ["m.GP1", "m.GP3"] };
|
||||||
|
p.elc.keys = ["common", "ia", "ib", "other"].map((id) => ({
|
||||||
|
key: id,
|
||||||
|
detect: "contact",
|
||||||
|
terminals: id === "common" || id === "other" ? { a: "m.GP2", b: "m.GP3" } : { a: "m.GP0", b: "m.GP1" },
|
||||||
|
diode: "b-to-a",
|
||||||
|
}));
|
||||||
|
p.map.layers[0]!.keymap = ["common", "ia", "ib", "other"].map((keyId) => ({ keyId, keycode: "KC_A" }));
|
||||||
|
expect(validateProject(p).some((issue) => issue.code === "wiring-collision")).toBe(false);
|
||||||
|
const generated = generateMockTarget(p);
|
||||||
|
expect(generated.target?.idx).toEqual(["common", "ib"]);
|
||||||
|
expect(p.idx.order).toEqual(["common", "ia", "ib", "other"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the independently calculated JCS checksum and reports stale references", async () => {
|
||||||
|
const phy = emptyPhy();
|
||||||
|
expect(await makePhyReference(phy)).toEqual({
|
||||||
|
source: "project:phy",
|
||||||
|
checksum: "sha256-jcs:277e45f2cae4b3883480599c5569e73fbeec9e1a22bfb9bc323aecc4cb91d843",
|
||||||
|
});
|
||||||
|
const p = await createProject(phy);
|
||||||
|
p.phy.metadata.name = "changed after reference creation";
|
||||||
|
const loaded = await loadProject(JSON.stringify(p));
|
||||||
|
expect(
|
||||||
|
loaded.errors.filter((issue) => issue.code === "phy-reference-mismatch").map((issue) => issue.path),
|
||||||
|
).toEqual(["/idx/phyReference", "/elc/phyReference", "/map/phyReference"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user