diff --git a/.env.example b/.env.example index c7f98a49..bd7ec4cc 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,11 @@ VITE_GOOGLE_CLIENT_ID= SPIN_DESKTOP_GOOGLE_CLIENT_ID= SPIN_DESKTOP_GOOGLE_CLIENT_SECRET= +# 데스크톱 앱이 공유 링크를 만들 때 쓰는 웹앱 공개 출처. 데스크톱은 출처가 tauri://localhost 라 +# 상대 경로 /api/share 가 닿지 않고 링크도 열 수 없으므로 여기 적은 도메인의 /api/share 를 부르고 +# 링크도 이 도메인으로 만듭니다. 비우면 데스크톱에서 링크 공유가 되지 않습니다(다른 기능은 무관). +SPIN_DESKTOP_WEB_ORIGIN= + # ── 선택 ────────────────────────────────────────────────────────────────────────── # 공유 링크 백엔드 주소. 비우면 같은 출처의 /api/share 를 씁니다(운영 구성). #VITE_SHARE_API_BASE= diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index af66d6e4..bdcca108 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -8,11 +8,48 @@ The format is based on [Keep a Changelog], and this project follows [Semantic Ve ### Added -- Added a desktop app (Linux, Windows, macOS) with Google Drive sync (sign-in opens in the default browser) +- Desktop app (Linux, Windows, macOS) with Google Drive sync (sign-in opens in the default browser) + +### Changed + +- Desktop save dialog starts in the home folder + +### Fixed + +- Desktop app not saving files at all +- Desktop app failing on [Share as link] +- Select boxes on a white background in the desktop app's dark theme +- Loading-screen spin kick rotating around the wrong point in the desktop app + +## [0.6.8] 2026-09-15 + +### Added + +- Four shape colors (white, sky, yellow, orange) — tap the rotation handle to cycle; translucent, so overlaps mix +- Keyboard control for shapes, lines and freehand strokes — WASD and arrow keys to move, Q and E to rotate (Shift for 15°) +- Move anchor above a selected shape, note, line, stroke or group — drag it to move objects that overlap (dashed rectangle marks the extent of a group or a single line or stroke) +- Multi-delete in the drill and session lists — [Select] to pick several at once, [Undo] restores all of them +- Save dialog for choosing where a file goes, plus a toast when the write finishes +- MP4 export on devices without a built-in codec (software encoder fallback) +- [Create video] button in video export + +### Changed + +- Faster first start — no more waiting on a blank screen, and less stutter while loading +- Denser shape fill and a black casing under the outline — clearer edges on the court +- Saving a video closes the export sheet (cancelling leaves it open) + +### Removed + +- [Video] and [Share as link] from the free tactics board's export sheet — use [Edit as drill] first ### Fixed -- Fixed the loading-screen spin kick in the desktop app rotating around the wrong point instead of the head (the WebKit webview reads SVG pivot px values differently from Chrome) +- Tapping a shape's rotation handle not changing its color +- [Undo] on a delete being reversed again on another device +- A failed [Undo] giving no message +- Select all (Ctrl+A) leaving out freehand strokes +- Cancelled saves still reported as done ## [0.6.7] 2026-09-09 diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index dfdcabeb..4e76507a 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -8,11 +8,48 @@ ### 追加 -- デスクトップアプリ(Linux・Windows・macOS)を追加、Google ドライブ同期に対応(ログインは既定のブラウザで実施) +- デスクトップアプリ(Linux・Windows・macOS)と Google ドライブ同期に対応(ログインは既定のブラウザ) + +### 変更 + +- デスクトップ版の保存ダイアログの初期位置をホームフォルダに変更 + +### 修正 + +- デスクトップ版でファイルが保存できない問題を修正 +- デスクトップ版の[リンクで共有]が失敗する問題を修正 +- デスクトップ版のダークテーマで select が白背景になる問題を修正 +- デスクトップ版のローディング画面でスピンキックの回転軸がずれる問題を修正 + +## [0.6.8] 2026-09-15 + +### 追加 + +- 図形の色4種(白・空色・黄・オレンジ)に対応 — 回転ハンドルを軽く押すと順に切り替え、半透明で重ねると混色 +- 図形・線・自由線のキーボード操作に対応 — WASD・矢印キーで移動、Q・E で回転(Shift は15°) +- 移動アンカーを追加 — 図形・メモ・線・自由線と複数選択の上に表示、重なっていてもドラッグで移動(複数または線・自由線ひとつのときは点線の四角で範囲表示) +- ドリル・セッション一覧の複数削除 — [選択]で一括選択、[元に戻す]ですべて復元 +- 保存先を選ぶ保存ダイアログと保存完了の通知を追加 +- 内蔵コーデックのない端末での MP4 書き出しに対応(ソフトウェアエンコーダに切り替え) +- 動画書き出しに[動画を作る]ボタンを追加 + +### 変更 + +- 初回起動が速くなりました — 白い画面で待つ時間がなくなり、読み込み中の引っかかりも軽減 +- 図形の面を濃く、輪郭の下に黒いケーシングを追加 — コート上での輪郭の見分けを改善 +- 動画を保存するとエクスポートシートを閉じるよう変更(取り消し時は開いたまま) + +### 削除 + +- 自由戦術板の[書き出し]から[動画]・[リンクで共有]を削除 — [ドリルとして編集]の後に使用 ### 修正 -- デスクトップ版でローディング画面のスピンキックが頭ではない場所を軸に回っていた問題を修正(WebKit ウェブビューは SVG の回転軸 px 値を Chrome と異なる解釈をする) +- 図形の回転ハンドルを押しても色が変わらない問題を修正 +- 削除の[元に戻す]が他の端末で再び削除される問題を修正 +- [元に戻す]が失敗しても何も表示されない問題を修正 +- すべて選択(Ctrl+A)から自由線が抜ける問題を修正 +- 保存を取り消しても完了として通知される問題を修正 ## [0.6.7] 2026-09-09 diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f327b6..6b2fe01d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,46 @@ - 데스크톱 앱(리눅스·윈도우·맥) 출시, 구글 드라이브 동기화 지원(로그인은 기본 브라우저에서 진행) +### 변경됨 + +- 데스크톱 앱 저장 대화상자 시작 위치를 홈 폴더로 변경 + +### 고쳐짐 + +- 데스크톱 앱에서 파일 저장이 되지 않던 것 수정 +- 데스크톱 앱의 [링크로 공유] 가 실패하던 것 수정 +- 데스크톱 앱 다크 테마에서 select 상자가 흰 바탕으로 나오던 것 수정 +- 데스크톱 앱 로딩 화면의 회전킥 회전축이 어긋나던 것 수정 + +## [0.6.8] 2026-09-15 + +### 추가됨 + +- 도형 색 넷(흰·하늘·노랑·주황) 지원 — 회전 손잡이를 끌지 않고 누르면 순환, 반투명이라 겹치면 섞임 +- 도형·선·자유선 키보드 조작 지원 — WASD·방향키로 이동, Q·E 로 회전(Shift 는 15°) +- 이동 앵커 추가 — 도형·메모·선·자유선과 여럿 선택 위에 떠서, 개체가 겹쳐도 끌어 옮기기(여럿이거나 선·자유선 하나면 점선 사각형으로 범위 표시) +- 드릴·세션 목록 다중 삭제 — [선택] 으로 여럿을 골라 한 번에 삭제, [되돌리기] 는 전부 복원 +- 저장 위치를 고르는 저장 대화상자와 저장 완료 알림 추가 +- 내장 코덱이 없는 기기의 MP4 내보내기 지원(소프트웨어 인코더로 전환) +- 영상 내보내기에 [영상 만들기] 버튼 추가 + +### 변경됨 + +- 첫 실행이 빨라짐 — 빈 화면으로 기다리던 구간이 사라지고 로딩 중 끊김이 줄어듦 +- 도형 면을 더 진하게, 테두리 밑에 검정 케이싱 적용 — 코트 위에서 경계 식별 개선 +- 영상을 저장하면 내보내기 시트가 닫히도록 변경(저장을 물리면 열린 채 유지) + +### 제거됨 + +- 자유 전술판 [내보내기] 에서 [영상]·[링크로 공유] 제거 — [드릴로 편집] 뒤에 사용 + ### 고쳐짐 -- 데스크톱 앱에서 로딩 화면의 회전킥이 머리가 아닌 곳을 축으로 돌던 것 수정(WebKit 웹뷰가 SVG 회전축 px 값을 크롬과 다르게 읽는다) +- 도형의 회전 손잡이를 눌러도 색이 바뀌지 않던 것 수정 +- 삭제를 [되돌리기] 해도 다른 기기에서 다시 지워지던 것 수정 +- [되돌리기] 가 실패해도 아무 안내가 없던 것 수정 +- 전부 선택(Ctrl+A)에서 자유선이 빠지던 것 수정 +- 저장을 물려도 완료로 알리던 것 수정 ## [0.6.7] 2026-09-09 diff --git a/ROADMAP.md b/ROADMAP.md index 61ab4de3..4728dc9e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ > 겪은 낭비입니다. **갱신**: 릴리스마다. 버전을 올릴 때 그 절을 닫고 다음 절을 엽니다. -**현재 버전**: 0.6.7 (2026-09-09) — [팀] 메뉴 신설(여러 팀의 명단·스태프·라인업, 팔레트·홈·어웨이·중립 색 세트, 파일·드라이브·인쇄), 목록의 [링크로 가져오기], 개인정보처리방침 노출 정비를 담은 회차입니다. +**현재 버전**: 0.6.8 (2026-09-15) — 도형에 색 넷과 검정 케이싱, 도형·선·자유선의 키보드 조작(WASD·Q·E)과 이동 앵커, 드릴·세션 목록의 다중 삭제, 저장 대화상자·완료 알림, 첫 실행 속도(빈 화면 구간 제거)를 담은 회차입니다. 데스크톱 앱은 아직 [Unreleased] 에 남아 있습니다. 0.4.0 은 따로 태그하지 않았습니다(실기 검증 전 devel 에서 0.5 작업이 바로 이어졌습니다). ⚠️ **번호와 절이 어긋나 있습니다.** 아래 「0.6 — Tauri 데스크톱」 절은 2026-09-05 병합 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 12716a27..855bbfef 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -59,6 +59,37 @@ | §8 | 소유권 표의 모듈 15종 | 화면 모듈이 **개명·분화**했다(아래 §8 각주) | `src/features/` | | §3.5 | 렌더 레이어 순서 **고정, per-object z 없음** · `CURRENT_DRILL_SCHEMA = 8` | 스텝이 쥔 `zOrder` 목록을 `sceneOrder` 하나가 푼다(기본층 7종, 도형 포함) · 도장 **11**. 2026-09-06, `docs/PLAN-Z-ORDER.md` | `src/model/zOrder.ts`, `src/model/drill.ts` | | §5.12 | 히트 우선순위 6단(**개체 종류가 서열**) · "가까운 쪽이 이긴다" | 4단 — 앵커 → 몸통을 `sceneOrder` **위→아래** → 존 핸들 → 휠체어 패드. 종류 서열 폐기. 2026-09-06 | `physics/hitTest.ts` | + +> **§6.10d 이동 앵커(2026-09-13, 기현님 지시).** 고른 것을 통째로 옮기는 손잡이 하나가 개체 +> 상자의 **화면 기준** 중앙 위에 뜬다(위가 모자라면 중앙 아래). 뜨는 자리는 넷 — 도형 하나· +> 메모 하나·**선 하나·자유선 하나**·여럿(⚠️ 2026-09-14 기현님 지시로 선·자유선이 더해졌다. +> 옛 근거 *"몸통 어디를 잡아도 끌린다"* 는 그 선이 **맨 위에 있을 때만** 참이었다). 휠체어·공·콘 **하나**에는 뜨지 않는다: 그 셋은 몸통을 잡으면 물리 드래그 +> (견인·회전·충돌·리시)가 열리는데 앵커는 모델을 직접 미는 길이라 같은 손짓이 다른 이동이 된다. +> +> 앵커는 **히트테스트를 타지 않는다**(위 우선순위 표 밖이다). 밑에 무엇이 깔렸든 상관없다는 +> 것이 겹침을 피하려고 만든 이유 자체이기 때문이다. 누름은 `PointerMeta.moveAnchor` 한 칸으로 +> 컨트롤러에 전해지고, 옮기는 일은 §6.10b 덩어리 이동(`GROUP_NUDGE`)이 그대로 한다 — +> 새 이동 회로를 만들지 않았다. 명단은 `moveAnchorIds` 하나에서 오고(그리는 쪽·미는 쪽 공용), +> 자리는 `moveAnchorPlacement` 하나에서 온다. **기존 이동 방법은 하나도 줄지 않는다.** +> +> **§6.10e 비키기·가이드(2026-09-14, 기현님 실기 셋 중 둘).** +> ① *"원, 사각형은 회전 앵커와 겹친다."* — 회전 손잡이는 **월드 고정**(중심에서 h/2+16 월드 px), +> 앵커는 **화면 고정**(상자 위 21 CSS px)이라 화면 거리가 `|21 − 15·pxPerUnit|` 이다. 배율 ≈1.4 +> 에서 정확히 0 이고 기본 배율이 그 근처였다 — 앵커가 위에 그려지므로 **회전 손잡이가 통째로 안 +> 눌렸다**(실측: `elementFromPoint` 가 앵커를 돌려줬다). 상수로는 못 고친다(월드 상수 하나로 모든 +> 배율의 화면 44px 을 살 수 없다). 그래서 앵커가 **가로로만** 비킨다 — +> `moveAnchorAvoidX`(순수)가 `x` **하나만** 돌려주는 것이 세로 규칙 불가침의 구조적 보증이다. +> 이격은 `2 × handleHitRadiusCssPx`(44) — 잡는 원 둘이 안 겹치는 하한이고, 33(=22+11)은 +> «보이는 점만 눌린다» 라 표적 하한(--hit 44)을 스스로 깨는 값이라 쓰지 않는다. +> 비킨 앵커의 꼭지는 상자에서 **가장 가까운 점**을 가리킨다(안 비키면 정확히 수직 = 픽셀 동일). +> ② *"그룹화 했을 때 가이드 사각형이 필요하다."* — `SelectionGuide` 가 앵커와 **같은 상자**를 +> 파선 + 모서리 갈고리로 그린다. 조건은 `moveAnchorGuide` 하나: **상자가 몸통과 다를 때만** +> (여럿 · 선 하나 · 자유선 하나). 도형 하나는 손잡이가, 메모 하나는 선택 링이 이미 상자를 말한다. +> +> 앞선 전제 두 가지를 먼저 고쳤다(`physics/bounds.ts`): 삼각형 상자는 `(x,y)` 를 중심으로 놓았는데 +> 그 점은 **무게중심**이라 꼭짓점을 h/6 놓쳤고, 화살표 상자는 **화살촉**을 안 담았다. 겹침 판정에만 +> 쓰일 때는 «메뉴가 한 칸 더 켜진다» 로 끝났지만, 이제 그 상자를 **그리므로** 개체를 자르고 지나간다. + | §7.6 | 화면 전환 시 `#main` 포커스 + 발표가 **전환 effect 안에서 즉시** | 로더가 뜰 수 있는 환경에서는 **로더가 걷히는 커밋**으로 미룬다(0ms 환경에서는 종전대로 즉시). 2026-09-04, 계약 전문 §6.13 | `src/app/AppShell.tsx`, `src/app/loader/appLoaderTiming.ts` | **여기 없는 것도 어긋나 있을 수 있다.** 이 문서에 적힌 `파일:행` 참조는 재편으로 파일이 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f5c3022b..012ce49d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -116,6 +116,22 @@ sudo apt install libwebkit2gtk-4.1-dev libxdo-dev libayatana-appindicator3-dev l 알아 둘 것: - 라우터가 이미 `createHashRouter` 라 네이티브 셸에서 그대로 돕니다([OVERVIEW.md §4 코드 지도](OVERVIEW.md) 참조). +- 공유 링크는 데스크톱에서 `SPIN_DESKTOP_WEB_ORIGIN`(예 `https://spin.atit.app`)이 있어야 됩니다. 앱 출처가 + `tauri://localhost` 라 상대 경로 `/api/share` 는 아무 데도 닿지 않고 `location.origin` 으로 만든 링크는 받는 + 쪽이 열 수 없기 때문입니다(2026-09-13 실기). 읽는 자리는 `src/share/api.ts` 의 `desktopWebOrigin()` 하나이고, + 같은 이유로 창 CSP 의 `connect-src` 에 `https:` 가 열려 있습니다(직접 호스팅하는 쪽은 자기 도메인을 넣으면 됩니다). +- **파일 저장은 네이티브 대화상자**를 씁니다(`src-tauri/src/save_file.rs` 의 `save_bytes_dialog`). + 웹은 `` 로 브라우저의 다운로드 기능을 빌리지만 데스크톱 웹뷰에는 빌릴 브라우저가 + 없어 눌러도 아무 일도 일어나지 않았습니다(2026-09-13 실기). 고르는 일과 쓰는 일을 러스트 안에서 + 붙여 두어 **웹뷰에는 파일 권한이 0** 입니다 — `tauri-plugin-fs` 를 쓰지 않는 이유가 그것입니다. + 바이트는 `invoke` 의 날바디로, 파일명은 `x-spin-filename` 헤더(퍼센트 인코딩)로 갑니다. + 대화상자는 **홈 폴더**에서 시작합니다(기현님 지시 2026-09-13) — 정해 주지 않으면 GTK 가 앱이 뜬 + 자리에서 시작해 제 폴더를 찾아 올라가야 합니다. +- 창 CSP 의 `script-src` 에 `'unsafe-eval'` 이 들어 있는 이유는 **영상 내보내기의 소프트웨어 인코더**입니다. + 내장 코덱(WebCodecs)이 없는 기계에서 쓰는 `h264-mp4-encoder` 는 emscripten embind 로 만들어져 바인딩마다 + `new Function` 을 부릅니다 — `'wasm-unsafe-eval'` 만으로는 스크립트가 평가 중에 터집니다(2026-09-13 실측). + 이 값을 지우면 그 기계에서 MP4 내보내기가 «영상을 만들지 못했습니다» 로 끝납니다. 웹 배포는 CSP 헤더를 + 두지 않아 해당 없습니다. - 창 CSP 는 `src-tauri/tauri.conf.json` 에 있습니다. `index.html` 의 FOUC 방지 부트 스크립트가 인라인이라 `script-src` 에 `'unsafe-inline'` 이 들어 있습니다 — 앱은 원격 문서를 열지 않고 렌더는 전부 React 라 유입 경로가 없지만, 부트 스크립트를 외부 파일로 diff --git a/docs/PLAN-DELETE-SAFETY.md b/docs/PLAN-DELETE-SAFETY.md index 93f3882f..d4044f9c 100644 --- a/docs/PLAN-DELETE-SAFETY.md +++ b/docs/PLAN-DELETE-SAFETY.md @@ -300,3 +300,27 @@ restoreDrill(d: Drill): Promise // put(문서) + delete(톰스톤) 원 **남긴 것**: 톰스톤(`storage/syncMeta.ts`)은 **동기화 전용**으로 그대로 둔다 — `deletedAt` 타임스탬프만 담고 문서 본문이 없으므로 애초에 복원용이 아니었다. §E 는 그 톰스톤이 되돌리기와 **충돌**하는 것을 고치는 일이지, 톰스톤을 휴지통으로 쓰자는 것이 아니다. + + +## ⚠️ 2026-09-14 — 목록 다중 삭제와 되돌리기의 구멍 하나 + +기현님 지시(*"드릴, 세션 목록에서 선택해서 지우는 동작도 구현"*)로 §C-1 의 «선택 모드» 규약이 +편집기 사이드바 밖으로 나왔다. 목록에서 달라진 것 하나: **진짜 ``** 를 쓴다. +사이드바가 `aria-pressed` 를 쓴 것은 폭 134px 에 형제를 낼 자리가 없어서였지 원리가 아니었고, +목록에는 그 제약이 없다 — 낭독기가 «확인란, 선택됨» 으로 읽고 Space 토글이 공짜로 따라온다. + +§C-2 의 처방 *"단일은 알려주고, 일괄은 묻는다"* 는 그대로다. 일괄 확인은 **제목 다섯 + «외 N개»** +를 보인다(숫자만 있는 확인은 검문이 아니고, 스무 줄은 아무도 안 읽는다). 드릴 쪽은 참조를 +**합집합 개수 둘**로 덧붙인다(«이 가운데 n개가 세션 m개에 쓰입니다»). + +**그리고 이 작업이 드러낸 것 — [되돌리기]는 2기기에서 거짓말이었다.** `restoreDrill`· +`restoreSession` 은 문서를 **옛 `updatedAt` 그대로** 되돌려 놓았다. 로컬 톰스톤은 같은 tx 에서 +지우므로(위 §E) 로컬만 보면 맞지만, 삭제가 이미 드라이브로 올라갔으면 원격에 `deletedAt` 이 +남아 있고 그 값이 옛 `updatedAt` 보다 크다 — `sync/plan.ts` 의 `localWins = L > R` 이 거짓이 되어 +다음 동기화가 되살린 문서를 **다시 지운다**. 동기화 디바운스 3초, 되돌리기 토스트 8초라 그 창은 +실제로 열려 있었다. 이제 되살리기가 `updatedAt` 을 새로 찍는다 — 되돌리기는 «옛 상태로» 이면서 +동시에 «지금 이 문서가 최신» 이라는 선언이다. + +여럿 삭제는 **한 트랜잭션**이다(`deleteDrills`/`deleteSessions`). 하나씩 N번 부르면 중간 실패가 +«절반만 지워진» 상태를 남기는데, 그 상태는 사람에게 설명할 길이 없다 — 원자성이 곧 설명 +가능성이다. 톰스톤의 `deletedAt` 은 묶음이 공유한다. diff --git a/docs/PLAN-SHARE-LINK.md b/docs/PLAN-SHARE-LINK.md index 975bd9dc..61660a88 100644 --- a/docs/PLAN-SHARE-LINK.md +++ b/docs/PLAN-SHARE-LINK.md @@ -31,6 +31,13 @@ | 9 | 가져오기: `/s/:id` 는 `parsePath` 가 라이브러리 화면 + 공유 시트로 푼다(새 화면 없음). fetch → 복호 → inflate → `parseSpinFile` → `migrateDoc` → `validateDrill` **필수** → 미리보기(제목·스텝 수·썸네일) → [내 목록에 저장]. 같은 id 가 있으면 **새 id 사본**(원본 안 덮음) | PLAN-URL-SHARE 결정 4·6·7. 삭제 안전망 결 | | 10 | 오류 문구 4종: 링크 없음/만료(404) · 열쇠가 맞지 않음(복호 실패, 링크가 잘린 경우) · 이 앱이 너무 오래됨(`too-new`) · 서버에 못 닿음 | 각각 사용자가 할 일이 다르다 | | 11 | 만드는 자리 둘: [보드] 하단 [내보내기] 시트 4번째 [링크] · 라이브러리 카드 ⋯ [링크로 공유]. 결과 모달: 링크 · [복사] · "링크를 가진 사람만 열 수 있고 서버는 암호문만 보관, 180일 뒤 만료" 한 줄. deleteToken 은 `localStorage spin.shareLinks` 에 저장(회수 UI 는 후속) | 이미 있는 내보내기 입구(PLAN-2026-08 §6) | + +⚠️ **2026-09-13 — 만드는 자리는 이제 하나다(기현님 지시, 근거는 지우지 않는다).** *"이미 있는 +내보내기 입구"* 라는 이유로 [보드] 시트에 링크를 얹었는데, 그 자리에 있는 것은 **드릴이 아니라 +1스텝짜리 전술판 스냅샷**이다(storage/board.ts 머리말). 링크를 받은 쪽은 그것을 제 라이브러리에 +**드릴로** 앉히게 되므로, 저장한 적도 없는 판이 남의 목록에 드릴로 생긴다. 같은 이유로 [영상]도 +전술판에서 뺐다 — 정지 화면 한 장이 30fps 로 흐르는 파일이다. 남는 입구는 라이브러리 카드의 +[링크로 공유] 하나이고, 전술판을 넘기고 싶으면 [+ 드릴로 편집] 로 드릴이 된 뒤에 넘긴다. | 12 | 서버 배포: `deploy/share/spin-share.service`(systemd, User=bitnami, `/opt/spin-share`) · `deploy/share/apache-share.conf`(ProxyPass 조각) · `scripts/deploy-share.sh`(rsync + 유닛 설치 + 재시작 + healthz). **실행은 기현님 승인 뒤** | 서버 설정 변경 | | 13 | 개인정보처리방침 3언어에 "공유 링크" 조 추가: 서버는 암호문·크기·시각·IP 속도 제한 기록만, 내용은 운영자도 못 봄, 180일 만료, 삭제 요청 | 법 문서가 사실이어야 한다 | | 14 | 한계를 문서에 적는다: 암호화하는 JS 를 서버가 내려주므로 배포본이 악의적이면 키를 빼돌릴 수 있다 — 소스 공개가 답 | 정직 | diff --git a/docs/PLAN-VIDEO-EXPORT.md b/docs/PLAN-VIDEO-EXPORT.md index eee0f5b3..2afe6817 100644 --- a/docs/PLAN-VIDEO-EXPORT.md +++ b/docs/PLAN-VIDEO-EXPORT.md @@ -36,6 +36,20 @@ |---|---|---| | 1 | **파이프라인**: `sampleDrill(t)` → `buildStaticScene(frame, opts, order)` → `svgDataUri` → `Image` → 캔버스 하나에 `drawImage` + `paintTexts` → `CanvasSource.add(t, 1/fps)`. 캔버스 1장 재사용, 순차(await), 워커 없음 | SVG 디코드는 DOM `Image` 가 필요해 워커에서 못 한다. PNG 경로의 "순차로 굽는다" 원칙(ExportSheet.tsx:201) 그대로 | | 2 | **인코딩**: WebCodecs + `mediabunny`(`Output` + `BufferTarget` + `Mp4OutputFormat({fastStart:'in-memory'})` + `CanvasSource(canvas, {codec:'avc', quality: new Quality({bitrate})})`). 코덱은 `avc` 하나. `canEncode('avc')` 가 false 이거나 `VideoEncoder` 가 없으면 항목을 **비활성 + 사유 문구**(i18n `export.video.unsupported`) | H.264 가 카톡·iOS·인스타의 공통분모. VP9-in-MP4 는 iOS 가 못 열어 대안이 못 된다. `fastStart` 는 모바일 스트리밍 재생용(moov 앞) | + +⚠️ **2026-09-13 — [영상] 은 자유 전술판 시트에 뜨지 않는다(기현님 지시).** 전술판은 1스텝짜리 +판이라(storage/board.ts) 굽는다 해도 정지 화면 한 장짜리 파일이 된다. 드릴 편집 시트에서만 뜬다. + +⚠️ **2026-09-13 — 결정 2 를 넓혔다(근거는 지우지 않는다).** "`canEncode('avc')` 가 false 면 비활성" 이라는 전제는 +*코덱이 없는 브라우저는 어차피 못 굽는다* 였는데, 실기에서 그 전제가 죽었다. WebKitGTK(리눅스 데스크톱 앱)는 +H.264 인코딩을 **시스템 GStreamer 플러그인**에 기댄다 — gofu 의 2.52.6 은 세 프레임을 정상으로 구웠지만(실측), +그 플러그인이 없는 기계에서는 같은 앱이 통째로 못 굽는다. 그래서 이제 엔진이 **둘**이다: 내장 코덱이 있으면 +WebCodecs(그대로), 없으면 wasm 소프트웨어 인코더(`h264-mp4-encoder` = minih264 + minimp4)로 내려간다. +고르는 규칙은 `videoEngine.ts` 의 `chooseVideoEngine` 하나이고, 비활성은 **둘 다 없을 때만**이다. +대가 셋: ①자산 1.7MB(쓰는 기기만 받는다 — 동적 import + `?url`), ②720p 25.7ms/프레임·1080p 55.6ms/프레임(실측, +내장 코덱보다 수십 배 느리다 — 시트가 `export.video.software` 로 미리 말한다), ③**CSP 에 `'unsafe-eval'` 이 +필요하다**(embind 가 바인딩마다 `new Function` 을 쓴다 — `'wasm-unsafe-eval'` 로는 안 되는 것을 실측했다). +③ 때문에 `src-tauri/tauri.conf.json` 의 `script-src` 가 넓어졌다. 웹은 CSP 헤더가 없어 그대로다. | 3 | `mediabunny` 는 **동적 `import()`** 로만 불러 별도 청크에 둔다. 정적 import 금지. 메인 청크 크기 변화 0 을 검수가 증명 | §0 둘째 뒤집기의 전제. 내보내기 안 하는 사용자는 1바이트도 안 받는다 | | 4 | **타이밍**: fps 30 고정. `baseMs = PLAYBACK.stepIntervalMs[1]`(1500), `transitionMs = PLAYBACK.transitionMsFor(baseMs)`(600), `loop:false`, `reduceMotion` 무시. 총 길이 `drillTotalMs`. 프레임 i 의 시각 `t_i = i·1000/fps`, 프레임 수 `N = ceil(total·fps/1000) + 1`(마지막 프레임 = t=total 의 정지 포즈), 각 프레임 길이 `1/fps` 초 | 배속·루프는 파일에 의미 없다. 마지막 스텝의 정지가 이미 `durationMs` 에 들어 있어 별도 꼬리 유지 안 둔다. 30fps 는 seamless 체인이 매끄러운 최저선 | | 5 | **크기**: 시트에서 `720p`(기본)·`1080p` 둘 중 하나. 정의는 **긴 변** 1280 / 1920. `StaticSceneOpts.resolution` 타입을 `1 \| 2` → `number`(긴 변 = 1024×resolution 배수, 주석 갱신) 로 넓혀 `1280/1024`·`1920/1024` 를 넣는다. 캔버스는 `metrics.widthPx/heightPx` 를 **짝수로 올림**, `drawImage` 는 metrics 크기 그대로, 남는 1px 줄은 배경색(캡션 띠/여백 색, `staticSceneLayout` 의 것)으로 채운다 | 코트가 가로형이라 긴 변 기준이 자연스럽다. 축척을 흔들어 짝수를 만드는 것보다 1px 여백이 정직하다. 옵션은 저장 안 한다(prefs 스키마 불변) — 매번 720p 로 시작 | @@ -43,6 +57,18 @@ | 7 | **캡션**: PNG 와 같은 옵션(제목·실명 roster 여부)을 시트의 기존 캡션 설정에서 그대로 읽되, **인코딩 시작 전에 한 번 확정**해 모든 프레임에 같은 `caption` 형태(→ 같은 `captionH`)를 넘긴다. 글은 도착 스텝 기준 "제목 · n/N · 스텝 이름" 으로 프레임마다 바뀐다 | 높이 고정이 곧 해상도 고정. 실명은 PNG §6 규칙과 동일(파일에 실린다는 안내 재사용) | | 8 | **범위**: 영상은 항상 **드릴 전체**. 시트의 범위 fieldset 은 영상 항목에 적용되지 않음을 문구로 밝힌다 | 스텝 일부만의 영상은 트윈 시작점이 애매하다. 필요해지면 그때 | | 9 | **진행·취소**: 시트 안 인라인 상태 3단 — 진행 중(`n/N` + 퍼센트 + [취소]) → 완료(`SPIN_{slug}_{YYYYMMDD}.mp4 · 12.3 MB` + [저장]) → 오류(문구 + [다시]). **저장은 완료 뒤 버튼 클릭에서** `downloadBlob` 호출 | iOS `navigator.share` 는 사용자 제스처 안에서만 되고, 인코딩 수 초 뒤엔 활성화가 끝나 있다. 클릭 → 저장이 유일하게 안전한 순서. 취소는 `AbortSignal` → 루프 중단 + `output.cancel()` | + +⚠️ **2026-09-13 — 결정 9 의 UI 를 셋 고쳤다(기현님 실기, 근거는 지우지 않는다).** +①**시작이 보이지 않았다.** 항목 머리(제목 줄) 자체가 버튼이라 "눌러야 시작된다" 는 것이 어디에도 +없었다. 머리는 글이 되고, 시작은 [영상 만들기] 한 개가 진다 — `aria-disabled` + 사유 문구를 지는 +자리도 그 버튼으로 옮겼다(못 하는 기기에서도 이 버튼만은 남는다). +②**[저장] 이 데스크톱에서 아무 일도 하지 않았다.** `downloadBlob` 은 `` 인데 Tauri +웹뷰에는 빌릴 브라우저가 없다. 이제 데스크톱이면 네이티브 저장 대화상자로 간다 +(`src-tauri/src/save_file.rs`, 자바스크립트에는 파일 권한을 주지 않는다). +③**저장 뒤 시트가 남았다.** *"시트는 닫지 않는다 — 공유 시트를 취소한 사람이 다시 누를 자리가 +있어야 한다"* 는 근거를 **좁힌다**: 그 걱정은 «취소» 에만 참이고, 그때는 지금도 열어 둔다. +저장이 끝났을 때만 닫는다(PNG·ZIP 이 이미 그렇게 한다). `downloadBlob` 이 `saved | cancelled | +started` 를 돌려주는 것이 이 판단의 유일한 근거다. | 10 | **API**(UI·엔진 병렬 구현의 계약): `src/features/export/video/encodeDrillVideo.ts` 가 `export async function encodeDrillVideo(drill: Drill, opts: VideoExportOpts, hooks: { onProgress?: (done: number, total: number) => void; signal?: AbortSignal }): Promise`; `VideoExportOpts = { size: 720 \| 1080; locale: Locale; caption: StaticSceneOpts['caption'] \| undefined /* roster 포함, 글은 엔진이 프레임마다 채움 */ }`; `VideoExportResult = { blob: Blob; bytes: number; frames: number; durationMs: number; width: number; height: number }`; `export async function isVideoExportSupported(): Promise`(VideoEncoder 존재 + `canEncode('avc')`); 취소 시 `DOMException('AbortError')` 로 reject. 파일명 `exportNames.ts` 의 `videoFileName(drill, date) → SPIN_{slug}_{YYYYMMDD}.mp4` | 두 구현자가 이 시그니처만 보고 각자 간다 | | 11 | **순수 로직 분리**(테스트 가능한 것): `video/videoTiming.ts` — `videoFrameTimes(totalMs, fps): number[]`, `videoBitrate(w,h,fps)`; `video/videoMetrics.ts` — `videoCanvasSize(metrics) → {w,h 짝수, padColor}`, `videoResolution(size) → number`. 인코더·캔버스는 테스트 안 붙인다(rasterize.ts 와 같은 선언) | AGENTS 테스트 규칙: jsdom 이 못 재는 것은 실기로. 단언은 돌연변이로 실효 확인 | | 12 | `rasterize.ts` 에 새 출구 `paintSceneToCanvas(scene: StaticScene, canvas, locale): Promise`(Image 로드 + drawImage + paintTexts, PNG 왕복 없음)를 만들고 `rasterizeFrameToPng` 가 그것을 쓰도록 접는다. 폰트 대기는 영상 루프 **앞에서 한 번** | 프레임마다 PNG 인코딩·폰트 대기를 하면 순수 낭비 | diff --git a/index.html b/index.html index 11768fe3..8a2abec4 100644 --- a/index.html +++ b/index.html @@ -108,6 +108,27 @@ 규칙 글은 감추지 않는다: 거기선 로더가 안 뜨고(prerenderLanding.ts) 이 글이 곧 내용이라 감추면 빈 화면 → 글이 된다. */ :root[data-js] .seo-prerender[data-seo-page='home'] { visibility: hidden; } + + /* 부트 마크 (2026-09-15) — 첫 픽셀까지의 백지를 없앤다. + ⚠️ 이 블록은 **레이아웃만** 진다. 움직임은 하나도 없다 — 아래 마크는 정지 자세이고, + 움직이는 로더는 React 의 `AppLoaderOverlay` 가 마운트되면서 이어받는다. + `--bg` 는 같은 순간에 오는 번들 스타일시트(tokens.css)가 준다. 그게 오기 전 몇 ms 를 + 위해 var() 안에 기본값을 둔다 — 두 값을 따로 선언하면 안 된다(var 가 무효일 때 + 앞 선언으로 안 돌아가고 transparent 가 된다). */ + #spin-boot { + position: fixed; + inset: 0; + /* AppLoaderOverlay 의 LOADER_Z 와 같은 값 */ + z-index: 220; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg, #0b0f14); + } + :root[data-theme='light'] #spin-boot { background: var(--bg, #ffffff); } + /* 크기는 appLoaderTiming.ts 의 MARK_SIZE.boot(min 96 · 20vmin · max 144)와 같은 식이다. + 값이 갈리면 React 로더가 이어받는 순간 마크가 한 번 커지거나 작아진다. */ + #spin-boot img { display: block; width: clamp(96px, 20vmin, 144px); height: auto; } + + diff --git a/package-lock.json b/package-lock.json index 02240216..b8898c5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,17 @@ { "name": "spin", - "version": "0.6.7", + "version": "0.6.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "spin", - "version": "0.6.7", + "version": "0.6.8", + "license": "MIT", "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-opener": "^2.5.4", + "h264-mp4-encoder": "^1.0.12", "idb": "^8.0.3", "matter-js": "^0.20.0", "mediabunny": "^1.55.7", @@ -33,7 +35,10 @@ "oxlint": "^1.75.0", "typescript": "~6.0.2", "vite": "^8.2.0", - "vitest": "^4.1.10" + "vitest": "^4.1.11" + }, + "engines": { + "node": ">=22.18" } }, "node_modules/@adobe/css-tools": { @@ -285,9 +290,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -1339,16 +1344,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1357,13 +1362,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1384,9 +1389,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -1397,13 +1402,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -1411,14 +1416,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1427,9 +1432,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -1437,13 +1442,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.10.tgz", - "integrity": "sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.11.tgz", + "integrity": "sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -1455,17 +1460,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.10" + "vitest": "4.1.11" } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1740,6 +1745,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/h264-mp4-encoder": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/h264-mp4-encoder/-/h264-mp4-encoder-1.0.12.tgz", + "integrity": "sha512-xih3J+Go0o1RqGjhOt6TwXLWWGqLONRPyS8yoMu/RoS/S8WyEv4HuHp1KBsDDl8srZQ3gw9f95JYkCSjCuZbHQ==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -2747,19 +2758,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -2787,12 +2798,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index fe11f033..89d385dc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "spin", "private": true, - "version": "0.6.7", + "version": "0.6.8", "license": "MIT", "homepage": "https://spin.atit.app", "repository": { @@ -45,6 +45,7 @@ "dependencies": { "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-opener": "^2.5.4", + "h264-mp4-encoder": "^1.0.12", "idb": "^8.0.3", "matter-js": "^0.20.0", "mediabunny": "^1.55.7", @@ -68,6 +69,6 @@ "oxlint": "^1.75.0", "typescript": "~6.0.2", "vite": "^8.2.0", - "vitest": "^4.1.10" + "vitest": "^4.1.11" } } diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index fa4d5941..5c4ac40f 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -85,8 +85,17 @@ function render(page) { // 주소는 항상 슬래시로 끝나므로(pageUrl) `/privacy/`·`/ja/terms/` 가 모두 걸리고, 홈 // (`/`·`/en/`)은 안 걸린다. const seoPage = /\/rules\//.test(page.url) ? 'rules' : /\/(privacy|terms)\//.test(page.url) ? 'legal' : 'home'; + // ⚠️ 2026-09-15 — 못 찾으면 **터뜨린다.** 이 치환은 문자열 그대로를 찾는데, `index.html` 의 + // `#root` 줄을 누가 조금만 건드리면(안에 무엇을 넣거나 속성을 더하거나) `replace` 는 조용히 + // 아무 일도 안 하고 33장이 **본문 없이** 나간다 — 빌드는 초록이고 에러도 안 난다. + // 같은 날 `#root` 바로 뒤에 부트 마크(`#spin-boot`)를 더하면서, 다음에 그것을 `#root` **안** + // 으로 옮기고 싶어지는 사람이 생겼다. 그때 빌드가 멈추는 것이 이 세 줄의 값이다. + const ROOT = '
'; + if (!html.includes(ROOT)) { + throw new Error(`prerender: index.html 에서 ${ROOT} 를 못 찾았다 — 본문을 넣을 자리가 없다(${page.url}).`); + } html = html.replace( - '
', + ROOT, `
\n${page.body}\n
`, ); return html; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 30c50b1c..91712e40 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2316,6 +2316,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2847,6 +2848,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3253,6 +3278,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-log", "tauri-plugin-opener", ] @@ -3555,6 +3581,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61854a36651aa48381e5e209f69a01273b77f3f9f91f0c430b1b98d33bd47229" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.20", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-log" version = "2.9.0" @@ -4626,6 +4694,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -4659,13 +4736,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -4696,6 +4790,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -4708,6 +4808,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -4720,12 +4826,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -4738,6 +4856,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -4750,6 +4874,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -4762,6 +4892,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -4774,6 +4910,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ae0901f6..4396652e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,3 +31,5 @@ log = "0.4" tauri = { version = "2.11.3", features = ["devtools"] } tauri-plugin-log = "2" tauri-plugin-opener = "2" +# 네이티브 저장 대화상자. 자바스크립트에는 권한을 주지 않는다 — save_file.rs 머리말 참조. +tauri-plugin-dialog = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d5d66082..7663d03a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod oauth; +mod save_file; mod secret_store; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -7,6 +8,8 @@ pub fn run() { // 기본 브라우저로 구글 로그인을 내보내는 데 쓴다(데스크톱 OAuth). 웹뷰 안에서는 // 팝업이 안 열리고 origin 도 등록할 수 없다 — 근거는 oauth.rs 머리말. .plugin(tauri_plugin_opener::init()) + // 저장 대화상자. 웹뷰에는 권한을 열지 않고 러스트 명령 하나(save_bytes_dialog)로만 쓴다. + .plugin(tauri_plugin_dialog::init()) // 로그인 한 번의 수명만 사는 루프백 수신기의 자리. .manage(oauth::OauthState::default()) .invoke_handler(tauri::generate_handler![ @@ -16,6 +19,7 @@ pub fn run() { secret_store::secret_save, secret_store::secret_load, secret_store::secret_clear, + save_file::save_bytes_dialog, ]) .setup(|app| { if cfg!(debug_assertions) { diff --git a/src-tauri/src/save_file.rs b/src-tauri/src/save_file.rs new file mode 100644 index 00000000..a2f89b1e --- /dev/null +++ b/src-tauri/src/save_file.rs @@ -0,0 +1,132 @@ +//! 네이티브 **저장 대화상자**. 웹에서는 `
` 가 브라우저의 다운로드 기능을 빌려 +//! 쓰지만, 데스크톱 웹뷰에는 빌릴 브라우저가 없다 — 눌러도 아무 일도 일어나지 않거나 어디에 +//! 저장됐는지 알 수 없다(2026-09-13 기현님 실기). 그래서 파일을 내보내는 모든 길(영상·그림· +//! ZIP·백업·드릴/세션/팀 파일)이 이 명령 하나로 모인다. +//! +//! 왜 `tauri-plugin-fs` 를 쓰지 않나: 그쪽은 **자바스크립트에 파일 쓰기 권한**을 열어 주는 +//! 물건이라 capability 에 쓰기 범위를 적어야 하고, 그 범위는 한 번 열면 앱의 모든 코드에 +//! 열린다. 여기서 필요한 것은 "사람이 대화상자에서 고른 그 파일 한 개" 뿐이므로, 고르는 일과 +//! 쓰는 일을 **러스트 안에서 붙여** 둔다. 웹뷰가 받는 권한은 0 이다. + +use tauri::Manager; +use tauri_plugin_dialog::DialogExt; + +/// 헤더로 온 파일명을 되돌린다. HTTP 헤더 값은 ASCII 만 담을 수 있는데 드릴 제목에는 한글이 +/// 흔하므로, 웹 쪽이 `encodeURIComponent` 로 싸서 보낸다. 되돌릴 수 없는 바이트열이면 이름을 +/// 통째로 버린다 — 깨진 이름으로 저장하느니 기본값이 낫다. +fn percent_decode(raw: &str) -> Option { + let bytes = raw.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let hex = bytes.get(i + 1..i + 3)?; + let s = std::str::from_utf8(hex).ok()?; + out.push(u8::from_str_radix(s, 16).ok()?); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).ok() +} + +/// 확장자로 거르개 한 벌을 만든다. 이름이 `SPIN_drill_….spin.json` 처럼 두 겹이어도 마지막 +/// 조각만 본다 — 대화상자가 거르개에 쓰는 것은 마지막 확장자다. +fn filter_for(filename: &str) -> (String, String) { + // 점이 없으면 확장자가 **없는** 것이다 — `rsplit` 은 이름 전체를 돌려주므로 먼저 가른다. + let ext = match filename.rsplit_once('.') { + Some((_, e)) => e.to_ascii_lowercase(), + None => String::new(), + }; + let label = match ext.as_str() { + "mp4" => "MP4", + "png" => "PNG", + "zip" => "ZIP", + "json" => "JSON", + _ => "File", + }; + (label.to_string(), ext) +} + +/// 저장 대화상자를 띄우고, 고른 자리에 바이트를 쓴다. +/// +/// 돌려주는 값: `true` = 저장했다, `false` = **사람이 취소했다**(오류가 아니다 — 부르는 쪽이 +/// 시트를 닫지 않는 근거로 쓴다). 오류는 진짜 실패(쓰기 실패)일 때만이다. +/// +/// ⚠️ 인자를 `Vec` 로 받지 않는 이유: Tauri 의 `invoke` 는 인자 **묶음** 안의 바이트열을 +/// 날바디로 보내지 못한다(`InvokeArgs` 가 `ArrayBuffer | Uint8Array` **통째**만 허용한다). +/// 필드로 받으면 2MB 영상이 200만 개짜리 JSON 숫자 배열이 된다. 그래서 바이트는 날바디로, +/// 파일명은 헤더로 온다. +#[tauri::command] +pub async fn save_bytes_dialog( + app: tauri::AppHandle, + request: tauri::ipc::Request<'_>, +) -> Result { + let filename = request + .headers() + .get("x-spin-filename") + .and_then(|v| v.to_str().ok()) + .and_then(percent_decode) + .unwrap_or_else(|| "spin".to_string()); + + let bytes = match request.body() { + tauri::ipc::InvokeBody::Raw(b) => b.clone(), + tauri::ipc::InvokeBody::Json(_) => return Err("save_bytes_dialog: 날바디가 아니다".into()), + }; + + // ⚠️ `blocking_save_file` 은 **주 스레드에서 부르면 안 된다**(GTK 대화상자를 주 스레드로 + // 보내 놓고 그 자리에서 기다리므로 교착한다). 명령이 async 라 워커에서 돌지만, 그것에 + // 기대지 않고 `spawn_blocking` 으로 못을 박는다. + let handle = tauri::async_runtime::spawn_blocking(move || { + let (label, ext) = filter_for(&filename); + let mut builder = app.dialog().file().set_file_name(&filename); + // 시작 자리는 **홈 폴더**다(기현님 지시 2026-09-13). 이것을 정해 주지 않으면 GTK 는 앱이 + // 뜬 자리(리눅스에서는 `/` 이거나 AppImage 를 띄운 자리)에서 시작해, 저장하려던 사람이 + // 제 폴더를 찾아 올라가야 한다. 홈을 못 찾는 기계에서는 그냥 대화상자에 맡긴다. + if let Ok(home) = app.path().home_dir() { + builder = builder.set_directory(home); + } + // 확장자가 없으면 거르개를 걸지 않는다 — 빈 확장자 거르개는 대화상자가 아무 파일도 못 보게 만든다. + if !ext.is_empty() { + builder = builder.add_filter(label, &[ext.as_str()]); + } + let picked = builder.blocking_save_file(); + + let Some(path) = picked else { return Ok(false) }; + let path = path.into_path().map_err(|e| e.to_string())?; + std::fs::write(&path, &bytes).map_err(|e| e.to_string())?; + Ok(true) + }); + + handle.await.map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + + // 드릴 제목은 한글이 기본이다 — 이 한 줄이 틀리면 저장 대화상자의 이름칸이 통째로 깨진다. + #[test] + fn percent_decode_restores_korean() { + assert_eq!(percent_decode("SPIN_%EB%93%9C%EB%A6%B4.mp4").as_deref(), Some("SPIN_드릴.mp4")); + assert_eq!(percent_decode("plain.mp4").as_deref(), Some("plain.mp4")); + } + + // 잘린 escape 나 UTF-8 이 아닌 바이트열은 이름을 버리고 기본값으로 간다(패닉이 아니다). + #[test] + fn percent_decode_rejects_broken_input() { + assert_eq!(percent_decode("bad%"), None); + assert_eq!(percent_decode("bad%zz"), None); + assert_eq!(percent_decode("%FF%FE"), None); + } + + // `.spin.json` 처럼 두 겹인 이름도 마지막 조각만 거르개가 된다. + #[test] + fn filter_uses_last_extension() { + assert_eq!(filter_for("SPIN_drill.spin.json"), ("JSON".to_string(), "json".to_string())); + assert_eq!(filter_for("clip.MP4"), ("MP4".to_string(), "mp4".to_string())); + assert_eq!(filter_for("noext"), ("File".to_string(), String::new())); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 03cbabca..3420be6e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -24,7 +24,7 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https://www.googleapis.com https://accounts.google.com https://oauth2.googleapis.com" + "csp": "default-src 'self'; img-src 'self' data: blob: asset: http://asset.localhost; media-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self' ipc: http://ipc.localhost https:" } }, "bundle": { diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index af42bfe7..8010152d 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -39,7 +39,7 @@ // 발행한다.** "걷힘" 은 `loader.visible === false` 가 **아니라 퇴장 transition 까지 끝난 // 시점**이다(2026-09-04 실측: 페이드 도중 프레임에 말풍선이 이미 떠 있었다). 그 시점은 // 오버레이가 `onExited` 로 알려 준다 — 아래 coverSettled 주석. -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { SkipLink } from '../ui/SkipLink.tsx'; import { useIsNarrow } from '../ui/useIsNarrow.ts'; import { LiveRegion, liveRegion } from '../ui/LiveRegion.tsx'; @@ -388,6 +388,7 @@ export function AppShell() { const isFirstRender = useRef(true); const locale = useLocale(); const t = useT(); + // 3.-2 §5.2 — 좁으면 84px 레일을 걷고 같은 3항목을 헤더 좌측 세그먼트로 세운다. **판정은 // 여기 한 번뿐이다**: 레일과 세그먼트가 같은 boolean 을 나눠 써야 "둘 다 서 있다/둘 다 // 없다" 는 프레임이 열리지 않는다. 판 위에 오버레이로 얹지 않는 이유는 AppNavSegment.tsx @@ -451,6 +452,24 @@ export function AppShell() { fromHistory: nav.lastNavFromHistory ?? false, }); + /** 첫 커밋에 덮개가 서는 환경인가. 아래 부트 마크 뒷문이 이 값 하나만 본다. */ + const loaderShowsRef = useRef(loader.visible); + + // ── 부트 마크 (2026-09-15) — 0ms 환경의 뒷문 ─────────────────────────────────────── + // + // `index.html` 이 첫 픽셀에 그려 둔 정지 마크를 **평소에는 `AppLoaderOverlay` 가** 걷는다 + // (자기 회전이 시작되는 바로 그 순간에 걷어야 그림이 안 튀기 때문이다 — 그쪽 머리말). + // 여기는 **로더가 한 번도 안 뜨는 환경**만 맡는다: 감축 모션·테스트의 0ms 경로에서는 + // 오버레이 DOM 이 아예 안 생겨(`useAppLoader` 머리말) 아무도 그 마크를 못 걷는다. + // + // ⚠️ 여기에 state 를 두지 마라. 한때 `bootSettled` 를 이 컴포넌트의 state 로 두었다가 + // 진정 순간에 **트리 전체가 다시 렌더**되어 그 자리에 121ms·102ms 짜리 끊김이 새로 + // 생겼다(실측). 하필 회전이 시작되는 순간이라 고치려던 바로 그 증상이었다. + useLayoutEffect(() => { + if (loaderShowsRef.current) return; + document.getElementById('spin-boot')?.remove(); + }, []); + // ── 결정 30 의 "걷힘" 은 `visible === false` 가 아니라 **퇴장 완료**다 ──────────────────── // 오버레이는 `visible` 이 꺼진 뒤에도 EXIT_MS(160~200ms) 동안 살아 opacity 를 녹인다(결정 16). // 그 구간은 사람 눈에 아직 덮여 있는 구간이라, `!loader.visible` 을 "걷혔다" 로 읽으면 아직 diff --git a/src/app/loader/AppLoaderOverlay.tsx b/src/app/loader/AppLoaderOverlay.tsx index bfd6e5d6..8af92d7b 100644 --- a/src/app/loader/AppLoaderOverlay.tsx +++ b/src/app/loader/AppLoaderOverlay.tsx @@ -44,7 +44,7 @@ // 판 자신 말고는 아무도 그 시점을 알 수 없다 — 퇴장 길이는 `kind` 마다 다르고, 퇴장 중에 다음 // 전환이 들어오면 되조준되어 아예 오지 않는다. import { useEffect, useRef, useState } from 'react'; -import { CYCLE_MS, EXIT_MS, markSizePx } from './appLoaderTiming.ts'; +import { BOOT_SETTLE_CAP_MS, BOOT_SETTLE_FRAMES, BOOT_SETTLE_FRAME_MS, CYCLE_MS, EXIT_MS, markSizePx } from './appLoaderTiming.ts'; import type { AppLoaderKind } from './appLoaderTiming.ts'; import { SpinLoaderMark } from './SpinLoaderMark.tsx'; @@ -77,6 +77,67 @@ export function AppLoaderOverlay({ visible, kind, reduceMotion = false, onExited const onExitedRef = useRef(onExited); onExitedRef.current = onExited; + // ── 부트 마크 넘겨받기 · 회전 시작 시점 (2026-09-15) ────────────────────────────────── + // + // `index.html` 이 첫 픽셀(실측 211ms)에 그려 둔 **정지** 마크를 이 판이 이어받는다. 두 일이 + // **한 순간**이라야 한다: 부트 마크를 지우는 것과 이 판의 회전을 시작하는 것. + // · 먼저 지우면 그 사이 프레임에 정지 마크가 두 겹으로 보이거나 한 겹이 사라진다. + // · 먼저 돌리면 부트 마크 **뒤에서** 사이클이 흘러가다가, 마크를 지우는 순간 이미 중간 + // 자세(실측 53%)라 그림이 튄다. + // 정지 자세 = 사이클 0% 이므로(SpinLoaderMark 머리말), 같은 순간에 하면 이음매가 없다. + // + // **언제가 그 순간인가: 메인 스레드가 조용해졌을 때.** 근거·실측은 `appLoaderTiming.ts` 의 + // 「부트 진정」 절에 있다 — 첫 마운트 직후 400ms 넘게 커밋·이펙트·첫 페인트가 스레드를 잡고, + // SVG 변환 애니메이션은 합성 스레드로 안 내려가 그 구간에서 **반드시 선다.** + // + // ⚠️ 판정이 **이 컴포넌트 안**에 있는 것이 중요하다. 처음에는 AppShell 의 state 로 뒀는데, + // 진정 순간에 트리 전체가 다시 렌더되어 121ms·102ms 짜리 끊김이 거기 새로 생겼다(실측). + // 고치려던 바로 그 자리였다. 여기서는 다시 그리는 것이 마크 하나뿐이다. + const [spun, setSpun] = useState(() => typeof document === 'undefined' || document.getElementById('spin-boot') === null); + useEffect(() => { + if (spun) return undefined; + const boot = document.getElementById('spin-boot'); + if (boot === null) { + setSpun(true); + return undefined; + } + let raf = 0; + let good = 0; + let removed = false; + let last = performance.now(); + const deadline = last + BOOT_SETTLE_CAP_MS; + const tick = (t: number): void => { + good = t - last <= BOOT_SETTLE_FRAME_MS ? good + 1 : 0; + last = t; + const settled = good >= BOOT_SETTLE_FRAMES || t >= deadline; + // ⚠️ 두 걸음으로 나눈다. 부트 마크를 걷는 그 프레임은 **비싸다** — 그때까지 화면을 덮고 + // 있던 불투명한 판이 사라지면서 그 아래 앱이 **처음으로 래스터화**되기 때문이다(실측 + // 75ms). 그 한 프레임을 회전 시작과 같은 프레임에 두면 첫 발길질이 통째로 떨어진다. + // 그래서 먼저 걷고, 프레임이 다시 제때 오는 것을 보고 나서 돌린다. 그 사이 화면은 이 + // 판의 정지 마크라 그림이 안 바뀐다 — 사람 눈에는 아무 일도 일어나지 않는다. + if (!removed && settled) { + removed = true; + boot.remove(); + good = 0; + raf = requestAnimationFrame(tick); + return; + } + if (removed && settled) { + setSpun(true); + return; + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => { + cancelAnimationFrame(raf); + // 판이 사라지는데 마크가 남으면 화면에 정지 마크만 남는다. + if (!removed) boot.remove(); + }; + // 마운트 1회. `spun` 이 참이 되면 위에서 곧장 빠져나온다. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // 등장은 **렌더 중**에 정한다(effect 가 아니라) — `useAppLoader` 가 열쇠 변화를 렌더 중에 // 보는 것과 같은 규율이다(그 파일 머리말). 판이 같은 커밋에 서는 것 자체는 아래 null 조건이 // `visible` 도 보므로 이 줄이 없어도 성립하지만, 그러면 `mounted` 가 한 커밋 동안 거짓이라 @@ -148,7 +209,7 @@ export function AppLoaderOverlay({ visible, kind, reduceMotion = false, onExited }} > diff --git a/src/app/loader/appLoaderTiming.ts b/src/app/loader/appLoaderTiming.ts index f8374da2..80206004 100644 --- a/src/app/loader/appLoaderTiming.ts +++ b/src/app/loader/appLoaderTiming.ts @@ -39,6 +39,28 @@ export const CYCLE_MS: Record = { boot: 1200, rail: 820 } * — 로더가 물러나는 것이 아니라 앱이 열리는 것으로 읽힌다(§5). */ export const EXIT_MS: Record = { boot: 200, rail: 160 }; +// ── 부트 진정(2026-09-15) — 회전은 **메인 스레드가 조용해진 뒤에** 시작한다 ──────────────── +// +// 기현님 실기: *"첫 로딩 애니가 툭툭 끊긴다"*. 데스크톱과 같은 엔진(WebKitGTK 4.1)에 재 보니 +// 첫 마운트 직후가 이렇다: React 가 트리를 그리는 데는 19ms 뿐이고, 그 뒤 커밋·이펙트·첫 +// 레이아웃/페인트가 **400ms 넘게** 메인 스레드를 잡는다(부팅 화면이 board = EditorWorkspace 라 +// 가장 무거운 마운트가 첫 화면이다). SVG 변환 애니메이션은 합성 스레드로 안 내려가므로 그 +// 구간에서 마크는 **선다** — 무엇을 어떻게 그리든. +// +// 그래서 그 구간에는 **아예 안 돌린다.** 정지 자세(사이클 0% = logo.svg)로 서 있다가, 프레임이 +// 실제로 제때 오기 시작하면 그때 0% 부터 돈다. 사람 눈에는 "늦게 시작한 매끄러운 회전" 이고, +// 예전에는 "일찍 시작한 끊기는 회전" 이었다. +// +// ⚠️ 프레임 간격 기준이 33ms 보다 **넉넉해야** 한다 — 이 웹뷰의 rAF 주기 자체가 30Hz 라 +// (사각형 하나뿐인 빈 페이지도 중앙값 32ms) 16.7ms 를 기준으로 삼으면 영영 안 진정된다. +/** 이 시간(ms) 안에 온 프레임은 «제때 왔다» 로 친다. */ +export const BOOT_SETTLE_FRAME_MS = 50; +/** 연속으로 몇 번 제때 와야 진정으로 보는가. 한 번이면 긴 작업 사이의 우연한 짧은 틈에 속는다. */ +export const BOOT_SETTLE_FRAMES = 3; +/** 그래도 안 진정되면 여기서 포기하고 돌린다(ms, 마운트 기준). 부팅 로더가 1500ms 이므로 + * 최악에도 회전이 300ms 는 보인다. 느린 기기에서 영영 정지 마크만 보는 일을 막는 상한이다. */ +export const BOOT_SETTLE_CAP_MS = 1200; + /** 마크 크기 = `clamp(min, vmin%, max)`(§5). CSS 문자열이 아니라 수로 두는 이유: * `SpinLoaderMark` 의 계약이 `sizePx: number` 라(§2) 크기 계산이 한 곳에 있어야 한다. */ export const MARK_SIZE = { diff --git a/src/app/loader/bootHandover.test.tsx b/src/app/loader/bootHandover.test.tsx new file mode 100644 index 00000000..cfc02e95 --- /dev/null +++ b/src/app/loader/bootHandover.test.tsx @@ -0,0 +1,115 @@ +// 부트 마크 넘겨받기 — `index.html` 의 정지 마크에서 React 로더의 회전으로 (2026-09-15). +// +// 지우면 새는 것 셋. 셋 다 **조용한** 고장이라 다른 테스트가 못 잡는다: +// ① 마크를 안 걷으면 **화면이 영영 덮인다.** 부트 마크는 `position:fixed; inset:0` 에 불투명한 +// 전면 판이다. 감지가 한 줄만 어긋나도 앱은 멀쩡히 돌면서 아무것도 안 보인다 — 에러 0개. +// ② 걷는 것과 돌리는 것의 **순서**. 먼저 돌리면 부트 마크 뒤에서 사이클이 흘러가다가 마크를 +// 지우는 순간 중간 자세(실기 53%)라 그림이 튄다. 정지 자세 = 사이클 0% 라는 성질이 여기서만 +// 값을 낸다. +// ③ 프레임이 끝내 안 좋아져도 **언젠가는 돌아야 한다**(BOOT_SETTLE_CAP_MS). 상한이 없으면 느린 +// 기기에서 정지 마크만 보다 끝난다. +// +// rAF 를 직접 돌린다 — jsdom 에는 진짜 프레임이 없고, 여기서 재려는 것은 «프레임 간격을 이렇게 +// 주면 언제 넘어가는가» 라는 판정이지 실제 타이밍이 아니다(실제 타이밍은 WebKit 실측이 본다). +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render } from '@testing-library/react'; +import { AppLoaderOverlay } from './AppLoaderOverlay.tsx'; +import { BOOT_SETTLE_CAP_MS, BOOT_SETTLE_FRAMES, BOOT_SETTLE_FRAME_MS } from './appLoaderTiming.ts'; + +/** 대기 중인 rAF 콜백. 테스트가 시각을 직접 먹인다. */ +let queue: FrameRequestCallback[] = []; +let now = 0; + +function frame(gapMs: number): void { + now += gapMs; + const due = queue; + queue = []; + act(() => { + for (const cb of due) cb(now); + }); +} + +/** 회전 중인가 — `SpinLoaderMark` 가 `animation-play-state` 로 세우고 돌린다. */ +function spinning(): boolean { + const mark = document.querySelector('.spin-mark'); + return mark?.style.animationPlayState === 'running'; +} +const bootMark = (): HTMLElement | null => document.getElementById('spin-boot'); + +beforeEach(() => { + now = 0; + queue = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + queue.push(cb); + return queue.length; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); + vi.spyOn(performance, 'now').mockImplementation(() => now); + const el = document.createElement('div'); + el.id = 'spin-boot'; + document.body.appendChild(el); +}); + +afterEach(() => { + cleanup(); + bootMark()?.remove(); + vi.restoreAllMocks(); +}); + +/** 느린 프레임 한 번 = 아직 진정이 아니다. `BOOT_SETTLE_FRAME_MS` 를 **넘겨야** 한다. */ +const SLOW = BOOT_SETTLE_FRAME_MS + 20; +const FAST = BOOT_SETTLE_FRAME_MS - 10; + +describe('부트 마크 넘겨받기', () => { + it('프레임이 느린 동안은 정지 자세로 서 있는다 — 끊기는 회전을 보여 주느니 안 돌린다', () => { + render(); + expect(spinning(), '마운트 직후').toBe(false); + for (let i = 0; i < 6; i++) frame(SLOW); + expect(spinning(), '느린 프레임만 이어지면 계속 정지').toBe(false); + expect(bootMark(), '부트 마크도 그대로 — 두 마크가 같은 그림이라 화면은 안 바뀐다').not.toBeNull(); + }); + + it('프레임이 제때 오면 **먼저 걷고 그 다음에** 돈다 — 이 순서가 이음매를 없앤다', () => { + render(); + for (let i = 0; i < BOOT_SETTLE_FRAMES; i++) frame(FAST); + // 1단계: 마크는 걷혔는데 아직 안 돈다. 이 한 프레임이 «앱이 처음 래스터화되는» 비싼 + // 프레임이고(실측 75ms), 회전을 여기 겹치면 첫 발길질이 통째로 떨어진다. + expect(bootMark(), '걷혔다').toBeNull(); + expect(spinning(), '아직 안 돈다').toBe(false); + + for (let i = 0; i < BOOT_SETTLE_FRAMES; i++) frame(FAST); + expect(spinning(), '프레임이 다시 제때 오는 것을 보고 나서 돈다').toBe(true); + }); + + it('연속이 끊기면 처음부터 다시 센다 — 긴 작업 사이의 우연한 틈에 안 속는다', () => { + render(); + for (let i = 0; i < BOOT_SETTLE_FRAMES - 1; i++) frame(FAST); + frame(SLOW); + for (let i = 0; i < BOOT_SETTLE_FRAMES - 1; i++) frame(FAST); + expect(bootMark(), '아직 한 칸 모자라다').not.toBeNull(); + frame(FAST); + expect(bootMark()).toBeNull(); + }); + + it('끝내 안 좋아져도 상한에서 넘어간다 — 느린 기기에서 정지 마크만 보다 끝나지 않는다', () => { + render(); + // 느린 프레임만으로 상한을 넘긴다. + while (now <= BOOT_SETTLE_CAP_MS) frame(SLOW); + expect(bootMark(), '상한에서 걷는다').toBeNull(); + frame(SLOW); + expect(spinning(), '상한 뒤에는 프레임이 나빠도 돈다').toBe(true); + }); + + it('판이 사라져도 마크는 걷힌다 — 정리 없이 언마운트되면 화면이 영영 덮인다', () => { + const view = render(); + frame(FAST); + view.unmount(); + expect(bootMark()).toBeNull(); + }); + + it('부트 마크가 애초에 없으면(프리렌더 글·감축 모션) 기다리지 않고 바로 돈다', () => { + bootMark()!.remove(); + render(); + expect(spinning()).toBe(true); + }); +}); diff --git a/src/core/colors.ts b/src/core/colors.ts index 74f720f7..0dfea73e 100644 --- a/src/core/colors.ts +++ b/src/core/colors.ts @@ -118,6 +118,23 @@ export const ARROW_COLOR: string = ARROW_COLORS[0]; * 위 "색을 굽지 않는다" 원칙(첨자 저장)은 화살표 전용이라 여기는 적용되지 않는다. */ export const NOTE_COLOR_CHOICES = ['#ffffff', ...ARROW_COLORS] as const; +/** 도형 색 선택지(2026-09-14 기현님 지시 — *"반투명한 흰, 하늘, 노랑, 주황 4개의 색으로"*). + * + * 메모처럼 **hex 를 그대로 저장**한다(첨자가 아니다) — 도형은 썸네일이 모델을 통째로 복제해 + * 담으므로(`model/thumb.ts`) 첨자로 구울 이유가 없다. + * + * ⚠️ 네 번째가 **주황(#f97316)이고 빨강이 아니다.** 화살표·메모 팔레트(`NOTE_COLOR_CHOICES`)를 + * 그대로 쓰지 않는 이유가 그것이라, 재사용하려다 빨강이 섞이지 않게 배열을 따로 둔다. + * 판 위에서 빨강은 이미 다른 뜻(반칙 판정·잠김)을 지고 있다. + * + * 이색각 분리 실측(Viénot–Brettel–Mollon, 임계 0.25 — `colors.test.ts` 와 같은 식): + * 네 색 여섯 쌍의 최솟값이 적색맹 0.728 · 녹색맹 0.592 다(가장 약한 쌍은 노랑↔주황). + * 빨강판(0.772 / 0.737)보다 낮지만 임계의 두 배를 넘는다. + * + * ⚠️ **첫 값은 기본색이다**(흰색). 순환이 한 바퀴 돌아 여기로 오면 `color` 키를 지운다 — + * 화살표·획과 같은 규약이고, 그래야 옛 저장본과 새 저장본의 모양이 같다. */ +export const SHAPE_COLOR_CHOICES = ['#ffffff', '#38bdf8', '#fde047', '#f97316'] as const; + const srgb = (v: number): number => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); export function relLuminance(hex: string): number { const [r, g, b] = (hex.replace('#', '').match(/../g) ?? []).map((x) => srgb(parseInt(x, 16) / 255)); diff --git a/src/core/constants.ts b/src/core/constants.ts index e836938a..efb830ca 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -330,6 +330,9 @@ export const INTERACT = { zoneDirectMinPxPerUnit: 2.2154, handleHitRadiusCssPx: 22, handleViewRadiusCssPx: 11, + /** 이동 앵커와 개체 상자 사이 틈(2026-09-13). 손잡이와 한 덩어리로 보이지 않을 만큼만 띄운다 — + * 더 멀면 "무엇의 앵커인가" 가 흐려지고, 더 가까우면 도형 손잡이와 구분이 안 된다. */ + moveAnchorGapCssPx: 10, /** 핸들의 월드 고정 레버(px). 렌더 위치와 래치 레버가 같은 함수에서 나와야 스냅이 없다. * * 앞뒤 두 핸들(towRear·towFront)은 "줄을 매달아 끌고 간다"는 컨셉이므로 차체에서 떼어 놓는다. diff --git a/src/features/editor/EditorStage.tsx b/src/features/editor/EditorStage.tsx index ca4e5fc4..6193dd6f 100644 --- a/src/features/editor/EditorStage.tsx +++ b/src/features/editor/EditorStage.tsx @@ -8,12 +8,16 @@ import { NudgePad, type NudgePadTarget } from './NudgePad.tsx'; import { useLongPressMenu } from './useLongPressMenu.ts'; import { sameKindGroup } from './selectSame.ts'; import { sceneOrder, zMoves } from '../../model/zOrder.ts'; -import { overlappingIds } from '../../physics/bounds.ts'; +import { overlappingIds, selectionBounds } from '../../physics/bounds.ts'; import type { Dispatch, KeyboardEvent as ReactKeyboardEvent, RefObject } from 'react'; import { RAD } from '../../core/angle.ts'; +import type { Vec2 } from '../../core/units.ts'; import { isId } from '../../core/ids.ts'; +import { moveAnchorGuide, moveAnchorIds } from './moveAnchorIds.ts'; +import { canRotateKind } from './nudgeCaps.ts'; +import { COALESCE_MS } from '../../store/editor/history.ts'; import { eventCode, lookupDef } from '../../core/keymap.ts'; -import type { ArrowId, CastId, ChairId, NoteId } from '../../core/ids.ts'; +import type { CastId, ChairId, NoteId } from '../../core/ids.ts'; import type { ToolId } from '../../physics/index.ts'; import type { EditorWorldRef } from '../../store/editor/EditorProvider.tsx'; import { poseFrame } from '../../store/editor/tween.ts'; @@ -21,7 +25,8 @@ import type { EditorAction } from '../../store/editor/actions.ts'; import type { BallRing, Drill, DrillStep, NoteLabel, TeamSide } from '../../model/drill.ts'; import type { ZoneConfig } from '../../model/chair.ts'; -import { nudgeArrow } from '../../model/arrow.ts'; +import { arrowMid, nudgeArrow, rotateArrowAbout } from '../../model/arrow.ts'; +import { rotateShapeBy } from '../../model/shape.ts'; import type { Arrow, ArrowPart } from '../../model/arrow.ts'; import { nudgeStroke, rotateStrokeAbout, strokeCenter } from '../../model/stroke.ts'; import { courtDefFor, goalBaseDir, gridCellCenter, cellLabelAt, type CourtMode, type CourtSize } from '../../model/court.ts'; @@ -36,6 +41,7 @@ import type { RuleOverlayApi, RuleRosterEntry } from '../../render/ruleOverlay.t import { liveRegion } from '../../ui/LiveRegion.tsx'; import { ZONE_CURSOR_DRAGGING } from '../../render/zoneCursors.ts'; import { useEditorPointer } from './useEditorPointer.ts'; +import { translate } from '../../i18n/useT.ts'; import { useLocale } from '../../i18n/useLocale.ts'; export interface EditorStageProps { @@ -345,15 +351,32 @@ export const EditorStage = forwardRef(functi }); }, [goalHomes, worldRef]); - // §7.5b 순회 순서: 팀A 선수 → 팀B 선수 → 공 → 콘 → 메모 → 화살표. + // §7.5b 순회 순서: 팀A 선수 → 팀B 선수 → 공 → 콘 → 메모 → 화살표 → 획 → 도형. + // + // ⚠️ 2026-09-14 — 꼬리에 획(`fh`)·도형(`sh`)이 더해졌다. 그전에는 둘 다 목록에 없어 `[`·`]` + // 로 **닿을 수가 없었고**, 도형은 아예 초점을 못 받았다(ObjectLayer 의 `aria-hidden`). + // 꼬리에 붙이는 이유: `order[0]` 이 안 바뀌어야 기본 초점과 손버릇이 그대로다. const order = useMemo(() => { const homeIds = chairs.filter((c) => drill.cast.chairs.find((d) => d.id === c.id)?.team === 'home').map((c) => c.id as string); const awayIds = chairs.filter((c) => drill.cast.chairs.find((d) => d.id === c.id)?.team === 'away').map((c) => c.id as string); - return [...homeIds, ...awayIds, ...balls, ...cones.map((c) => c.id as string), ...step.notes.map((n) => n.id as string), ...step.arrows.map((a) => a.id as string)]; - }, [chairs, drill.cast.chairs, balls, cones, step.notes, step.arrows]); + return [ + ...homeIds, + ...awayIds, + ...balls, + ...cones.map((c) => c.id as string), + ...step.notes.map((n) => n.id as string), + ...step.arrows.map((a) => a.id as string), + ...(step.strokes ?? []).map((s) => s.id as string), + ...step.shapes.map((s) => s.id as string), + ]; + }, [chairs, drill.cast.chairs, balls, cones, step.notes, step.arrows, step.strokes, step.shapes]); const activeId = selection.size > 0 ? (Array.from(selection)[0] ?? null) : (rovingId ?? order[0] ?? null); + /** 획 회전의 **축 래치**. `strokeCenter` 가 돌리면 함께 움직이는 값이라 매번 다시 재면 + * 획이 흘러간다 — 되돌리기 병합 창(COALESCE_MS) 안에서는 처음 잡은 축을 그대로 쓴다. */ + const rotAxisRef = useRef<{ id: string; c: Vec2; at: number } | null>(null); + const nudge = useCallback( (id: string, dx: number, dy: number, dThetaRad: number, arrowPart: ArrowPart = 'whole') => { // ★ 잠긴 개체는 키보드로도 안 움직인다(2026-08-14). 손으로만 막으면 반쪽이다 — @@ -391,13 +414,36 @@ export const EditorStage = forwardRef(functi if (note) dispatch({ type: 'NOTE_SET', note: { ...note, x: note.x + dx, y: note.y + dy } }); return; } + if (isId(id, 'sh')) { + // 도형(2026-09-14 기현님 지시로 개통). 물리 바디가 없다. 회전축은 `x,y` 자신이라 + // (삼각형은 무게중심) 래치가 필요 없다 — 돌려도 축이 제자리다. + const shape = step.shapes.find((x) => x.id === id); + if (!shape) return; + if (dThetaRad !== 0) { + // 환산(라디안→도)과 0~360 접기는 **모델이 한다**(`rotateShapeBy`) — `rot` 의 단위와 + // 규약을 아는 곳이 둘이 되면 같은 도형이 −5° 와 355° 두 값으로 저장된다. + dispatch({ type: 'SHAPE_SET', shape: rotateShapeBy(shape, dThetaRad) }); + return; + } + dispatch({ type: 'SHAPE_SET', shape: { ...shape, x: shape.x + dx, y: shape.y + dy } }); + return; + } if (isId(id, 'ar')) { // 화살표 개체(§4.3 1.11). 메모와 같이 물리 바디가 없다. 여기가 비어 있던 탓에 // 화살표는 **키보드로 전혀 움직이지 않았다** — 유일한 조작 경로가 12px 드래그와 // 반경 22 CSS px 핸들 3개의 정밀 드래그뿐이었다 — 정밀 포인팅을 전제하는 조작은 // 그것이 어려운 입력에는 사실상 없는 기능과 같다. const arrow = step.arrows.find((a) => a.id === id); - if (arrow) dispatch({ type: 'ARROW_SET', arrow: nudgeArrow(arrow, arrowPart, { x: dx, y: dy }) }); + if (!arrow) return; + if (dThetaRad !== 0) { + // 축은 `arrowMid`(곡선의 t=0.5 점) — 포인터의 회전 앵커와 **같은 축**이라 손과 + // 키보드가 안 갈린다. 래치가 필요 없는 이유: 회전은 아핀 변환이고 `arrowMid` 는 + // 세 점의 아핀 결합((f+2c+t)/4)이라, 세 점을 그 점 둘레로 돌리면 그 점 자신은 + // **고정점**이다. 즉 매 프레임 다시 재도 같은 자리다(획은 그렇지 않다 — 아래). + dispatch({ type: 'ARROW_SET', arrow: rotateArrowAbout(arrow, arrowMid(arrow), dThetaRad) }); + return; + } + dispatch({ type: 'ARROW_SET', arrow: nudgeArrow(arrow, arrowPart, { x: dx, y: dy }) }); return; } if (isId(id, 'fh')) { @@ -407,13 +453,23 @@ export const EditorStage = forwardRef(functi const stroke = step.strokes?.find((s) => s.id === id); if (!stroke) return; if (dThetaRad !== 0) { - dispatch({ type: 'STROKE_SET', stroke: rotateStrokeAbout(stroke, strokeCenter(stroke), dThetaRad) }); + // ⚠️ 축을 **래치한다**(2026-09-14). `strokeCenter` 는 경계상자의 중심이고, 획을 돌리면 + // 그 상자가 바뀌므로 중심도 함께 움직인다 — 매번 다시 재면 5°씩 72번 돌렸을 때 획이 + // 제자리로 안 돌아오고 슬금슬금 흘러간다(화살표의 `arrowMid` 와 다른 점이 이것이다). + // 창은 되돌리기 병합 창과 **같은 값**이다: 「축 한 벌 = 되돌리기 한 칸」이라야 사람이 + // 되돌렸을 때 축과 상태가 같은 경계에서 끊긴다. + const now = performance.now(); + const latch = rotAxisRef.current; + const center = + latch && latch.id === id && now - latch.at <= COALESCE_MS ? latch.c : strokeCenter(stroke); + rotAxisRef.current = { id, c: center, at: now }; + dispatch({ type: 'STROKE_SET', stroke: rotateStrokeAbout(stroke, center, dThetaRad) }); return; } dispatch({ type: 'STROKE_SET', stroke: nudgeStroke(stroke, { x: dx, y: dy }) }); } }, - [dispatch, step.arrows, step.notes, step.strokes, worldRef, selection, lockedSet, ignoredSet], + [dispatch, step.arrows, step.notes, step.strokes, step.shapes, worldRef, selection, lockedSet, ignoredSet], ); const handleObjectKeyDown = useCallback( @@ -455,11 +511,22 @@ export const EditorStage = forwardRef(functi return; } case 'obj.rotate': { - // 회전이 없는 개체(공·콘·메모·화살표·도형)에서는 조용히 아무 일도 안 한다 — - // 휠체어만 방향을 가진다. - if (!isId(id, 'ch')) return; + // ⚠️ 2026-09-14 — 옛 줄은 `if (!isId(id, 'ch')) return;` 였고 주석은 *"회전이 없는 + // 개체(공·콘·메모·화살표·도형)에서는 조용히 아무 일도 안 한다 — 휠체어만 방향을 + // 가진다"* 였다. 그 말은 **그때도 사실이 아니었다**: 획은 `nudge()` 안에 회전 갈래가 + // 이미 있었고 [미세 조정] 패드는 그것을 썼는데, 이 한 줄이 키보드만 막고 있었다. + // 기현님 지시(*"모든 객체가 wasd,qe 키에 의해 위치 및 회전이 되어야한다"*)로 게이트를 + // 종류 판정 하나(`canRotateKind`)로 바꾼다 — 패드·메뉴와 **같은 함수**다. e.preventDefault(); e.stopPropagation(); + // 다중 선택에서는 회전이 없다(기현님이 명시한 예외). 여태는 **막힌 것이 아니라** + // 포커스가 앉은 하나만 조용히 돌았다 — 여럿을 골라 놓고 E 를 눌렀는데 하나만 도는 + // 것은 «무리»라는 말과 어긋난다. 이제 아무것도 안 돌리고, 왜 안 도는지 읽어 준다. + if (selection.size > 1 && selection.has(id)) { + liveRegion.say(translate(locale, 'editor.announce.rotateMulti')); + return; + } + if (!canRotateKind(id)) return; nudge(id, 0, 0, (eventCode(e) === 'KeyQ' ? -1 : 1) * deg * RAD); return; } @@ -602,32 +669,53 @@ export const EditorStage = forwardRef(functi [selection], ); - const selectedArrow = useMemo(() => { + /** 손잡이를 낼 수 있는 단일 선택인가. 셋(도형·화살표·획)이 **같은 판정**을 쓰게 묶는다. + * + * ⚠️ 2026-09-14 — 전에는 셋이 조금씩 달랐다: 도형·획은 `lockedSet` 만 보고 화살표는 아무것도 + * 안 봤다(`lockedSet` 을 deps 에만 적어 두고 본문에서 안 읽었다). 잠긴 화살표에 손잡이가 + * 그려지면 끌어도 안 바뀌는 손잡이가 되고, 이동 앵커가 «그려진 손잡이» 를 피해 다니는 지금은 + * **안 그려진 손잡이를 피해 비키는** 앵커까지 생긴다. 판정이 하나여야 둘 다 안 생긴다. */ + const handleTargetId = useMemo(() => { if (selection.size !== 1) return null; - const id = Array.from(selection).find((x) => isId(x, 'ar')) as ArrowId | undefined; - return id ? (step.arrows.find((a) => a.id === id) ?? null) : null; - }, [selection, step.arrows, lockedSet]); + const id = [...selection][0]!; + if (lockedSet.has(id) || ignoredSet.has(id)) return null; + return id; + }, [selection, lockedSet, ignoredSet]); + + const selectedArrow = useMemo(() => { + const id = handleTargetId; + return id && isId(id, 'ar') ? (step.arrows.find((a) => a.id === id) ?? null) : null; + }, [handleTargetId, step.arrows]); + + /** 이동 앵커가 감쌀 상자(§6.10d, 2026-09-13 기현님 지시). 뜨는 자리는 셋이다: + * **도형 하나 · 메모 하나 · 여럿**. 셋을 고른 이유는 지시 그대로 — 겹쳐 놓았을 때 몸통을 + * 집기 어려운 것들이다. 휠체어·공·콘 하나는 빠진다: 몸통이 크고 물리 드래그(견인·회전)가 + * 붙어 있어, 앵커로 옮기면 그 회로를 건너뛰어 **다른 이동**이 된다. + * + * 잠긴 것·무시된 것은 상자에서도 빠진다 — 옮길 수 없는 것을 감싼 앵커는 눌러도 안 움직인다. + * 전부 빠지면 null 이라 앵커 자체가 안 뜬다. */ + const moveAnchor = useMemo(() => { + const ids = moveAnchorIds(selection, lockedSet, ignoredSet); + if (ids.length === 0) return { bounds: null, guide: false }; + return { bounds: selectionBounds(step, drill.cast, new Set(ids)), guide: moveAnchorGuide(ids) }; + }, [selection, lockedSet, ignoredSet, step, drill.cast]); /** 선택이 정확히 하나이고 그것이 도형일 때만 손잡이를 띄운다 — 여럿을 고른 채로 손잡이를 * 내면 "무엇의 가로인가" 가 사라진다(화살표 핸들이 간 길과 같다). */ const selectedShape = useMemo(() => { - if (selection.size !== 1) return null; - const id = [...selection][0]!; // 잠긴 도형에는 손잡이를 안 낸다 — 끌어도 안 바뀌는 손잡이는 화면이 거짓말하는 것이다. - // 덮개(보라)가 "이건 잠겼다" 를 이미 말하고, 푸는 문은 메뉴다. - if (lockedSet.has(id)) return null; - return step.shapes.find((sh) => sh.id === id) ?? null; - }, [selection, step.shapes, lockedSet]); + // 덮개(보라)가 "이건 잠겼다" 를 이미 말하고, 푸는 문은 메뉴다. 판정은 handleTargetId 하나. + const id = handleTargetId; + return id ? (step.shapes.find((sh) => sh.id === id) ?? null) : null; + }, [handleTargetId, step.shapes]); /** 앵커를 띄울 획 — 도형·화살표와 **같은 세 조건**이다: 선택이 정확히 하나, 그것이 획, * 잠기지 않음. 잠긴 것에 손잡이를 내면 끌어도 안 바뀌는 손잡이라 화면이 거짓말을 한다. * hitTest 쪽 게이트(`selectedStrokeId`)와 같은 판정이라야 **보이는 앵커만 잡힌다**. */ const selectedStroke = useMemo(() => { - if (selection.size !== 1) return null; - const id = [...selection][0]!; - if (lockedSet.has(id)) return null; - return step.strokes?.find((s) => s.id === id) ?? null; - }, [selection, step.strokes, lockedSet]); + const id = handleTargetId; + return id ? (step.strokes?.find((s) => s.id === id) ?? null) : null; + }, [handleTargetId, step.strokes]); // ── 개체 메뉴 (2026-08-14 기현 지시) ──────────────────────────────────────────────── const [menu, setMenu] = useState(null); @@ -782,6 +870,7 @@ export const EditorStage = forwardRef(functi }} onShapeChange={(next) => dispatch({ type: 'SHAPE_SET', shape: next })} shapeHandles={{ shape: selectedShape }} + moveAnchor={moveAnchor} locked={lockedSet} ignored={ignoredSet} onStageContextMenu={(id, e) => { diff --git a/src/features/editor/EditorWorkspace.tsx b/src/features/editor/EditorWorkspace.tsx index b644f37a..cbcda221 100644 --- a/src/features/editor/EditorWorkspace.tsx +++ b/src/features/editor/EditorWorkspace.tsx @@ -620,6 +620,11 @@ export function EditorWorkspace({ mode = 'drill', board, onDrillInfo }: EditorWo ...step.notes.map((n) => n.id as string), ...step.arrows.map((a) => a.id as string), ...(step.shapes ?? []).map((s) => s.id as string), + // ⚠️ 2026-09-14 — 획(자유선)이 빠져 있었다. 이 목록은 2026-08 에 쓰였고 획은 2026-09-03 + // 에 생겼는데 그때 여기를 안 들렀다. 위 주석이 «판 위에 있는 것만» 이라고 말하는 한 + // 획이 빠진 것은 규칙이 아니라 누락이다 — 그려 놓은 자유선만 [전체 선택]에서 조용히 + // 남았다(이동 앵커 실기 중 발견). + ...(step.strokes ?? []).map((s) => s.id as string), ].filter((id) => !locked.has(id)); dispatch({ type: 'SELECT_SET', ids }); }, diff --git a/src/features/editor/FunctionBar.tsx b/src/features/editor/FunctionBar.tsx index e471b52e..095a24c2 100644 --- a/src/features/editor/FunctionBar.tsx +++ b/src/features/editor/FunctionBar.tsx @@ -793,6 +793,8 @@ export function FunctionBar({ setExportOpen(false)} + // 전술판이면 시트에서 [영상]·[링크로 공유] 가 사라진다(ExportSheetProps.mode 주석). + mode={mode} drill={drill} stepIndex={stepIndex} checkedStepIds={checkedStepIds} diff --git a/src/features/editor/NudgePad.tsx b/src/features/editor/NudgePad.tsx index 21f46fc6..bb659540 100644 --- a/src/features/editor/NudgePad.tsx +++ b/src/features/editor/NudgePad.tsx @@ -23,29 +23,14 @@ // 그 답은 이 패드 없이도 참이다). import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { isId } from '../../core/ids.ts'; import { useT } from '../../i18n/useT.ts'; -/** 미세 조정이 **먹는** 개체인가. `EditorStage.nudge` 가 실제로 다루는 종류와 같아야 한다 — - * 도형(`sh`)은 거기서도 빠져 있어 여기서도 뺀다(있는데 안 먹는 칸을 내지 않는다). - * export 인 이유: 개체 메뉴가 [미세 조정] 칸을 낼지 말지 **같은 판정**으로 정해야 한다. */ -export const canNudge = (ids: readonly string[]): boolean => - ids.length > 0 && - ids.every((id) => isId(id, 'ch') || isId(id, 'bl') || isId(id, 'cn') || isId(id, 'nt') || isId(id, 'ar') || isId(id, 'fh')); - -/** 회전은 **하나일 때만**, 그리고 **회전축이 하나로 정해지는 개체**에만 낸다. - * - * · 휠체어(`ch`) — 방향을 가진 유일한 캐스트다. 축은 피벗이고, 키보드 Q·E 와 같은 길이다. - * · 획(`fh`, 2026-09-03) — 축은 경계상자 중심(`strokeCenter`)이고, 포인터의 회전 앵커가 - * 이미 그 축으로 돈다. 여기서 안 내면 **정밀 드래그로만 되는 조작**이 하나 생기는데, - * 회전 앵커는 반경 22 CSS px 표적이라 그것이 어려운 손에는 사실상 없는 기능과 같다. - * (개체 순회 `order` 에는 아직 획이 없어 물리 키보드로는 못 닿는다 — 도형과 같은 상태다. - * 이 패드는 개체 메뉴에서 열리므로 그 제약을 안 탄다.) - * - * 화살표가 빠지는 것은 원리가 아니라 아직 안 이은 배선이다(포인터에는 회전 앵커가 있다) — - * 뒤집을 때 이 문장이 근거가 되라고 적어 둔다. 공·콘·메모는 축이 곧 자기 좌표라 돌 것이 없다. */ -export const canRotate = (ids: readonly string[]): boolean => - ids.length === 1 && (isId(ids[0]!, 'ch') || isId(ids[0]!, 'fh')); +// ⚠️ 2026-09-14 — `canNudge`·`canRotate` 는 **이 파일에서 나갔다**(`nudgeCaps.ts`). 판정이 화면 +// 부품 안에 살던 동안 키보드가 그것을 안 읽어, 패드는 획을 돌릴 수 있는데 Q·E 는 못 돌리는 +// 비대칭이 살아 있었다. 이름을 그대로 재수출하는 것은 바깥 호출자(개체 메뉴)를 안 건드리려는 +// 것이고, **판정 자체는 이제 여기 없다**. +import { canRotate } from './nudgeCaps.ts'; +export { canNudge, canRotate } from './nudgeCaps.ts'; /** 누르고 있으면 반복한다. 없으면 25px 을 옮기는 데 열 번을 눌러야 하고, 그건 정밀 조작을 * 주려다 반복 조작을 새로 만드는 것이다. 700ms 병합(COALESCE_TYPES)이 있으므로 이 반복은 diff --git a/src/features/editor/moveAnchorDrag.test.tsx b/src/features/editor/moveAnchorDrag.test.tsx new file mode 100644 index 00000000..ef56807c --- /dev/null +++ b/src/features/editor/moveAnchorDrag.test.tsx @@ -0,0 +1,197 @@ +// §6.10d 이동 앵커 — 누르면 **고른 것이 통째로 따라오는가**. 2026-09-13 기현님 지시에서 나왔다: +// *"도형, 메모, 다중 선택에서 객체가 겹쳐 있으면 집어 드래그로 옮기기가 쉽지 않더라."* +// +// 앵커의 자리(화면 위/아래)는 `moveAnchorPlacement.test.ts` 가, 언제 뜨는지는 +// `moveAnchorIds.test.ts` 가 잰다. 여기서 재는 것은 **그 누름이 실제로 무엇을 하는가** 하나다. +// 지우면 새는 것: 앵커가 떠 있는데 눌러도 안 움직이는 회귀, 그리고 앵커가 히트테스트를 타서 +// 밑에 깔린 남의 개체를 잡는 회귀 — 후자는 이 기능의 존재 이유 자체를 무너뜨린다. +// +// 포인터 사건을 DOM 에 쏘지 않고 controller 를 직접 부른다 — jsdom 은 getBoundingClientRect 가 +// 전부 0 이라 client→world 변환이 NaN 이 된다(tapDeselect.test.tsx 머리말과 같은 이유). +// 무대 핸들이 없으므로 pxPerUnit=1 이고, 탭 임계는 6 CSS px = 6 월드 px 이다. +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useRef } from 'react'; +import type { ReactNode } from 'react'; +import { BALL, DEFAULT_ZONES } from '../../core/constants.ts'; +import { newId } from '../../core/ids.ts'; +import type { ArrowId, NoteId, ShapeId, StrokeId } from '../../core/ids.ts'; +import { createDrill } from '../../model/defaults.ts'; +import type { Drill, DrillStep } from '../../model/drill.ts'; +import type { CourtStageHandle, PointerMeta } from '../../render/CourtStage.tsx'; +import { SettingsProvider } from '../../store/settings/SettingsProvider.tsx'; +import { EditorProvider, useEditorDispatch, useEditorState, useEditorWorld, useEditorWriter } from '../../store/editor/EditorProvider.tsx'; +import { useEditorPointer } from './useEditorPointer.ts'; + +const META: PointerMeta = { pointerType: 'touch', button: 0, shiftKey: false, metaKey: false, ctrlKey: false, altKey: false }; +const ANCHOR_META: PointerMeta = { ...META, moveAnchor: true }; +const CLIENT = { x: 10, y: 10 }; + +/** 앵커를 누르는 자리는 개체 **위쪽 허공**이다 — 실제 앵커가 뜨는 곳이고, 히트테스트를 타면 + * 아무것도 안 잡히는 곳이기도 하다. 그런데도 움직여야 한다는 것이 이 테스트의 요지다. */ +const ANCHOR_AT = { x: 300, y: 160 }; +const SHAPE_AT = { x: 300, y: 220 }; +const NOTE_AT = { x: 320, y: 240 }; + +function makeDrill(): { drill: Drill; shapeId: ShapeId; underId: ShapeId; noteId: NoteId; arrowId: ArrowId; strokeId: StrokeId } { + const base = createDrill({ courtMode: 'full', formation: '1-2-1', empty: true }); + const shapeId = newId('sh'); + const underId = newId('sh'); + const noteId = newId('nt'); + const arrowId = newId('ar'); + const strokeId = newId('fh'); + const step0 = base.steps[0]!; + const step: DrillStep = { + ...step0, + // 두 도형을 **같은 자리에** 겹쳐 둔다 — 몸통을 집으면 위엣것이 손을 먹는 그 상황이다. + shapes: [ + { id: underId, kind: 'rect', x: SHAPE_AT.x, y: SHAPE_AT.y, w: 80, h: 60, rot: 0 }, + { id: shapeId, kind: 'rect', x: SHAPE_AT.x, y: SHAPE_AT.y, w: 80, h: 60, rot: 0 }, + ], + notes: [{ id: noteId, x: NOTE_AT.x, y: NOTE_AT.y, text: '메모' }], + arrows: [{ id: arrowId, from: { x: 100, y: 400 }, ctrl: { x: 150, y: 380 }, to: { x: 200, y: 420 } }], + strokes: [{ id: strokeId, points: [{ x: 400, y: 100 }, { x: 430, y: 130 }, { x: 460, y: 110 }] }], + }; + return { drill: { ...base, steps: [step] }, shapeId, underId, noteId, arrowId, strokeId }; +} + +const noop = () => {}; + +function useHarness() { + const state = useEditorState(); + const dispatch = useEditorDispatch(); + const worldRef = useEditorWorld(); + const writer = useEditorWriter(); + const stageRef = useRef(null); + const drill = state.present; + const step = drill.steps[0]!; + const pointer = useEditorPointer({ + drill, + stepIndex: 0, + step, + tool: 'select', + coneSlot: 0, + selection: state.selection, + dispatch, + worldRef, + writer, + stageRef, + zones: DEFAULT_ZONES, + ballMax: BALL.maxCount, + pendingPlayerId: null, + onPlayerPlaced: noop, + showToast: noop, + forceHandlesVisible: false, + largeTargets: false, + }); + return { state, dispatch, pointer }; +} + +function mount(drill: Drill) { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + return renderHook(() => useHarness(), { wrapper }); +} + +type Harness = ReturnType['result']; + +/** 앵커를 눌러 dx 만큼 끌고 손을 뗀다. 한 번의 이동이 탭 임계(6)를 넘어야 실제로 밀린다. */ +function dragAnchor(result: Harness, dx: number, dy = 0) { + const ctrl = () => result.current.pointer.controller; + act(() => void ctrl().onPointerDown(ANCHOR_AT, ANCHOR_META)); + act(() => ctrl().onPointerMove({ x: ANCHOR_AT.x + dx, y: ANCHOR_AT.y + dy }, performance.now())); + act(() => ctrl().onPointerUp(CLIENT)); +} + +const shapeOf = (result: Harness, id: string) => result.current.state.present.steps[0]!.shapes.find((s) => s.id === id)!; +const noteOf = (result: Harness, id: string) => result.current.state.present.steps[0]!.notes.find((n) => n.id === id)!; + +afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); +}); + +describe('이동 앵커 — 누르면 고른 것이 따라온다', () => { + it('겹친 도형 중 **고른 것만** 옮긴다 — 앵커는 히트테스트를 타지 않으므로 밑에 깔린 것은 그대로다', () => { + const { drill, shapeId, underId } = makeDrill(); + const { result } = mount(drill); + act(() => void result.current.dispatch({ type: 'SELECT_SET', ids: [shapeId] })); + + dragAnchor(result, 40); + + expect(shapeOf(result, shapeId).x, '고른 도형은 40 만큼 갔다').toBe(SHAPE_AT.x + 40); + expect(shapeOf(result, underId).x, '밑에 깔린 도형은 제자리다').toBe(SHAPE_AT.x); + }); + + it('메모 하나도 같은 길로 옮겨진다', () => { + const { drill, noteId } = makeDrill(); + const { result } = mount(drill); + act(() => void result.current.dispatch({ type: 'SELECT_SET', ids: [noteId] })); + + dragAnchor(result, 0, 30); + + expect(noteOf(result, noteId).y).toBe(NOTE_AT.y + 30); + }); + + it('여럿을 골랐으면 통째로 — 도형과 메모가 같은 거리만큼 간다', () => { + const { drill, shapeId, noteId } = makeDrill(); + const { result } = mount(drill); + act(() => void result.current.dispatch({ type: 'SELECT_SET', ids: [shapeId, noteId] })); + + dragAnchor(result, 25); + + expect(shapeOf(result, shapeId).x).toBe(SHAPE_AT.x + 25); + expect(noteOf(result, noteId).x).toBe(NOTE_AT.x + 25); + }); + + // 2026-09-14 기현님 지시: *"일단 선, 자유선에도 적용하고"*. 선은 세 점, 획은 점 N개라 + // **전부 같은 거리만큼** 가야 한다 — 한 점만 밀면 모양이 바뀐다(그건 이동이 아니라 편집이다). + it('선(화살표)은 세 점이 함께 간다 — 한 점만 밀면 모양이 바뀐다', () => { + const { drill, arrowId } = makeDrill(); + const { result } = mount(drill); + act(() => void result.current.dispatch({ type: 'SELECT_SET', ids: [arrowId] })); + + dragAnchor(result, 35); + + const a = result.current.state.present.steps[0]!.arrows.find((x) => x.id === arrowId)!; + expect(a.from.x).toBe(100 + 35); + expect(a.ctrl.x).toBe(150 + 35); + expect(a.to.x).toBe(200 + 35); + expect(a.from.y, '가로로만 끌었으니 세로는 그대로다').toBe(400); + }); + + it('자유선(획)은 점 전부가 함께 간다', () => { + const { drill, strokeId } = makeDrill(); + const { result } = mount(drill); + act(() => void result.current.dispatch({ type: 'SELECT_SET', ids: [strokeId] })); + + dragAnchor(result, 0, -20); + + const st = result.current.state.present.steps[0]!.strokes!.find((x) => x.id === strokeId)!; + expect(st.points.map((p) => p.y)).toEqual([80, 110, 90]); + expect(st.points.map((p) => p.x), '세로로만 끌었으니 가로는 그대로다').toEqual([400, 430, 460]); + }); + + it('탭 임계(6) 안의 떨림으로는 한 톨도 안 움직인다 — 누르기만 한 손이 판을 흔들면 안 된다', () => { + const { drill, shapeId } = makeDrill(); + const { result } = mount(drill); + act(() => void result.current.dispatch({ type: 'SELECT_SET', ids: [shapeId] })); + + dragAnchor(result, 4); + + expect(shapeOf(result, shapeId).x).toBe(SHAPE_AT.x); + }); + + it('앵커 누름은 선택을 바꾸지 않는다 — 허공을 눌렀다고 선택이 풀리면 옮길 것이 사라진다', () => { + const { drill, shapeId, noteId } = makeDrill(); + const { result } = mount(drill); + act(() => void result.current.dispatch({ type: 'SELECT_SET', ids: [shapeId, noteId] })); + + dragAnchor(result, 25); + + expect(result.current.state.selection.size).toBe(2); + }); +}); diff --git a/src/features/editor/moveAnchorIds.test.ts b/src/features/editor/moveAnchorIds.test.ts new file mode 100644 index 00000000..341a57aa --- /dev/null +++ b/src/features/editor/moveAnchorIds.test.ts @@ -0,0 +1,60 @@ +// 이동 앵커가 언제 뜨고 무엇을 옮기는가(§6.10d, 2026-09-13 기현님 지시). +// +// 이 명단이 앵커의 **상자**와 앵커가 미는 **대상**을 한꺼번에 정한다. 지우면 새는 것 셋: +// ① 휠체어 하나에도 앵커가 떠서 물리 드래그(견인·회전·충돌)를 건너뛰는 이동이 생긴다, +// ② 잠근 것을 감싼 앵커가 떠서 눌러도 안 움직이는 버튼이 된다, +// ③ 그리는 쪽과 미는 쪽이 갈려 "감싼 것과 옮겨지는 것이 다른" 화면이 된다. +import { describe, expect, it } from 'vitest'; +import { moveAnchorGuide, moveAnchorIds } from './moveAnchorIds.ts'; + +const set = (...ids: string[]) => new Set(ids); + +describe('moveAnchorIds', () => { + it('도형 하나·메모 하나에는 뜬다 — 겹쳐 놓으면 몸통을 못 집는 둘이다', () => { + expect(moveAnchorIds(set('sh_1'))).toEqual(['sh_1']); + expect(moveAnchorIds(set('nt_1'))).toEqual(['nt_1']); + }); + + // ⚠️ 2026-09-14 — 옛 단언은 화살표·획도 «안 뜬다» 였다(기현님 지시로 뒤집힘: *"선, 자유선에도 + // 적용"*). 옛 줄을 고쳐 쓰지 않고 둘로 가르는 이유는, 무엇이 뒤집혔는지가 파일에 남아야 + // 다음 사람이 «둘 다 원래 그랬나» 를 되묻지 않기 때문이다. + it('휠체어·공·콘 **하나**에는 안 뜬다 — 그 셋은 몸통을 잡으면 물리 드래그가 열린다', () => { + for (const id of ['ch_1', 'bl_1', 'cn_1']) { + expect(moveAnchorIds(set(id)), id).toEqual([]); + } + }); + + it('선(화살표)·자유선(획) 하나에도 뜬다 — 가는 선은 겹치면 오히려 더 집기 어렵다', () => { + expect(moveAnchorIds(set('ar_1'))).toEqual(['ar_1']); + expect(moveAnchorIds(set('fh_1'))).toEqual(['fh_1']); + }); + + it('여럿이면 종류를 가리지 않는다 — 휠체어와 메모를 같이 골라도 통째로 옮긴다', () => { + expect(moveAnchorIds(set('ch_1', 'nt_1')).sort()).toEqual(['ch_1', 'nt_1']); + }); + + it('잠긴 것·무시된 것은 빠진다 — 눌러도 안 움직이는 것을 감싸면 고장 난 버튼이다', () => { + expect(moveAnchorIds(set('sh_1', 'sh_2'), set('sh_2'))).toEqual(['sh_1']); + expect(moveAnchorIds(set('sh_1', 'sh_2'), undefined, set('sh_1'))).toEqual(['sh_2']); + expect(moveAnchorIds(set('sh_1'), set('sh_1')), '전부 잠겼으면 앵커 자체가 없다').toEqual([]); + }); + + it('걸러서 휠체어 하나만 남으면 안 뜬다 — 거른 **뒤**의 명단으로 판정한다', () => { + expect(moveAnchorIds(set('ch_1', 'sh_1'), set('sh_1'))).toEqual([]); + }); + + it('빈 선택은 빈 명단', () => { + expect(moveAnchorIds(set())).toEqual([]); + }); + + // 가이드 사각형의 조건 — 「상자가 몸통과 다를 때만」. 이것이 틀리면 판 위에 사각형이 하나 더 + // 늘거나(도형·메모), 여럿을 고른 사람이 앵커가 왜 거기 있는지 영영 모른다(원래 지적). + it('가이드 사각형은 여럿·선·자유선에만 — 도형 하나·메모 하나에는 안 그린다', () => { + expect(moveAnchorGuide(['sh_1', 'nt_1']), '여럿').toBe(true); + expect(moveAnchorGuide(['ar_1']), '선 하나').toBe(true); + expect(moveAnchorGuide(['fh_1']), '자유선 하나').toBe(true); + expect(moveAnchorGuide(['sh_1']), '도형은 손잡이가 상자를 말한다').toBe(false); + expect(moveAnchorGuide(['nt_1']), '메모는 선택 링이 칩을 감싼다').toBe(false); + expect(moveAnchorGuide([]), '빈 선택').toBe(false); + }); +}); diff --git a/src/features/editor/moveAnchorIds.ts b/src/features/editor/moveAnchorIds.ts new file mode 100644 index 00000000..930b8e98 --- /dev/null +++ b/src/features/editor/moveAnchorIds.ts @@ -0,0 +1,59 @@ +// 이동 앵커가 **무엇을 감싸고 무엇을 옮기는가**. 순수 함수 하나(§6.10d, 2026-09-13). +// +// 왜 따로 두나: 이 명단이 두 곳에서 쓰인다 — 앵커가 뜰 상자를 재는 곳(EditorStage)과, 앵커를 +// 눌렀을 때 실제로 미는 곳(useEditorPointer). 두 곳이 각자 명단을 만들면 언젠가 갈라지고, +// 갈라지는 순간 **앵커가 감싼 것과 옮겨지는 것이 달라진다** — 화면이 거짓말하는 부류의 사고다. +import { isId } from '../../core/ids.ts'; + +/** + * 지금 선택에서 앵커가 다룰 id 들. 앵커를 띄우지 않을 자리에서는 **빈 배열**이다. + * + * 규칙 둘: + * ① 잠긴 것·무시된 것은 뺀다 — 눌러도 안 움직이는 것을 감싼 앵커는 고장 난 버튼이다. + * ② 거르고 **하나만 남았다면** 그것이 도형(`sh`)이나 메모(`nt`)일 때만 낸다. + * + * ②의 허용 목록은 기현님 지시 둘이 함께 정한다 — 2026-09-13 *"도형, 메모, 다중 선택에서…"* 와 + * 2026-09-14 *"선, 자유선에도 적용"*. 휠체어·공·콘 하나가 빠지는 이유는 + * 크기가 아니라 **회로**다: 그 셋은 몸통을 잡으면 물리 드래그(견인·회전·충돌·리시)가 열리는데, + * 앵커는 모델을 직접 미는 길이라 같은 손짓이 다른 이동이 된다. 겹쳐서 못 집는 문제도 그 셋에는 + * 거의 없다 — 몸통이 넓고, 가리면 표시순서로 꺼낸다. + * + * ⚠️ 2026-09-14 — 위 문단의 *"화살표·획이 하나일 때도 빠진다: 그쪽은 몸통 어디를 잡아도 끌리고, + * 이미 제 손잡이가 있다"* 는 기현님 지시로 **뒤집혔다**(*"일단 선, 자유선에도 적용하고"*). + * 근거는 지우지 않는다 — 틀린 말이 아니었고, 다만 전제가 좁았다: «몸통 어디를 잡아도 끌린다» 는 + * 그 선이 **맨 위에 있을 때만** 참이다. 가는 선은 겹치면 도형·메모보다 오히려 집기 어렵고, + * 제 손잡이는 끝점·굽힘점을 **바꾸는** 것이라 통째로 옮기는 손잡이가 아니다. + * 휠체어·공·콘은 그대로 빠진다(위 ②의 근거는 아직 살아 있다). + */ +export function moveAnchorIds( + selection: ReadonlySet, + locked?: ReadonlySet, + ignored?: ReadonlySet, +): string[] { + const ids = Array.from(selection).filter((id) => !locked?.has(id) && !ignored?.has(id)); + if (ids.length === 0) return []; + if (ids.length === 1) { + const only = ids[0]!; + if (!isId(only, 'sh') && !isId(only, 'nt') && !isId(only, 'ar') && !isId(only, 'fh')) return []; + } + return ids; +} + +/** + * 이 선택에 **가이드 사각형**을 그리는가(§6.10e, 2026-09-14 기현님 지시). + * + * 기준 하나: **감싼 상자가 개체의 몸통과 다를 때만** 그린다. + * - 여럿 → 그린다. 상자가 어느 개체와도 안 맞아 앵커가 허공에 뜬 것처럼 보인다(지적의 원문). + * - 선·자유선 하나 → 그린다. 가늘고 비스듬한 선의 AABB 는 선 자체와 전혀 달라 같은 일이 난다. + * - 도형 하나 → 안 그린다. 손잡이 셋이 이미 상자를 말한다. + * - 메모 하나 → 안 그린다. 선택 링이 이미 칩을 감싸고 있다. + * + * 안 그리는 둘에 굳이 그리면 판 위에 사각형이 하나 더 늘 뿐이다 — 규칙 존·러버밴드·도형과 + * 겹쳐 읽히는 위험만 커진다. + */ +export function moveAnchorGuide(ids: readonly string[]): boolean { + if (ids.length === 0) return false; + if (ids.length > 1) return true; + const only = ids[0]!; + return isId(only, 'ar') || isId(only, 'fh'); +} diff --git a/src/features/editor/nudgeCaps.ts b/src/features/editor/nudgeCaps.ts new file mode 100644 index 00000000..ff7652cd --- /dev/null +++ b/src/features/editor/nudgeCaps.ts @@ -0,0 +1,50 @@ +// 「이 개체를 키보드·패드로 **옮길 수 있는가·돌릴 수 있는가**」의 유일한 출처. +// +// ⚠️ 2026-09-14 — 이 두 함수는 `NudgePad.tsx` 안에 살았다. 화면 부품 안에 판정이 있으면 그것을 +// 읽지 않는 길이 생기고, 실제로 생겼다: 터치 패드는 획(`fh`)을 **돌릴 수 있었는데** 키보드 Q·E 는 +// `if (!isId(id, 'ch')) return;` 으로 휠체어만 돌렸다. 같은 앱에서 손가락으로 되는 일이 키보드로 +// 안 되는 자리다(§7.5 가 금지하는 바로 그 비대칭). 판정을 부품 밖으로 꺼내 **세 문**(키보드· +// [미세 조정] 패드·개체 메뉴)이 한 답을 쓰게 한다. +import { isId } from '../../core/ids.ts'; + +/** 미세 조정이 **먹는** 개체인가. + * + * ⚠️ 2026-09-14 — 옛 주석은 *"도형(`sh`)은 `EditorStage.nudge` 에서도 빠져 있어 여기서도 + * 뺀다(있는데 안 먹는 칸을 내지 않는다)"* 였다. 근거는 옳았고 **전제가 고쳐졌다**: 이제 도형도 + * 키보드로 움직인다(기현님 지시 *"모든 객체가 wasd,qe 키에 의해 위치 및 회전이 되어야한다"*). + * 그러니 여기서도 낸다 — 두 곳이 갈라지지 않게 하는 것이 이 파일의 존재 이유다. */ +export const canNudge = (ids: readonly string[]): boolean => + ids.length > 0 && + ids.every( + (id) => + isId(id, 'ch') || + isId(id, 'bl') || + isId(id, 'cn') || + isId(id, 'nt') || + isId(id, 'ar') || + isId(id, 'fh') || + isId(id, 'sh'), + ); + +/** 회전이 **뜻을 갖는** 종류인가 — 축이 값 하나로 정해지는 넷. + * + * · 휠체어(`ch`) — 방향을 가진 유일한 캐스트. 축은 피벗. + * · 도형(`sh`) — `rot`(도, 시계방향). 축은 `x,y`(삼각형은 무게중심). + * · 화살표(`ar`) — 축은 `arrowMid`(곡선의 t=0.5 점). 포인터의 회전 앵커와 같은 축이다. + * · 획(`fh`) — 축은 경계상자 중심(`strokeCenter`). 역시 포인터와 같은 축. + * + * 빠지는 셋에는 **돌릴 것이 없다**: 공은 원이라 돌아도 같은 그림이고, 콘·메모는 모델에 각도 + * 필드 자체가 없다. 메모는 기현님이 명시적으로 예외로 지정하기도 했다(*"단 메모와 다중 + * 선택에서 회전은 예외"*) — 없는 것을 굳이 만들지 않는다는 뜻이라 코드와 지시가 같다. + * + * ⚠️ 2026-09-14 — 옛 주석의 *"화살표가 빠지는 것은 원리가 아니라 아직 안 이은 배선이다 … + * 뒤집을 때 이 문장이 근거가 되라고 적어 둔다"* 는 그 약속대로 지금 뒤집혔다. */ +export const canRotateKind = (id: string): boolean => + isId(id, 'ch') || isId(id, 'sh') || isId(id, 'ar') || isId(id, 'fh'); + +/** 회전은 **하나일 때만**이다. + * + * 무리의 회전축은 답이 하나로 안 나온다(`applyGroupNudge` 주석) — 무게중심인지, 감싼 상자의 + * 중심인지, 포커스가 앉은 개체인지. 기현님도 같은 자리를 예외로 지정했다: *"다중 선택에서 + * 회전은 예외"*. 그래서 이 함수는 개수부터 본다. */ +export const canRotate = (ids: readonly string[]): boolean => ids.length === 1 && canRotateKind(ids[0]!); diff --git a/src/features/editor/objectMenu.test.tsx b/src/features/editor/objectMenu.test.tsx index 11965c75..7e28a3fa 100644 --- a/src/features/editor/objectMenu.test.tsx +++ b/src/features/editor/objectMenu.test.tsx @@ -395,7 +395,9 @@ describe('[복제] — 무대 끝까지', () => { expect(b.y - a.y).toBe(12.5); // 선택이 사본으로 갔다 — 다음 조작(끌어 자리 잡기)이 향하는 곳이 방금 만든 쪽이라야 한다. // ShapeLayer 는 선택된 도형의 테두리를 accent 로 갈아 끼운다. - expect(nodes[1]!.querySelector('rect')?.getAttribute('stroke')).toBe('var(--accent)'); + // ⚠️ 2026-09-14 — 도형 한 장이 **두 요소**다(검정 케이싱 + 본체). 첫 rect 는 케이싱이므로 + // 색을 보려면 **마지막** 것을 집는다. + expect(nodes[1]!.querySelector('[data-shape-face]')?.getAttribute('stroke')).toBe('var(--accent)'); expect(nodes[0]!.querySelector('rect')?.getAttribute('stroke')).not.toBe('var(--accent)'); }); @@ -575,10 +577,13 @@ describe('[미세 조정] 칸', () => { it('잠긴 개체에는 안 난다 — 눌러도 안 움직이는 칸을 내지 않는다', () => { open(['ch_1'], true); expect(screen.queryByRole('menuitem', { name: '미세 조정' })).toBeNull(); - // 미세 조정이 안 먹는 개체(도형)도 마찬가지다 — 잠김과 다른 입력, 같은 결과. + // ⚠️ 2026-09-14 — 옛 단언은 *"미세 조정이 안 먹는 개체(도형)도 마찬가지다"* 로 도형에도 + // 칸이 안 나는 것을 못박고 있었다. 그 전제가 기현님 지시로 뒤집혔다(*"모든 객체가 + // wasd,qe 키에 의해 위치 및 회전이 되어야한다"*) — 도형은 이제 옮길 수 있으므로 칸이 + // **나야** 한다. 잠김 쪽 단언(위)은 그대로다. cleanup(); open(['sh_1']); - expect(screen.queryByRole('menuitem', { name: '미세 조정' })).toBeNull(); + expect(screen.queryByRole('menuitem', { name: '미세 조정' })).not.toBeNull(); }); }); diff --git a/src/features/editor/objectRotateKeys.test.tsx b/src/features/editor/objectRotateKeys.test.tsx new file mode 100644 index 00000000..3287081b --- /dev/null +++ b/src/features/editor/objectRotateKeys.test.tsx @@ -0,0 +1,118 @@ +// Q·E 회전이 **어느 개체에 걸리고 어느 개체에 안 걸리는가**(2026-09-14 기현님 지시: +// *"모든 객체가 wasd,qe 키에 의해 위치 및 회전이 되어야한다. 단 메모와 다중 선택에서 회전은 예외"*). +// +// 지우면 새는 것 셋: +// ① 도(度)와 라디안을 한 번 헷갈리면 한 번 눌렀는데 도형이 286° 돈다 — 화면에서는 "왜 이러지" +// 한 번이고, 테스트로는 **리터럴 숫자 하나**로 잡힌다. 그래서 여기 값은 전부 손으로 적었다. +// ② 획의 회전축을 매 키마다 다시 재면 획이 슬금슬금 흘러간다(경계상자 중심이 돌면 같이 움직인다). +// ⚠️ 그 드리프트는 **한 바퀴(360°)에서 정확히 상쇄된다** — 처음에 72번 돌려 제자리인지 재려다 +// 래치 없이도 초록이 나오는 것을 보고 알았다. 90°로 잰다(아래 주석에 실측표). +// ③ 여럿을 고른 채 E 를 누르면 **아무것도 안 돌아야** 한다. 예전에는 포커스 하나만 조용히 돌았다. +// +// 리듀서를 직접 돌린다 — 키 이벤트→DOM 경로는 jsdom 에서 좌표가 0 이라 못 재고, 여기서 재려는 +// 것은 «어느 값이 얼마나 바뀌는가» 이지 «키가 배선됐는가» 가 아니다(배선은 keymap.contract 가 잰다). +import { describe, expect, it } from 'vitest'; +import { canRotate, canRotateKind, canNudge } from './nudgeCaps.ts'; +import { rotateStrokeAbout, strokeCenter } from '../../model/stroke.ts'; +import { arrowMid, rotateArrowAbout } from '../../model/arrow.ts'; +import type { Stroke } from '../../model/stroke.ts'; +import type { Arrow } from '../../model/arrow.ts'; +import { rotateShapeBy } from '../../model/shape.ts'; +import type { Shape } from '../../model/shape.ts'; +import type { ArrowId, ShapeId, StrokeId } from '../../core/ids.ts'; + +/** Q 는 −5°, E 는 +5°(Shift 는 15°) — EditorStage 의 FINE/COARSE_STEP_DEG 와 같은 값이다. */ +const FINE_DEG = 5; +const RAD = Math.PI / 180; + +describe('회전이 걸리는 종류', () => { + it('휠체어·도형·선·자유선은 돌고, 공·콘·메모는 안 돈다 — 없는 축을 지어내지 않는다', () => { + for (const id of ['ch_1', 'sh_1', 'ar_1', 'fh_1']) expect(canRotateKind(id), id).toBe(true); + // 메모는 기현님이 명시한 예외이고, 공·콘은 모델에 각도 필드가 아예 없다. + for (const id of ['nt_1', 'bl_1', 'cn_1']) expect(canRotateKind(id), id).toBe(false); + }); + + it('여럿이면 종류와 무관하게 안 돈다 — 무리의 회전축은 답이 하나로 안 나온다', () => { + expect(canRotate(['sh_1'])).toBe(true); + expect(canRotate(['sh_1', 'sh_2'])).toBe(false); + expect(canRotate(['ch_1', 'nt_1'])).toBe(false); + expect(canRotate([])).toBe(false); + }); + + it('도형은 이제 옮길 수 있다 — 이 줄이 빨개지면 [미세 조정] 칸과 키보드가 다시 갈린 것이다', () => { + expect(canNudge(['sh_1'])).toBe(true); + expect(canNudge(['sh_1', 'ch_1'])).toBe(true); + }); +}); + +describe('도형 회전 — 도(度)와 라디안을 섞지 않는다', () => { + // ⚠️ **진짜 함수를 부른다.** 처음에는 같은 식을 테스트 안에 옮겨 적었는데, 그러면 EditorStage + // 가 환산을 빼먹어도 초록이다(검사표가 제 손으로 답을 만든 셈). 그래서 산수를 모델 + // (`rotateShapeBy`)로 옮기고 여기서 그것을 부른다 — 기대값만 리터럴로 적는다. + const shape = (rot: number): Shape => ({ id: 'sh_1' as ShapeId, kind: 'rect', x: 0, y: 0, w: 100, h: 60, rot }); + + it('E 한 번이면 5°, Q 한 번이면 355° — 라디안을 그대로 더하면 286° 가 나온다', () => { + expect(rotateShapeBy(shape(0), FINE_DEG * RAD).rot).toBeCloseTo(5, 9); + expect(rotateShapeBy(shape(0), -FINE_DEG * RAD).rot).toBeCloseTo(355, 9); + }); + + it('한 바퀴를 넘으면 0~360 으로 접힌다 — 같은 도형이 −5° 와 355° 두 값으로 저장되면 안 된다', () => { + expect(rotateShapeBy(shape(357), FINE_DEG * RAD).rot).toBeCloseTo(2, 9); + expect(rotateShapeBy(shape(2), -FINE_DEG * RAD).rot).toBeCloseTo(357, 9); + }); + + it('자리·크기는 안 건드린다 — 회전 키가 도형을 옮기면 그건 다른 기능이다', () => { + const before = shape(30); + const after = rotateShapeBy(before, FINE_DEG * RAD); + expect({ x: after.x, y: after.y, w: after.w, h: after.h }).toEqual({ x: 0, y: 0, w: 100, h: 60 }); + }); +}); + +describe('회전축 — 흘러가는가', () => { + const stroke: Stroke = { + id: 'fh_1' as StrokeId, + points: [ + { x: 100, y: 100 }, + { x: 180, y: 140 }, + { x: 140, y: 220 }, + ], + }; + + it('획은 축을 **래치해야** 한 번에 90° 돌린 것과 같다 — 매번 다시 재면 15px 흘러간다', () => { + // ⚠️ **360°로 재면 안 된다.** 한 바퀴를 채우면 흘러간 양이 정확히 상쇄되어 차이가 0 이 된다 + // (실측: 30° 4.3px · 45° 7.2px · 90° 15.3px · 180° 21.6px · 360° 0.000px). 하필 그 각도로 + // 재면 래치를 빼도 초록이라, 이 테스트가 아무것도 안 지키게 된다. 90°로 잰다. + const c0 = strokeCenter(stroke); + const STEPS = 18; // 5° × 18 = 90° + + let latched = stroke; + for (let i = 0; i < STEPS; i++) latched = rotateStrokeAbout(latched, c0, FINE_DEG * RAD); + // 래치한 결과는 **한 번에 90° 돌린 것과 같아야** 한다 — 이것이 «축이 하나로 고정됐다» 의 뜻이다. + const once = rotateStrokeAbout(stroke, c0, 90 * RAD); + for (let i = 0; i < stroke.points.length; i++) { + expect(Math.hypot(latched.points[i]!.x - once.points[i]!.x, latched.points[i]!.y - once.points[i]!.y)).toBeLessThan(0.001); + } + + // 대조군 — 매번 다시 잰 축. 이쪽이 어긋난다는 것이 래치의 존재 이유다. + let drifting = stroke; + for (let i = 0; i < STEPS; i++) drifting = rotateStrokeAbout(drifting, strokeCenter(drifting), FINE_DEG * RAD); + const gap = Math.max( + ...drifting.points.map((p, i) => Math.hypot(p.x - latched.points[i]!.x, p.y - latched.points[i]!.y)), + ); + expect(gap, '축을 다시 재면 15px 쯤 어긋난다').toBeGreaterThan(10); + }); + + it('선(화살표)은 래치가 필요 없다 — `arrowMid` 는 회전의 **고정점**이다', () => { + const arrow: Arrow = { + id: 'ar_1' as ArrowId, + from: { x: 100, y: 100 }, + ctrl: { x: 160, y: 60 }, + to: { x: 220, y: 140 }, + }; + const m0 = arrowMid(arrow); + let a = arrow; + for (let i = 0; i < 72; i++) a = rotateArrowAbout(a, arrowMid(a), FINE_DEG * RAD); + const m1 = arrowMid(a); + expect(Math.hypot(m1.x - m0.x, m1.y - m0.y), '매번 다시 재도 같은 자리다').toBeLessThan(0.01); + }); +}); diff --git a/src/features/editor/shapeTool.test.tsx b/src/features/editor/shapeTool.test.tsx index 7434f536..dc83252a 100644 --- a/src/features/editor/shapeTool.test.tsx +++ b/src/features/editor/shapeTool.test.tsx @@ -175,7 +175,8 @@ describe('★ 겹치면 진해진다 — 알파 합성을 깨뜨리지 않는다 expect(layer.style.mixBlendMode).toBe(''); for (const g of shapeNodes()) { - const face = g.querySelector('rect, ellipse, polygon')!; + // ⚠️ 2026-09-14 — 첫 요소는 검정 케이싱(fill=none)이다. 면을 보려면 **마지막** 것을 집는다. + const face = g.querySelector('[data-shape-face]')!; expect(Number(face.getAttribute('fill-opacity'))).toBeCloseTo(SHAPE_FILL_OPACITY, 6); } }); diff --git a/src/features/editor/useEditorPointer.ts b/src/features/editor/useEditorPointer.ts index 80c34bf2..933c3872 100644 --- a/src/features/editor/useEditorPointer.ts +++ b/src/features/editor/useEditorPointer.ts @@ -26,6 +26,7 @@ import { formationSlots } from '../../model/defaults.ts'; import { poseToStored } from '../../model/chair.ts'; import { isOnSurface } from '../../model/court.ts'; import type { ChairPose, DragZone, ZoneConfig } from '../../model/chair.ts'; +import { moveAnchorIds } from './moveAnchorIds.ts'; import type { Arrow, ArrowGrip } from '../../model/arrow.ts'; import { arrowColorName, arrowMid, cycleArrowColor, cycleHead, defaultCtrl, headFromOf, headToOf, nudgeArrow, rotateArrowAbout } from '../../model/arrow.ts'; import type { Stroke, StrokeGrip } from '../../model/stroke.ts'; @@ -590,6 +591,35 @@ export function useEditorPointer(opts: UseEditorPointerOptions): UseEditorPointe // 삼켜진다. 소리가 꺼져 있으면 이 호출은 아무것도 열지 않는다(cues.ts 계약 ①). cues.arm(); + // ── 이동 앵커(§6.10d, 2026-09-13 기현님 지시) ───────────────────────────── + // *"도형·메모·다중 선택에서 객체가 겹쳐 있으면 집어 드래그로 옮기기가 쉽지 않더라."* + // 겹치면 위에 있는 것이 손을 먼저 먹는다 — 밑에 깔린 것을 옮기려면 위엣것을 잠그거나 + // 치우거나 표시순서를 바꿔야 했다. 앵커는 그 겹침 **바깥**에 떠서 언제나 집힌다. + // + // 히트테스트를 **부르지 않는다.** 앵커 밑에 무엇이 깔려 있든 이 손짓과 상관이 없기 + // 때문이고, 그것이 겹침을 피하려고 앵커를 만든 이유 자체다. + // + // 도구보다 먼저 본다: 배치 도구나 지우개를 쥔 채 앵커를 눌러도 **옮긴다**. 앵커는 고른 + // 것 위에만 뜨므로 그 누름의 뜻은 하나뿐이고, 여기서 도구에 양보하면 "옮기려다 지웠다" + // 가 된다. 기존 이동 방법(몸통 드래그·키보드)은 하나도 줄지 않는다. + if (meta.moveAnchor) { + // 명단은 앵커를 **그리는 쪽과 같은 함수**에서 온다(moveAnchorIds) — 두 곳이 각자 + // 거르면 언젠가 갈라지고, 그러면 앵커가 감싼 것과 옮겨지는 것이 달라진다. + const ids = moveAnchorIds(ctx.selection, ctx.locked, ctx.ignored); + if (ids.length === 0) return; + const grp = { id: ids[0]!, ids, last: world, start: world, moved: false }; + groupDragRef.current = grp; + // 트레이·되돌리기 규약도 그대로 물려받는다(위 §6.10b 블록의 주석이 정본이다). + trayCargoRef.current = { + ids, + revert: () => { + const d = { x: grp.start.x - grp.last.x, y: grp.start.y - grp.last.y }; + if (d.x !== 0 || d.y !== 0) ctxRef.current.dispatch({ type: 'GROUP_NUDGE', ids, d }); + }, + }; + return {}; + } + if (TOOL_TO_PLACE[ctx.tool]) { placeAt(world); return; diff --git a/src/features/export/ExportSheet.test.tsx b/src/features/export/ExportSheet.test.tsx index c4e8d62e..83f9421e 100644 --- a/src/features/export/ExportSheet.test.tsx +++ b/src/features/export/ExportSheet.test.tsx @@ -24,9 +24,9 @@ vi.mock('./rasterize.ts', () => ({ // 영상 엔진도 jsdom 에서 못 돈다(VideoEncoder·캔버스 없음 — 그 파일 머리말). 모킹해서 **시트가 // 지는 계약**만 본다: 미지원 표시 · 엔진에 무엇을 넘기는가 · 저장은 클릭에서 · 끊기는가. // 실제 인코딩(치수·길이·프레임 수)은 헤드리스 크롬 검수와 실기 항목이다(PLAN-VIDEO-EXPORT §4). -const { supportMock, encodeMock } = vi.hoisted(() => ({ supportMock: vi.fn(), encodeMock: vi.fn() })); +const { engineMock, encodeMock } = vi.hoisted(() => ({ engineMock: vi.fn(), encodeMock: vi.fn() })); vi.mock('./video/encodeDrillVideo.ts', () => ({ - isVideoExportSupported: supportMock, + videoExportEngine: engineMock, encodeDrillVideo: encodeMock, })); vi.mock('../../storage/files.ts', async (importOriginal) => { @@ -77,12 +77,13 @@ function ToastProbe() { /** ⚠️ open 을 **진짜 state 로** 든다. 상수 true 로 두면 항목이 onClose 를 불러도 시트가 닫히지 * 않아, "닫힌 뒤에도 인쇄가 끝까지 간다"(PrintRoot 상주) 같은 계약이 통째로 검증되지 않는다 — * 실제로 그렇게 두었더니 PrintRoot 를 시트 안으로 되돌려도 이 파일이 전건 초록이었다. */ -function Harness({ onClose }: { onClose?: () => void }) { +function Harness({ onClose, mode }: { onClose?: () => void; mode?: 'board' | 'drill' }) { const [open, setOpen] = useState(true); return ( { setOpen(false); @@ -120,7 +121,7 @@ describe('닫힌 시트는 예산에 0을 더한다', () => { renderSheet(true); const dialog = screen.getByRole('dialog'); expect(dialog.getAttribute('aria-modal')).toBe('true'); - for (const name of [/^그림 \(PNG\)/, /^인쇄 · PDF/, /^영상 \(MP4\)/, /^링크로 공유/]) { + for (const name of [/^그림 \(PNG\)/, /^인쇄 · PDF/, /^영상 만들기$/, /^링크로 공유/]) { expect(screen.getByRole('button', { name })).toBeTruthy(); } // 2026-08-20 — [기기 이사 파일 (JSON)] 항목은 설정 화면으로 옮겼다(SettingsScreen.test.tsx). @@ -131,10 +132,29 @@ describe('닫힌 시트는 예산에 0을 더한다', () => { // ⚠️ 2026-09-08: 3 → 4 ([영상 (MP4)], PLAN-VIDEO-EXPORT). 영상은 상태를 갖는 항목이지만 // **머리 버튼은 하나**다 — [취소]·[저장]·[다시] 는 그 단계에 들어가야 생긴다(여기는 idle). // 크기 라디오는 role=radio 라 이 수에 안 든다. + // ⚠️ 2026-09-13: 영상의 그 버튼은 이제 **머리가 아니라 [영상 만들기]** 다(기현님 지시 — + // 제목처럼 생긴 것을 눌러야 시작되는 것이 안 보였다). 수는 그대로 5 다. expect(screen.getAllByRole('button')).toHaveLength(5); }); }); +// 2026-09-13 기현님 지시 — 자유 전술판에는 [영상]·[링크로 공유] 가 **아예 없다**. 근거는 +// 전술판이 1스텝짜리 판이라는 것이다(storage/board.ts): 영상은 정지 화면 한 장, 링크는 저장도 +// 안 된 판을 받는 쪽 라이브러리에 드릴로 앉힌다. 대조군(드릴 쪽 5개)은 위 "세 항목" it 이 진다. +describe('[보드] 시트에서 빠지는 두 칸', () => { + it('자유 전술판에는 [영상]·[링크로 공유] 가 없고, 그림·인쇄는 그대로다', async () => { + engineMock.mockResolvedValue('webcodecs'); + render(); + expect(screen.getByRole('button', { name: /^그림 \(PNG\)/ })).toBeTruthy(); + expect(screen.getByRole('button', { name: /^인쇄 · PDF/ })).toBeTruthy(); + expect(screen.queryByRole('button', { name: /^영상 만들기$/ }), '정지 화면 한 장짜리 영상을 권하지 않는다').toBeNull(); + expect(screen.queryByText(/^영상 \(MP4\)/), '항목 제목도 남으면 안 된다').toBeNull(); + expect(screen.queryByRole('button', { name: /^링크로 공유/ }), '저장도 안 된 판을 드릴로 넘기지 않는다').toBeNull(); + // 항목 2 + 닫기 1 = 3. 드릴 쪽 5와 짝을 이룬다. + expect(screen.getAllByRole('button')).toHaveLength(3); + }); +}); + describe('[그림] → 4.4 의 래스터 어댑터를 부른다', () => { it('누르기 전 0회 · 누른 뒤 정확히 1회, 그리고 그 Blob 이 downloadBlob 으로 간다', async () => { const onClose = vi.fn(); @@ -388,6 +408,7 @@ describe('내보내기 범위', () => { // 끝나자마자 저장해 iOS 공유 시트가 안 열리는 회귀, 시트를 닫아도 계속 도는 인코더. describe('[영상] 미지원 · 인코딩 · 저장 · 끊기', () => { const RESULT: VideoExportResult = { + engine: 'webcodecs', blob: new Blob(['mp4'], { type: 'video/mp4' }), bytes: 1234567, frames: 90, @@ -397,16 +418,17 @@ describe('[영상] 미지원 · 인코딩 · 저장 · 끊기', () => { }; beforeEach(() => { - supportMock.mockResolvedValue(true); + engineMock.mockResolvedValue('webcodecs'); encodeMock.mockResolvedValue(RESULT); }); - /** 머리 버튼 — **미지원일 때도 DOM 에 남는다**(disabled 가 아니라 aria-disabled 라서). */ - const head = () => screen.getByRole('button', { name: /^영상 \(MP4\)/ }); + /** 시작 버튼 — **미지원일 때도 DOM 에 남는다**(disabled 가 아니라 aria-disabled 라서). + * 2026-09-13 이전에는 항목 머리(제목 줄) 자체가 이 버튼이었다. */ + const head = () => screen.getByRole('button', { name: /^영상 만들기$/ }); const describedText = () => document.getElementById(head().getAttribute('aria-describedby') ?? '')?.textContent ?? ''; it('미지원 브라우저: aria-disabled 이고 사유가 설명으로 걸리며, 눌러도 인코딩이 시작되지 않는다', async () => { - supportMock.mockResolvedValue(false); + engineMock.mockResolvedValue(null); render(); await waitFor(() => expect(head().getAttribute('aria-disabled')).toBe('true')); const reason = describedText(); @@ -416,7 +438,7 @@ describe('[영상] 미지원 · 인코딩 · 저장 · 끊기', () => { // ★ 대조군 — 지원되는 기기에서는 같은 자리의 **설명이 달라진다.** 이 짝이 없으면 위 단언은 // "설명이 늘 같은 글자" 여도 통과한다(사유를 안 갈아 끼우는 회귀를 못 잡는다). cleanup(); - supportMock.mockResolvedValue(true); + engineMock.mockResolvedValue('webcodecs'); render(); await waitFor(() => expect(head().getAttribute('aria-disabled')).toBe('false')); expect(describedText()).not.toBe(reason); @@ -458,6 +480,33 @@ describe('[영상] 미지원 · 인코딩 · 저장 · 끊기', () => { expect(name).toMatch(/\.mp4$/); }); + // 2026-09-13 기현님 지시: 저장하면 시트를 닫는다. 그런데 **취소했을 때는 닫지 않는다** — + // 옛 주석이 지키던 것이 그것이고(공유 시트를 물린 사람이 다시 누를 자리), 그 걱정은 아직 참이다. + // 둘을 한 it 에서 보는 이유: 한쪽만 있으면 "늘 닫는다"·"늘 안 닫는다" 가 통과한다. + it('저장이 끝나면 시트가 닫히고, 사람이 물리면 열린 채로 남는다', async () => { + downloadMock.mockResolvedValue('saved'); + const onClose = vi.fn(); + render(); + await waitFor(() => expect(head().getAttribute('aria-disabled')).toBe('false')); + await userEvent.click(head()); + await userEvent.click(await screen.findByRole('button', { name: '저장' })); + await waitFor(() => expect(onClose, '저장했으면 닫는다').toHaveBeenCalledTimes(1)); + expect(await screen.findByText(/저장했습니다/), '저장했다고 말해 준다').toBeTruthy(); + + cleanup(); + downloadMock.mockResolvedValue('cancelled'); + const onClose2 = vi.fn(); + render(); + await waitFor(() => expect(head().getAttribute('aria-disabled')).toBe('false')); + await userEvent.click(head()); + const save = await screen.findByRole('button', { name: '저장' }); + await userEvent.click(save); + await waitFor(() => expect(downloadMock).toHaveBeenCalledTimes(2)); + expect(onClose2, '물렸으면 다시 누를 자리를 남긴다').toHaveBeenCalledTimes(0); + expect(save.isConnected, '[저장] 버튼이 그대로 있어야 다시 누른다').toBe(true); + expect(screen.queryByText(/저장했습니다/), '물렸는데 저장했다고 말하면 거짓말이다').toBeNull(); + }); + /** 끝나지 않는 인코딩 — signal 을 붙잡아 두고 abort 될 때만 취소로 끝난다. */ const hangingEncode = () => { const box: { signal?: AbortSignal } = {}; diff --git a/src/features/export/ExportSheet.tsx b/src/features/export/ExportSheet.tsx index 7f4ea21e..49a02f16 100644 --- a/src/features/export/ExportSheet.tsx +++ b/src/features/export/ExportSheet.tsx @@ -66,6 +66,7 @@ import { sceneFileName, sceneZipName, videoFileName } from './exportNames.ts'; // ⚠️ 값이 아니라 **타입만** 가져온다(결정 3) — verbatimModuleSyntax 라 이 줄은 컴파일에서 // 통째로 지워지고, 엔진 모듈은 아래 `import(...)` 로만 실린다. import type { VideoSize } from './video/encodeDrillVideo.ts'; +import type { VideoEngine } from './video/videoEngine.ts'; import { Button } from '../../ui/Button.tsx'; import { useToast } from '../../store/toast/ToastProvider.tsx'; import { PrintRoot, printWhenReady } from '../print/index.ts'; @@ -81,6 +82,12 @@ import { storageErrorText } from '../../i18n/storageError.ts'; export interface ExportSheetProps { open: boolean; onClose(): void; + /** 어느 화면의 시트인가. `board`(자유 전술판)에서는 **[영상]·[링크로 공유]가 아예 없다** + * (2026-09-13 기현님 지시). 근거는 전술판이 **1스텝짜리 판**이라는 데 있다(storage/board.ts + * 머리말): 영상은 정지 화면 한 장이 30fps 로 흐르는 파일이 되고, 링크는 저장도 되지 않은 + * 판을 받는 쪽 라이브러리에 **드릴로** 앉힌다. 둘 다 기능이 아니라 함정이다. + * 기본값이 `drill` 인 이유: 빠뜨렸을 때 **덜 감추는** 쪽이 안전하다. */ + mode?: 'board' | 'drill'; /** 지금 판(자유 전술판 또는 드릴 편집본). 그림·인쇄 둘 다 이 한 벌에서 굽는다. */ drill: Drill; /** 그림으로 구울 스텝(0-based). 자유 전술판은 언제나 0 이다. */ @@ -129,7 +136,7 @@ function isAbortError(e: unknown): boolean { return typeof e === 'object' && e !== null && (e as { name?: unknown }).name === 'AbortError'; } -export function ExportSheet({ open, onClose, drill, stepIndex, checkedStepIds, showGrid, showGridLabels, showRuleZones, returnFocusRef }: ExportSheetProps) { +export function ExportSheet({ open, onClose, mode = 'drill', drill, stepIndex, checkedStepIds, showGrid, showGridLabels, showRuleZones, returnFocusRef }: ExportSheetProps) { const titleId = useId(); const toast = useToast(); const t = useT(); @@ -186,7 +193,9 @@ export function ExportSheet({ open, onClose, drill, stepIndex, checkedStepIds, s /** null = 아직 안 물어봤다. 물어보는 데 await 이 필요해(canEncode) 첫 렌더에는 답이 없다 — * 그동안 항목을 비활성으로 그리면 열자마자 깜빡인다. 모르는 동안은 **누를 수 있게** 두고, * 못 하는 기기라면 그 사실이 몇 ms 뒤 문구로 도착한다. */ - const [videoSupported, setVideoSupported] = useState(null); + // null = 못 굽는다, undefined = 아직 안 물어봤다, 그 밖 = 그 엔진으로 굽는다. + const [videoEngine, setVideoEngine] = useState(undefined); + const videoSupported = videoEngine === undefined ? null : videoEngine !== null; const [videoSize, setVideoSize] = useState(720); const [video, setVideo] = useState({ phase: 'idle' }); const videoAbortRef = useRef(null); @@ -201,12 +210,14 @@ export function ExportSheet({ open, onClose, drill, stepIndex, checkedStepIds, s let alive = true; void (async () => { try { - const { isVideoExportSupported } = await import('./video/encodeDrillVideo.ts'); - const ok = await isVideoExportSupported(); - if (alive) setVideoSupported(ok); + const { videoExportEngine } = await import('./video/encodeDrillVideo.ts'); + // 되나/안 되나가 아니라 **무엇으로 되나** 를 묻는다(2026-09-13) — 소프트웨어 길이면 + // 눌러 놓고 한참 기다리게 되므로 미리 말해 준다. + const picked = await videoExportEngine(); + if (alive) setVideoEngine(picked); } catch { // 청크를 못 받았거나(오프라인·차단) 물음 자체가 던졌다 = 이 기기에서는 못 만든다. - if (alive) setVideoSupported(false); + if (alive) setVideoEngine(null); } })(); return () => { @@ -313,17 +324,42 @@ export function ExportSheet({ open, onClose, drill, stepIndex, checkedStepIds, s // 여러개면 zip으로 가자 한개면 png고"*). 낱개 순차 다운로드는 브라우저마다 막는 방식이 // 다르고(iOS 는 share 제스처가 await 를 못 넘긴다 — storage/files.ts 머리말), ZIP 은 // 어디서나 한 번에 끝난다. 압축은 하지 않는다 — PNG 는 이미 압축된 포맷이다(storage/zip.ts). - if (baked.length === 1) { - downloadBlob(baked[0]!.blob, baked[0]!.name); - } else { - const entries = await Promise.all( - baked.map(async (b) => ({ name: b.name, bytes: new Uint8Array(await b.blob.arrayBuffer()) })), - ); - downloadBlob(buildZip(entries), sceneZipName(drill.title)); - } + const name = baked.length === 1 ? baked[0]!.name : sceneZipName(drill.title); + const outcome = + baked.length === 1 + ? await downloadBlob(baked[0]!.blob, name) + : await downloadBlob( + buildZip( + await Promise.all( + baked.map(async (b) => ({ name: b.name, bytes: new Uint8Array(await b.blob.arrayBuffer()) })), + ), + ), + name, + ); + // 취소는 성공이 아니다(2026-09-13) — 물린 사람에게 저장했다고 말하지 않고, 다시 누를 + // 자리를 남긴다(시트를 닫지 않는다). + if (outcome === 'cancelled') return; + toast.show(t('export.savedToast', { name })); onClose(); }, t('export.pngFailed')); + /** 영상 저장. **동기 구간이 계약이다**(storage/files.ts 머리말): `downloadBlob` 을 클릭과 + * 같은 틱에서 불러야 iOS 공유 시트가 열린다 — 이 함수가 `async` 여도 첫 `await` 전까지는 + * 동기로 돌므로 그 호출이 먼저다. 닫기는 저장이 **끝난 뒤**이고, 사람이 물렸으면 닫지 않는다. */ + const saveVideo = async () => { + if (video.phase !== 'done') return; + try { + const outcome = await downloadBlob(video.blob, video.name); + if (outcome === 'cancelled') return; + toast.show(t('export.savedToast', { name: video.name })); + onClose(); + } catch (e) { + // 대화상자에서 자리를 고른 뒤 쓰다가 실패하면 여기로 온다(2026-09-13). 시트는 닫지 + // 않는다 — 다시 누를 자리가 있어야 한다. + toast.show(storageErrorText(e, locale, t('export.saveFailed'))); + } + }; + /** 영상 인코딩 시작(그리고 [다시]). `run()` 을 쓰지 않는 이유는 저 헬퍼가 **끝날 때까지 * 기다렸다가 토스트를 띄우는** 짧은 동작용이라서다 — 영상은 수 초~수십 초라 진행·취소를 * 시트 안에 그려야 한다(결정 9). 대신 `busyRef` 는 공유한다: 인코딩 중에 PNG 60장을 @@ -390,6 +426,8 @@ export function ExportSheet({ open, onClose, drill, stepIndex, checkedStepIds, s /** 머리 버튼을 누를 수 있는 단계 — 여기서만 인코딩이 **시작**된다. 진행 중에는 두 번 시작할 수 * 없고, 완료·오류 단계에서는 그 아래 [저장]·[다시] 가 다음 행동을 쥔다(표적이 뜻마다 하나다). */ + // 자유 전술판인가. 여기서만 [영상]·[링크로 공유] 두 칸이 사라진다(prop 주석 참조). + const isBoard = mode === 'board'; const videoIdle = video.phase === 'idle' || video.phase === 'cancelled'; const videoBlocked = videoSupported === false || !videoIdle; /** 크기는 **다음 인코딩**의 입력이다 — 오류 뒤에도 보인다(1080p 가 무거워 실패했다면 720p 로 @@ -426,25 +464,25 @@ export function ExportSheet({ open, onClose, drill, stepIndex, checkedStepIds, s SheetItem 한 줄로 접히지 않는다(머리말 「영상(MP4) 항목만…」). 자리가 [인쇄] 다음인 이유: 코치가 가장 자주 하는 일 순서다(그림 › 종이 › 영상). 영상은 만드는 데 수 초~수십 초가 들어 "지금 이 판을 빨리 꺼내는" 행위가 아니다. */} + {/* 자유 전술판에는 영상이 없다(2026-09-13) — 1스텝짜리 판이라 정지 화면 한 장짜리 + 파일이 나온다. 만들 수 있다는 것과 만들 뜻이 있다는 것은 다르다. */} + {!isBoard && (
- +
{videoSupported !== false && ( <> {videoCanPickSize && ( @@ -469,6 +507,28 @@ export function ExportSheet({ open, onClose, drill, stepIndex, checkedStepIds, s {/* 결정 8 — 범위 칩은 영상에 안 걸린다. 칩이 떠 있을 때(스텝 2장 이상)만 말한다: 고를 것이 없는 판에서 "적용되지 않습니다" 는 없는 기능을 설명하는 소음이다. */} {drill.steps.length > 1 &&

{t('export.video.wholeDrill')}

} + + )} + {/* 시작 버튼 — **지원 분기 밖**이다. 못 하는 기기에서도 이 버튼만은 남아야 + 위 사유 문구(aria-describedby)를 읽어 줄 초점 자리가 있다. + ⚠️ `disabled` 가 아니라 `aria-disabled` 인 이유가 그것이다(머리말 ④) — 그래서 + 클릭 차단은 손으로 한다. 굽는 중·끝난 뒤에는 감춘다: 그 자리는 [취소]·[저장] 이 진다. */} + {(videoIdle || videoSupported === false) && ( +
+ +
+ )} + {videoSupported !== false && ( + <> {video.phase === 'running' && (

@@ -491,9 +551,12 @@ export function ExportSheet({ open, onClose, drill, stepIndex, checkedStepIds, s {t('export.video.done', { name: video.name, size: formatBytes(video.bytes) })}

{/* ⚠️ 저장은 **여기 클릭에서만** 한다(머리말 ②). 인코딩 직후 자동 저장으로 - 옮기면 iOS 공유 시트가 제스처 만료로 안 열린다. 시트는 닫지 않는다 — - 공유 시트를 취소한 사람이 다시 누를 자리가 있어야 한다. */} -
@@ -514,6 +577,7 @@ export function ExportSheet({ open, onClose, drill, stepIndex, checkedStepIds, s )} + )} {/* 마지막 칸 — 링크(2026-09-07, PLAN-SHARE-LINK 결정 11). ⚠️ 계획서는 이 항목을 "시트 4번째" 라고 적었지만 이 시트의 항목은 지금 **둘**이라 실제로는 세 번째다. 4번이었던 시절(그림·인쇄·기기 이사 파일)의 [기기 이사 파일]은 @@ -526,14 +590,16 @@ export function ExportSheet({ open, onClose, drill, stepIndex, checkedStepIds, s 참이 아니다 — [영상] 도 언제나 드릴 전체다(PLAN-VIDEO-EXPORT 결정 8). 자리 근거는 뒤 문장 하나로 좁혀졌다: **서버·인터넷을 요구하는 유일한 항목**이라 맨 끝이다 (영상은 기기 안에서 끝난다). ── */} - { - onClose(); - setShareOpen(true); - }} - /> + {!isBoard && ( + { + onClose(); + setShareOpen(true); + }} + /> + )} diff --git a/src/features/export/buildStaticSvg.test.ts b/src/features/export/buildStaticSvg.test.ts index 3fe15ee9..bb471fe9 100644 --- a/src/features/export/buildStaticSvg.test.ts +++ b/src/features/export/buildStaticSvg.test.ts @@ -160,7 +160,11 @@ describe('buildStaticSvg — 화면에 있는 층이 그림에도 있다', () => it('작도 도형이 실린다 — 넘긴 것만, 넘긴 만큼', () => { // 센터 서클을 5.3 이 지운 뒤로 코트 라인에는 ellipse 가 없다. 그래서 이 수는 도형만 센다. - expect(parse(buildStaticSvg(bare(), { ...OPTS, shapes: [SHAPE] })).querySelectorAll('ellipse')).toHaveLength(1); + // ⚠️ 2026-09-14 — 도형 한 장이 **두 요소**가 됐다(검정 케이싱 + 면). «몇 장인가» 를 물을 + // 때는 면 표식(`data-shape-face`)을 센다 — 요소 수를 세면 겹 수까지 함께 세게 된다. + const svg1 = parse(buildStaticSvg(bare(), { ...OPTS, shapes: [SHAPE] })); + expect(svg1.querySelectorAll('[data-shape-face]')).toHaveLength(1); + expect(svg1.querySelectorAll('ellipse')).toHaveLength(2); expect(parse(buildStaticSvg(bare(), OPTS)).querySelectorAll('ellipse')).toHaveLength(0); }); diff --git a/src/features/export/buildStaticSvg.ts b/src/features/export/buildStaticSvg.ts index 7b576b87..97c57398 100644 --- a/src/features/export/buildStaticSvg.ts +++ b/src/features/export/buildStaticSvg.ts @@ -76,7 +76,7 @@ import { NOTE_DEFAULT_SIZE_PX, noteChipHeightPx, noteChipPathD, noteFoldPathD } // 진영 깃발의 좌표는 **저쪽 함수 하나**에서 온다(render/sideFlags.ts 의 sideFlagGroups 머리말). // 색·굵기도 같이 읽는다 — 여기 리터럴로 적으면 그림에서만 깃발이 어긋난다. import { FLAG_STROKE, FLAG_STROKE_W, POLE_W, sideFlagGroups } from '../../render/sideFlags.ts'; -import { pointsAttr, shapeSize, triPointsOf, SHAPE_COLOR, SHAPE_FILL_OPACITY, SHAPE_STROKE_OPACITY, SHAPE_STROKE_PX } from '../../model/shape.ts'; +import { pointsAttr, shapeColor, shapeSize, triPointsOf, SHAPE_CASING_PX, SHAPE_COLOR, SHAPE_FILL_OPACITY, SHAPE_STROKE_PX } from '../../model/shape.ts'; import { num, safeColor, safeId } from './svgSafe.ts'; import { teamMarkFor } from './teamMark.ts'; import { @@ -351,17 +351,32 @@ export function shapesMarkup(shapes: readonly Shape[]): string { * 굽는 자리가 필요해졌다 — 위 `shapesMarkup` 은 이것을 여러 번 부르는 껍데기다. */ function shapeMarkup(s: Shape): string { const { w, h } = shapeSize(s); - const paint = - `fill="${SHAPE_COLOR}" fill-opacity="${SHAPE_FILL_OPACITY}"` + - ` stroke="${SHAPE_COLOR}" stroke-opacity="${SHAPE_STROKE_OPACITY}" stroke-width="${SHAPE_STROKE_PX}"`; + // ⚠️ `safeColor` 를 **반드시** 지난다. 여기는 문자열을 손으로 잇는 자리라, 남이 보낸 드릴의 + // 색이 `#fff" onload=…` 이면 SVG 가 통째로 다른 문서가 된다(화살표·획·깃발이 같은 길이다). + const color = safeColor(shapeColor(s), SHAPE_COLOR); + const casing = SHAPE_STROKE_PX + SHAPE_CASING_PX; + // 케이싱 → 본체 두 겹(ShapeMark 와 **같은 순서·같은 값**). 케이싱은 면을 칠하지 않는다 — + // 칠하면 α 0.13 의 «겹칠수록 진해진다» 가 검정으로 덮여 죽는다. + const casingPaint = `fill="none" stroke="#000000" stroke-width="${casing}"`; + const paint = `fill="${color}" fill-opacity="${SHAPE_FILL_OPACITY}" stroke="${color}" stroke-width="${SHAPE_STROKE_PX}"`; // 삼각형의 모양은 w/h 가 아니라 꼭짓점이 진다(2026-08-15 자유 삼각형) — 그래서 // `triPointsOf`/`pointsAttr` 을 화면과 **같이** 지난다. w/h 는 경계상자일 뿐이다. + const two = (attrs: string, extra = ''): string => { + const tag = attrs.split(' ')[0]!; + const rest = attrs.split(' ').slice(1).join(' '); + return ( + `<${tag} ${rest} ${casingPaint}${extra}/>` + + // `data-shape-face` 는 «이 요소가 색을 진다» 는 표식이다 — 화면 쪽과 같은 이름이라야 + // 두 벌 대조(courtLines.contract)가 속성까지 맞는다. + `<${tag} data-shape-face="" ${rest} ${paint}${extra}/>` + ); + }; const body = s.kind === 'ellipse' - ? `` + ? two(`ellipse rx="${w / 2}" ry="${h / 2}"`) : s.kind === 'rect' - ? `` - : ``; + ? two(`rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}"`) + : two(`polygon points="${pointsAttr(triPointsOf(s))}"`, ' stroke-linejoin="round"'); return `${body}`; } diff --git a/src/features/export/courtLines.contract.test.ts b/src/features/export/courtLines.contract.test.ts index 0512b46d..3d94354e 100644 --- a/src/features/export/courtLines.contract.test.ts +++ b/src/features/export/courtLines.contract.test.ts @@ -245,11 +245,24 @@ describe('작도 도형 — ShapeLayer 와 같은 도형을 그린다', () => { }); it('대조군 — 세 종류가 실제로 세어지고, 빈 목록은 아무것도 안 낸다', () => { - expect(shapesOf(shapesMarkup(SHAPES))).toHaveLength(3); + // ⚠️ 2026-09-14 — 도형 한 장이 **두 요소**가 됐다(검정 케이싱 + 본체). 색이 생기면서 + // 테두리 반투명으로는 경계가 안 보이는 색이 나왔기 때문이다(주황 1.26:1). 그래서 + // 도형 셋이 요소 여섯이다. `transformsOf` 는 껍데기 를 세므로 여전히 셋이고, + // 그 둘이 갈리는 것이 «한 장이 두 겹» 이라는 사실의 유일한 표시다. + expect(shapesOf(shapesMarkup(SHAPES))).toHaveLength(6); expect(shapesMarkup([])).toBe(''); expect(transformsOf(shapesMarkup(SHAPES))).toHaveLength(3); }); + // 2026-09-14 — 색이 **두 벌 모두**에 실리는가. 이 파일의 픽스처는 색 없는 도형뿐이었고, + // 그러면 «PNG·영상만 흰색» 이 초록인 채로 성립한다(renderPaths 레지스트리는 색을 안 본다). + it('색이 PNG 쪽에도 실린다 — 판과 같은 hex 가 나온다', () => { + const colored = SHAPES.map((s, i) => ({ ...s, color: ['#38bdf8', '#fde047', '#f97316'][i]! })); + const markup = shapesMarkup(colored); + for (const hex of ['#38bdf8', '#fde047', '#f97316']) expect(markup, hex).toContain(hex); + expect(shapesOf(markup)).toEqual(shapesOf(renderToStaticMarkup(createElement(ShapeLayer, { shapes: colored })))); + }); + it('⚠️ 그룹에 opacity 를 걸지 않는다 — 겹치면 진해지는 성질이 납작해진다', () => { expect(shapesMarkup(SHAPES)).not.toMatch(/]*opacity/); }); diff --git a/src/features/export/video/encodeDrillVideo.ts b/src/features/export/video/encodeDrillVideo.ts index 567503b0..ec5f1321 100644 --- a/src/features/export/video/encodeDrillVideo.ts +++ b/src/features/export/video/encodeDrillVideo.ts @@ -18,6 +18,7 @@ import { PLAYBACK } from '../../../core/constants.ts'; import { translate } from '../../../i18n/useT.ts'; import { VIDEO_FPS, videoBitrate, videoFrameTimes } from './videoTiming.ts'; import { videoCanvasSize, videoResolution } from './videoMetrics.ts'; +import { chooseVideoEngine, type VideoCaps, type VideoEngine, type VideoFrameSink } from './videoEngine.ts'; export type VideoSize = 720 | 1080; @@ -37,6 +38,8 @@ export interface VideoExportOpts { } export interface VideoExportResult { + /** 실제로 구운 엔진. 실기 보고에서 "느린데요" 가 어느 길이었는지 가르는 유일한 표다. */ + engine: VideoEngine; blob: Blob; bytes: number; frames: number; @@ -50,21 +53,70 @@ export interface VideoExportHooks { signal?: AbortSignal; } -/** VideoEncoder 가 있고 H.264(avc) 를 인코딩할 수 있는가. 아니면 시트가 항목을 비활성으로 보인다(결정 2). - * - * 둘을 다 묻는 이유: `VideoEncoder` 존재는 WebCodecs 자체의 유무이고(안드로이드 파이어폭스는 없다), - * `canEncode('avc')` 는 그 브라우저·기기가 H.264 **인코딩** 을 실제로 하느냐다(디코딩만 되는 조합이 있다). */ -export async function isVideoExportSupported(): Promise { - if (typeof VideoEncoder === 'undefined') return false; +/** 이 기기의 능력. `webcodecsAvc` 는 존재 여부가 아니라 **실제로 구워 본** 결과다 — + * mediabunny `canEncode('avc')` 가 작은 프레임을 한 장 인코딩해 보고 답한다(디코딩만 되는 + * 조합과, WebKitGTK 처럼 시스템 플러그인이 빠져 실패하는 조합을 여기서 가른다). */ +async function detectVideoCaps(): Promise { + const wasm = typeof WebAssembly !== 'undefined'; + if (typeof VideoEncoder === 'undefined') return { webcodecsAvc: false, wasm }; try { const { canEncode } = await import('mediabunny'); - return await canEncode('avc'); + return { webcodecsAvc: await canEncode('avc'), wasm }; } catch { - // 청크 로드 실패(오프라인·차단)도 "못 한다" 다 — 여기서 던지면 시트가 통째로 안 열린다. - return false; + // 청크 로드 실패(오프라인·차단)는 "내장 코덱을 못 쓴다" 일 뿐이다 — 소프트웨어 길은 남는다. + return { webcodecsAvc: false, wasm }; } } +/** 무엇으로 구울지. null 이면 못 굽는다 — 그때만 시트가 항목을 비활성으로 보인다(결정 2). */ +export async function videoExportEngine(): Promise { + return chooseVideoEngine(await detectVideoCaps()); +} + +/** 영상 내보내기가 되는가. 내장 코덱이 없어도 wasm 이 있으면 **된다**(2026-09-13). */ +export async function isVideoExportSupported(): Promise { + return (await videoExportEngine()) !== null; +} + +/** 내장 코덱(WebCodecs) 싱크 — mediabunny 가 인코딩과 MP4 먹싱을 다 한다. + * ⚠️ 정적 import 금지(결정 3) — 이 함수 안의 한 줄이 mediabunny 를 내보내기 청크에 가둔다. */ +async function createWebCodecsSink( + canvas: HTMLCanvasElement, + bitrateBps: number, +): Promise { + const { Output, BufferTarget, Mp4OutputFormat, CanvasSource, Quality } = await import('mediabunny'); + const output = new Output({ + // fastStart: moov 를 앞에 둔다 — 모바일 메신저가 다 받기 전에도 재생을 시작한다. + format: new Mp4OutputFormat({ fastStart: 'in-memory' }), + target: new BufferTarget(), + }); + const source = new CanvasSource(canvas, { + codec: 'avc', + quality: new Quality({ bitrate: bitrateBps, bitrateMode: 'variable' }), + }); + output.addVideoTrack(source); + await output.start(); + + return { + async add(tSec: number) { + // add 가 돌려주는 약속을 기다리는 것이 인코더 배압(backpressure) 처리이자, 브라우저에 + // 숨 쉴 틈을 주는 양보이기도 하다 — 안 기다리면 긴 드릴에서 탭이 굳는다. + await source.add(tSec, 1 / VIDEO_FPS); + }, + async finish() { + await output.finalize(); + const buffer = output.target.buffer; + // 여기 오면 finalize 가 성공했다는 뜻이라 buffer 는 있어야 한다. i18n 키를 쓰지 않는 이유는 + // 이용자에게 보일 문구가 아니기 때문이다 — 시트가 export.video.failed 로 갈아 보여준다(결정 9). + if (!buffer) throw new Error('mediabunny: BufferTarget produced no buffer'); + return new Blob([buffer], { type: 'video/mp4' }); + }, + async dispose() { + await output.cancel().catch(() => {}); + }, + }; +} + /** 취소되면 DOMException('AbortError') 로 reject 한다(결정 9). */ export async function encodeDrillVideo( drill: Drill, @@ -107,21 +159,22 @@ export async function encodeDrillVideo( await waitForFonts(); throwIfAborted(signal); - // ⚠️ 정적 import 금지(결정 3) — 이 한 줄이 mediabunny 를 내보내기 청크에 가둔다. - const { Output, BufferTarget, Mp4OutputFormat, CanvasSource, Quality } = await import('mediabunny'); - const output = new Output({ - // fastStart: moov 를 앞에 둔다 — 모바일 메신저가 다 받기 전에도 재생을 시작한다. - format: new Mp4OutputFormat({ fastStart: 'in-memory' }), - target: new BufferTarget(), - }); - const source = new CanvasSource(canvas, { - codec: 'avc', - quality: new Quality({ bitrate: videoBitrate(w, h, VIDEO_FPS), bitrateMode: 'variable' }), - }); - output.addVideoTrack(source); + let blob: Blob; + + // 엔진은 **굽기 직전**에 고른다(결정 2 확장, 2026-09-13). 시트가 열릴 때 잰 값을 들고 + // 있지 않는 이유: 시트를 열어 둔 채 기기가 바뀌는 일은 없어도, 두 번 재는 값이 다르면 그때 + // 어느 쪽이 참인지 가릴 길이 없다 — 굽는 쪽이 참이다. + const engine = await videoExportEngine(); + if (!engine) throw new Error(translate(opts.locale, 'export.video.unsupported')); + const bitrateBps = videoBitrate(w, h, VIDEO_FPS); + const sink: VideoFrameSink = + engine === 'webcodecs' + ? await createWebCodecsSink(canvas, bitrateBps) + // 소프트웨어 인코더는 쓰는 기기에서만 받는다(1.7MB) — 동적 import 가 그 약속이다. + : await (await import('./wasmH264.ts')).createWasmH264Sink({ ctx, w, h, fps: VIDEO_FPS, bitrateBps }); + throwIfAborted(signal); try { - await output.start(); for (let i = 0; i < times.length; i++) { throwIfAborted(signal); const t = times[i]!; @@ -138,25 +191,19 @@ export async function encodeDrillVideo( ctx.fillStyle = padColor; ctx.fillRect(0, 0, w, h); await paintSceneToCanvas(scene, canvas, opts.locale); - // add 가 돌려주는 약속을 기다리는 것이 인코더 배압(backpressure) 처리이자, 브라우저에 - // 숨 쉴 틈을 주는 양보이기도 하다 — 안 기다리면 긴 드릴에서 탭이 굳는다. - await source.add(t / 1000, 1 / VIDEO_FPS); + await sink.add(t / 1000); onProgress?.(i + 1, times.length); } throwIfAborted(signal); - await output.finalize(); + blob = await sink.finish(); } catch (e) { - // 취소든 실패든 인코더·워커를 놓아준다. cancel 자체의 실패는 삼킨다 — 원래 오류가 더 중요하다. - await output.cancel().catch(() => {}); + // 취소든 실패든 인코더·워커를 놓아준다. dispose 자체의 실패는 삼킨다 — 원래 오류가 더 중요하다. + await sink.dispose(); throw e; } - const buffer = output.target.buffer; - // 여기 오면 finalize 가 성공했다는 뜻이라 buffer 는 있어야 한다. i18n 키를 쓰지 않는 이유는 - // 이용자에게 보일 문구가 아니기 때문이다 — 시트가 export.video.failed 로 갈아 보여준다(결정 9). - if (!buffer) throw new Error('mediabunny: BufferTarget produced no buffer'); - const blob = new Blob([buffer], { type: 'video/mp4' }); return { + engine, blob, bytes: blob.size, frames: times.length, diff --git a/src/features/export/video/videoEngine.test.ts b/src/features/export/video/videoEngine.test.ts new file mode 100644 index 00000000..8da96e4e --- /dev/null +++ b/src/features/export/video/videoEngine.test.ts @@ -0,0 +1,19 @@ +// 엔진 고르기. 세 줄짜리 함수지만 세 줄 다 실기에서만 드러나는 것을 막는다: +// ① 순서가 뒤집히면 **모두가** 수십 배 느린 소프트웨어 길로 간다(빠른 기계에서는 티가 안 난다), +// ② 마지막 줄이 틀리면 못 굽는 기기가 내보내기를 눌러 놓고 오류만 본다. +import { describe, expect, it } from 'vitest'; +import { chooseVideoEngine } from './videoEngine.ts'; + +describe('chooseVideoEngine', () => { + it('내장 코덱이 있으면 wasm 이 있어도 내장을 쓴다 — 소프트웨어 인코딩은 수십 배 느리다', () => { + expect(chooseVideoEngine({ webcodecsAvc: true, wasm: true })).toBe('webcodecs'); + }); + + it('내장 코덱이 없으면 wasm 으로 내려간다 — WebKitGTK 에 GStreamer H.264 가 없는 기계가 이 길이다', () => { + expect(chooseVideoEngine({ webcodecsAvc: false, wasm: true })).toBe('wasm'); + }); + + it('둘 다 없으면 null — 시트가 항목을 비활성으로 보이는 유일한 근거다', () => { + expect(chooseVideoEngine({ webcodecsAvc: false, wasm: false })).toBeNull(); + }); +}); diff --git a/src/features/export/video/videoEngine.ts b/src/features/export/video/videoEngine.ts new file mode 100644 index 00000000..cfb115bc --- /dev/null +++ b/src/features/export/video/videoEngine.ts @@ -0,0 +1,41 @@ +// 영상(MP4) 을 **무엇으로 굽느냐**. 순수 함수와 타입만 있다(DOM·wasm·인코더 없음). +// 정본 docs/PLAN-VIDEO-EXPORT.md — 2026-09-13 에 결정 2(지원 판정)를 넓힌 자리다. +// +// 왜 둘로 갈랐나: WebCodecs(`VideoEncoder`)는 브라우저가 코덱을 **내장**했을 때만 있다. 크롬· +// 엣지·최신 사파리에는 있지만, 리눅스 데스크톱 앱이 쓰는 WebKitGTK 는 H.264 인코딩을 시스템 +// GStreamer 플러그인에 기대므로 그 플러그인이 없는 기계에서는 통째로 없는 것과 같다(우분투 +// 최소 설치가 그렇다). 그때 "이 브라우저는 안 됩니다" 로 끝내는 대신 **소프트웨어 인코더를 +// 내려받아** 굽는다 — 느리지만 되는 쪽이 낫다. + +/** `webcodecs` = 브라우저 내장 코덱(빠르다·하드웨어를 탄다). `wasm` = 내려받는 소프트웨어 + * 인코더(minih264, 느리다). 고르는 규칙은 `chooseVideoEngine` 하나다. */ +export type VideoEngine = 'webcodecs' | 'wasm'; + +export interface VideoCaps { + /** 브라우저가 H.264 를 **인코딩** 할 수 있는가. 존재 여부가 아니라 실제 인코드 검사다 + * (디코딩만 되는 조합이 있다 — mediabunny `canEncode('avc')` 가 한 장 구워 보고 답한다). */ + webcodecsAvc: boolean; + /** WebAssembly 가 도는가. 소프트웨어 인코더의 유일한 전제다. */ + wasm: boolean; +} + +/** 엔진 선택. 내장 코덱이 있으면 **무조건** 그쪽이다 — 소프트웨어 인코더는 같은 드릴을 수십 배 + * 느리게 굽고 화질도 못하다(기저 프로파일·고정 QP). 둘 다 없으면 null 이고, 그때만 시트가 + * 항목을 비활성으로 보인다. */ +export function chooseVideoEngine(caps: VideoCaps): VideoEngine | null { + if (caps.webcodecsAvc) return 'webcodecs'; + if (caps.wasm) return 'wasm'; + return null; +} + +/** 프레임을 받아 파일 한 개를 만드는 곳. 두 엔진이 이 모양만 맞추면 굽는 회로(캔버스에 장면을 + * 그리는 쪽)는 하나로 남는다 — 엔진이 갈려도 **그림이 갈리지 않는다** 는 뜻이라 중요하다. */ +export interface VideoFrameSink { + /** 캔버스에 이미 그려진 한 장을 넣는다. `tSec` 은 그 프레임의 시각(초)이다 — 고정 fps 로 + * 굽는 엔진(wasm)은 쓰지 않지만, 타임스탬프를 갖는 엔진(WebCodecs)에는 필요하다. */ + add(tSec: number): Promise; + /** 파일을 닫고 돌려준다. */ + finish(): Promise; + /** 실패·취소 때 자원을 놓는다. **던지지 않는다** — 원래 오류가 더 중요하다. */ + dispose(): Promise; +} diff --git a/src/features/export/video/wasmH264.ts b/src/features/export/video/wasmH264.ts new file mode 100644 index 00000000..5a386f6e --- /dev/null +++ b/src/features/export/video/wasmH264.ts @@ -0,0 +1,139 @@ +// WebCodecs 가 없을 때 쓰는 **소프트웨어** H.264 인코더. 정본 docs/PLAN-VIDEO-EXPORT.md +// (2026-09-13, 결정 2 확장). 알맹이는 `h264-mp4-encoder` — minih264(인코더) + minimp4(먹서)를 +// wasm 으로 담은 것이라 브라우저 코덱을 한 조각도 쓰지 않는다. +// +// ⚠️ 이 파일은 `encodeDrillVideo.ts` 에서 **동적 import 로만** 불린다(결정 3 과 같은 이유). +// 붙어 오는 wasm 이 1.7MB 라 내장 코덱이 있는 기계는 한 바이트도 받지 않아야 한다. +// +// 왜 `import` 가 아니라