diff --git a/docs/1104_pointerPolicy.md b/docs/1104_pointerPolicy.md new file mode 100644 index 0000000..9a3f1b7 --- /dev/null +++ b/docs/1104_pointerPolicy.md @@ -0,0 +1,132 @@ +# pointer 정책 확정 + +1104_pointerPolicy_problem.md 문서와 관련해 정책을 정리합니다 + +## 목표: 사용자 기대 동작 + +### all pointer 모드 + +#### 일반 모드 + +- 손 입력 + + - 한 손가락: 그리기 (현 구현 상태에서 테스트 시 의도대로 정상 동작 O) + - 두 손가락: 캔버스 이동 (의도대로 동작 X) + - 두 손가락: 핀치 줌 / 아웃 (X) + - 세 손가락: 캔버스 이동 (O) + +- 스타일러스 입력 + - 그리기 (O) + +#### 링커 모드 (링커 툴 선택) + +- 손 입력 + + - 한 손가락 탭: 링크 터치/액션 (O) + - 한 손가락 드래그: 링크 그리기 (O) + - 두 손가락 이동/줌: 일반모드와 동일 (줌만 O, 이동 X) + +- 스타일러스 입력 + - 드래그: 링크 그리기 (O) + - 탭: 링크 터치 (O) + +### stylus only 모드 + +#### 일반 모드 + +- 손 입력 + + - 한 손가락: 이동 (O) + - 두 손가락: 이동 (O) + - 두 손가락: 핀치 줌 / 아웃 (O) + - 세 손가락: 캔버스 이동 (O) + +- 스타일러스 입력 + - 그리기 (O) + +#### 링커 모드 + +- 손 입력 + + - 한 손가락: 터치 시 링크 이동 또는 동작 안함 (링크 이동 O) + - 두 손가락: 이동 (줌만 O, 이동 X) + - 두 손가락: 핀치 줌 / 아웃 (O) + - 세 손가락: 캔버스 이동 (O) + +- 스타일러스 입력 + - 링크 그리기 (O) + - 링크 터치 시 동작 (O) + +--- + +## 현재 문제 분석 + +### 근본 원인 + +#### 1. Scribble 패키지의 Listener 구조 + +``` +포인터 이벤트 흐름: + 터치 이벤트 + ↓ + Hit Test + ↓ + Scribble Widget + └─ Listener (raw 포인터 이벤트 캡처) + └─ notifier.onPointerDown() + └─ supportedPointerKinds 체크 (내부 필터링) +``` + +**문제점:** + +- Listener는 제스처 아레나에 참여하지 않음 +- raw 포인터를 직접 받아서 내부에서만 필터링 +- InteractiveViewer와 제스처 경쟁 불가능 + +#### 2. All 모드에서 멀티터치 충돌 + +``` +두 손가락 터치: + 첫 번째 손가락 → Scribble Listener → 그리기 시작 + 두 번째 손가락 → Scribble Listener → 또 다른 그리기 시작 + InteractiveViewer → 제스처 아레나에서 패배 → 줌/패닝 불가 +``` + +**결과:** + +- 두 손가락 이동 불가 ❌ +- 핀치 줌 불가 ❌ + +#### 3. 링커 모드에서 패닝 차단 + +```dart +// note_page_view_item.dart:163 +panEnabled: !isLinkerMode, // 링커 모드 = false +``` + +**의도:** 한 손가락 드래그를 LinkerGestureLayer가 받도록 패닝 차단 + +**부작용:** + +- 두 손가락 이동도 차단됨 +- 줌만 가능 (scaleEnabled: true) + +### 위젯 레이어 분석 + +``` +NotePageViewItem +└─ InteractiveViewer + ├─ panEnabled: !isLinkerMode (문제) + ├─ scaleEnabled: true + └─ Stack + ├─ CanvasBackgroundWidget + ├─ SavedLinksLayer + ├─ IgnorePointer(ignoring: toolMode.isLinker) + │ └─ Scribble (Listener 사용, 문제 원인) + └─ LinkerGestureLayer (GestureDetector 사용) +``` + +**충돌 포인트:** + +1. Scribble Listener ↔ InteractiveViewer GestureDetector +2. LinkerGestureLayer ↔ InteractiveViewer (panEnabled 정적 제어) diff --git a/docs/1104_pointerPolicy_problem.md b/docs/1104_pointerPolicy_problem.md new file mode 100644 index 0000000..e0ba60f --- /dev/null +++ b/docs/1104_pointerPolicy_problem.md @@ -0,0 +1,257 @@ +📊 pointerPolicy가 제어하는 입력 흐름 전체 분석 + +1. pointerPolicy의 정의와 전파 + +전역 설정 (DB 저장) +↓ +pointerPolicyProvider (ScribblePointerMode) +↓ +├─→ CustomScribbleNotifier.setAllowedPointersMode() [Line 257-258, 274-279] +│ (각 페이지의 notifier에 전파) +│ +└─→ LinkerGestureLayer.pointerMode [note_page_view_item.dart:220-223] +(ScribblePointerMode → LinkerPointerMode 변환) + +2. 현재 입력 필터링이 적용되는 정확한 위치 2곳 + +🎯 위치 1: CustomScribbleNotifier 내부 (custom_scribble_notifier.dart) + +// Line 83-87 +@override +void onPointerDown(PointerDownEvent event) { +if (toolMode.isLinker) return; // 링커 모드 차단 +if (!value.supportedPointerKinds.contains(event.kind)) { // ← 여기서 필터링! +return; +} +// ... 필기 처리 +} + +// Line 127-131 +@override +void onPointerUpdate(PointerMoveEvent event) { +if (toolMode.isLinker) return; // 링커 모드 차단 +if (!value.supportedPointerKinds.contains(event.kind)) { // ← 여기서 필터링! +return; +} +// ... 필기 처리 +} + +작동 방식: + +- value.supportedPointerKinds는 ScribbleState 내부에 저장된 allowedPointersMode에서 + 파생됨 +- notifier.setAllowedPointersMode(pointerPolicy)로 설정됨 + (note_editor_provider.dart:258) +- 문제점: Scribble 패키지 내부의 Listener가 raw 포인터 이벤트를 먼저 받아서 이 + 메서드를 호출함 + +🎯 위치 2: LinkerGestureLayer (linker_gesture_layer.dart) + +// Line 131-152 +// 드래그 허용 포인터 +final dragDevices = { +ui.PointerDeviceKind.stylus, +ui.PointerDeviceKind.invertedStylus, +}; +if (widget.pointerMode == LinkerPointerMode.all) { +dragDevices +..add(ui.PointerDeviceKind.touch) +..add(ui.PointerDeviceKind.mouse) +..add(ui.PointerDeviceKind.trackpad); +} + +// 탭 허용 포인터 (두 모드 모두 손가락 탭으로 링크 확인 허용) +final tapDevices = { +ui.PointerDeviceKind.stylus, +ui.PointerDeviceKind.invertedStylus, +ui.PointerDeviceKind.touch, // ← 항상 포함! +}; +if (widget.pointerMode == LinkerPointerMode.all) { +tapDevices +..add(ui.PointerDeviceKind.mouse) +..add(ui.PointerDeviceKind.trackpad); +} + +작동 방식: + +- GestureDetector(supportedDevices: dragDevices, ...): 드래그 제스처 필터링 +- GestureDetector(supportedDevices: tapDevices, ...): 탭 제스처 필터링 +- 중요: 탭은 stylusOnly 모드에서도 touch를 항상 허용함 (의도적 설계) + +3. 🚨 입력 충돌이 발생할 수 있는 지점 + +A. 일반 필기 모드 (toolMode != linker) + +위젯 스택 (위→아래): +┌────────────────────────────────────┐ +│ InteractiveViewer │ ← panEnabled: true (패닝 허용) +│ └─ Stack │ +│ ├─ CanvasBackgroundWidget │ +│ ├─ SavedLinksLayer │ +│ ├─ Scribble │ ← 입력 처리 +│ │ (IgnorePointer: false) │ ← 포인터 이벤트 받음 +│ │ (drawPen: true) │ +│ │ → CustomScribbleNotifier │ +│ │ → onPointerDown() │ +│ │ → supportedPointerKinds │ ← 🔥 필터링 지점 1 +│ │ 체크 │ +│ └─ LinkerGestureLayer │ +│ (toolMode != linker) │ ← Container() 반환 (비활성) +└────────────────────────────────────┘ + +충돌 가능 시나리오: + +1. InteractiveViewer가 터치 이벤트를 패닝으로 처리 +2. Scribble도 동시에 터치 이벤트를 받아서 필기 시작 +3. → 패닝과 필기가 동시에 발생할 수 있음 + +현재 보호 장치: + +- CustomScribbleNotifier가 supportedPointerKinds로 필터링 +- stylusOnly 모드면 touch 이벤트를 무시함 + +B. 링커 모드 (toolMode == linker) + +위젯 스택 (위→아래): +┌────────────────────────────────────┐ +│ InteractiveViewer │ ← panEnabled: FALSE (패닝 차단) +│ └─ Stack │ +│ ├─ CanvasBackgroundWidget │ +│ ├─ SavedLinksLayer │ +│ ├─ Scribble │ ← 입력 차단됨 +│ │ (IgnorePointer: true) ❌ │ ← 모든 포인터 무시 +│ │ (drawPen: false) │ +│ └─ LinkerGestureLayer │ ← 입력 처리 +│ ├─ Listener (raw debug) │ +│ ├─ GestureDetector (tap) │ +│ │ supportedDevices: │ ← 🔥 필터링 지점 2 +│ │ [stylus, touch, ...] │ +│ └─ GestureDetector (drag) │ +│ supportedDevices: │ ← 🔥 필터링 지점 3 +│ [stylus, (touch?)] │ +└────────────────────────────────────┘ + +충돌 가능 시나리오: + +1. pointerMode == stylusOnly인데 +2. 손가락으로 드래그하면? +3. → dragDevices에 touch가 없으므로 드래그 무시 ✅ +4. BUT: 손가락 탭은 항상 허용됨! (Line 143-147) + +5. 🔍 실제 문제가 발생하는 정확한 원인 + +문제 1: Scribble 패키지의 내부 Listener + +Scribble 위젯은 아마도 다음과 같은 구조를 가지고 있을 것: +class Scribble extends StatelessWidget { +@override +Widget build(BuildContext context) { +return Listener( // ← raw 포인터 이벤트를 먼저 캡처 +onPointerDown: (event) { +notifier.onPointerDown(event); +}, +onPointerMove: (event) { +notifier.onPointerUpdate(event); +}, +child: CustomPaint(...), +); +} +} + +문제: + +- Listener는 hit-test를 통과한 모든 포인터 이벤트를 받음 +- IgnorePointer(ignoring: false)일 때, Listener는 모든 이벤트를 notifier에 전달 +- notifier 내부에서 supportedPointerKinds 체크를 하지만, 이미 위젯 트리에서 + 이벤트가 전파됨 + +문제 2: GestureDetector와 Listener의 충돌 + +포인터 이벤트 흐름: +터치 이벤트 +↓ +Hit Test +↓ +┌─────────────────┐ +│ LinkerGestureLayer │ ← 링커 모드: GestureDetector가 supportedDevices로 필터링 +│ (Container) │ ← 일반 모드: 아무것도 안 함 +└─────────────────┘ +↓ +┌─────────────────┐ +│ Scribble │ ← Listener가 raw 이벤트 받음 +│ → Listener │ ← 내부에서 supportedPointerKinds 체크 +└─────────────────┘ + +충돌 시나리오: + +1. stylusOnly 모드 + 일반 필기 모드 + + + - 손가락 터치 → Scribble의 Listener → notifier.onPointerDown() + - → supportedPointerKinds.contains(touch) = false + - → return으로 무시 ✅ (문제 없음) + +2. stylusOnly 모드 + 링커 모드 + + + - 손가락 드래그 → LinkerGestureLayer의 GestureDetector + - → dragDevices.contains(touch) = false + - → 드래그 무시 ✅ + - 손가락 탭 → LinkerGestureLayer의 GestureDetector + - → tapDevices.contains(touch) = true ✅ + - → 탭 허용 (의도된 동작: 링크 찾기는 손가락 가능) + +3. all 모드 + 일반 필기 모드 + + + - InteractiveViewer가 panEnabled: true + - Scribble도 입력 받음 + - → 패닝과 필기가 동시에 시작될 수 있음 🚨 + +문제 3: InteractiveViewer의 제스처 우선순위 + +// note_page_view_item.dart:163 +panEnabled: !isLinkerMode, + +- 일반 모드: panEnabled=true → 터치 드래그가 패닝으로 처리될 수 있음 +- 링커 모드: panEnabled=false → 패닝 차단, LinkerGestureLayer가 선점 + +충돌: + +- InteractiveViewer와 Scribble이 동시에 터치 이벤트에 반응할 수 있음 +- Flutter의 제스처 arbitration(중재)에 의존하고 있음 +- → 예측 불가능한 동작 발생 가능 + +5. 📋 정리: 현재 pointerPolicy가 제어하는 것 vs 제어하지 못하는 것 + +✅ 제어되는 것: + +1. CustomScribbleNotifier 내부 필기 처리 (onPointerDown/Update) +2. LinkerGestureLayer의 드래그 제스처 (supportedDevices) +3. LinkerGestureLayer의 탭 제스처 (supportedDevices) + +❌ 제어되지 않는 것: + +1. InteractiveViewer의 패닝 제스처 +2. 위젯 간 제스처 충돌 (InteractiveViewer ↔ Scribble) +3. hit-test 단계의 이벤트 전파 +4. 링커 모드에서 탭은 항상 touch 허용 (의도적이지만 일관성 부족) + +5. 🎯 구체적인 문제 증상 + +사용자가 경험하는 문제는 아마도: + +1. stylusOnly 모드에서 손가락으로 패닝하려고 했는데 필기가 시작됨 + + + - InteractiveViewer보다 Scribble이 이벤트를 먼저 받음 + +2. all 모드에서 드래그가 패닝과 필기를 동시에 트리거함 + + + - 제스처 중재 실패 + +3. 링커 모드에서 손가락 탭이 작동함 (stylusOnly인데도) + + + - tapDevices에 touch가 항상 포함됨 (line 146) diff --git a/docs/1104_pointerPolicy_scribble_fix_plan.md b/docs/1104_pointerPolicy_scribble_fix_plan.md new file mode 100644 index 0000000..fc60704 --- /dev/null +++ b/docs/1104_pointerPolicy_scribble_fix_plan.md @@ -0,0 +1,79 @@ +# 1104 pointerPolicy 개선 작업 가이드 + +> 목표: `ScribblePointerMode.all`일 때는 **싱글 터치 = 필기**, **멀티 터치 = 패닝/줌**이 자연스럽게 공존하도록 하고, `penOnly` 모드는 기존 동작을 유지합니다. + +## 1. 원인 요약 (재확인) + +- `scribble` 포크(`lib/src/view/scribble.dart`)가 항상 `GestureCatcher`로 `RawGestureDetector`를 등록합니다. +- `GestureCatcher`는 전달받은 `pointerKindsToCatch` 전체를 즉시 `GestureDisposition.accepted`로 만들어 InteractiveViewer 제스처 아레나를 이깁니다. +- `ScribblePointerMode.all`일 때 터치가 포함된 세트를 그대로 전달해서, 2번째 손가락이 들어와도 InteractiveViewer는 이벤트를 못 받습니다. + +## 2. Scribble 위젯 수정 지침 + +### 2.1 수정 대상 + +- 경로: `~/.pub-cache/git/scribble-/lib/src/view/scribble.dart` +- `build` 메서드 내부에서 `GestureCatcher`를 감싸는 부분 + +### 2.2 적용 아이디어 + +1. `state.allowedPointersMode`에 따라 `GestureCatcher` 적용 여부를 분기합니다. + ```dart + final shouldCatch = switch (state.allowedPointersMode) { + ScribblePointerMode.penOnly || + ScribblePointerMode.mouseOnly || + ScribblePointerMode.mouseAndPen => true, + ScribblePointerMode.all => false, + }; + ``` +2. `shouldCatch == false`이면 기존 `GestureCatcher` 래핑을 건너뛰고 `MouseRegion` → `Listener`만 렌더링합니다. + - 이렇게 하면 터치 포인터가 제스처 아레나로 흘러 InteractiveViewer가 두 번째 손가락을 받을 수 있습니다. +3. `shouldCatch == true`인 경우(= 터치가 금지된 모드)는 현재 구조를 유지하면 됩니다. +4. `state.supportedPointerKinds`에는 아무 변화가 없어도 됩니다. `CustomScribbleNotifier`가 이미 포인터 종류를 다시 필터링합니다. + +### 2.3 구현 힌트 + +- 가독성을 위해 `Widget _buildActiveChild(...)` 같은 private 메서드로 child 빌드를 분리해도 좋습니다. +- 조건 분기 이후에는 다음 형태를 유지하면 됩니다. + ```dart + if (!state.active) return child; + if (!shouldCatch) { + return MouseRegion(... Listener(child)); + } + return GestureCatcher(... MouseRegion(... Listener(child))); + ``` +- `_GestureCatcherRecognizer` 자체를 수정할 필요는 없습니다. + +## 3. 앱 코드 후속 정리 + +### 3.1 InteractiveViewer 설정 (`lib/features/canvas/widgets/note_page_view_item.dart`) + +- `panEnabled`를 무조건 `!isLinkerMode`로 고정하면, `all` 모드에서도 한 손가락 드래그가 패닝으로 연결되는 순간 필기와 충돌합니다. +- 전역 포인터 정책을 읽어서 아래처럼 조건을 나눕니다. + ```dart + final pointerPolicy = ref.watch(pointerPolicyProvider); + final canPanWithSingleFinger = pointerPolicy == ScribblePointerMode.penOnly; + panEnabled: !isLinkerMode && canPanWithSingleFinger, + ``` +- 두 손가락 이상은 `GestureDetector`의 scale 제스처가 처리하므로 `scaleEnabled: true`는 그대로 유지합니다. + +### 3.2 멀티 포인터 안전장치 (선택) + +- `CustomScribbleNotifier`는 이미 `value.active`로 2개 이상 포인터가 들어오면 `pointerPosition`을 null로 돌립니다. 그래도 확실히 하고 싶다면, `onPointerDown`에서 `activePointerIds.length >= 1`일 때 항상 `activeLine`을 `null`로 정리해도 됩니다. + +## 4. 테스트 & 검증 + +1. `fvm flutter analyze`가 통과해야 합니다. +2. 물리 디바이스나 시뮬레이터에서 아래 시나리오를 확인합니다. + - `ScribblePointerMode.all`: 한 손가락 필기, 두 손가락 패닝, 두 손가락 핀치 줌 모두 정상. + - `ScribblePointerMode.penOnly`: 손가락 단독은 패닝만, 펜 입력은 필기. +3. 링커 모드에서도 기존 동작(손가락 탭 허용, 두 손가락 줌 가능)이 유지되는지 확인합니다. + +## 5. 작업 순서 제안 + +1. `scribble` 포크 브랜치를 체크아웃하고 위의 2단계 수정 적용. +2. 앱 레이어(`note_page_view_item.dart`)에서 `panEnabled` 조건 수정. +3. 필요 시 `docs/1104_pointerPolicy.md`에 구현 완료 메모 추가. +4. `fvm flutter analyze`, `fvm flutter test` 후 동작 검증. + +이 문서를 기반으로 수정 요청을 전달하면 됩니다. Codex 인스턴스에는 **패키지 포크 수정**과 **앱 레이어 조정** 두 파트를 명시적으로 할당하세요. diff --git a/docs/1105_pointer_interaction_issue.md b/docs/1105_pointer_interaction_issue.md new file mode 100644 index 0000000..f2934e9 --- /dev/null +++ b/docs/1105_pointer_interaction_issue.md @@ -0,0 +1,135 @@ +# 1105 싱글 터치/링커 제스처 이슈 정리 + +## 1. 최근 진행 사항 + +### 1.1 Scribble 포크 수정 + +- 경로: `lib/src/view/scribble.dart` +- `ScribblePointerMode.all`일 때 `GestureCatcher`를 건너뛰도록 바꿔, 두 번째 손가락부터는 Flutter 제스처 아레나가 정상적으로 열리도록 조정. +- 기존 `Listener` 기반 필기 로직은 그대로 두고, 마우스 커서 처리 등은 유지. + +### 1.2 앱 레이어 수정 + +- 경로: `lib/features/canvas/widgets/note_page_view_item.dart` +- `ValueListenableBuilder`로 현재 포인터 수를 관찰. +- `panEnabled` 계산식: + ``` + final multiplePointersActive = scribbleState.activePointerIds.length >= 2; + final allowSingleFingerPan = pointerPolicy == ScribblePointerMode.penOnly; + final panEnabled = + !isLinkerMode && (allowSingleFingerPan || multiplePointersActive); + ``` +- 결과: `pointerPolicy == all` 상태에서 한 손가락은 Scribble만 반응하고, 두 손가락 이상이 되면 InteractiveViewer가 패닝/핀치를 담당. + +## 2. 현재 동작 요약 + +| 모드 | 포인터 수 | 실제 동작 | +| ------- | --------- | ------------------------------------------------------------------------------ | +| all | 1 | Scribble 필기만 동작, InteractiveViewer 패닝 차단 | +| all | ≥2 | InteractiveViewer 패닝/핀치 정상 작동, Scribble는 포인터가 2개 이상이라 비활성 | +| penOnly | 1 | InteractiveViewer 싱글 터치 패닝 허용 (기존 동작 유지) | +| penOnly | ≥2 | InteractiveViewer 멀티 터치 패닝/핀치 | + +싱글 터치 패닝 충돌은 이 방식으로 해결됐다. + +## 3. Flutter 제스처 모델 이해 요약 + +1. **Listener는 제스처 아레나 밖** + `Listener`는 히트 테스트만 통과하면 포인터 이벤트를 무조건 전달받는다. 아레나 경쟁이 없으므로 Scribble은 항상 이벤트를 수신한다. +2. **InteractiveViewer는 제스처 아레나 안** + 내부 `ScaleGestureRecognizer`가 1포인터 드래그를 팬으로 인식하고 `accept`하면 같은 스트림의 이벤트를 계속 소비한다. +3. **충돌 구조** + 두 위젯이 동시에 이벤트를 받는 구조였기 때문에, `panEnabled`를 싱글 포인터 구간에서만 꺼주어 충돌을 풀었다. + +## 4. 문제 재현 요약 (Stylus Only 모드) + +1. 링커 툴 선택 후 링크 영역을 펜/손가락으로 탭해도 반응 없음. +2. 링커 툴에서 펜으로 사각형을 한 번 그린 뒤, 손가락으로 뷰어를 이동하거나 핀치해도 반응 없음. + +→ stylusOnly 정책에서 링커 제스처와 InteractiveViewer가 동시에 비활성화되는 것이 핵심 증상. + +## 5. 근본 원인 분석 + +### 5.1 포인터 필터 흐름 + +| 계층 | 위치 | stylusOnly에서의 동작 | +| ------------------ | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Scribble | `note_page_view_item.dart:210-221`
`CustomScribbleNotifier` | 링커 모드 진입 시 `IgnorePointer`로 완전 차단. | +| LinkerGestureLayer | `linker_gesture_layer.dart:87-205` | `_allowTouchTap`과 `_allowTouchDrag`가 둘 다 `false` → 손가락 탭/드래그 모두 reject. `_activeKind`가 stylus여도 tap 콜백을 호출하지 않음. | +| InteractiveViewer | `note_page_view_item.dart:159-175` | `isLinkerMode`면 `panEnabled=false` → 손가락 이동/핀치가 전부 막힘. | + +### 5.2 정리 + +- “Stylus 전용이라도 탭은 허용한다”는 설계 의도와 달리 `_allowTouchTap`이 `LinkerPointerMode.all`일 때만 `true`라 손가락 탭이 막혀 Step 3가 발생함. +- `_handlePointerUp`이 `_activeKind`를 확인하지 않아 펜 탭이 콜백으로 전달되지 않음(링크 탭 미동작). +- InteractiveViewer가 완전히 비활성화되어 Step 4의 “손가락 이동 불가” 증상이 유지됨. stylusOnly에서도 손가락 제스처가 전부 버려지고 있음. + +## 6. 수정 계획 + +1. **링커 탭 복구** + + - `_allowTouchTap`을 stylusOnly에서도 `true`로 유지하거나, 최소한 `_activeKind`가 stylus일 때는 강제로 탭 콜백을 호출하도록 `_handlePointerUp`을 수정한다. + - `_supportsPointer`도 stylus 탭을 reject하지 않도록 정리한다. + +2. **링커 모드 패닝 허용** + + - `InteractiveViewer.panEnabled` 계산식을 조정해 `isLinkerMode && pointerPolicy == penOnly`일 때는 `true`로 유지한다. 손가락 입력은 InteractiveViewer가 처리하고, 펜 드래그는 Linker가 담당한다. + +3. **스타일러스/손가락 공존 확인** + + - 멀티 포인터가 들어오면 기존처럼 `_resetGesture()`로 Linker 추적을 중단해 두 손가락 핀치가 InteractiveViewer로 흘러가도록 유지한다(`linker_gesture_layer.dart:168-207`). + +4. **Scribble 레이어 유지** + - Scribble을 끄는 로직은 그대로 두되(링커 모드에서는 필기 차단 필요), 상위 제스처가 손가락 입력을 소비하지 않도록 조건을 조정한다. + +## 7. 구현 시나리오 초안 + +1. `LinkerGestureLayer`에서 `_allowTouchTap` 계산을 고쳐 stylusOnly에서도 탭을 허용하고, `_handlePointerUp`에 `_activeKind` 분기를 추가한다. +2. `note_page_view_item.dart`에서 `panEnabled`를 `isLinkerMode && pointerPolicy == ScribblePointerMode.penOnly`인 경우에도 `true`로 설정한다. +3. 에뮬레이터/기기에서 아래 플로우를 검증한다. + - 펜 탭 → 링크 액션 패널 표시 + - 손가락 탭 → 동일하게 패널 표시 + - 펜 드래그 → 링크 사각형 생성 + - 손가락 드래그/핀치 → 뷰어 이동/확대 축소 +4. 필요 시 추가 회귀 테스트(`pointerPolicy == all`)도 실행해 싱글 터치 패닝 방식이 변하지 않았는지 확인한다. + +## 8. Stylus-only에서 링크 생성 시 InteractiveViewer 패닝 문제 + +### 증상 + +- 스타일러스 모드 + 링커 모드에서 사각형을 그리면, 링크 직사각형과 함께 InteractiveViewer도 움직여 화면이 쓸려 나감. + +### 근본 원인 + +- `panEnabled`를 stylus-only 모드에서도 켜둔 상태라, Flutter 제스처 아레나에서 InteractiveViewer의 `ScaleGestureRecognizer`가 스타일러스 드래그를 계속 팬(Pan)으로 인식함. +- `LinkerGestureLayer`는 Listener 기반이라 포인터 이벤트를 모두 받고 드래그를 추적하지만, 동시에 InteractiveViewer도 제스처를 `accept`해 두 위젯이 같은 드래그 스트림을 공유하게 됨. +- 결과적으로 스타일러스 드래그가 링커 사각형 + 뷰어 패닝을 동시에 유발. + +### 해결 + +1. `LinkerGestureLayer`에 `onStylusInteractionChanged` 콜백을 추가해 스타일러스 드래그가 시작/종료될 때 상위 레이어에 알림. + - 포인터 다운/업/취소 시 `_notifyStylusInteraction`으로 상태 전환 (`lib/features/canvas/widgets/linker_gesture_layer.dart`). +2. `NotePageViewItem`에서 `_stylusLinkerActive` 상태를 추가해 콜백 값을 추적하고, 스타일러스가 활성일 동안에는 `panEnabled`를 일시적으로 꺼 InteractiveViewer가 드래그를 수락하지 않도록 함. +3. 스타일러스가 올라가면 즉시 `panEnabled`가 다시 켜져 손가락 패닝/핀치를 계속 허용. + +## 9. 페이지 이탈 시 MouseTracker Assertion 문제 + +### 증상 + +- 스타일러스 호버 상태(커서 점이 보이는 상태)에서 노트 편집 화면을 떠나면 다음 예외가 연속 발생: + ``` + 'package:flutter/src/rendering/mouse_tracker.dart': Failed assertion: line 203 pos 12: '!_debugDuringDeviceUpdate': is not true. + A CustomScribbleNotifier was used after being disposed. + ``` + +### 근본 원인 + +- 화면이 Pop되면서 `CustomScribbleNotifier`가 `dispose()`된 뒤에도 시스템이 마지막 스타일러스 포인터에 대한 `PointerExitEvent`를 전달. +- Scribble 패키지 기본 구현은 exit 시 `notifyListeners()`를 호출하지만, 이미 dispose된 노티파이어에서 이를 실행해 Flutter의 `ChangeNotifier` 보호 로직이 assertion을 발생시킴. +- MouseTracker는 여전히 디바이스 업데이트 중이어서 `_debugDuringDeviceUpdate` 플래그가 켜진 상태로 재귀 호출이 일어나 추가 assertion이 이어짐. + +### 해결 + +1. `CustomScribbleNotifier`에 `_isDisposed` 플래그를 도입하고 `dispose()`에서 true로 설정. +2. `onPointerDown`, `onPointerUpdate`, `onPointerExit` 등 포인터 핸들러가 `_isDisposed`를 먼저 확인해 dispose 이후 이벤트를 무시하도록 변경. +3. `onPointerExit`를 override해 dispose된 상태에서는 상위 구현을 호출하지 않도록 함으로써 MouseTracker 업데이트 루프에서 더 이상 dispose된 노티파이어를 참조하지 않게 함. diff --git a/lib/features/canvas/notifiers/custom_scribble_notifier.dart b/lib/features/canvas/notifiers/custom_scribble_notifier.dart index 5f2bcad..90e9269 100644 --- a/lib/features/canvas/notifiers/custom_scribble_notifier.dart +++ b/lib/features/canvas/notifiers/custom_scribble_notifier.dart @@ -71,16 +71,22 @@ class CustomScribbleNotifier extends ScribbleNotifier with ToolManagementMixin { /// 런타임에서 필압 사용 여부를 토글할 수 있도록 내부 플래그를 유지합니다. /// 생성 시 초기값은 [simulatePressure] 파라미터로부터 전달됩니다. bool _simulatePressureEnabled = false; + bool _isDisposed = false; /// 필압 사용 여부를 런타임에서 변경합니다. 재생성 없이 즉시 적용됩니다. void setSimulatePressureEnabled(bool enabled) { _simulatePressureEnabled = enabled; } + bool get _isActiveNotifier => !_isDisposed; + /// 포인터 다운 이벤트를 처리합니다. /// 링커 모드일 때는 아무것도 하지 않습니다. @override void onPointerDown(PointerDownEvent event) { + if (!_isActiveNotifier) { + return; + } if (toolMode.isLinker) return; // 링커 모드일 때는 아무것도 하지 않음 if (!value.supportedPointerKinds.contains(event.kind)) { return; @@ -125,6 +131,9 @@ class CustomScribbleNotifier extends ScribbleNotifier with ToolManagementMixin { /// 링커 모드일 때는 아무것도 하지 않습니다. @override void onPointerUpdate(PointerMoveEvent event) { + if (!_isActiveNotifier) { + return; + } if (toolMode.isLinker) return; // 링커 모드일 때는 아무것도 하지 않음 if (!value.supportedPointerKinds.contains(event.kind)) { return; @@ -270,6 +279,22 @@ class CustomScribbleNotifier extends ScribbleNotifier with ToolManagementMixin { } return s; } + @override + void onPointerExit(PointerExitEvent event) { + if (!_isActiveNotifier) { + return; + } + super.onPointerExit(event); + } + + @override + void dispose() { + if (_isDisposed) { + return; + } + _isDisposed = true; + super.dispose(); + } } /// 기본 필압 곡선 (입력 t를 그대로 반환하여 필압 반영) diff --git a/lib/features/canvas/widgets/linker_gesture_layer.dart b/lib/features/canvas/widgets/linker_gesture_layer.dart index e85cbfd..4d48279 100644 --- a/lib/features/canvas/widgets/linker_gesture_layer.dart +++ b/lib/features/canvas/widgets/linker_gesture_layer.dart @@ -1,8 +1,8 @@ -import 'dart:ui' as ui; +import 'dart:ui'; import 'package:flutter/material.dart'; -import '../models/tool_mode.dart'; // ToolMode 정의 필요 +import '../models/tool_mode.dart'; import 'link_drag_overlay_painter.dart'; /// 링커 입력 포인터 정책 @@ -41,14 +41,9 @@ class LinkerGestureLayer extends StatefulWidget { /// 현재 드래그 중인 링커의 테두리 두께. final double currentLinkerBorderWidth; - /// [LinkerGestureLayer]의 생성자. - /// - /// [toolMode]는 현재 도구 모드입니다. - /// [pointerMode]는 입력 포인터 정책입니다. - /// [onRectCompleted]는 드래그 완료 시 바운딩 박스를 전달합니다. - /// [onTapAt]은 탭 좌표를 부모로 전달합니다. - /// [minLinkerRectangleSize]는 유효한 링커로 인식될 최소 크기입니다. - /// [currentLinkerFillColor], [currentLinkerBorderColor], [currentLinkerBorderWidth]는 현재 드래그 중인 링커의 스타일을 정의합니다. + /// 스타일러스 드래그/탭이 진행 중인지 외부에 알립니다. + final ValueChanged? onStylusInteractionChanged; + const LinkerGestureLayer({ super.key, required this.toolMode, @@ -59,6 +54,7 @@ class LinkerGestureLayer extends StatefulWidget { this.currentLinkerFillColor = Colors.green, this.currentLinkerBorderColor = Colors.green, this.currentLinkerBorderWidth = 2.0, + this.onStylusInteractionChanged, }); @override @@ -66,128 +62,222 @@ class LinkerGestureLayer extends StatefulWidget { } class _LinkerGestureLayerState extends State { - Offset? _currentDragStart; - Offset? _currentDragEnd; - - /// 드래그 시작 시 호출 - void _onDragStart(DragStartDetails details) { - setState(() { - _currentDragStart = details.localPosition; - _currentDragEnd = details.localPosition; - }); + static const _tapMaxDistance = 4.0; + static const _tapMaxDuration = Duration(milliseconds: 200); + + final Set _pointersDown = {}; + PointerDeviceKind? _activeKind; + int? _activePointer; + Offset? _pointerDownPosition; + Offset? _currentPosition; + late final Stopwatch _gestureStopwatch; + bool _stylusActive = false; + + @override + void initState() { + super.initState(); + _gestureStopwatch = Stopwatch(); } - /// 드래그 중 호출 - void _onDragUpdate(DragUpdateDetails details) { - setState(() { - _currentDragEnd = details.localPosition; - }); + @override + void dispose() { + _gestureStopwatch.stop(); + super.dispose(); } - /// 드래그 종료 시 호출 - void _onDragEnd(DragEndDetails details) { - debugPrint( - '[LinkerGestureLayer] onDragEnd. ' - 'start=$_currentDragStart end=$_currentDragEnd', - ); - setState(() { - if (_currentDragStart != null && _currentDragEnd != null) { - final rect = Rect.fromPoints(_currentDragStart!, _currentDragEnd!); - debugPrint( - '[LinkerGestureLayer] completed rect ' - '(${rect.left.toStringAsFixed(1)},' - '${rect.top.toStringAsFixed(1)},' - '${rect.width.toStringAsFixed(1)}x' - '${rect.height.toStringAsFixed(1)})', - ); - if (rect.width.abs() > widget.minLinkerRectangleSize && - rect.height.abs() > widget.minLinkerRectangleSize) { - widget.onRectCompleted(rect); + bool get _isActive => _activePointer != null; + + bool get _isLinkerMode => widget.toolMode == ToolMode.linker; + + bool get _allowTouchDrag => widget.pointerMode == LinkerPointerMode.all; + + // 스타일러스 전용 모드에서도 손가락 탭을 허용해야 링크 액션을 열 수 있다. + bool get _allowTouchTap => true; + + bool _isStylusKind(PointerDeviceKind? kind) { + return kind == PointerDeviceKind.stylus || + kind == PointerDeviceKind.invertedStylus; + } + + void _notifyStylusInteraction(bool active, {bool defer = false}) { + if (_stylusActive == active) { + return; + } + _stylusActive = active; + final callback = widget.onStylusInteractionChanged; + if (callback == null) { + return; + } + if (defer) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; } - } - _currentDragStart = null; - _currentDragEnd = null; - }); + callback(active); + }); + return; + } + callback(active); } - /// 탭 업(손가락 떼는) 시 호출 - void _onTapUp(TapUpDetails details) { - debugPrint( - '[LinkerGestureLayer] onTapUp at ' - '${details.localPosition.dx.toStringAsFixed(1)},' - '${details.localPosition.dy.toStringAsFixed(1)}', - ); - widget.onTapAt(details.localPosition); + bool _supportsPointer(PointerDownEvent event) { + switch (event.kind) { + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + return true; + case PointerDeviceKind.touch: + return _allowTouchDrag || _allowTouchTap; + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + return widget.pointerMode == LinkerPointerMode.all; + default: + return false; + } + } + + bool _shouldStartDrag(PointerEvent event) { + switch (event.kind) { + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + return true; + case PointerDeviceKind.touch: + return _allowTouchDrag; + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + return widget.pointerMode == LinkerPointerMode.all; + default: + return false; + } + } + + void _resetGesture() { + _notifyStylusInteraction(false, defer: true); + _activePointer = null; + _activeKind = null; + _pointerDownPosition = null; + _currentPosition = null; + _gestureStopwatch + ..reset() + ..stop(); + } + + void _startGesture(PointerDownEvent event) { + _activePointer = event.pointer; + _activeKind = event.kind; + _pointerDownPosition = event.localPosition; + _currentPosition = event.localPosition; + _gestureStopwatch + ..reset() + ..start(); + setState(() {}); + } + + bool _isTapGesture(Offset upPosition) { + if (_pointerDownPosition == null) { + return false; + } + final distance = (upPosition - _pointerDownPosition!).distance; + final elapsed = _gestureStopwatch.elapsed; + return distance <= _tapMaxDistance && elapsed <= _tapMaxDuration; + } + + bool _isRectLargeEnough() { + if (_pointerDownPosition == null || _currentPosition == null) { + return false; + } + final width = (_currentPosition!.dx - _pointerDownPosition!.dx).abs(); + final height = (_currentPosition!.dy - _pointerDownPosition!.dy).abs(); + return width > widget.minLinkerRectangleSize && + height > widget.minLinkerRectangleSize; + } + + void _handlePointerDown(PointerDownEvent event) { + if (!_isLinkerMode) { + return; + } + if (!_supportsPointer(event)) { + return; + } + _pointersDown.add(event.pointer); + if (_pointersDown.length >= 2 && _isActive) { + _resetGesture(); + setState(() {}); + return; + } + if (_isActive) { + // 이미 다른 포인터를 추적 중이면 무시해서 InteractiveViewer가 멀티터치를 잡도록 함. + return; + } + if (_isStylusKind(event.kind)) { + _notifyStylusInteraction(true); + } + _startGesture(event); + } + + void _handlePointerMove(PointerMoveEvent event) { + if (!_isActive || event.pointer != _activePointer) { + return; + } + // Stylus 전용 모드에서 터치 포인터는 드래그를 만들지 않는다. + if (!_shouldStartDrag(event)) { + // 드래그가 허용되지 않은 포인터라도 움직임은 기록해 탭 판정(거리)에 사용한다. + _currentPosition = event.localPosition; + return; + } + _currentPosition = event.localPosition; + setState(() {}); + } + + void _handlePointerUp(PointerUpEvent event) { + _pointersDown.remove(event.pointer); + if (!_isActive || event.pointer != _activePointer) { + return; + } + final upPosition = event.localPosition; + final isTap = _isTapGesture(upPosition); + final canPerformDrag = _isStylusKind(_activeKind) || _allowTouchDrag; + final isStylusTap = isTap && _isStylusKind(_activeKind); + final isDrag = canPerformDrag && !isTap && _isRectLargeEnough(); + + if (isTap && (_allowTouchTap || isStylusTap)) { + widget.onTapAt(upPosition); + } else if (isDrag) { + final rect = Rect.fromPoints(_pointerDownPosition!, upPosition); + widget.onRectCompleted(rect); + } + _resetGesture(); + setState(() {}); + } + + void _handlePointerCancel(PointerCancelEvent event) { + _pointersDown.remove(event.pointer); + if (!_isActive || event.pointer != _activePointer) { + return; + } + _resetGesture(); + setState(() {}); } @override Widget build(BuildContext context) { - // toolMode가 linker일 때만 GestureDetector를 활성화 - if (widget.toolMode != ToolMode.linker) { - return Container(); // 링커 모드가 아니면 아무것도 렌더링하지 않음 - } - - // 드래그 허용 포인터 - final dragDevices = { - ui.PointerDeviceKind.stylus, - ui.PointerDeviceKind.invertedStylus, - }; - if (widget.pointerMode == LinkerPointerMode.all) { - dragDevices - ..add(ui.PointerDeviceKind.touch) - ..add(ui.PointerDeviceKind.mouse) - ..add(ui.PointerDeviceKind.trackpad); - } - - // 탭 허용 포인터(두 모드 모두 손가락 탭으로 링크 확인 허용) - final tapDevices = { - ui.PointerDeviceKind.stylus, - ui.PointerDeviceKind.invertedStylus, - ui.PointerDeviceKind.touch, - }; - if (widget.pointerMode == LinkerPointerMode.all) { - tapDevices - ..add(ui.PointerDeviceKind.mouse) - ..add(ui.PointerDeviceKind.trackpad); - } - - // 탭과 드래그를 서로 다른 supportedDevices로 분리 처리 + if (!_isLinkerMode) { + return const SizedBox.shrink(); + } + return Listener( - onPointerDown: (event) { - debugPrint( - '[LinkerGestureLayer] raw PointerDown kind=${event.kind} ' - 'pos=${event.position.dx.toStringAsFixed(1)},' - '${event.position.dy.toStringAsFixed(1)}', - ); - }, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - supportedDevices: tapDevices, - onTapUp: _onTapUp, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - supportedDevices: dragDevices, - onPanDown: (details) { - debugPrint( - '[LinkerGestureLayer] onPanDown at ' - '${details.localPosition.dx.toStringAsFixed(1)},' - '${details.localPosition.dy.toStringAsFixed(1)}', - ); - }, - onPanStart: _onDragStart, - onPanUpdate: _onDragUpdate, - onPanEnd: _onDragEnd, - child: CustomPaint( - size: Size.infinite, // CustomPaint가 전체 영역을 차지하도록 설정 - painter: LinkDragOverlayPainter( - currentDragStart: _currentDragStart, - currentDragEnd: _currentDragEnd, - currentFillColor: widget.currentLinkerFillColor, - currentBorderColor: widget.currentLinkerBorderColor, - currentBorderWidth: widget.currentLinkerBorderWidth, - ), - child: Container(), // GestureDetector가 전체 영역을 감지하도록 함 - ), + onPointerDown: _handlePointerDown, + onPointerMove: _handlePointerMove, + onPointerUp: _handlePointerUp, + onPointerCancel: _handlePointerCancel, + behavior: HitTestBehavior.translucent, + child: CustomPaint( + size: Size.infinite, + painter: LinkDragOverlayPainter( + currentDragStart: _pointerDownPosition, + currentDragEnd: _currentPosition, + currentFillColor: widget.currentLinkerFillColor, + currentBorderColor: widget.currentLinkerBorderColor, + currentBorderWidth: widget.currentLinkerBorderWidth, ), ), ); diff --git a/lib/features/canvas/widgets/note_page_view_item.dart b/lib/features/canvas/widgets/note_page_view_item.dart index 3b179fd..f6c43b2 100644 --- a/lib/features/canvas/widgets/note_page_view_item.dart +++ b/lib/features/canvas/widgets/note_page_view_item.dart @@ -50,6 +50,8 @@ class _NotePageViewItemState extends ConsumerState { double _lastScale = 1.0; // 임시 드래그 상태는 LinkerGestureLayer 내부에서만 관리되므로 상태 제거 final GlobalKey _linkerLayerKey = GlobalKey(); + // 스타일러스 기반 링커 제스처가 진행 중인지 추적. + bool _stylusLinkerActive = false; // 비-build 컨텍스트에서 현재 노트의 notifier 접근용 CustomScribbleNotifier get _currentNotifier => @@ -117,6 +119,7 @@ class _NotePageViewItemState extends ConsumerState { return const SizedBox.shrink(); } + final pointerPolicy = ref.watch(pointerPolicyProvider); final notifier = ref.watch( pageNotifierProvider(widget.noteId, widget.pageIndex), ); @@ -152,294 +155,329 @@ class _NotePageViewItemState extends ConsumerState { surfaceTintColor: Colors.white, child: ClipRRect( borderRadius: BorderRadius.circular(6), - child: InteractiveViewer( - transformationController: ref.watch( - transformationControllerProvider(widget.noteId), - ), - minScale: 0.3, - maxScale: 3.0, - constrained: false, - // 링커 모드에서는 패닝을 비활성화하여 제스처 레이어가 드래그를 선점하도록 함 - panEnabled: !isLinkerMode, - scaleEnabled: true, - onInteractionEnd: (details) { - _debounceTimer?.cancel(); - _updateScale(); - }, - child: SizedBox( - width: drawingWidth * NoteEditorConstants.canvasScale, - height: drawingHeight * NoteEditorConstants.canvasScale, - child: Center( + child: ValueListenableBuilder( + valueListenable: notifier, + builder: (context, scribbleState, _) { + final multiplePointersActive = + scribbleState.activePointerIds.length >= 2; + final allowSingleFingerPan = + pointerPolicy == ScribblePointerMode.penOnly; + final stylusBlocksPan = + isLinkerMode && _stylusLinkerActive; + final panEnabled = + (!isLinkerMode && + (allowSingleFingerPan || multiplePointersActive)) || + (isLinkerMode && + pointerPolicy == ScribblePointerMode.penOnly && + !stylusBlocksPan); + + return InteractiveViewer( + transformationController: ref.watch( + transformationControllerProvider(widget.noteId), + ), + minScale: 0.3, + maxScale: 3.0, + constrained: false, + panEnabled: panEnabled, + scaleEnabled: true, + onInteractionEnd: (details) { + _debounceTimer?.cancel(); + _updateScale(); + }, child: SizedBox( - width: drawingWidth, - height: drawingHeight, - child: ValueListenableBuilder( - valueListenable: notifier, - builder: (context, scribbleState, child) { - final currentToolMode = ref - .read(toolSettingsNotifierProvider(widget.noteId)) - .toolMode; - final pointerPolicy = ref.watch(pointerPolicyProvider); - return Stack( - children: [ - // 배경 레이어 - CanvasBackgroundWidget( - page: notifier.page!, - width: drawingWidth, - height: drawingHeight, - ), - // 저장된 링크 레이어 (Provider 기반) - SavedLinksLayer( - pageId: notifier.page!.pageId, - fillColor: AppColors.linkerBlue.withAlpha( - (255 * 0.15).round(), - ), - borderColor: AppColors.linkerBlue, - borderWidth: 2.0, - ), - // 필기 레이어 (링커 모드가 아닐 때만 활성화) - IgnorePointer( - ignoring: currentToolMode.isLinker, - child: ClipRect( - child: Scribble( - notifier: notifier, - drawPen: !currentToolMode.isLinker, - simulatePressure: ref.watch( - simulatePressureProvider, + width: drawingWidth * NoteEditorConstants.canvasScale, + height: drawingHeight * NoteEditorConstants.canvasScale, + child: Center( + child: SizedBox( + width: drawingWidth, + height: drawingHeight, + child: ValueListenableBuilder( + valueListenable: notifier, + builder: (context, scribbleState, child) { + final currentToolMode = ref + .read(toolSettingsNotifierProvider(widget.noteId)) + .toolMode; + return Stack( + children: [ + // 배경 레이어 + CanvasBackgroundWidget( + page: notifier.page!, + width: drawingWidth, + height: drawingHeight, + ), + // 저장된 링크 레이어 (Provider 기반) + SavedLinksLayer( + pageId: notifier.page!.pageId, + fillColor: AppColors.linkerBlue.withAlpha( + (255 * 0.15).round(), ), + borderColor: AppColors.linkerBlue, + borderWidth: 2.0, ), - ), - ), - // 패닝은 InteractiveViewer가 처리 - // 링커 제스처 및 그리기 레이어 (항상 존재하며, 내부적으로 toolMode에 따라 드래그/탭 처리) - Positioned.fill( - child: LinkerGestureLayer( - key: _linkerLayerKey, - toolMode: currentToolMode, - // Use global pointer policy - pointerMode: - pointerPolicy == ScribblePointerMode.all - ? LinkerPointerMode.all - : LinkerPointerMode.stylusOnly, - onRectCompleted: (rect) async { - final currentPage = notifier.page!; - unawaited( - ref - .read(firebaseAnalyticsLoggerProvider) - .logLinkDrawn( - sourceNoteId: currentPage.noteId, - sourcePageId: currentPage.pageId, - ), - ); - final res = await LinkCreationDialog.show( - context, - sourceNoteId: currentPage.noteId, - ); - if (res == null) return; // 취소 - try { - await ref - .read(linkCreationControllerProvider) - .createFromRect( - sourceNoteId: currentPage.noteId, - sourcePageId: currentPage.pageId, - rect: rect, - targetNoteId: res.targetNoteId, - targetTitle: res.targetTitle, - ); - if (!mounted) return; - AppSnackBar.show( - context, - AppErrorSpec.success('링크를 생성했습니다.'), - ); - } catch (e) { - if (!mounted) return; - final spec = AppErrorMapper.toSpec(e); - AppSnackBar.show(context, spec); - } - }, - // 링크 찾아서 모달 표시 (링크 이동 / 링크 수정 / 링크 삭제) - onTapAt: (localPoint) async { - // provider 로 수정필요 - final pageId = notifier.page!.pageId; - final link = ref.read( - linkAtPointProvider(pageId, localPoint), - ); - if (link != null) { - // 탭 지점의 글로벌 좌표 계산 - Offset anchorGlobal = localPoint; - final box = - _linkerLayerKey.currentContext - ?.findRenderObject() - as RenderBox?; - if (box != null) { - anchorGlobal = box.localToGlobal(localPoint); - } - final action = await LinkActionsSheet.show( - context, - link, - anchorGlobal: anchorGlobal, - displayTitle: link.label, - ); - if (!mounted || action == null) return; - switch (action) { - case LinkAction.navigate: - // Save current page before navigating to the target note - await SketchPersistService.saveCurrentPage( - ref, - widget.noteId, - ); - // Store per-route resume index for this editor instance - final idx = ref.read( - currentPageIndexProvider(widget.noteId), - ); - final routeId = ref.read( - noteRouteIdProvider(widget.noteId), + // 필기 레이어 (링커 모드가 아닐 때만 활성화) + IgnorePointer( + ignoring: currentToolMode.isLinker, + child: ClipRect( + child: Scribble( + notifier: notifier, + drawPen: !currentToolMode.isLinker, + simulatePressure: ref.watch( + simulatePressureProvider, + ), + ), + ), + ), + // 패닝은 InteractiveViewer가 처리 + // 링커 제스처 및 그리기 레이어 (항상 존재하며, 내부적으로 toolMode에 따라 드래그/탭 처리) + Positioned.fill( + child: LinkerGestureLayer( + key: _linkerLayerKey, + toolMode: currentToolMode, + pointerMode: + pointerPolicy == ScribblePointerMode.all + ? LinkerPointerMode.all + : LinkerPointerMode.stylusOnly, + onStylusInteractionChanged: (active) { + if (_stylusLinkerActive == active) { + return; + } + if (!mounted) { + _stylusLinkerActive = active; + return; + } + setState(() { + _stylusLinkerActive = active; + }); + }, + onRectCompleted: (rect) async { + final currentPage = notifier.page!; + unawaited( + ref + .read(firebaseAnalyticsLoggerProvider) + .logLinkDrawn( + sourceNoteId: currentPage.noteId, + sourcePageId: currentPage.pageId, + ), + ); + final res = await LinkCreationDialog.show( + context, + sourceNoteId: currentPage.noteId, + ); + if (res == null) return; // 취소 + try { + await ref + .read(linkCreationControllerProvider) + .createFromRect( + sourceNoteId: currentPage.noteId, + sourcePageId: currentPage.pageId, + rect: rect, + targetNoteId: res.targetNoteId, + targetTitle: res.targetTitle, + ); + if (!mounted) return; + AppSnackBar.show( + context, + AppErrorSpec.success('링크를 생성했습니다.'), ); - if (routeId != null) { - ref - .read( - resumePageIndexMapProvider( - widget.noteId, - ).notifier, - ) - .save(routeId, idx); + } catch (e) { + if (!mounted) return; + final spec = AppErrorMapper.toSpec(e); + AppSnackBar.show(context, spec); + } + }, + // 링크 찾아서 모달 표시 (링크 이동 / 링크 수정 / 링크 삭제) + onTapAt: (localPoint) async { + // provider 로 수정필요 + final pageId = notifier.page!.pageId; + final link = ref.read( + linkAtPointProvider(pageId, localPoint), + ); + if (link != null) { + // 탭 지점의 글로벌 좌표 계산 + Offset anchorGlobal = localPoint; + final box = + _linkerLayerKey.currentContext + ?.findRenderObject() + as RenderBox?; + if (box != null) { + anchorGlobal = box.localToGlobal( + localPoint, + ); } - // Update last known index as well - ref - .read( - lastKnownPageIndexProvider( + final action = await LinkActionsSheet.show( + context, + link, + anchorGlobal: anchorGlobal, + displayTitle: link.label, + ); + if (!mounted || action == null) return; + switch (action) { + case LinkAction.navigate: + // Save current page before navigating to the target note + await SketchPersistService.saveCurrentPage( + ref, + widget.noteId, + ); + // Store per-route resume index for this editor instance + final idx = ref.read( + currentPageIndexProvider( widget.noteId, - ).notifier, - ) - .setValue(idx); - unawaited( - ref - .read( - firebaseAnalyticsLoggerProvider, - ) - .logLinkFollow( - entry: 'canvas_link', - sourceNoteId: link.sourceNoteId, - targetNoteId: link.targetNoteId, ), - ); - context.pushNamed( - AppRoutes.noteEditName, - pathParameters: { - 'noteId': link.targetNoteId, - }, - ); - break; - case LinkAction.edit: - // 링크 수정: 타깃 노트 선택(기존 생성 다이얼로그 재사용) - final editRes = - await LinkCreationDialog.show( - context, - sourceNoteId: link.sourceNoteId, ); - if (editRes == null) break; + final routeId = ref.read( + noteRouteIdProvider(widget.noteId), + ); + if (routeId != null) { + ref + .read( + resumePageIndexMapProvider( + widget.noteId, + ).notifier, + ) + .save(routeId, idx); + } + // Update last known index as well + ref + .read( + lastKnownPageIndexProvider( + widget.noteId, + ).notifier, + ) + .setValue(idx); + unawaited( + ref + .read( + firebaseAnalyticsLoggerProvider, + ) + .logLinkFollow( + entry: 'canvas_link', + sourceNoteId: link.sourceNoteId, + targetNoteId: link.targetNoteId, + ), + ); + context.pushNamed( + AppRoutes.noteEditName, + pathParameters: { + 'noteId': link.targetNoteId, + }, + ); + break; + case LinkAction.edit: + // 링크 수정: 타깃 노트 선택(기존 생성 다이얼로그 재사용) + final editRes = + await LinkCreationDialog.show( + context, + sourceNoteId: link.sourceNoteId, + ); + if (editRes == null) break; - final prevLabel = - (link.label?.trim().isNotEmpty == true) - ? link.label!.trim() - : '링크'; + final prevLabel = + (link.label?.trim().isNotEmpty == + true) + ? link.label!.trim() + : '링크'; - try { - final updatedLink = await ref - .read( - linkCreationControllerProvider, - ) - .updateTargetLink( - link, - targetNoteId: editRes.targetNoteId, - targetTitle: editRes.targetTitle, - ); - if (!mounted) return; + try { + final updatedLink = await ref + .read( + linkCreationControllerProvider, + ) + .updateTargetLink( + link, + targetNoteId: + editRes.targetNoteId, + targetTitle: + editRes.targetTitle, + ); + if (!mounted) return; - final newLabel = - (updatedLink.label - ?.trim() - .isNotEmpty == - true) - ? updatedLink.label!.trim() - : '링크'; + final newLabel = + (updatedLink.label + ?.trim() + .isNotEmpty == + true) + ? updatedLink.label!.trim() + : '링크'; - AppSnackBar.show( - context, - AppErrorSpec.success( - '"$prevLabel" 링크를 "$newLabel"로 수정했습니다.', - ), - ); - } catch (e) { - if (!mounted) return; - final spec = AppErrorMapper.toSpec(e); - AppSnackBar.show(context, spec); - } - break; - case LinkAction.delete: - final delLabel = - (link.label?.trim().isNotEmpty == true) - ? link.label!.trim() - : '링크'; + AppSnackBar.show( + context, + AppErrorSpec.success( + '"$prevLabel" 링크를 "$newLabel"로 수정했습니다.', + ), + ); + } catch (e) { + if (!mounted) return; + final spec = AppErrorMapper.toSpec(e); + AppSnackBar.show(context, spec); + } + break; + case LinkAction.delete: + final delLabel = + (link.label?.trim().isNotEmpty == + true) + ? link.label!.trim() + : '링크'; - final shouldDelete = - await showDesignConfirmDialog( - context: context, - title: '링크 삭제 확인', - message: - '이 "$delLabel" 링크를 삭제할까요?\n이 작업은 되돌릴 수 없습니다.', - confirmLabel: '삭제', - destructive: true, - ); - if (!shouldDelete) { - AppSnackBar.show( - context, - AppErrorSpec.info('삭제를 취소했어요.'), - ); - break; - } - try { - debugPrint( - '[LinkDelete/UI] delete linkId=${link.id} ' - 'src=${link.sourceNoteId}/${link.sourcePageId} ' - 'tgt=${link.targetNoteId}', - ); - await ref - .read(linkRepositoryProvider) - .delete(link.id); - if (!mounted) return; - debugPrint( - '[LinkDelete/UI] deleted linkId=${link.id}', - ); - AppSnackBar.show( - context, - AppErrorSpec.success('링크를 삭제했습니다.'), - ); - } catch (e) { - if (!mounted) return; - final spec = AppErrorMapper.toSpec(e); - AppSnackBar.show(context, spec); + final shouldDelete = + await showDesignConfirmDialog( + context: context, + title: '링크 삭제 확인', + message: + '이 "$delLabel" 링크를 삭제할까요?\n이 작업은 되돌릴 수 없습니다.', + confirmLabel: '삭제', + destructive: true, + ); + if (!shouldDelete) { + AppSnackBar.show( + context, + AppErrorSpec.info('삭제를 취소했어요.'), + ); + break; + } + try { + debugPrint( + '[LinkDelete/UI] delete linkId=${link.id} ' + 'src=${link.sourceNoteId}/${link.sourcePageId} ' + 'tgt=${link.targetNoteId}', + ); + await ref + .read(linkRepositoryProvider) + .delete(link.id); + if (!mounted) return; + debugPrint( + '[LinkDelete/UI] deleted linkId=${link.id}', + ); + AppSnackBar.show( + context, + AppErrorSpec.success('링크를 삭제했습니다.'), + ); + } catch (e) { + if (!mounted) return; + final spec = AppErrorMapper.toSpec(e); + AppSnackBar.show(context, spec); + } + break; } - break; - } - } - }, - minLinkerRectangleSize: 16.0, - currentLinkerFillColor: AppColors.linkerBlue - .withAlpha( - (255 * 0.15).round(), - ), - currentLinkerBorderColor: AppColors.linkerBlue, - currentLinkerBorderWidth: 1.5, - ), - ), - ], - ); - }, + } + }, + minLinkerRectangleSize: 16.0, + currentLinkerFillColor: AppColors.linkerBlue + .withAlpha( + (255 * 0.15).round(), + ), + currentLinkerBorderColor: AppColors.linkerBlue, + currentLinkerBorderWidth: 1.5, + ), + ), + ], + ); + }, + ), + ), ), ), - ), - ), + ); + }, ), ), ); diff --git a/pubspec.yaml b/pubspec.yaml index 98cac93..3041cf1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: scribble: git: url: https://github.com/ehdnd/scribble.git - ref: main + ref: fix/pointer-policy go_router: ^16.0.0 pdfx: ^2.5.0 file_picker: ^8.0.6