diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c2421df..892152a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -105,6 +105,10 @@ jobs: # Win: tauri/cargo が出す単一 .exe(フロント資産は exe に埋め込み済み)。 # WebView2 は同梱せず OS 既定ランタイムを使う。 # Mac: .app バンドル自体がポータブル。zip して配布する。 + # 注意: ポータブル版のファイル名(WallMaker-*-portable-windows-x64.exe / + # WallMaker-*-portable-macos-universal.zip)と NSIS/MSI/dmg の命名は + # `src-tauri/src/update.rs` の `pick_asset` が照合している。名前を変えると + # アプリ内の更新通知がアセットを見つけられなくなる(リリースページ誘導のみになる)。 - name: Stage portable (Windows — standalone .exe) if: matrix.platform == 'windows-latest' shell: bash diff --git a/CLAUDE.md b/CLAUDE.md index f74e656..c164cab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,7 @@ export async function getPackWriter(): Promise { - 出力先は**ユーザに選ばせて `tauri-plugin-store` に記憶する**のが基本動作。`.minecraft` の自動検出はしない (MCSR は MultiMC/Prism のインスタンス別フォルダが多い)。初回ダイアログの既定パスはホームディレクトリ。 - 権限は `src-tauri/capabilities/` で最小化(`requireLiteralLeadingDot: false` で `.minecraft` を扱う)。 -- 配布対象は Win+Mac、自動更新なし、署名最小限(未署名警告は許容)。 +- 配布対象は Win+Mac、自動更新なし(起動時の更新通知+配布形態に合うリリースアセットを実行ファイルと同じフォルダへダウンロードする補助のみ。適用はユーザー操作)、署名最小限(未署名警告は許容)。 ## Verification diff --git a/REWRITE_SPEC.md b/REWRITE_SPEC.md index 1159856..90f875e 100644 --- a/REWRITE_SPEC.md +++ b/REWRITE_SPEC.md @@ -519,7 +519,7 @@ interface Area { - **状態**:Zustand 5 + `persist`(2層永続化、第7.2章)。 - **デスクトップ**:Tauri 2(Rust シェル、`dialog`/`fs`/`store`/`opener` プラグイン)。 - **配布**:対象は **Windows + macOS**。両 OS の実機でビルドするため CI は必須としない。 - **自動更新なし**(`tauri-plugin-updater` は導入しない)。**署名は最小限**。 + **自動更新なし(更新通知+リリースアセットのダウンロード補助のみ。`update_check`/`update_download`)**(`tauri-plugin-updater` は導入しない)。**署名は最小限**。 → macOS は notarization 無しで Gatekeeper 警告(初回は右クリック→開く)、Windows は未署名で SmartScreen 警告が出る点は許容する。 - **ZIP**:JSZip(Web アダプタ)。 - **音声**:ffmpeg.wasm を両環境で共有。**ffmpeg-core を自前バンドル/ホスト**して unpkg 依存を排除。 diff --git a/package.json b/package.json index 4b584d0..dcb02b3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wall-maker-v3", "private": false, - "version": "3.2.0", + "version": "3.3.0", "type": "module", "license": "GPL-3.0-or-later", "engines": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 782d225..0fb700e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -459,6 +459,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.45" @@ -549,6 +560,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1241,8 +1261,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1264,10 +1286,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1532,6 +1557,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1970,6 +2011,12 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "markup5ever" version = "0.38.0" @@ -2666,6 +2713,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2687,6 +2790,32 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2762,6 +2891,44 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -2820,6 +2987,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2848,12 +3029,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3044,6 +3266,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -3114,7 +3348,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3252,6 +3486,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3413,7 +3653,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -3855,6 +4095,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4174,6 +4424,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4267,8 +4523,9 @@ dependencies = [ [[package]] name = "wall-maker-v3" -version = "3.2.0" +version = "3.3.0" dependencies = [ + "reqwest 0.12.28", "serde", "serde_json", "tauri", @@ -4424,6 +4681,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.4" @@ -4480,6 +4747,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4710,6 +4986,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5245,6 +5530,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 95faf5d..ee6304e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -4,7 +4,7 @@ name = "wall-maker-v3" # ないため、**リリース時に package.json の `version` と手動で合わせること**。 # アプリとして配布されるバージョンは `tauri.conf.json` の `version`(= `../package.json` # 参照)が使われるので、ここがズレてもバンドル成果物のバージョンは package.json 由来になる。 -version = "3.2.0" +version = "3.3.0" description = "SeedQueue Wall Maker — Minecraft Speedrunning 用 SeedQueue リソースパックエディタ" authors = ["bafv4"] edition = "2021" @@ -30,4 +30,7 @@ tauri-plugin-store = "2" tauri-plugin-os = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +# 更新チェック(GitHub Releases API)とリリースアセットのダウンロード用。 +# rustls-tls: OS の OpenSSL に依存しない。default-features 無効で不要機能を落とす。 +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 25578fc..77fdaeb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,9 @@ // * `read_pack_zip` — 任意の .zip パスを丸ごと生バイトで返す // * `read_pack_folder` — 任意のフォルダを再帰 walk し、相対パス付きの生バイトで返す // * `path_is_dir` — 指定パスが実在するフォルダかを返す(記憶した保存先の存在検証用) +// * `update_check` / `update_download`(実装は update.rs) +// — GitHub Releases の最新版チェックと、配布形態に合うアセットの +// 「実行ファイルと同じフォルダ」へのダウンロード(自動適用はしない) // * `binary_put` / `binary_get` / `binary_delete` / `binary_keys` // — 永続化バイナリ(画像/音声)の実体ストア。appDataDir/binaries/ に置く // (フロントの `store/storage/desktop.ts` が使う。CLAUDE.md 第7.2章) @@ -41,6 +44,8 @@ use serde::{Deserialize, Serialize}; use tauri::ipc::{InvokeBody, Request, Response}; use tauri::Manager; +mod update; + // --------------------------------------------------------------------------- // 生バイト IPC コンテナ // --------------------------------------------------------------------------- @@ -480,6 +485,8 @@ pub fn run() { binary_get, binary_delete, binary_keys, + update::update_check, + update::update_download, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/update.rs b/src-tauri/src/update.rs new file mode 100644 index 0000000..c4b3459 --- /dev/null +++ b/src-tauri/src/update.rs @@ -0,0 +1,405 @@ +// 更新通知+リリースアセットのダウンロード。 +// +// 方針(CLAUDE.md「自動更新なし」の範囲内): +// * インストールの自動適用はしない。GitHub Releases の最新版をチェックして通知し、 +// ユーザーが押したときだけ、**現在の実行ファイルと同じフォルダ**へ該当アセットを +// ダウンロードする(適用=インストーラ実行や差し替えはユーザー自身が行う)。 +// * 配布形態に合わせたアセットを選ぶ。ポータブル版で使用中ならポータブル版を、 +// インストーラ導入なら NSIS setup(無ければ MSI)を選ぶ。判定はアンインストーラの +// 有無と実行ファイルの場所で行い、**判別できないときはポータブル版に倒す** +// (ポータブル exe はリネームされ得るため、ファイル名には依存しない)。 +// * ダウンロード先が書き込み不可(Program Files 等)の場合は OS のダウンロード +// フォルダへフォールバックし、実際の保存先を返す。 + +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::sync::OnceLock; + +use serde::{Deserialize, Serialize}; +use tauri::Manager; + +/// 更新チェック対象のリポジトリ(このアプリ自身)。 +const UPDATE_REPO_API: &str = + "https://api.github.com/repos/bafv4/wall-maker-v3/releases/latest"; +/// ダウンロードを許可する URL の接頭辞。これ以外への fetch はコマンド側で拒否する +/// (webview から任意 URL のダウンロードに悪用されないための最終防衛線)。 +const UPDATE_DOWNLOAD_PREFIX: &str = + "https://github.com/bafv4/wall-maker-v3/releases/download/"; + +#[derive(Deserialize)] +struct GhReleaseAsset { + name: String, + browser_download_url: String, + size: u64, +} + +#[derive(Deserialize)] +struct GhRelease { + tag_name: String, + html_url: String, + assets: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateInfo { + /// 最新版のバージョン(先頭 v なし。例 "3.3.0") + version: String, + /// リリースページ URL + release_url: String, + /// この配布形態に合うアセット。見つからなければ None(リリースページへ誘導) + asset_name: Option, + asset_url: Option, + asset_size: Option, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DownloadProgress { + received: u64, + /// Content-Length が無いと None + total: Option, +} + +/// "v3.2.0" / "3.2.0" → (3,2,0)。3 要素の数値でなければ None(プレリリース等は比較しない)。 +fn parse_semver_triple(s: &str) -> Option<(u64, u64, u64)> { + let s = s.strip_prefix('v').unwrap_or(s); + let mut it = s.split('.'); + let major = it.next()?.parse().ok()?; + let minor = it.next()?.parse().ok()?; + let patch = it.next()?.parse().ok()?; + if it.next().is_some() { + return None; + } + Some((major, minor, patch)) +} + +fn is_newer_version(latest_tag: &str, current: &str) -> bool { + match (parse_semver_triple(latest_tag), parse_semver_triple(current)) { + (Some(l), Some(c)) => l > c, + _ => false, + } +} + +/// この実行環境の配布形態。 +#[derive(Clone, Copy)] +enum InstallKind { + /// ポータブル(単一 exe / 展開した .app)。判別不能時の既定でもある。 + Portable, + /// インストーラ導入(Windows: NSIS/MSI、macOS: /Applications 配下) + Installed, +} + +#[cfg(target_os = "windows")] +fn detect_install_kind() -> InstallKind { + let Ok(exe) = std::env::current_exe() else { + return InstallKind::Portable; + }; + // Tauri の NSIS インストーラは同じフォルダに uninstall.exe を置く + if exe + .parent() + .is_some_and(|dir| dir.join("uninstall.exe").is_file()) + { + return InstallKind::Installed; + } + if exe + .to_string_lossy() + .to_ascii_lowercase() + .contains("\\program files") + { + return InstallKind::Installed; // MSI(per-machine) + } + InstallKind::Portable +} + +#[cfg(target_os = "macos")] +fn detect_install_kind() -> InstallKind { + match std::env::current_exe() { + Ok(exe) if exe.to_string_lossy().starts_with("/Applications/") => { + InstallKind::Installed + } + _ => InstallKind::Portable, + } +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn detect_install_kind() -> InstallKind { + InstallKind::Portable +} + +fn find_asset( + assets: &[GhReleaseAsset], + pred: impl Fn(&str) -> bool, +) -> Option<&GhReleaseAsset> { + assets.iter().find(|a| pred(&a.name.to_ascii_lowercase())) +} + +/// 配布形態に合うアセットをリリースのアセット一覧から選ぶ。 +/// アセット命名は release.yml が生成する実物に合わせる(v3.2.0 で確認。 +/// release.yml 側にも「この名前を変えると更新通知が壊れる」旨を注記してある): +/// Wall.Maker_3.2.0_x64-setup.exe / Wall.Maker_3.2.0_x64_en-US.msi / +/// WallMaker-v3.2.0-portable-windows-x64.exe / +/// Wall.Maker_3.2.0_universal.dmg / WallMaker-v3.2.0-portable-macos-universal.zip +#[cfg(target_os = "windows")] +fn pick_asset(assets: &[GhReleaseAsset], kind: InstallKind) -> Option<&GhReleaseAsset> { + match kind { + InstallKind::Portable => find_asset(assets, |n| n.contains("portable-windows")), + InstallKind::Installed => find_asset(assets, |n| n.ends_with("-setup.exe")) + .or_else(|| find_asset(assets, |n| n.ends_with(".msi"))), + } +} + +#[cfg(target_os = "macos")] +fn pick_asset(assets: &[GhReleaseAsset], kind: InstallKind) -> Option<&GhReleaseAsset> { + match kind { + InstallKind::Portable => find_asset(assets, |n| n.contains("portable-macos")), + InstallKind::Installed => find_asset(assets, |n| n.ends_with(".dmg")), + } +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn pick_asset(_assets: &[GhReleaseAsset], _kind: InstallKind) -> Option<&GhReleaseAsset> { + None +} + +/// ダウンロード先フォルダ = 実行ファイルと同じフォルダ。 +/// macOS の .app バンドル内(Contents/MacOS)で動いている場合は、ファイルが +/// バンドルの中に埋もれないよう .app を**含む**フォルダまで上がる。 +fn update_dest_dir() -> Result { + let exe = std::env::current_exe().map_err(|e| format!("実行パス取得に失敗: {e}"))?; + let dir = exe + .parent() + .ok_or("実行ファイルの親フォルダがありません")? + .to_path_buf(); + #[cfg(target_os = "macos")] + { + let mut cur = dir.clone(); + loop { + if cur.extension().map(|e| e == "app").unwrap_or(false) { + if let Some(p) = cur.parent() { + return Ok(p.to_path_buf()); + } + break; + } + match cur.parent() { + Some(p) => cur = p.to_path_buf(), + None => break, + } + } + } + Ok(dir) +} + +/// 共有 HTTP クライアント。TLS 設定(webpki ルート証明書のパース)とコネクション +/// プールを update_check / update_download で使い回す(Client の clone は Arc の複製)。 +fn github_client() -> Result { + static CLIENT: OnceLock = OnceLock::new(); + if let Some(c) = CLIENT.get() { + return Ok(c.clone()); + } + let built = reqwest::Client::builder() + // GitHub API は User-Agent 必須(無いと 403) + .user_agent(concat!("wall-maker-v3/", env!("CARGO_PKG_VERSION"))) + .timeout(std::time::Duration::from_secs(60 * 10)) + .connect_timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| format!("HTTP クライアント初期化に失敗: {e}"))?; + Ok(CLIENT.get_or_init(|| built).clone()) +} + +/// 最新リリースを調べ、現在より新しければ配布形態に合うアセット情報を返す。 +/// 新しくなければ Ok(None)。ネットワーク失敗はエラー(フロントは黙って握る)。 +#[tauri::command] +pub async fn update_check(current_version: String) -> Result, String> { + let client = github_client()?; + let release: GhRelease = client + .get(UPDATE_REPO_API) + .header("Accept", "application/vnd.github+json") + .send() + .await + .map_err(|e| format!("更新チェックのリクエストに失敗: {e}"))? + .error_for_status() + .map_err(|e| format!("更新チェックが HTTP エラー: {e}"))? + .json() + .await + .map_err(|e| format!("更新チェックの応答を解釈できません: {e}"))?; + + if !is_newer_version(&release.tag_name, ¤t_version) { + return Ok(None); + } + let asset = pick_asset(&release.assets, detect_install_kind()); + Ok(Some(UpdateInfo { + version: release + .tag_name + .strip_prefix('v') + .unwrap_or(&release.tag_name) + .to_string(), + release_url: release.html_url, + asset_name: asset.map(|a| a.name.clone()), + asset_url: asset.map(|a| a.browser_download_url.clone()), + asset_size: asset.map(|a| a.size), + })) +} + +/// リリースアセットを実行ファイルと同じフォルダへダウンロードする。 +/// 書き込めない場合(Program Files 等)は OS のダウンロードフォルダへ。 +/// `.part` に書いてから rename するので、中断で壊れたファイルが残らない。 +/// 戻り値は保存した絶対パス。 +#[tauri::command] +pub async fn update_download( + app: tauri::AppHandle, + url: String, + file_name: String, + on_progress: tauri::ipc::Channel, +) -> Result { + if !url.starts_with(UPDATE_DOWNLOAD_PREFIX) { + return Err("このリポジトリのリリース以外からはダウンロードできません".into()); + } + // アセット名は単一のファイル名として検証する(区切り文字・`..`・Windows のドライブ + // 記号を拒否)。lib.rs の validate_binary_key(UUID 用)や write_pack_folder の + // 相対パス検証(複数セグメント用)とは対象の形が違うため、ここで独立に持つ。 + if file_name.is_empty() + || file_name.chars().any(|c| c == '/' || c == '\\' || c == ':') + || file_name.contains("..") + { + return Err(format!("不正なファイル名: {file_name}")); + } + + let client = github_client()?; + let mut resp = client + .get(&url) + .send() + .await + .map_err(|e| format!("ダウンロード開始に失敗: {e}"))? + .error_for_status() + .map_err(|e| format!("ダウンロードが HTTP エラー: {e}"))?; + let total = resp.content_length(); + + // 保存先: 実行ファイルと同じフォルダ。`.part` の作成自体が書き込み可否の判定を + // 兼ねる(失敗したら OS のダウンロードフォルダで作り直す)。 + let mut dir = update_dest_dir()?; + let mut file = match fs::File::create(dir.join(format!("{file_name}.part"))) { + Ok(f) => f, + Err(_) => { + dir = app + .path() + .download_dir() + .map_err(|e| format!("ダウンロードフォルダの取得に失敗: {e}"))?; + let part = dir.join(format!("{file_name}.part")); + fs::File::create(&part) + .map_err(|e| format!("一時ファイル作成に失敗 {}: {e}", part.display()))? + } + }; + let part = dir.join(format!("{file_name}.part")); + let final_path = dir.join(&file_name); + + // チャンクは TLS レコード単位(8〜16KB)で届くため、素の File だと 100MB 級の + // アセットで数千〜万回の write システムコールになる。1MB バッファでまとめる。 + let mut writer = std::io::BufWriter::with_capacity(1 << 20, &mut file); + let mut received: u64 = 0; + let mut last_emit: u64 = 0; + loop { + let chunk = resp.chunk().await.map_err(|e| { + let _ = fs::remove_file(&part); + format!("ダウンロード中に切断されました: {e}") + })?; + let Some(bytes) = chunk else { break }; + writer.write_all(&bytes).map_err(|e| { + let _ = fs::remove_file(&part); + format!("書き込みに失敗 {}: {e}", part.display()) + })?; + received += bytes.len() as u64; + // 進捗はチャンクごとに送ると IPC が煩すぎるので 512KB ごと + if received - last_emit >= 512 * 1024 { + last_emit = received; + let _ = on_progress.send(DownloadProgress { received, total }); + } + } + writer.flush().map_err(|e| { + let _ = fs::remove_file(&part); + format!("書き込みに失敗 {}: {e}", part.display()) + })?; + drop(writer); + drop(file); + let _ = on_progress.send(DownloadProgress { received, total }); + + // 同名ファイルがあれば上書き(同じバージョンの再ダウンロード)。 + // fsync はしない: 中断は `.part` のままなので壊れたファイルが完成品に見える + // ことはなく、再ダウンロード可能な成果物に耐久性保証は不要。 + if final_path.exists() { + fs::remove_file(&final_path) + .map_err(|e| format!("既存ファイルの置換に失敗 {}: {e}", final_path.display()))?; + } + fs::rename(&part, &final_path) + .map_err(|e| format!("ファイルの確定に失敗 {}: {e}", final_path.display()))?; + Ok(final_path.to_string_lossy().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn semver_compare() { + assert!(is_newer_version("v3.3.0", "3.2.0")); + assert!(is_newer_version("3.10.0", "3.9.9")); + assert!(!is_newer_version("v3.2.0", "3.2.0")); + assert!(!is_newer_version("v3.1.9", "3.2.0")); + // プレリリースや不正な形式は「新しくない」扱い(通知しない) + assert!(!is_newer_version("v3.3.0-beta.1", "3.2.0")); + assert!(!is_newer_version("garbage", "3.2.0")); + assert!(!is_newer_version("v3.3.0", "garbage")); + } + + fn asset(name: &str) -> GhReleaseAsset { + GhReleaseAsset { + name: name.into(), + browser_download_url: format!("{UPDATE_DOWNLOAD_PREFIX}v9.9.9/{name}"), + size: 1, + } + } + + #[test] + fn asset_pick_matches_distribution() { + let assets = vec![ + asset("Wall.Maker_3.3.0_universal.dmg"), + asset("Wall.Maker_3.3.0_x64-setup.exe"), + asset("Wall.Maker_3.3.0_x64_en-US.msi"), + asset("WallMaker-v3.3.0-portable-macos-universal.zip"), + asset("WallMaker-v3.3.0-portable-windows-x64.exe"), + ]; + #[cfg(target_os = "windows")] + { + assert_eq!( + pick_asset(&assets, InstallKind::Portable).unwrap().name, + "WallMaker-v3.3.0-portable-windows-x64.exe" + ); + assert_eq!( + pick_asset(&assets, InstallKind::Installed).unwrap().name, + "Wall.Maker_3.3.0_x64-setup.exe" + ); + // NSIS setup が無いリリースでは MSI に倒れる + let no_nsis: Vec<_> = assets + .iter() + .filter(|a| !a.name.ends_with("-setup.exe")) + .map(|a| asset(&a.name)) + .collect(); + assert_eq!( + pick_asset(&no_nsis, InstallKind::Installed).unwrap().name, + "Wall.Maker_3.3.0_x64_en-US.msi" + ); + } + #[cfg(target_os = "macos")] + { + assert_eq!( + pick_asset(&assets, InstallKind::Portable).unwrap().name, + "WallMaker-v3.3.0-portable-macos-universal.zip" + ); + assert_eq!( + pick_asset(&assets, InstallKind::Installed).unwrap().name, + "Wall.Maker_3.3.0_universal.dmg" + ); + } + } +} diff --git a/src/App.tsx b/src/App.tsx index e31f29a..fc856ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,6 +61,12 @@ const SoundsEditor = lazy(() => default: m.SoundsEditor, })), ); +// 更新通知バナー(Desktop のみ描画。Web の初期チャンクに入れない) +const DesktopUpdateNotice = lazy(() => + import('./components/DesktopUpdateNotice').then((m) => ({ + default: m.DesktopUpdateNotice, + })), +); /** * タブ本文のチャンク取得中に出すプレースホルダ。 @@ -106,6 +112,11 @@ function App() {
{!desktop && } + {desktop && ( + + + + )}
{ + return invoke('update_check', { currentVersion }); +} + +/** + * アセットをダウンロードして保存した絶対パスを返す。 + * 進捗は 512KB ごとに `onProgress` へ届く(最後に必ず 1 回)。 + */ +export async function downloadAppUpdateDesktop( + url: string, + fileName: string, + onProgress: (p: AppUpdateProgress) => void, +): Promise { + const channel = new Channel(); + channel.onmessage = onProgress; + return invoke('update_download', { + url, + fileName, + onProgress: channel, + }); +} + +/** 保存したファイルを OS のファイルマネージャで表示する(失敗は呼び出し側で握る)。 */ +export async function revealDownloadedFile(path: string): Promise { + await revealItemInDir(path); +} + +/** + * URL を OS 既定ブラウザで開く。Tauri webview 内の `` は + * 環境によって何も起きないため、外部リンクは必ずこちらを使う。 + */ +export async function openExternalUrlDesktop(url: string): Promise { + await openUrl(url); +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 1ca4f90..733fc8b 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -16,6 +16,9 @@ import type { PackReadResult, PackReadSource } from '../core/types'; // 型のみの import。`import type` は実行時に消えるため、Web バンドルへ // `desktopSaveTarget`(= `@tauri-apps/*`)が引き込まれることはない。 import type { SaveTargetRestore } from './desktopSaveTarget'; +import type { AppUpdateInfo, AppUpdateProgress } from './desktopUpdate'; + +export type { AppUpdateInfo, AppUpdateProgress } from './desktopUpdate'; export type { PackReadResult, PackReadSource } from '../core/types'; @@ -86,3 +89,54 @@ export async function forgetSaveTarget(): Promise { const { clearSaveTarget } = await import('./desktopSaveTarget'); await clearSaveTarget(); } + +// --------------------------------------------------------------------------- +// 更新通知(Desktop のみ。Web はリロードで常に最新なので no-op) +// --------------------------------------------------------------------------- + +/** + * 最新リリースをチェックし、現在より新しければ情報を返す。Web では常に null。 + * ネットワーク失敗は throw する(呼び出し側は通知を出さず console に留めること)。 + */ +export async function checkAppUpdate( + currentVersion: string, +): Promise { + if (!isTauri()) return null; + const { checkAppUpdateDesktop } = await import('./desktopUpdate'); + return checkAppUpdateDesktop(currentVersion); +} + +/** + * リリースアセットを実行ファイルと同じフォルダへダウンロードし、保存パスを返す。 + * Desktop 専用(Web から呼ぶと reject)。 + */ +export async function downloadAppUpdate( + url: string, + fileName: string, + onProgress: (p: AppUpdateProgress) => void, +): Promise { + if (!isTauri()) throw new Error('downloadAppUpdate は Desktop 専用です'); + const { downloadAppUpdateDesktop } = await import('./desktopUpdate'); + return downloadAppUpdateDesktop(url, fileName, onProgress); +} + +/** 保存済みファイルを OS のファイルマネージャで表示する(Desktop 専用、失敗は無視してよい)。 */ +export async function revealAppUpdateFile(path: string): Promise { + if (!isTauri()) return; + const { revealDownloadedFile } = await import('./desktopUpdate'); + await revealDownloadedFile(path); +} + +/** + * 外部 URL を既定ブラウザで開く。Desktop は opener プラグイン経由 + * (webview 内アンカーの `target=_blank` は環境依存で無反応になるため)、 + * Web は通常の新規タブ。 + */ +export async function openExternalUrl(url: string): Promise { + if (isTauri()) { + const { openExternalUrlDesktop } = await import('./desktopUpdate'); + await openExternalUrlDesktop(url); + return; + } + window.open(url, '_blank', 'noopener,noreferrer'); +} diff --git a/src/components/AboutModal.tsx b/src/components/AboutModal.tsx index b44c481..0257227 100644 --- a/src/components/AboutModal.tsx +++ b/src/components/AboutModal.tsx @@ -10,7 +10,7 @@ import { useTranslation, Trans } from 'react-i18next'; import { SUPPORTED_LANGUAGES, setLanguage, type SupportedLanguage } from '../i18n'; -import { Modal } from './ui'; +import { ExternalLink, Modal } from './ui'; export interface AboutModalProps { open: boolean; @@ -24,25 +24,6 @@ const FFMPEG_WASM_REPO = 'https://github.com/ffmpegwasm/ffmpeg.wasm'; const GPL3_URL = 'https://www.gnu.org/licenses/gpl-3.0.html'; const GPL2_URL = 'https://www.gnu.org/licenses/old-licenses/gpl-2.0.html'; -interface LinkProps { - href: string; - // Trans に渡すときは children が空のまま使うため optional。 - children?: React.ReactNode; -} - -function ExternalLink({ href, children }: LinkProps) { - return ( - - {children} - - ); -} - function LanguageSwitcher() { const { t, i18n } = useTranslation(); const current = (i18n.resolvedLanguage ?? diff --git a/src/components/DesktopUpdateNotice.tsx b/src/components/DesktopUpdateNotice.tsx new file mode 100644 index 0000000..9334b68 --- /dev/null +++ b/src/components/DesktopUpdateNotice.tsx @@ -0,0 +1,142 @@ +/** + * DesktopUpdateNotice — 起動時の更新通知バナー(Desktop 専用)。 + * + * 起動時に一度だけ GitHub Releases の最新版をチェックし、新しいバージョンが + * あればタブの上に横断バナーを出す。「ダウンロード」で配布形態に合うアセットを + * **実行ファイルと同じフォルダ**へ保存する(適用はユーザー自身。自動更新はしない)。 + * + * - コンポーネントは App 直下(タブの外)に 1 度だけマウントされ、アンマウント + * されない。checkPromise だけモジュールレベルに置くのは StrictMode の + * 二重 effect と HMR での再フェッチを防ぐため。 + * - チェック失敗は console.warn のみ(オフライン起動を邪魔しない)。 + * - 対応アセットが見つからないリリース(命名変更など)はダウンロードボタンを + * 出さず、リリースページへのリンクだけ出す。 + */ + +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + checkAppUpdate, + downloadAppUpdate, + revealAppUpdateFile, + type AppUpdateInfo, +} from '../adapters'; +import { errMsg } from '../core/errors'; +import { formatBytes } from '../core/format'; +import { Button, ExternalLink, toast } from './ui'; + +// アプリ起動につき 1 回だけチェックする(StrictMode の二重 effect / HMR 対策) +let checkPromise: Promise | null = null; + +function ensureCheck(current: string): Promise { + checkPromise ??= checkAppUpdate(current).catch((e: unknown) => { + // オフラインや API レート制限で普通に失敗しうる。起動を妨げない。 + console.warn('update check failed', e); + return null; + }); + return checkPromise; +} + +type DownloadState = + | { kind: 'idle' } + | { kind: 'downloading'; percent: number | null } + | { kind: 'done'; path: string }; + +export function DesktopUpdateNotice() { + const { t } = useTranslation(); + const [info, setInfo] = useState(null); + const [download, setDownload] = useState({ kind: 'idle' }); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + let canceled = false; + void ensureCheck(__APP_VERSION__).then((result) => { + if (!canceled && result) setInfo(result); + }); + return () => { + canceled = true; + }; + }, []); + + if (!info || dismissed) return null; + + const startDownload = () => { + if (!info.assetUrl || !info.assetName) return; + setDownload({ kind: 'downloading', percent: null }); + downloadAppUpdate(info.assetUrl, info.assetName, (p) => { + setDownload({ + kind: 'downloading', + percent: + p.total && p.total > 0 + ? Math.min(100, Math.round((p.received / p.total) * 100)) + : null, + }); + }) + .then((path) => { + setDownload({ kind: 'done', path }); + toast.success(t('update.downloaded', { path })); + }) + .catch((e: unknown) => { + setDownload({ kind: 'idle' }); + toast.error(t('update.downloadFailed', { error: errMsg(e) })); + }); + }; + + + return ( +
+ + {t('update.available', { + version: info.version, + current: __APP_VERSION__, + })} + + + {download.kind === 'idle' && info.assetUrl && info.assetName && ( + + )} + {download.kind === 'downloading' && ( + + {download.percent === null + ? t('update.downloading') + : t('update.downloadingPercent', { percent: download.percent })} + + )} + {download.kind === 'done' && ( + <> + + {t('update.savedTo', { path: download.path })} + + + + )} + + + {t('update.releasePage')} + + + +
+ ); +} diff --git a/src/components/WallPreview.tsx b/src/components/WallPreview.tsx index 46427eb..e27a92c 100644 --- a/src/components/WallPreview.tsx +++ b/src/components/WallPreview.tsx @@ -8,8 +8,12 @@ * カラーピッカーのドラッグ 1 イベントごとに MB 級の再確保とフル解像度の再合成が走る。 * エクスポートは `buildPack` が独立にフル解像度で描くのでプレビュー品質とは無関係。 * - エリア(main / locked / preparing[i])はドラッグ移動・8 ハンドルでリサイズ。 - * - 選択中の画像レイヤ(`fit:'manual'`)も同様に move/resize。 + * Ctrl/⌘+クリックで複数選択でき、選択中のエリアはドラッグでまとめて移動する + * (Shift はドラッグ中のスナップ無効に割当済みのため、トグルには使わない)。 + * リサイズハンドルは単独選択のときだけ表示する(グループリサイズは非対応)。 + * - 選択中の画像レイヤ(`fit:'manual'`)も同様に move/resize(レイヤは複数選択の対象外)。 * - スナップ: 他エリア辺・中央・キャンバス端と中央へ磁着(プレビュー基準 6px)。Shift で無効化。 + * 複数選択の移動では**選択全体の外接矩形**の辺・中央で判定する(指示線も同じ基準)。 * - ドラッグ中は `x,y / w×h` のオーバーレイ表示。 * * 不変条件: 全 state mutation は store アクション経由(座標は store 側で `floorArea`/`floorCell`)。 @@ -26,7 +30,13 @@ import { type PointerEvent as ReactPointerEvent, } from 'react'; import { useTranslation } from 'react-i18next'; -import { floorCell, realToPreview } from '../core/coords'; +import { + MAX_DIMENSION, + MIN_COORDINATE, + boundingBox, + floorCell, + realToPreview, +} from '../core/coords'; import { renderBackgroundToCanvas } from '../core/renderBackground'; import type { AreaCell, @@ -36,7 +46,12 @@ import type { VisibleArea, WallState, } from '../core/state'; -import { useWallStore } from '../store/useWallStore'; +import { + DEFAULT_AREA_SELECTION, + useWallStore, + type AreaMove, + type AreaTarget, +} from '../store/useWallStore'; import { snapMove, snapResize, type Handle } from './snap'; import { cn } from './ui/cn'; @@ -45,21 +60,75 @@ import { cn } from './ui/cn'; // --------------------------------------------------------------------------- type AreaRef = - | { kind: 'main' } - | { kind: 'locked' } - | { kind: 'preparing'; index: number } + | AreaTarget | { kind: 'layer'; layerId: string }; type Mode = { type: 'move' } | { type: 'resize'; handle: Handle }; -interface DragState { +/** 選択集合の membership 判定・スナップ候補の除外に使う安定キー。 */ +function areaRefKey(ref: AreaRef): string { + switch (ref.kind) { + case 'main': + return 'main'; + case 'locked': + return 'locked'; + case 'preparing': + return `preparing:${ref.index}`; + case 'layer': + return `layer:${ref.layerId}`; + } +} + +// 描画・選択判定で使い回す固定参照(毎レンダーの再生成とキー書式の手書きを避ける) +const MAIN_REF: AreaRef = { kind: 'main' }; +const LOCKED_REF: AreaRef = { kind: 'locked' }; +const MAIN_KEY = areaRefKey(MAIN_REF); +const LOCKED_KEY = areaRefKey(LOCKED_REF); + +/** 選択集合から layer を除いて AreaTarget[] に絞る(選択にはエリアしか入らない)。 */ +function areaTargets(refs: readonly AreaRef[]): AreaTarget[] { + return refs.filter((r): r is AreaTarget => r.kind !== 'layer'); +} + +/** + * 複数選択ドラッグの開始閾値(CSS px)。この距離を超えるまでは store に反映しない。 + * 超えずに離した場合は「クリック」とみなし、押したメンバーの単独選択へ縮退する + * (選択済みメンバーの上でも通常クリックで単独選択に戻れるようにするため)。 + */ +const GROUP_DRAG_START_THRESHOLD_CSS = 3; + +interface DragItem { ref: AreaRef; + startCell: AreaCell; +} + +interface DragState { mode: Mode; + /** move: 選択中の全エリア(1..N)。resize / layer: 必ず 1 件。 */ + items: DragItem[]; + /** + * pointerdown されたエリア。move かつ複数選択で、閾値を超えずに離された + * 「クリック」のとき、このエリアの単独選択へ縮退させる。 + */ + pressedRef: AreaRef | null; + /** 複数選択の move が開始閾値を一度でも超えたか。 */ + moved: boolean; + /** + * ドラッグ中は不変な派生値。開始時と startCellRefreshed の読み直し後に + * initDragDerived で再計算する(pointermove ごとの再確保を避ける)。 + * - bbox0: 開始時セル群の外接矩形(move のスナップ・移動量の基準) + * - maxStartX/Y: メンバー x/y の最大値。クランプ境界(±MAX_DIMENSION)でも + * グループの剛体性が壊れないよう、移動量の上限を導くのに使う + * - cand: スナップ候補(移動中メンバーを除外済み。他エリアはドラッグ中に変化しない) + */ + bbox0: AreaCell; + maxStartX: number; + maxStartY: number; + cand: { xs: number[]; ys: number[] }; startClientX: number; startClientY: number; - startCell: AreaCell; /** - * startCell を最初の pointermove で store から読み直したか。 + * items の startCell を最初の pointermove で store から読み直したか。 * 数値入力欄の blur コミットは pointerdown の後・最初の pointermove の前に発火するため、 * pointerdown 時に捕捉した startCell はコミット直前の旧値であり得る。 * そのまま全フィールドを dispatch すると、blur でコミットした値を旧値で巻き戻してしまう。 @@ -68,7 +137,7 @@ interface DragState { pxToRealX: number; pxToRealY: number; /** - * 直前に dispatch した整数セルとスナップヒット。 + * 直前に dispatch した整数セル(move では選択全体の外接矩形)とスナップヒット。 * store の `mergeAreaPatch` は常に新しい area/layout オブジェクトを作るため、 * 整数化した結果が前回と同じでも再レンダリング+永続化まで走ってしまう。 * 主に効くのは、スナップ線に磁着している間(閾値内のポインタ移動はすべて @@ -234,6 +303,30 @@ function sameCell(a: AreaCell | null, b: AreaCell): boolean { ); } +/** + * resize / move 共通の dedupe。前回 dispatch した結果と比較し、変化があれば + * 記録して true を返す。false のときは store 更新もオーバーレイ更新も不要 + * (store 側は floorArea するので、floor 後が同じなら state は 1bit も変わらない)。 + */ +function rememberDragResult( + drag: DragState, + cell: AreaCell, + hitX: number | null, + hitY: number | null, +): boolean { + if ( + sameCell(drag.lastCell, cell) && + drag.lastHitX === hitX && + drag.lastHitY === hitY + ) { + return false; + } + drag.lastCell = cell; + drag.lastHitX = hitX; + drag.lastHitY = hitY; + return true; +} + // --------------------------------------------------------------------------- // プレビューサイズ // --------------------------------------------------------------------------- @@ -260,12 +353,13 @@ interface AreaBoxProps { resolution: Resolution; preview: Resolution; selected: boolean; + /** 選択がこの 1 件だけか。リサイズハンドルは selected && soleSelection のときだけ出す。 */ + soleSelection: boolean; onPointerDownArea: ( e: ReactPointerEvent, refId: AreaRef, mode: Mode, ) => void; - onSelect: (refId: AreaRef) => void; } function AreaBox({ @@ -276,9 +370,11 @@ function AreaBox({ resolution, preview, selected, + soleSelection, onPointerDownArea, - onSelect, }: AreaBoxProps) { + // グループリサイズは非対応なので、ハンドルは単独選択のときだけ表示する + const showHandles = selected && soleSelection; const pv = useMemo( () => realToPreview(area, { real: resolution, preview }), [area, resolution, preview], @@ -329,7 +425,7 @@ function AreaBox({ }} onPointerDown={(e) => { if (e.button !== 0) return; - onSelect(refId); + // 選択の更新(トグル/単独化)とドラッグ開始は親がまとめて判断する onPointerDownArea(e, refId, { type: 'move' }); }} > @@ -340,7 +436,7 @@ function AreaBox({ {label}
{gridLines} - {selected && + {showHandles && HANDLE_LIST.map((h) => (
s.setLocked); const updatePreparing = useWallStore((s) => s.updatePreparing); const updateBackgroundLayer = useWallStore((s) => s.updateBackgroundLayer); + const moveAreas = useWallStore((s) => s.moveAreas); const containerRef = useRef(null); const dragRef = useRef(null); const [preview, setPreview] = useState({ width: 0, height: 0 }); - const [selected, setSelected] = useState({ kind: 'main' }); + + // 選択集合(1 件以上)。Ctrl/⌘+クリックでトグル、通常クリックで単独選択。 + // 実体は store(ui.selectedAreas)。preparing 削除時の index 詰め直しは + // removePreparing が削除と同じ set() 内で行う(store 参照)。 + const rawSelected = useWallStore((s) => s.ui.selectedAreas); + const selectAreas = useWallStore((s) => s.selectAreas); + // 非表示のエリアを選択から除く。空になったら main に戻す + // (既存挙動: 空クリックでも main が選択される=常に何かが選択されている)。 + // ドラッグ中は layout.preparing の identity が毎フレーム変わるため、 + // 何も落ちなかったときは入力の identity をそのまま返して下流の memo を守る。 + const selectedRefs = useMemo(() => { + const alive = rawSelected.filter((r) => { + if (r.kind === 'main') return true; + if (r.kind === 'locked') return layout.locked.show; + return layout.preparing[r.index]?.show === true; + }); + if (alive.length === rawSelected.length) return rawSelected; + return alive.length > 0 ? alive : DEFAULT_AREA_SELECTION; + }, [rawSelected, layout.locked.show, layout.preparing]); + const selectedKeys = useMemo( + () => new Set(selectedRefs.map(areaRefKey)), + [selectedRefs], + ); /** ドラッグ中のオーバーレイ(px 表示・スナップ ヒット線)。null = 操作なし。 */ const [dragOverlay, setDragOverlay] = useState<{ @@ -483,19 +602,21 @@ export function WallPreview() { }, [resolution]); // ---- スナップ候補(他エリア+キャンバス端) ---- + // 移動中のエリア(複数選択なら選択中の全エリア)を候補から除く。 const buildSnapCandidates = useCallback( - (movingRef: AreaRef): { xs: number[]; ys: number[] } => { + (excludeKeys: ReadonlySet): { xs: number[]; ys: number[] } => { const xs: number[] = [0, resolution.width / 2, resolution.width]; const ys: number[] = [0, resolution.height / 2, resolution.height]; const addCell = (c: AreaCell) => { xs.push(c.x, c.x + c.width / 2, c.x + c.width); ys.push(c.y, c.y + c.height / 2, c.y + c.height); }; - if (movingRef.kind !== 'main') addCell(layout.main); - if (movingRef.kind !== 'locked' && layout.locked.show) + if (!excludeKeys.has(MAIN_KEY)) addCell(layout.main); + if (!excludeKeys.has(LOCKED_KEY) && layout.locked.show) addCell(layout.locked); layout.preparing.forEach((p, i) => { - if (movingRef.kind === 'preparing' && movingRef.index === i) return; + if (excludeKeys.has(areaRefKey({ kind: 'preparing', index: i }))) + return; if (!p.show) return; addCell(p); }); @@ -558,21 +679,55 @@ export function WallPreview() { // ---- pointer handlers ---- - const onPointerDownArea = useCallback( + /** + * items からドラッグ中不変の派生値(外接矩形・メンバー最大座標・スナップ候補)を + * 再計算して drag に書き込む。開始時と startCellRefreshed の読み直し後に呼ぶ。 + */ + const initDragDerived = useCallback( + ( + items: DragItem[], + ): Pick => { + const cells = items.map((i) => i.startCell); + const bbox0 = boundingBox(cells); + let maxStartX = -Infinity; + let maxStartY = -Infinity; + for (const c of cells) { + maxStartX = Math.max(maxStartX, c.x); + maxStartY = Math.max(maxStartY, c.y); + } + return { + bbox0, + maxStartX, + maxStartY, + cand: buildSnapCandidates(new Set(items.map((i) => areaRefKey(i.ref)))), + }; + }, + [buildSnapCandidates], + ); + + /** ドラッグを開始する。move では items が選択中の全エリア、それ以外は 1 件。 */ + const startDrag = useCallback( ( e: ReactPointerEvent, - refId: AreaRef, + refs: readonly AreaRef[], mode: Mode, + pressedRef: AreaRef | null = null, ) => { if (preview.width === 0 || preview.height === 0) return; - const startCell = getStartCell(refId); - if (!startCell) return; + const items: DragItem[] = []; + for (const ref of refs) { + const startCell = getStartCell(ref); + if (startCell) items.push({ ref, startCell }); + } + if (items.length === 0) return; dragRef.current = { - ref: refId, mode, + items, + pressedRef, + moved: false, + ...initDragDerived(items), startClientX: e.clientX, startClientY: e.clientY, - startCell, startCellRefreshed: false, pxToRealX: resolution.width / preview.width, pxToRealY: resolution.height / preview.height, @@ -580,10 +735,55 @@ export function WallPreview() { lastHitX: null, lastHitY: null, }; - setDragOverlay({ cell: startCell, hitX: null, hitY: null }); + setDragOverlay({ + cell: dragRef.current.bbox0, + hitX: null, + hitY: null, + }); e.currentTarget.setPointerCapture(e.pointerId); }, - [preview, resolution, getStartCell], + [preview, resolution, getStartCell, initDragDerived], + ); + + const onPointerDownArea = useCallback( + ( + e: ReactPointerEvent, + refId: AreaRef, + mode: Mode, + ) => { + if (mode.type === 'resize') { + // ハンドルは単独選択のときしか表示されないが、防御的に単独化しておく + if (refId.kind !== 'layer') selectAreas([refId]); + startDrag(e, [refId], mode); + return; + } + const key = areaRefKey(refId); + if (e.ctrlKey || e.metaKey) { + // トグルのみ(このジェスチャではドラッグを開始しない)。 + // Shift は「ドラッグ中のスナップ無効」に割当済みなのでトグルには使わない。 + // Shift+pointerdown をトグルにすると、ヒントに書かれた + // 「Shift を押しながらドラッグ=スナップなし移動」が開始できなくなる。 + if (refId.kind !== 'layer') { + selectAreas( + selectedKeys.has(key) + ? areaTargets(selectedRefs).filter( + (r) => areaRefKey(r) !== key, + ) + : [...areaTargets(selectedRefs), refId], + ); + } + return; + } + if (selectedKeys.has(key)) { + // 選択済みメンバーの通常ドラッグ → 選択全体をまとめて移動。 + // 閾値を超えず離された「クリック」なら pressedRef の単独選択へ縮退する。 + startDrag(e, selectedRefs, mode, refId); + } else { + if (refId.kind !== 'layer') selectAreas([refId]); + startDrag(e, [refId], mode); + } + }, + [selectedKeys, selectedRefs, selectAreas, startDrag], ); const onPointerDownLayer = useCallback( @@ -592,9 +792,10 @@ export function WallPreview() { layerId: string, mode: Mode, ) => { - onPointerDownArea(e, { kind: 'layer', layerId }, mode); + // レイヤは複数選択の対象外(常に単独ドラッグ) + startDrag(e, [{ kind: 'layer', layerId }], mode); }, - [onPointerDownArea], + [startDrag], ); const onPointerMove = useCallback( @@ -602,67 +803,165 @@ export function WallPreview() { const drag = dragRef.current; if (!drag) return; if (!drag.startCellRefreshed) { - // DragState.startCellRefreshed のコメント参照(blur コミット後の値で基準を取り直す) + // DragState.startCellRefreshed のコメント参照(blur コミット後の値で基準を取り直す)。 + // blur コミットはどのエリアの値も変えうるので、派生値(bbox0/候補)も作り直す。 drag.startCellRefreshed = true; - const fresh = getStartCell(drag.ref); - if (fresh) drag.startCell = fresh; + drag.items = drag.items.flatMap((item) => { + const fresh = getStartCell(item.ref); + return fresh ? [{ ref: item.ref, startCell: fresh }] : []; + }); + if (drag.items.length === 0) { + dragRef.current = null; + setDragOverlay(null); + return; + } + Object.assign(drag, initDragDerived(drag.items)); } - const dxReal = (e.clientX - drag.startClientX) * drag.pxToRealX; - const dyReal = (e.clientY - drag.startClientY) * drag.pxToRealY; - let nextCell: AreaCell; - if (drag.mode.type === 'move') { - nextCell = { - ...drag.startCell, - x: drag.startCell.x + dxReal, - y: drag.startCell.y + dyReal, - }; - } else { - nextCell = applyResize(drag.startCell, drag.mode.handle, dxReal, dyReal); + const dxCss = e.clientX - drag.startClientX; + const dyCss = e.clientY - drag.startClientY; + const dxReal = dxCss * drag.pxToRealX; + const dyReal = dyCss * drag.pxToRealY; + const thX = SNAP_PX_PREVIEW * drag.pxToRealX; + const thY = SNAP_PX_PREVIEW * drag.pxToRealY; + + if (drag.mode.type === 'resize') { + // リサイズは常に単独(ハンドルは単独選択時のみ表示) + const item = drag.items[0]; + let nextCell = applyResize( + item.startCell, + drag.mode.handle, + dxReal, + dyReal, + ); + let hitX: number | null = null; + let hitY: number | null = null; + if (!e.shiftKey) { + const result = snapResize( + nextCell, + drag.mode.handle, + drag.cand, + thX, + thY, + ); + nextCell = result.cell; + hitX = result.hitX; + hitY = result.hitY; + } + const nextFloored = floorCell(nextCell); + if (!rememberDragResult(drag, nextFloored, hitX, hitY)) return; + dispatchCell(item.ref, nextFloored); + setDragOverlay({ cell: nextFloored, hitX, hitY }); + return; } + // ---- move(1..N 件) ---- + // スナップ・指示線・オーバーレイは選択全体の外接矩形(bbox0)で判定する。 + // 開始セルは整数(store が floor 済み)なので bbox0 も整数。移動量を + // 「floor した外接矩形の変位」として整数化してから各エリアへ同量だけ足すことで、 + // グループ内の相対位置が 1px も崩れない剛体移動になる。 + // 複数選択では閾値を超えるまで store に反映しない。超えずに離されたら + // 「クリック」として onPointerUp が pressedRef の単独選択へ縮退させる。 + if ( + drag.items.length > 1 && + !drag.moved && + Math.abs(dxCss) < GROUP_DRAG_START_THRESHOLD_CSS && + Math.abs(dyCss) < GROUP_DRAG_START_THRESHOLD_CSS + ) { + return; + } + drag.moved = true; + + const bbox0 = drag.bbox0; + let movedBox: AreaCell = { + ...bbox0, + x: bbox0.x + dxReal, + y: bbox0.y + dyReal, + }; let hitX: number | null = null; let hitY: number | null = null; if (!e.shiftKey) { - const cand = buildSnapCandidates(drag.ref); - const thX = SNAP_PX_PREVIEW * drag.pxToRealX; - const thY = SNAP_PX_PREVIEW * drag.pxToRealY; - const result = - drag.mode.type === 'move' - ? snapMove(nextCell, cand, thX, thY) - : snapResize(nextCell, drag.mode.handle, cand, thX, thY); - nextCell = result.cell; + const result = snapMove(movedBox, drag.cand, thX, thY); + movedBox = result.cell; hitX = result.hitX; hitY = result.hitY; } - // 整数化してから前回と突き合わせる。同値なら store 更新もオーバーレイ更新も不要 - // (store 側は floorArea するので、floor 後が同じなら state は 1bit も変わらない)。 - const nextFloored = floorCell(nextCell); - if ( - sameCell(drag.lastCell, nextFloored) && - drag.lastHitX === hitX && - drag.lastHitY === hitY - ) { - return; - } - drag.lastCell = nextFloored; - drag.lastHitX = hitX; - drag.lastHitY = hitY; + // 移動量の整数化。さらに全メンバーが座標クランプ(±MAX_DIMENSION)の内側に + // 収まるよう移動量自体を制限する。制限せずに store 側で各メンバーが独立に + // クランプされると、境界に触れたメンバーだけ止まりグループが変形してしまう。 + const flooredBox = floorCell(movedBox); + const rawDx = flooredBox.x - bbox0.x; + const rawDy = flooredBox.y - bbox0.y; + const intDx = Math.max( + MIN_COORDINATE - bbox0.x, + Math.min(rawDx, MAX_DIMENSION - drag.maxStartX), + ); + const intDy = Math.max( + MIN_COORDINATE - bbox0.y, + Math.min(rawDy, MAX_DIMENSION - drag.maxStartY), + ); + // クランプで移動量が変わった軸は、スナップ位置に居ないので指示線を消す + if (intDx !== rawDx) hitX = null; + if (intDy !== rawDy) hitY = null; - dispatchCell(drag.ref, nextFloored); - setDragOverlay({ cell: nextFloored, hitX, hitY }); + // オーバーレイと dedupe は bbox0 の寸法をそのまま使う(floorCell の width + // クランプ(上限 MAX_DIMENSION)で、広い選択の寸法表示が誤らないように)。 + const finalBox: AreaCell = { + x: bbox0.x + intDx, + y: bbox0.y + intDy, + width: bbox0.width, + height: bbox0.height, + }; + if (!rememberDragResult(drag, finalBox, hitX, hitY)) return; + + // layer は選択に入らないので単独ドラッグでしか来ない。それ以外は + // 件数に関わらず moveAreas(1 回の set())でまとめて反映する。 + const soloLayer = + drag.items.length === 1 && drag.items[0].ref.kind === 'layer' + ? drag.items[0] + : null; + if (soloLayer) { + dispatchCell(soloLayer.ref, { + ...soloLayer.startCell, + x: soloLayer.startCell.x + intDx, + y: soloLayer.startCell.y + intDy, + }); + } else { + moveAreas( + drag.items.flatMap((item): AreaMove[] => + item.ref.kind === 'layer' + ? [] + : [ + { + ...item.ref, + x: item.startCell.x + intDx, + y: item.startCell.y + intDy, + }, + ], + ), + ); + } + setDragOverlay({ cell: finalBox, hitX, hitY }); }, - [buildSnapCandidates, dispatchCell, getStartCell], + [dispatchCell, moveAreas, getStartCell, initDragDerived], ); const onPointerUp = useCallback((e: ReactPointerEvent) => { - if (!dragRef.current) return; + const drag = dragRef.current; + if (!drag) return; + // 複数選択のメンバーを「クリック」(閾値未満で離した)→ そのエリアの単独選択へ。 + // 旧実装の「pointerdown したエリアが必ず単独選択になる」を、グループ移動と + // 両立する形(move が始まらなかったときだけ)で復元する。 + // pressedRef は複数選択の move でしか渡されないので、mode/件数の再検査は不要 + if (drag.pressedRef && !drag.moved && drag.pressedRef.kind !== 'layer') { + selectAreas([drag.pressedRef]); + } dragRef.current = null; setDragOverlay(null); if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId); } - }, []); + }, [selectAreas]); // ---- 描画用補助 ---- @@ -670,6 +969,8 @@ export function WallPreview() { .map((p, index) => ({ p, index })) .filter(({ p }) => p.show); + const soleSelection = selectedRefs.length === 1; + // ドラッグオーバーレイ位置(プレビュー px) const overlayPv = useMemo(() => { if (!dragOverlay) return null; @@ -698,7 +999,11 @@ export function WallPreview() { onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onPointerDown={(e) => { - if (e.target === e.currentTarget) setSelected({ kind: 'main' }); + // 空クリックで main の単独選択に戻す(Ctrl/⌘ 中はトグル操作の + // 空振りとして選択を維持する。Shift はスナップ無効用なので対象外) + if (e.target === e.currentTarget && !e.ctrlKey && !e.metaKey) { + selectAreas(DEFAULT_AREA_SELECTION); + } }} > {layout.locked.show && ( )} - {visiblePreparing.map(({ p, index }) => ( - - ))} + {visiblePreparing.map(({ p, index }) => { + const ref: AreaRef = { kind: 'preparing', index }; + return ( + + ); + })} {/* スナップヒット線 */} {hitLineX !== null && ( diff --git a/src/components/ui/ExternalLink.tsx b/src/components/ui/ExternalLink.tsx new file mode 100644 index 0000000..6953508 --- /dev/null +++ b/src/components/ui/ExternalLink.tsx @@ -0,0 +1,37 @@ +/** + * ExternalLink — 外部 URL を開くリンク。 + * + * Tauri webview 内の `` は環境依存で無反応になるため、 + * クリックは adapter の `openExternalUrl` に委ねる(Desktop = opener プラグインで + * 既定ブラウザ、Web = 通常の新規タブ)。href / target / rel は、ハンドラが + * 実行されない状況(JS エラー等)でのフォールバックとして残す。 + */ + +import { openExternalUrl } from '../../adapters'; + +export interface ExternalLinkProps { + href: string; + // Trans に渡すときは children が空のまま使うため optional。 + children?: React.ReactNode; + /** 省略時は本文中リンクの既定スタイル。 */ + className?: string; +} + +export function ExternalLink({ href, children, className }: ExternalLinkProps) { + return ( + { + e.preventDefault(); + void openExternalUrl(href).catch((err: unknown) => { + console.warn('open external url failed', err); + }); + }} + > + {children} + + ); +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 96c30d9..2ea456c 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -16,3 +16,4 @@ export { toast, ToastRoot } from './Toast'; export { Modal, type ModalProps } from './Modal'; export { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog'; export { InfoTooltip, type InfoTooltipProps } from './InfoTooltip'; +export { ExternalLink, type ExternalLinkProps } from './ExternalLink'; diff --git a/src/core/coords.ts b/src/core/coords.ts index e61db29..a885674 100644 --- a/src/core/coords.ts +++ b/src/core/coords.ts @@ -93,6 +93,26 @@ export function floorInt(n: number): number { return toSafeInt(n, 0, MIN_COORDINATE, MAX_DIMENSION); } +/** + * セル群の外接矩形。プレビューの複数選択ドラッグでは、スナップ・指示線・ + * オーバーレイ表示を個々のエリアではなく**選択全体**のこの矩形に対して行う。 + * 呼び出し側が 1 件以上を保証する。入力が整数(store は floorArea/floorCell 済み) + * なら結果も整数。クランプは行わない(移動量の導出に使うため、値を歪めない)。 + */ +export function boundingBox(cells: readonly AreaCell[]): AreaCell { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const c of cells) { + minX = Math.min(minX, c.x); + minY = Math.min(minY, c.y); + maxX = Math.max(maxX, c.x + c.width); + maxY = Math.max(maxY, c.y + c.height); + } + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} + export function floorCell(cell: T): T { return { ...cell, diff --git a/src/core/format.ts b/src/core/format.ts new file mode 100644 index 0000000..16a9adf --- /dev/null +++ b/src/core/format.ts @@ -0,0 +1,12 @@ +/** + * 表示用フォーマッタ。UI がバイト数などを人間向けに整形するときはここを通す + * (各コンポーネントで `(n / 1024).toFixed(1)` を撒かない)。 + */ + +/** バイト数 → "12.3 KB" / "23.4 MB"(1 桁小数、1MB 以上は MB)。 */ +export function formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024) { + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; + } + return `${(bytes / 1024).toFixed(1)} KB`; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 699702f..d89a1fd 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -11,9 +11,20 @@ "lock": "Lock", "sound": "Sound" }, + "update": { + "available": "A new version v{{version}} is available (current v{{current}})", + "download": "Download {{name}}", + "downloading": "Downloading…", + "downloadingPercent": "Downloading… {{percent}}%", + "savedTo": "Saved to {{path}}", + "downloaded": "Update file saved: {{path}} (run or replace the saved file to apply)", + "downloadFailed": "Failed to download the update: {{error}}", + "showInFolder": "Show in folder", + "releasePage": "Release page" + }, "preview": { "title": "Preview", - "hint": "Click an area to select. Drag to move; use corner / edge handles to resize. All values are stored as integers.", + "hint": "Click an area to select; Ctrl/⌘+click for multi-select. Drag to move (a multi-selection moves together, snapping against the selection's bounding box); use corner / edge handles to resize. All values are stored as integers.", "scale": "Real {{realWidth}}×{{realHeight}} / preview {{previewWidth}}×{{previewHeight}}", "snapHint": "Hold Shift to disable snapping" }, diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 58abefe..415e75a 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -11,9 +11,20 @@ "lock": "Lock", "sound": "サウンド" }, + "update": { + "available": "新しいバージョン v{{version}} が公開されています(現在 v{{current}})", + "download": "{{name}} をダウンロード", + "downloading": "ダウンロード中…", + "downloadingPercent": "ダウンロード中… {{percent}}%", + "savedTo": "{{path}} に保存しました", + "downloaded": "更新ファイルを保存しました: {{path}}(適用するには保存したファイルを実行/差し替えてください)", + "downloadFailed": "更新のダウンロードに失敗しました: {{error}}", + "showInFolder": "フォルダで表示", + "releasePage": "リリースページ" + }, "preview": { "title": "プレビュー", - "hint": "エリアをクリックで選択。ドラッグで移動、角・辺のハンドルでリサイズできます。操作値はすべて整数で記録されます。", + "hint": "エリアをクリックで選択、Ctrl/⌘+クリックで複数選択。ドラッグで移動(複数選択時はまとめて移動し、スナップは選択全体の外接矩形で判定)、角・辺のハンドルでリサイズできます。操作値はすべて整数で記録されます。", "scale": "実解像度 {{realWidth}}×{{realHeight}} / プレビュー {{previewWidth}}×{{previewHeight}}", "snapHint": "Shift でスナップ無効" }, diff --git a/src/store/useWallStore.ts b/src/store/useWallStore.ts index abe8898..82fa056 100644 --- a/src/store/useWallStore.ts +++ b/src/store/useWallStore.ts @@ -70,10 +70,15 @@ export type ExtraTextureSlot = /** * UI state — 永続化対象外(partialize で除外)。 - * 現状は背景レイヤの選択 ID のみ。後続 Phase でツール選択・モーダル状態などを追加可能。 */ export interface UIState { selectedBackgroundLayerId: string | null; + /** + * プレビューのエリア選択(1 件以上)。preparing は index 参照なので、 + * `removePreparing` が削除と同じ set() 内で index を詰め直す + * (`removeBackgroundLayer` が selectedBackgroundLayerId を直すのと同じ流儀)。 + */ + selectedAreas: readonly AreaTarget[]; } export interface WallStoreState { @@ -86,6 +91,7 @@ export interface WallStoreState { // --- UI --- selectBackgroundLayer: (id: string | null) => void; + selectAreas: (targets: readonly AreaTarget[]) => void; // --- 解像度 / 全体スケール --- setResolution: (r: Resolution) => void; @@ -103,6 +109,8 @@ export interface WallStoreState { addPreparing: (area?: VisibleArea) => void; removePreparing: (index: number) => void; updatePreparing: (index: number, patch: Partial) => void; + /** 複数エリアの位置を 1 回の set() でまとめて更新する(複数選択ドラッグ用)。 */ + moveAreas: (moves: AreaMove[]) => void; // --- background --- addBackgroundLayer: (layer: BackgroundLayer) => void; @@ -136,6 +144,26 @@ export interface WallStoreState { // ヘルパ // --------------------------------------------------------------------------- +/** + * レイアウト上のエリアの参照。プレビューの選択(`ui.selectedAreas`)と + * `moveAreas` が共有する語彙(preparing は配列 index で参照する。store・ + * LayoutEditor・AreaEditor が index 前提のため。安定 ID 化は将来課題)。 + */ +export type AreaTarget = + | { kind: 'main' } + | { kind: 'locked' } + | { kind: 'preparing'; index: number }; + +/** + * `moveAreas` の 1 件分。プレビューでの複数選択ドラッグが、選択中の全エリアを + * 1 回の set() でまとめて動かすために使う(エリアごとに setMain/setLocked/… を + * 呼ぶと 1 pointermove あたり N 回の再レンダリング+永続化が走ってしまう)。 + */ +export type AreaMove = AreaTarget & { x: number; y: number }; + +/** エリア選択の既定値(main の単独選択)。識別子を共有して identity 比較を安定させる。 */ +export const DEFAULT_AREA_SELECTION: readonly AreaTarget[] = [{ kind: 'main' }]; + /** * 座標 patch を Area に当てて正規化する。 * 整数化・非有限値の排除・最小サイズ 1・rows/columns >= 1 はすべて `floorArea`(coords.ts)が担保する。 @@ -198,12 +226,15 @@ export const useWallStore = create()( persist( (set) => ({ wall: createDefaultWallState(), - ui: { selectedBackgroundLayerId: null }, + ui: { selectedBackgroundLayerId: null, selectedAreas: DEFAULT_AREA_SELECTION }, reset: () => set({ wall: createDefaultWallState(), - ui: { selectedBackgroundLayerId: null }, + ui: { + selectedBackgroundLayerId: null, + selectedAreas: DEFAULT_AREA_SELECTION, + }, }), // import など外部由来の state も幾何の不変条件へ引き戻してから採用する。 @@ -212,6 +243,14 @@ export const useWallStore = create()( selectBackgroundLayer: (id) => set((s) => ({ ui: { ...s.ui, selectedBackgroundLayerId: id } })), + selectAreas: (targets) => + set((s) => ({ + ui: { + ...s.ui, + selectedAreas: targets.length > 0 ? targets : DEFAULT_AREA_SELECTION, + }, + })), + // 非有限値("1e999" → Infinity)が入ると sx/sy が Infinity になり layout 全体が壊れる。 // toSafeInt で 1..MAX_DIMENSION の整数に落としてからスケールする。 setResolution: (r) => @@ -294,15 +333,35 @@ export const useWallStore = create()( })), removePreparing: (index) => - set((s) => ({ - wall: { - ...s.wall, - layout: { - ...s.wall.layout, - preparing: s.wall.layout.preparing.filter((_, i) => i !== index), + set((s) => { + // 選択は index 参照なので、削除された番号を落とし、後続の番号を詰める。 + // これを削除と同じ set() でやらないと、選択が黙って別のエリアを指す。 + const selectedAreas = s.ui.selectedAreas.flatMap( + (t): AreaTarget[] => { + if (t.kind !== 'preparing') return [t]; + if (t.index === index) return []; + return t.index > index ? [{ ...t, index: t.index - 1 }] : [t]; }, - }, - })), + ); + return { + wall: { + ...s.wall, + layout: { + ...s.wall.layout, + preparing: s.wall.layout.preparing.filter( + (_, i) => i !== index, + ), + }, + }, + ui: { + ...s.ui, + selectedAreas: + selectedAreas.length > 0 + ? selectedAreas + : DEFAULT_AREA_SELECTION, + }, + }; + }), updatePreparing: (index, patch) => set((s) => ({ @@ -317,6 +376,37 @@ export const useWallStore = create()( }, })), + moveAreas: (moves) => + set((s) => { + let main = s.wall.layout.main; + let locked = s.wall.layout.locked; + let preparing: VisibleArea[] | null = null; + for (const m of moves) { + if (m.kind === 'main') { + main = mergeAreaPatch(main, { x: m.x, y: m.y }); + } else if (m.kind === 'locked') { + locked = mergeAreaPatch(locked, { x: m.x, y: m.y }); + } else { + preparing ??= [...s.wall.layout.preparing]; + const cur = preparing[m.index]; + if (cur) { + preparing[m.index] = mergeAreaPatch(cur, { x: m.x, y: m.y }); + } + } + } + return { + wall: { + ...s.wall, + layout: { + ...s.wall.layout, + main, + locked, + preparing: preparing ?? s.wall.layout.preparing, + }, + }, + }; + }), + addBackgroundLayer: (layer) => set((s) => ({ wall: {