diff --git a/myApp/HaruDanim/CLAUDE.md b/myApp/HaruDanim/CLAUDE.md index 10ab500..eb9a883 100644 --- a/myApp/HaruDanim/CLAUDE.md +++ b/myApp/HaruDanim/CLAUDE.md @@ -18,7 +18,7 @@ | 번들 ID | `com.yechan.HaruDanim` / App Group `group.com.yechan.HaruDanim` / CloudKit `iCloud.com.yechan.HaruDanim` | | 수익 모델 | 부분 유료화. 무료 한도(행동 10·목표 2·목표당 다짐 3) + 프리미엄 결제 시 해제 | | 결제 상품 | `com.yechan.HaruDanim.premium.monthly`(월 4,400원) / `.yearly`(년 44,000원) / `.lifetime`(77,000원) — **ASC 등록·판매 중(실제 가격, 2026-07-28)**. 로컬 테스트 구성(HaruDanim.storekit)도 동일 가격. 가격은 코드에 없고 `product.displayPrice`로 동적 표기, 년 구독 절약률 배지도 동적 계산 | -| 현재 상태 (2026-08-02) | **버전 1.2 App Store 출시 완료(2026-08-02, 사용자 확인) — 유지보수 모드. 1.3 또는 메이저 업데이트 준비 대기 상태**(사용자의 버그 보고·기능 요청을 받아 진행). 1.2 내용: 주기 몫 완료 상태 표기(§4.2)+도움말 '진행률 읽는 법' 갱신(상세는 아래 소기능 이력). **버전 1.1 출시 2026-07-30.** 1.1 내용: ①**맥 일기 개방**(§6.7 — 사이드바 노출 + '그리기' 토글로 마우스 필기, 텍스트 우선 도구줄. 아이패드 경로 불변. 맥 실기 확인 완료) ②**CSV 가져오기**(§6.8 — 프리미엄, 추가 전용·미리 보기·멱등. 검증 `-csvImportTest` 20건 ALL PASS) ③**일기 기본 표시 설정**(§6.7 — 캘린더·목표·행동 전 날짜 기본값 + 날짜별 재정의, 스키마 무변경 마커 방식) ④**'짧은 기록 무시' iCloud KVS 동기화**(§6.8 — 전 기기 공통) ⑤스크린샷 전면 개편(아이폰 10·아이패드 10·워치 합성 1 × ko/en/ja — ASC 업로드 완료) ⑥스토어 메타데이터 변경(앱 이름·부제목·키워드·프로모션 — 검색·색인 강화, `Marketing/AppStore/app-name-subtitle.txt`·`keywords.txt`·`promotional-text.txt`. 스토어 표기만 변경, 설치 이름 불변) ⑦MARKETING_VERSION 1.1. **버전 1.0 출시 2026-07-28.** 이후 작업은 사용자 실사용 중 발견한 버그 수정·사용자가 원하는 소기능 추가만 한다(선제적 기능 제안·대규모 리팩터링 금지). 출시 후 소기능 이력: **일기 도형·텍스트 상자 편집 강화**(2026-08-03, 사용자 요청 — §6.7. "정해진 모양·크기 그대로만 나온다"는 보고: 크기 손잡이(가로세로 자유·맥 마우스 대응)·회전 제스처·'도형 수정' 시트[색·선 굵기·채우기·모서리 둥글기·회전]·텍스트 시트 확장[글자 크기·굵게·정렬·테두리], DiaryPageItem에 기본값 필드 8개 추가)·**양식 페이지 펜 색 고정**(2026-08-03, 사용자 요청 — §6.7. 다크/라이트 잉크 반전으로 맥·아이패드에서 필기가 안 보이던 실사용 보고 해소, 내보내기 라이트 트레잇 고정 포함)·**주기 몫 완료 상태 표기**(2026-08-01, 사용자 요청 — §4.2. 도움말 '진행률 읽는 법' 문구도 갱신. 같은 날 MARKETING_VERSION 1.2(빌드 1)로 상향 + `Marketing/AppStore/whats-new-1.2.txt` 작성·TestFlight 업로드, **심사 통과 → App Store 출시 완료(2026-08-02, 사용자 확인)**). 업데이트 제출: 버전 올려 빌드 업로드 → 심사 제출(IAP는 첫 심사에서 승인됐으므로 첨부 불필요). 출시 전 이력 — RC는 커밋 `f05e611`(버전 1.0 (1)), 이후 실사용 보고 기반 수정 라운드(전부 커밋·푸시·실기기 확인 완료): l10n 누락 일괄·ja 앱 이름 'Haru Danim' 통일·워치 컴플리케이션 추천 캡 제거(§11)·⑥ 위젯 종료 버튼(§10)·진행률 환산 캡+수행일 집계+수행일 아님 표기(§4.2)·파일명/CSV 헤더 현지화(§7)·워치 컴플리케이션 목표 필터(§6.4·§11, 사용자 승인 소기능)·기록 시각 입력 상시 휠 다이얼+분 절사(§6.5)·일기 달력 기분 사진 백레이어(§6.7)·다짐 없는 목표 '다짐 없음' 표기(§4.3)·워치 측정 중 행 탭 종료(§11)·도움말 예시 스크린샷 18장(§6.8)·설정 하루 시작 시간 접힘 행(§6.5)·**모음 탭 즐겨찾기 모아 보기**(§6.1 — 행동 많아질 때 혼잡도 대응, 사용자 승인 소기능. v1 별도 카드 가로 줄[대안 A~F 중 A]이 "따로 노는 느낌" 실사용 피드백으로 v2 그리드 섹션 통합+나머지 접기로 교체, 같은 날 후속 피드백으로 편집 모드 섹션 인지·섹션 간 드래그 지정/해제·"지정 순서대로 뒤에" 규칙 추가, 2026-07-22)·**en 복수형 정리**(2026-07-22 — 수량 1일 때 "1 times/quests" 류 오류를 카탈로그 plural variation으로 14건 전환[iOS 10·위젯 3·워치 1], 모음 탭 섹션 제목 en "Favorite"→"Favorites"로 워치와 통일. ko/ja 무영향). **App Store 등록 자료는 `Marketing/`에 완비**(설명·키워드·구독 메타·스크린샷 원본/합성판, README에 재생성 절차 — 앱 변경이 자료에 영향 주면 요청 없이 함께 갱신할 것. 2026-07-22 즐겨찾기 섹션(v2) 반영해 아이폰·아이패드 01-main과 help-main ko/en/ja 재촬영·합성판 재생성. **2026-07-29 스크린샷 세트 확장** — 아이폰 8장(+위젯·버블 메뉴·iCloud 동기화 합성·다크 모드)/아이패드 7장(+주간 타임테이블·위젯·동기화 합성) × ko/en/ja, 촬영·합성 절차는 Marketing/README '추가 스크린샷' 절. **1.1 제출 때 ASC 업로드 완료**). **사업자 등록·통신판매업 신고 완료(2026-07-22)** — 서류 절차 끝, ASC 등록·유료 계약 진행 가능(순서는 Marketing/README 체크리스트). 사용자는 계속 실사용 테스트 중이며 버그 보고 시: 최소 변경 + 시뮬레이터 재검증 + l10n 루틴(§2.3) + Debug/Store(워치 파일이면 워치까지) 빌드 + 커밋·푸시 | +| 현재 상태 (2026-08-03) | **버전 1.2 App Store 출시 완료(2026-08-02). 1.3 준비 중(2026-08-03 MARKETING_VERSION 1.3 빌드 1 상향, `Marketing/AppStore/whats-new-1.3.txt` 작성 — 아래 소기능 이력의 일기 개선 묶음, 제출 시기는 사용자 결정)**(사용자의 버그 보고·기능 요청을 받아 진행). 1.2 내용: 주기 몫 완료 상태 표기(§4.2)+도움말 '진행률 읽는 법' 갱신(상세는 아래 소기능 이력). **버전 1.1 출시 2026-07-30.** 1.1 내용: ①**맥 일기 개방**(§6.7 — 사이드바 노출 + '그리기' 토글로 마우스 필기, 텍스트 우선 도구줄. 아이패드 경로 불변. 맥 실기 확인 완료) ②**CSV 가져오기**(§6.8 — 프리미엄, 추가 전용·미리 보기·멱등. 검증 `-csvImportTest` 20건 ALL PASS) ③**일기 기본 표시 설정**(§6.7 — 캘린더·목표·행동 전 날짜 기본값 + 날짜별 재정의, 스키마 무변경 마커 방식) ④**'짧은 기록 무시' iCloud KVS 동기화**(§6.8 — 전 기기 공통) ⑤스크린샷 전면 개편(아이폰 10·아이패드 10·워치 합성 1 × ko/en/ja — ASC 업로드 완료) ⑥스토어 메타데이터 변경(앱 이름·부제목·키워드·프로모션 — 검색·색인 강화, `Marketing/AppStore/app-name-subtitle.txt`·`keywords.txt`·`promotional-text.txt`. 스토어 표기만 변경, 설치 이름 불변) ⑦MARKETING_VERSION 1.1. **버전 1.0 출시 2026-07-28.** 이후 작업은 사용자 실사용 중 발견한 버그 수정·사용자가 원하는 소기능 추가만 한다(선제적 기능 제안·대규모 리팩터링 금지). 출시 후 소기능 이력: **일기 도형·텍스트 상자 UX 2차 개편**(2026-08-03, 사용자 요청 — §6.7. 종류별 모양 손잡이[사각형 꼭짓점 4개·원 상하좌우 4개·선/화살표 양 끝점]와 정사각형·정원 스냅 피드백, 텍스트 상자 제자리 입력[탭→바로 키보드]·테두리 색 독립, '글 수정'→'스타일' 시트로 재편, borderColorHex 필드 추가. 1.3으로 버전 상향)·**일기 도형·텍스트 상자 편집 강화**(2026-08-03, 사용자 요청 — §6.7. "정해진 모양·크기 그대로만 나온다"는 보고: 크기 손잡이(가로세로 자유·맥 마우스 대응)·회전 제스처·'도형 수정' 시트[색·선 굵기·채우기·모서리 둥글기·회전]·텍스트 시트 확장[글자 크기·굵게·정렬·테두리], DiaryPageItem에 기본값 필드 8개 추가)·**양식 페이지 펜 색 고정**(2026-08-03, 사용자 요청 — §6.7. 다크/라이트 잉크 반전으로 맥·아이패드에서 필기가 안 보이던 실사용 보고 해소, 내보내기 라이트 트레잇 고정 포함)·**주기 몫 완료 상태 표기**(2026-08-01, 사용자 요청 — §4.2. 도움말 '진행률 읽는 법' 문구도 갱신. 같은 날 MARKETING_VERSION 1.2(빌드 1)로 상향 + `Marketing/AppStore/whats-new-1.2.txt` 작성·TestFlight 업로드, **심사 통과 → App Store 출시 완료(2026-08-02, 사용자 확인)**). 업데이트 제출: 버전 올려 빌드 업로드 → 심사 제출(IAP는 첫 심사에서 승인됐으므로 첨부 불필요). 출시 전 이력 — RC는 커밋 `f05e611`(버전 1.0 (1)), 이후 실사용 보고 기반 수정 라운드(전부 커밋·푸시·실기기 확인 완료): l10n 누락 일괄·ja 앱 이름 'Haru Danim' 통일·워치 컴플리케이션 추천 캡 제거(§11)·⑥ 위젯 종료 버튼(§10)·진행률 환산 캡+수행일 집계+수행일 아님 표기(§4.2)·파일명/CSV 헤더 현지화(§7)·워치 컴플리케이션 목표 필터(§6.4·§11, 사용자 승인 소기능)·기록 시각 입력 상시 휠 다이얼+분 절사(§6.5)·일기 달력 기분 사진 백레이어(§6.7)·다짐 없는 목표 '다짐 없음' 표기(§4.3)·워치 측정 중 행 탭 종료(§11)·도움말 예시 스크린샷 18장(§6.8)·설정 하루 시작 시간 접힘 행(§6.5)·**모음 탭 즐겨찾기 모아 보기**(§6.1 — 행동 많아질 때 혼잡도 대응, 사용자 승인 소기능. v1 별도 카드 가로 줄[대안 A~F 중 A]이 "따로 노는 느낌" 실사용 피드백으로 v2 그리드 섹션 통합+나머지 접기로 교체, 같은 날 후속 피드백으로 편집 모드 섹션 인지·섹션 간 드래그 지정/해제·"지정 순서대로 뒤에" 규칙 추가, 2026-07-22)·**en 복수형 정리**(2026-07-22 — 수량 1일 때 "1 times/quests" 류 오류를 카탈로그 plural variation으로 14건 전환[iOS 10·위젯 3·워치 1], 모음 탭 섹션 제목 en "Favorite"→"Favorites"로 워치와 통일. ko/ja 무영향). **App Store 등록 자료는 `Marketing/`에 완비**(설명·키워드·구독 메타·스크린샷 원본/합성판, README에 재생성 절차 — 앱 변경이 자료에 영향 주면 요청 없이 함께 갱신할 것. 2026-07-22 즐겨찾기 섹션(v2) 반영해 아이폰·아이패드 01-main과 help-main ko/en/ja 재촬영·합성판 재생성. **2026-07-29 스크린샷 세트 확장** — 아이폰 8장(+위젯·버블 메뉴·iCloud 동기화 합성·다크 모드)/아이패드 7장(+주간 타임테이블·위젯·동기화 합성) × ko/en/ja, 촬영·합성 절차는 Marketing/README '추가 스크린샷' 절. **1.1 제출 때 ASC 업로드 완료**). **사업자 등록·통신판매업 신고 완료(2026-07-22)** — 서류 절차 끝, ASC 등록·유료 계약 진행 가능(순서는 Marketing/README 체크리스트). 사용자는 계속 실사용 테스트 중이며 버그 보고 시: 최소 변경 + 시뮬레이터 재검증 + l10n 루틴(§2.3) + Debug/Store(워치 파일이면 워치까지) 빌드 + 커밋·푸시 | | 출시·운영 메모 (2026-07-28) | **ASC 등록·심사·출시 전부 완료** — 제출 1건에 앱 1.0+구독 그룹+상품 3종 동반(신형 ASC는 각 항목의 "심사에 추가"로 하나의 제출 초안에 담는 방식), '앱 개인정보 보호'="데이터 수집 안 함", 유료 앱 계약·대한민국 세금 양식 활성, 소규모 사업자 프로그램 가입. 개발자 본인 프리미엄은 **평생 이용권 특가 코드(무료 Offer Code) 교환**으로 스토어 버전에 적용됨(IAP용 프로모션 코드는 2026-03 폐지 — 이후 무료 지급은 특가 코드만 가능, 생성 후 교환 가능까지 최대 1시간). 상시 참고: `ITSAppUsesNonExemptEncryption=NO`, PrivacyInfo.xcprivacy 4개 번들(수집·트래킹 없음), 개인정보처리방침=`github.com/notalentprogrammer/HaruDanim` PRIVACY.md·이용약관=Apple 표준 EULA(결제 화면·앱 설명 모두 링크). **iCloud 프로덕션 동기화 확인 완료(2026-07-28)** — 아이폰·아이패드·맥 3기기 반나절 실사용에서 기존 데이터 포함 정상 왕복(개발→프로덕션 CloudKit 환경 전환에 따른 재업로드 우려 해소. 향후 동기화 문제 보고 시 이 전환 이력 참고). 예정: 정산 계좌 변경(시기 무관, 예금주 명의 일치·지급 회차 밀림 고지 확인). 절차 상세 기록은 Marketing/README.md 체크리스트 | --- @@ -111,7 +111,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f | `DiaryTodo` | text, isDone, sortOrder | 요약 페이지 체크리스트 | | `DiaryPage` | index, lined, lineSpacing, drawingData(PKDrawing, externalStorage), **templateID**(DiaryTemplate.uuid 문자열, 빈 값=없음), **templatePageIndex**(PDF 쪽, 0부터) | 768×1024 논리 좌표. 양식만 깔린 페이지도 hasContent=true(작성함 취급·내보내기 포함) | | `DiaryTemplate` | uuid, name, kindRaw(pdf/image), data(externalStorage — PDF는 원본 통째 1개), thumbnailData(첫 쪽), pageCount | 노트 배경 양식. PDF 여러 쪽이어도 복제 없이 원본 1개 + 쪽 인덱스 참조 | -| `DiaryPageItem` | kind(photo/rectangle/ellipse/arrow/line/**text**), imageData, **text**(텍스트 상자 내용), colorHex, centerX/Y·widthRatio(비율 좌표), **heightRatio**(페이지 폭 대비 — 0=미지정→kind.defaultAspect, 사진은 항상 원본 비율), rotationDegrees, **lineWidth**(0=kind.defaultLineWidth), **filled**(사각형·원 채움), **cornerRadius**(음수=기본 10), **fontSize**(0=기본 24)·**textBold**·**textAlignRaw**(""=왼쪽/center/trailing)·**textBorder**(텍스트 전용) | 스타일 필드는 전부 기본값(2026-08-03 추가 — 구버전 데이터·CloudKit 안전) | +| `DiaryPageItem` | kind(photo/rectangle/ellipse/arrow/line/**text**), imageData, **text**(텍스트 상자 내용), colorHex, centerX/Y·widthRatio(비율 좌표), **heightRatio**(페이지 폭 대비 — 0=미지정→kind.defaultAspect, 사진은 항상 원본 비율), rotationDegrees, **lineWidth**(0=kind.defaultLineWidth), **filled**(사각형·원 채움), **cornerRadius**(음수=기본 10), **fontSize**(0=기본 24)·**textBold**·**textAlignRaw**(""=왼쪽/center/trailing)·**textBorder**·**borderColorHex**(""=글자 색 따름 — 텍스트 전용) | 스타일 필드는 전부 기본값(2026-08-03 추가 — 구버전 데이터·CloudKit 안전) | --- @@ -235,7 +235,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f - **프리미엄 만료 열람 모드**: 이미 쓴 일기(hasContent)가 있으면 잠금 화면 대신 달력을 열람 전용으로 연다 — 배너 + 일기 있는 날짜만 활성, `environment(\.diaryReadOnly)`로 전 화면의 편집 UI(기분/할일/필기/필터/캘린더/⋯메뉴) 비활성, 열람 진입은 엔트리를 생성하지 않음. 일기를 쓴 적 없으면 기존 잠금 안내 - **양식 페이지 펜 색 고정(2026-08-03, 사용자 요청)**: 양식이 깔린 페이지(`template != nil`)는 캔버스 `overrideUserInterfaceStyle`과 `PKToolPicker.colorUserInterfaceStyle`을 `.light`로 고정 — PencilKit의 다크/라이트 잉크 자동 반전 때문에 다크 모드 아이패드에서 쓴 획이 라이트 모드 맥의 흰 양식 위에서 흰색으로 반전돼 안 보이던 실사용 보고의 해소(동기화 문제로 오인됐던 건). 팔레트 색 해석까지 함께 고정해야 WYSIWYG(트레잇만 고정하면 다크에서 고른 '검정'이 흰 획으로 저장되는 반전이 남음). 고정 이전에 다크에서 쓴 기존 획은 라이트 기준 색 그대로(흰색)라 계속 안 보임 — 데이터 무변경, 사용자 인지 완료. 양식 없는 페이지는 기존 동적 반전 유지(다크 배경 종이에선 그게 맞음). 내보내기의 `PKDrawing.image`도 라이트 트레잇으로 고정(전 페이지 공통 — 호출 시점 트레잇 의존이라 다크에서 내보내면 획이 반전 렌더될 수 있던 잠재 결함 제거, 인쇄 페이지는 원래 라이트 고정이라 화면과 일치) - **양식(속지)** (`DiaryTemplates.swift`): 달력 툴바 '양식 관리'에서 PDF(≤20MB)·사진을 가져와 라이브러리로 관리. 페이지 추가 시 [빈 캔버스/줄 노트/내 양식] 선택(양식 0개면 시트 없이 기존처럼 즉시 추가), 여러 쪽 PDF는 2단계 쪽 썸네일 선택(최대 24쪽, 원본은 통째 1개 저장 + 쪽 인덱스 참조). 렌더: 페이지 논리 크기에 aspect-fit(좌표계 불변), **PDF는 줌 종료 시 현재 배율로 백그라운드 재래스터**(`DiaryTemplateImageView` — 펜슬 캔버스 선명도 훅에 함께 연결), 이미지는 가져올 때 긴 변 3072px 리샘플(필기 래스터 상한 3×와 동일 → 흐림 없음). 양식 페이지에선 줄 노트 토글 숨김. 양식 삭제 시 사용 중 페이지 수 경고 → 해당 페이지는 빈 캔버스 폴백(필기·요소 보존). 내보내기에도 동일 반영 -- 노트 페이지: 768×1024 논리 좌표 고정 + scaleEffect. 배치 요소: 사진·도형(사각/원/화살표/선)·**텍스트 상자**(키보드 입력). **요소 편집(2026-08-03 강화)**: 배치 모드에서 드래그 이동 + **오른쪽 아래 크기 손잡이**(도형·텍스트는 가로세로 자유[heightRatio], 사진은 원본 비율 균등 — 핀치가 안 되는 맥 마우스의 유일한 크기 수단) + 핀치(균등 스케일) + **두 손가락 돌리기 회전**(rotationDegrees, 손잡이는 rotationEffect 안쪽 overlay라 로컬 좌표 드래그가 회전 상태에서도 정확). 선택 후 **'도형 수정' 시트**(색·선 굵기·채우기[사각/원]·모서리 둥글기[사각]·회전 슬라이더 — 모델 직접 바인딩이라 실시간 미리보기), 텍스트는 **'글 수정' 시트**(내용·색 + 글자 크기·굵게·정렬[왼쪽/가운데/오른쪽]·테두리[+굵기]·회전). 스타일 기본값 규칙은 §3 DiaryPageItem — 내보내기는 DiaryItemView를 그대로 재사용해 자동 일치. ⋯ 메뉴 '페이지 순서 변경'으로 노트 페이지 드래그 재배열(요약은 항상 첫 장). **실기기 필기는 펜슬 전용, 손가락은 이동/줌만**(시뮬레이터는 anyInput). 핀치줌 최대 4배, `SharpPencilCanvasView`가 줌에 맞춰 래스터 배율 유지(획 선명도). ⚠️ 배율 적용은 **뷰의 contentScaleFactor만** — 서브레이어 contentsScale+setNeedsDisplay를 직접 건드리면 PencilKit 획 타일 픽셀이 지워진 채 재렌더되지 않아 확대 시 필기가 사라진다(수정된 버그). 줄 노트(간격 4단계) 배치 모드 저장은 비율 좌표 +- 노트 페이지: 768×1024 논리 좌표 고정 + scaleEffect. 배치 요소: 사진·도형(사각/원/화살표/선)·**텍스트 상자**(키보드 입력). **요소 편집(2026-08-03 개편, 2차까지)**: 배치 모드에서 드래그 이동 + 핀치(모양 비율 유지 균등 스케일) + 두 손가락 돌리기 회전. **종류별 모양 손잡이**(선택 시 표시, 드래그 값은 `.named(DiaryPageMetrics.coordSpace)` 페이지 좌표로 받아 실시간 회전에도 안정): 사각형=꼭짓점 4개(끈 모서리만 움직이고 반대편 고정 — 로컬 축 변환 후 중심을 절반씩 이동), 원=상하좌우 4개(타원 만들기), 선·화살표=양 끝점(반대 끝 고정, 길이+방향 동시 — atan2로 rotationDegrees 갱신), 사진·텍스트=우하단 1개(사진은 원본 비율 균등). **정사각형·정원 스냅**: 조절 중 |w−h|≤10pt면 딱 맞추고 손잡이·선택 테두리가 노란색+햅틱. **텍스트 상자는 제자리 입력**: 선택된 상자를 한 번 더 탭 → 그 자리에서 TextEditor로 입력·줄바꿈(UITextView 기본 여백 보정으로 표시 Text와 동일 위치), 탭 밖·배치 모드 종료·키보드 내림에 저장. 생성 직후에도 바로 입력 시작(시트 없음). 선택 후 **'도형 수정' 시트**(색·선 굵기·채우기[사각/원]·모서리 둥글기[사각]·회전 슬라이더 — 모델 직접 바인딩이라 실시간 미리보기), 텍스트는 **'스타일' 시트**(글자 색·크기·굵게·정렬[왼쪽/가운데/오른쪽]·테두리[켬/끔+색(borderColorHex, 빈 값=글자 색)+굵기]·회전 — 내용 입력 필드는 제자리 입력으로 대체돼 없음). 스타일 기본값 규칙은 §3 DiaryPageItem — 내보내기는 DiaryItemView를 그대로 재사용해 자동 일치(인라인 편집은 기본값 바인딩이라 비활성). ⋯ 메뉴 '페이지 순서 변경'으로 노트 페이지 드래그 재배열(요약은 항상 첫 장). **실기기 필기는 펜슬 전용, 손가락은 이동/줌만**(시뮬레이터는 anyInput). 핀치줌 최대 4배, `SharpPencilCanvasView`가 줌에 맞춰 래스터 배율 유지(획 선명도). ⚠️ 배율 적용은 **뷰의 contentScaleFactor만** — 서브레이어 contentsScale+setNeedsDisplay를 직접 건드리면 PencilKit 획 타일 픽셀이 지워진 채 재렌더되지 않아 확대 시 필기가 사라진다(수정된 버그). 줄 노트(간격 4단계) 배치 모드 저장은 비율 좌표 ### 6.8 설정 탭 (`SettingsView`) - 테마(**시스템 설정 따름(기본)**/라이트/다크 — 즉시 적용, App Group 저장으로 위젯 공유. "system"은 스킴을 강제하지 않아 앱·위젯 모두 시스템 모드를 따름 — 2026-07-23 UX 감사에서 추가, 기본값도 light→system) / 언어(재시작 적용, AppleLanguages 오버라이드) @@ -351,7 +351,7 @@ xcrun xcstringstool sync Widgets/Localizable.xcstrings --stringsdata "${widget_f | 기록/통계 | `-historyMode timetable` `-historyWeekly` `-historyDaysAgo `(N일 전 날짜로 시작 — 지난주 등 과거 주 표시, 마케팅 촬영용) `-excludeActions "이름,이름"` `-historyShowFilter` `-statShowFilter` `-statSpan ` `-statScrollBottom` `-showExport` `-exportDump`(Documents/export-dump.png) `-recordShowEditor `(최신 기록 수정 시트) | | 설정/기타 | `-settingsScrollGoal` `-settingsScrollPremium` `-settingsScrollMeasure`(측정 섹션 — 짧은 기록 무시 푸터 검증) `-settingsShowDayStartWheel`(하루 시작 시간 휠 펼침 시작) `-premiumPreview` `-helpPreview`(+`-helpExpandAll`: 전 주제 펼침, `-helpScrollTo `: 이미지 주제로 스크롤) `-dataExportPreview`(+`-dataExportRun`이면 CSV 자동 생성 후 Documents 복사) `-dataImportPreview`(가져오기 화면 직접 표시 — -premium과 함께) `-csvImportTest`(CSV 가져오기 자가 검증 20건[파서·판별·연결·중복·멱등] → Documents/csv-import-test.txt — 인메모리 컨테이너라 실데이터 무영향) `-actionShowFavoriteOrder`(행동 탭 즐겨찾기 순서 시트 — `-startTab action`과 함께) `-showOnboarding`(온보딩 강제 표시) `-widgetPreview YES|lock|marketing`(marketing = 디버그 라벨 없는 위젯 실렌더 모아 보기 — 마케팅 스크린샷 05-widgets/06-widgets 촬영용, Marketing/README 참고) `-widgetPreviewScroll <앵커>` `-widgetRenderMode accented|vibrant`(틴트/투명 홈 렌더링 분기 검증) `cloudSync`(standard bool로 강제) `-intentSmokeTest`(시리 인텐트 10개 시나리오 실행 → Documents/intent-smoke-test.txt, -seedDemo·-premium과 함께) | | 버블 | `-radialExpanded` `-radialDoubleTapTest`(2초 뒤 FAB 더블탭 재현 — `-settings.radialDoubleTapTab <탭>`과 함께) | -| 일기 | `-diarySeed`(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) `-diaryDrawMode`(맥 분기에서 '그리기' 토글 켠 채 시작) `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage ` `-diaryZoomScale <배>`(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diaryShowDefaults`(기본 표시 설정 시트) `-diaryDefaultsSeed`(첫 목표·첫 행동 기본 숨김+캘린더 기본 '모든 캘린더' 주입 — 기본값 적용 검증용) `-diarySeedPhoto`(오늘=이모지+사진·어제=사진만 기분 사진 시드 — 달력 백레이어 검증) `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryLockScreen`(잠금 게이트 화면 강제 — 인증 없이 표시) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) `-diaryShapeEdit`(활성 노트 페이지 첫 도형 선택+'도형 수정' 시트 — `-diarySeed`·`-diaryOpenToday`·`-diaryPage 1`과 함께. 시드 요소들이 스타일 필드[굵기·둥글기·채움·자유 비율·회전·글자 크기·정렬·테두리]를 전부 렌더) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` | +| 일기 | `-diarySeed`(필기 획 2줄 포함 — 달력 화면에서 먼저 시드 후 재실행으로 열 것) `-diaryDrawMode`(맥 분기에서 '그리기' 토글 켠 채 시작) `-diaryOpenToday`(**`-startTab diary` 필수**) `-diaryPage ` `-diaryZoomScale <배>`(2.5초 뒤 fit×N 프로그램 줌 — 획 유지·선명도 확인) `-diaryShowConfig` `-diaryShowExport`(달력 화면 onAppear — `-diaryOpenToday`와 함께 쓰면 안 뜸) `-diaryExportRun pdf|image`(결과를 Documents로 복사) `-diaryShowCalendarPicker` `-diarySeedEvents` `-diaryHideFirstAction` `-diaryShowActionFilter` `-diaryHideFirstGoal` `-diaryShowGoalFilter` `-diaryShowDefaults`(기본 표시 설정 시트) `-diaryDefaultsSeed`(첫 목표·첫 행동 기본 숨김+캘린더 기본 '모든 캘린더' 주입 — 기본값 적용 검증용) `-diarySeedPhoto`(오늘=이모지+사진·어제=사진만 기분 사진 시드 — 달력 백레이어 검증) `-diarySeedTemplate`(2쪽 샘플 PDF 양식 생성) `-diaryLockScreen`(잠금 게이트 화면 강제 — 인증 없이 표시) `-diaryShowTemplates`(양식 관리 시트) `-diaryShowPageChooser`(페이지 추가 선택 시트) `-diaryApplyTemplate`(첫 양식으로 페이지 추가) `-diaryShapeEdit`(활성 노트 페이지 첫 도형 선택+'도형 수정' 시트 — `-diarySeed`·`-diaryOpenToday`·`-diaryPage 1`과 함께. 시드 요소들이 스타일 필드[굵기·둥글기·채움·자유 비율·회전·글자 크기·정렬·테두리 색]를 전부 렌더) `-diaryTextEdit`(첫 텍스트 상자를 제자리 입력 상태로 — 같은 조합) / 섹션 강제: `-diary.sectionOrder "timetable,hero,goals"` `-diary.hiddenSections "mood,todos,records,bars"` | | 워치 | `-complicationPreview` `-complicationScroll` `-complicationScrollTo ` `-autoRunFirstAction` `-autoStopRunning`(측정 중 행 탭과 동일 경로로 대표 측정 종료 — 페어링 심 E2E용) | --- diff --git a/myApp/HaruDanim/IOS/Localizable.xcstrings b/myApp/HaruDanim/IOS/Localizable.xcstrings index c62345f..6a0f1b3 100644 --- a/myApp/HaruDanim/IOS/Localizable.xcstrings +++ b/myApp/HaruDanim/IOS/Localizable.xcstrings @@ -2364,22 +2364,6 @@ } } }, - "글 수정" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Edit Text" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "テキストを編集" - } - } - } - }, "글래스 버블 메뉴" : { "localizations" : { "en" : { @@ -3620,22 +3604,6 @@ } } }, - "내용을 입력하세요" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enter text" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "内容を入力" - } - } - } - }, "넓게" : { "localizations" : { "en" : { @@ -7408,6 +7376,22 @@ } } }, + "스타일" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Style" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "スタイル" + } + } + } + }, "습관이나 할 일을 '행동'으로 등록하면\n버튼 한 번으로 시간과 횟수를 기록할 수 있어요." : { "localizations" : { "en" : { @@ -12199,6 +12183,22 @@ } } }, + "테두리 색" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Border Color" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "枠線の色" + } + } + } + }, "테마" : { "localizations" : { "en" : { @@ -12231,18 +12231,18 @@ } } }, - "텍스트 상자" : { + "텍스트 스타일" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Text Box" + "value" : "Text Style" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "テキストボックス" + "value" : "テキストのスタイル" } } } @@ -12587,18 +12587,18 @@ } } }, - "페이지를 넘기면 자유 노트가 이어져요. 애플 펜슬로 쓰고 그리고, '사진'·'도형'·'텍스트'(키보드로 쓰는 글 상자)를 추가한 뒤 '배치 모드'에서 꾸며요 — 드래그로 옮기고, 선택하면 나타나는 오른쪽 아래 손잡이나 핀치로 크기를 바꾸고(도형·텍스트는 가로세로를 따로), 두 손가락 돌리기로 돌릴 수 있어요. 도형을 선택하고 '도형 수정'을 누르면 색·선 굵기·채우기·모서리 둥글기·회전을, 텍스트는 '글 수정'에서 내용과 글자 색·크기·굵게·정렬·테두리를 바꿀 수 있어요. 두 손가락 핀치로 페이지를 확대해 세밀하게 쓸 수 있고, 줄 노트를 켜면 줄 간격도 고를 수 있어요. 페이지가 여러 장이면 오른쪽 위 ⋯ 메뉴의 '페이지 순서 변경'으로 순서를 바꿀 수 있어요. 맥에서는 텍스트 입력이 기본이고, 도구 줄의 '그리기'를 켜면 마우스나 트랙패드로도 그릴 수 있어요 — 평소에는 클릭과 스크롤이 이동·버튼에 쓰여요." : { + "페이지를 넘기면 자유 노트가 이어져요. 애플 펜슬로 쓰고 그리고, '사진'·'도형'·'텍스트'를 추가한 뒤 '배치 모드'에서 꾸며요 — 드래그로 옮기고, 핀치로 크기를(모양 비율은 그대로), 두 손가락 돌리기로 회전을 바꿔요. 선택하면 나타나는 손잡이로 모양을 다듬어요: 사각형은 꼭짓점을 끌면 그 모서리만 움직이고, 원은 상하좌우 손잡이로 늘리거나 눌러 타원을 만들고, 화살표·선은 양 끝점을 끌어 길이와 방향을 바꿔요. 조절 중에 정사각형·정원이 되는 순간 손잡이가 노란색으로 변하며 살짝 붙잡아 줘요. 텍스트 상자는 선택된 상태에서 한 번 더 탭하면 바로 그 자리에서 키보드로 입력·줄바꿈이 되고, '스타일'에서 글자 색·크기·굵게·정렬·테두리(색·굵기 포함)를 바꿀 수 있어요. 도형은 '도형 수정'에서 색·선 굵기·채우기·모서리 둥글기·회전을 바꿔요. 두 손가락 핀치로 페이지를 확대해 세밀하게 쓸 수 있고, 줄 노트를 켜면 줄 간격도 고를 수 있어요. 페이지가 여러 장이면 오른쪽 위 ⋯ 메뉴의 '페이지 순서 변경'으로 순서를 바꿀 수 있어요. 맥에서는 텍스트 입력이 기본이고, 도구 줄의 '그리기'를 켜면 마우스나 트랙패드로도 그릴 수 있어요 — 평소에는 클릭과 스크롤이 이동·버튼에 쓰여요." : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Turn the page and free-form notes continue. Write and draw with Apple Pencil, add 'Photos', 'Shapes', and 'Text' (a text box you type in), then style them in 'Arrange mode' — drag to move, resize with the bottom-right handle that appears when selected or by pinching (shapes and text can be sized freely in width and height), and rotate with a two-finger twist. Select a shape and tap 'Edit Shape' to change its color, line width, fill, corner radius, and rotation; for text, 'Edit Text' lets you change the content plus text color, size, bold, alignment, and border. Pinch with two fingers to zoom in for fine writing, and turn on lined paper to pick the line spacing. With multiple pages, use 'Reorder Pages' in the ⋯ menu at the top right. On Mac, typing is the default — turn on 'Draw' in the toolbar to draw with your mouse or trackpad; otherwise clicks and scrolling are used for moving around and buttons." + "value" : "Turn the page and free-form notes continue. Write and draw with Apple Pencil, add 'Photos', 'Shapes', and 'Text', then style them in 'Arrange mode' — drag to move, pinch to resize (keeping the shape's proportions), and twist with two fingers to rotate. Use the handles that appear when selected to refine the shape: drag a rectangle's corner to move just that corner, use a circle's top/bottom/left/right handles to stretch or squash it into an ellipse, and drag either end of an arrow or line to change its length and direction. The moment it becomes a perfect square or circle while adjusting, the handles turn yellow and gently snap into place. Tap a selected text box once more to type right in place with the keyboard, line breaks included, and use 'Style' to change text color, size, bold, alignment, and the border (including its color and width). For shapes, 'Edit Shape' changes color, line width, fill, corner radius, and rotation. Pinch with two fingers to zoom in for fine writing, and turn on lined paper to pick the line spacing. With multiple pages, use 'Reorder Pages' in the ⋯ menu at the top right. On Mac, typing is the default — turn on 'Draw' in the toolbar to draw with your mouse or trackpad; otherwise clicks and scrolling are used for moving around and buttons." } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ページをめくると自由ノートが続きます。Apple Pencilで書いて描いて、「写真」「図形」「テキスト」(キーボードで入力するテキストボックス)を追加したら「配置モード」で仕上げます — ドラッグで移動し、選択すると現れる右下のハンドルやピンチでサイズを変え(図形・テキストは縦横を自由に)、2本指でひねって回転できます。図形を選んで「図形を編集」を押すと色・線の太さ・塗りつぶし・角の丸み・回転を、テキストは「テキストを編集」で内容と文字の色・サイズ・太字・配置・枠線を変えられます。2本指ピンチでページを拡大して細かく書き込め、罫線ノートをオンにすると行間も選べます。ページが複数あるときは右上の⋯メニューの「ページの順序を変更」で並べ替えられます。Macでは文字入力が基本で、ツールバーの「描く」をオンにするとマウスやトラックパッドでも描けます — ふだんはクリックとスクロールが移動やボタン操作に使われます。" + "value" : "ページをめくると自由ノートが続きます。Apple Pencilで書いて描いて、「写真」「図形」「テキスト」を追加したら「配置モード」で仕上げます — ドラッグで移動し、ピンチでサイズを変え(形の比率はそのまま)、2本指でひねって回転します。選択すると現れるハンドルで形を整えられます: 四角形は角をドラッグするとその角だけが動き、円は上下左右のハンドルで伸ばしたりつぶしたりして楕円にでき、矢印・線は両端をドラッグして長さと向きを変えられます。調整中に正方形・正円になった瞬間、ハンドルが黄色に変わりそっと吸い付きます。選択中のテキストボックスをもう一度タップすると、その場でキーボード入力・改行ができ、「スタイル」で文字の色・サイズ・太字・配置・枠線(色・太さを含む)を変えられます。図形は「図形を編集」で色・線の太さ・塗りつぶし・角の丸み・回転を変更します。2本指ピンチでページを拡大して細かく書き込め、罫線ノートをオンにすると行間も選べます。ページが複数あるときは右上の⋯メニューの「ページの順序を変更」で並べ替えられます。Macでは文字入力が基本で、ツールバーの「描く」をオンにするとマウスやトラックパッドでも描けます — ふだんはクリックとスクロールが移動やボタン操作に使われます。" } } } diff --git a/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift b/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift index f31059a..a2a5d8c 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryNotePage.swift @@ -23,6 +23,8 @@ nonisolated enum DiaryPageMetrics { static let size = CGSize(width: 768, height: 1024) static let lineSpacing: CGFloat = 44 static let lineTopInset: CGFloat = 72 + /// 페이지 좌표계 이름 — 요소 손잡이 드래그가 회전·줌과 무관하게 페이지 기준 값을 받도록 + static let coordSpace = "diaryPage" } struct DiaryNotePageView: View { @@ -37,13 +39,16 @@ struct DiaryNotePageView: View { @State private var arranging = false @State private var selectedItemID: UUID? @State private var photoSelection: PhotosPickerItem? - /// 텍스트 상자 내용 편집 대상 + /// 텍스트 상자 스타일 편집 대상 (글자 크기·굵게·정렬·테두리 — 내용은 인라인 입력) @State private var editingTextItem: DiaryPageItem? /// 도형 스타일 편집 대상 (색·굵기·채우기·둥글기·회전) @State private var editingShapeItem: DiaryPageItem? + /// 인라인(제자리) 텍스트 입력 중인 상자 + @State private var inlineEditingTextID: UUID? #if DEBUG - /// -diaryShapeEdit 소비 여부 (활성 페이지에서 1회만) + /// -diaryShapeEdit·-diaryTextEdit 소비 여부 (활성 페이지에서 1회만) @State private var debugShapeEditDone = false + @State private var debugTextEditDone = false #endif /// 필기 입력 게이트. 맥(Designed for iPad)은 마우스가 이동·버튼 조작과 겸용이라 /// 도구 줄의 '그리기' 토글을 켠 동안만 캔버스가 입력을 받는다 (텍스트 우선 UX, §6.7). @@ -93,8 +98,14 @@ struct DiaryNotePageView: View { .sheet(item: $editingShapeItem) { item in DiaryShapeEditSheet(item: item, onCommit: save) } - .onAppear { debugOpenShapeEditIfNeeded() } - .onChange(of: isActive) { debugOpenShapeEditIfNeeded() } + .onAppear { + debugOpenShapeEditIfNeeded() + debugOpenTextEditIfNeeded() + } + .onChange(of: isActive) { + debugOpenShapeEditIfNeeded() + debugOpenTextEditIfNeeded() + } } /// 검증·촬영용: -diaryShapeEdit YES → 활성 페이지의 첫 도형을 선택하고 '도형 수정' 시트 표시 @@ -111,6 +122,20 @@ struct DiaryNotePageView: View { #endif } + /// 검증·촬영용: -diaryTextEdit YES → 활성 페이지의 첫 텍스트 상자를 인라인 입력 상태로 + private func debugOpenTextEditIfNeeded() { + #if DEBUG + guard !debugTextEditDone, isActive, + UserDefaults.standard.bool(forKey: "diaryTextEdit"), + let textItem = page.items.first(where: { $0.kind == .text }) + else { return } + debugTextEditDone = true + arranging = true + selectedItemID = textItem.uuid + inlineEditingTextID = textItem.uuid + #endif + } + // MARK: 페이지 도구 줄 /// 아이패드: 기존 순서 그대로 (펜슬이 메인이라 필기 토글 없음). @@ -222,9 +247,10 @@ struct DiaryNotePageView: View { let item = page.items.first(where: { $0.uuid == selectedItemID }) { if item.kind == .text { Button { + endInlineTextEditing() editingTextItem = item } label: { - Label("글 수정", systemImage: "square.and.pencil") + Label("스타일", systemImage: "textformat") } } else if item.kind != .photo { Button { @@ -247,7 +273,10 @@ struct DiaryNotePageView: View { } .toggleStyle(.button) .onChange(of: arranging) { - if !arranging { selectedItemID = nil } + if !arranging { + selectedItemID = nil + endInlineTextEditing() + } // 맥: 배치와 그리기는 상호 배타 — 마우스 입력이 어디로 가는지 항상 명확하게 if arranging && DeviceLayout.isMac { drawMode = false } } @@ -289,6 +318,7 @@ struct DiaryNotePageView: View { pageSize: DiaryPageMetrics.size, arranging: arranging, selectedID: $selectedItemID, + inlineEditingID: $inlineEditingTextID, onCommit: save ) } @@ -313,11 +343,22 @@ struct DiaryNotePageView: View { } } .contentShape(Rectangle()) + .coordinateSpace(name: DiaryPageMetrics.coordSpace) .onTapGesture { - if arranging { selectedItemID = nil } + if arranging { + selectedItemID = nil + endInlineTextEditing() + } } } + /// 인라인 텍스트 입력을 끝내고 저장 (빈 화면 탭·배치 모드 종료 공용) + private func endInlineTextEditing() { + guard inlineEditingTextID != nil else { return } + inlineEditingTextID = nil + save() + } + // MARK: 요소 추가/삭제 private func addPhoto(_ image: UIImage) { @@ -340,7 +381,7 @@ struct DiaryNotePageView: View { save() } - /// 텍스트 상자 추가 — 만들자마자 내용 입력 시트를 띄운다 + /// 텍스트 상자 추가 — 만들자마자 그 자리에서 바로 입력 시작 (워드프로세서식) private func addText() { let item = DiaryPageItem(kind: .text) item.colorHex = "#3A3A3A" @@ -350,7 +391,7 @@ struct DiaryNotePageView: View { arranging = true selectedItemID = item.uuid save() - editingTextItem = item + inlineEditingTextID = item.uuid } private func deleteItem(_ item: DiaryPageItem) { @@ -544,15 +585,25 @@ struct DiaryItemView: View { let pageSize: CGSize let arranging: Bool @Binding var selectedID: UUID? + /// 텍스트 상자 인라인 입력 대상 (워드프로세서식 제자리 편집 — 내보내기 등에서는 기본값) + var inlineEditingID: Binding = .constant(nil) var onCommit: () -> Void = {} @State private var dragTranslation: CGSize = .zero @State private var pinchScale: CGFloat = 1 @State private var rotationDelta: Angle = .zero - /// 크기 손잡이 드래그 중의 실시간 변화량 (요소 로컬 좌표 — 끝났을 때만 모델에 반영) - @State private var resizeDelta: CGSize = .zero + /// 손잡이 드래그 중의 실시간 크기 (pt — 끝났을 때만 모델에 반영) + @State private var liveSize: CGSize? + /// 손잡이 드래그 중의 중심 이동 (페이지 좌표 — 반대편 꼭짓점·끝점 고정을 위해) + @State private var liveCenterShift: CGSize = .zero + /// 끝점 드래그 중의 실시간 각도 (선·화살표) + @State private var liveRotation: Double? + /// 정사각형·정원 스냅 중 (손잡이·테두리를 노란색으로 강조) + @State private var isSnapped = false + @FocusState private var inlineFocused: Bool private var isSelected: Bool { selectedID == item.uuid } + private var isInlineEditing: Bool { inlineEditingID.wrappedValue == item.uuid } private var baseWidth: CGFloat { pageSize.width * item.widthRatio } @@ -566,15 +617,17 @@ struct DiaryItemView: View { } private var width: CGFloat { - max(24, baseWidth * pinchScale + resizeDelta.width) + if let liveSize { return liveSize.width } + return max(24, baseWidth * pinchScale) } private var height: CGFloat { + if let liveSize { return liveSize.height } // 사진은 원본 비율 고정 — 손잡이·핀치 모두 균등 크기 조절 if item.kind == .photo, baseWidth > 0 { return baseHeight * (width / baseWidth) } - return max(24, baseHeight * pinchScale + resizeDelta.height) + return max(24, baseHeight * pinchScale) } var body: some View { @@ -583,57 +636,204 @@ struct DiaryItemView: View { .overlay { if arranging && isSelected { RoundedRectangle(cornerRadius: 6) - .strokeBorder(AppTheme.green, lineWidth: 2) + .strokeBorder(isSnapped ? AppTheme.yellow : AppTheme.green, lineWidth: 2) } } - // 손잡이는 rotationEffect보다 안쪽에 붙어 요소와 함께 회전 — 드래그 translation이 - // 요소 로컬 좌표(회전 전 축)로 들어와 회전 상태에서도 폭/높이 계산이 그대로 맞는다 - .overlay(alignment: .bottomTrailing) { - if arranging && isSelected { - resizeHandle + // 손잡이는 rotationEffect보다 안쪽에 붙어 요소와 함께 회전한다. + // 드래그 값은 페이지 좌표계(.named)로 받아 실시간 회전에도 흔들리지 않는다. + .overlay { + if arranging && isSelected && !isInlineEditing { + handles } } - .rotationEffect(.degrees(item.rotationDegrees) + rotationDelta) + .rotationEffect(.degrees(liveRotation ?? item.rotationDegrees) + rotationDelta) .position( - x: pageSize.width * item.centerX + dragTranslation.width, - y: pageSize.height * item.centerY + dragTranslation.height + x: pageSize.width * item.centerX + dragTranslation.width + liveCenterShift.width, + y: pageSize.height * item.centerY + dragTranslation.height + liveCenterShift.height ) .allowsHitTesting(arranging) - .onTapGesture { selectedID = item.uuid } - .gesture(arranging ? moveResizeRotate : nil) + .onTapGesture { + // 텍스트: 선택된 상자를 한 번 더 탭하면 제자리 입력 시작 (워드프로세서식) + if item.kind == .text, isSelected { + inlineEditingID.wrappedValue = item.uuid + } + selectedID = item.uuid + } + .gesture(arranging && !isInlineEditing ? moveResizeRotate : nil) } - /// 오른쪽 아래 크기 손잡이 — 도형·텍스트는 가로세로 자유 비율, 사진은 원본 비율 유지. - /// 핀치가 어려운 맥(마우스)에서도 크기를 바꿀 수 있는 유일한 수단이기도 하다. - private var resizeHandle: some View { - ZStack { - Circle().fill(AppTheme.green) - Image(systemName: "arrow.up.left.and.arrow.down.right") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(.white) + // MARK: 모양 손잡이 (종류별) + + /// 사각형 = 꼭짓점 4개(반대편 고정 자유 리사이즈, 정사각형 스냅) / + /// 원 = 상하좌우 4개(타원 찌그러뜨리기, 정원 스냅) / + /// 선·화살표 = 양 끝점(길이·방향) / 사진·텍스트 = 오른쪽 아래 1개 + @ViewBuilder + private var handles: some View { + switch item.kind { + case .rectangle: + handleDot() + .position(x: 0, y: 0) + .gesture(sizeHandleDrag(sx: -1, sy: -1)) + handleDot() + .position(x: width, y: 0) + .gesture(sizeHandleDrag(sx: 1, sy: -1)) + handleDot() + .position(x: 0, y: height) + .gesture(sizeHandleDrag(sx: -1, sy: 1)) + handleDot() + .position(x: width, y: height) + .gesture(sizeHandleDrag(sx: 1, sy: 1)) + case .ellipse: + handleDot() + .position(x: width / 2, y: 0) + .gesture(sizeHandleDrag(sx: 0, sy: -1)) + handleDot() + .position(x: width / 2, y: height) + .gesture(sizeHandleDrag(sx: 0, sy: 1)) + handleDot() + .position(x: 0, y: height / 2) + .gesture(sizeHandleDrag(sx: -1, sy: 0)) + handleDot() + .position(x: width, y: height / 2) + .gesture(sizeHandleDrag(sx: 1, sy: 0)) + case .arrow, .line: + handleDot() + .position(x: 0, y: height / 2) + .gesture(endpointDrag(isEnd: false)) + handleDot() + .position(x: width, y: height / 2) + .gesture(endpointDrag(isEnd: true)) + case .photo, .text: + handleDot() + .position(x: width, y: height) + .gesture(sizeHandleDrag(sx: 1, sy: 1)) } - .frame(width: 26, height: 26) - .padding(6) - .contentShape(Rectangle()) - .offset(x: 16, y: 16) + } + + private func handleDot() -> some View { + ZStack { + Circle() + .fill(isSnapped ? AppTheme.yellow : AppTheme.green) + Circle() + .fill(.white) + .frame(width: 8, height: 8) + } + .frame(width: 22, height: 22) + .padding(9) + .contentShape(Circle()) .accessibilityLabel(Text("크기 조절")) - .gesture( - DragGesture(minimumDistance: 1) - .onChanged { value in - selectedID = item.uuid - resizeDelta = value.translation - } - .onEnded { value in - let newWidth = max(24, baseWidth + value.translation.width) - item.widthRatio = min(max(newWidth / pageSize.width, 0.04), 0.95) - if item.kind != .photo { - let newHeight = max(24, baseHeight + value.translation.height) - item.heightRatio = min(max(newHeight / pageSize.width, 0.02), 1.25) - } - resizeDelta = .zero - onCommit() - } - ) + } + + /// 페이지 좌표 벡터 회전 (요소 로컬 축 ↔ 페이지 축 변환) + private func rotatedVector(_ v: CGSize, degrees: Double) -> CGSize { + let r = degrees * .pi / 180 + return CGSize(width: v.width * cos(r) - v.height * sin(r), + height: v.width * sin(r) + v.height * cos(r)) + } + + /// 꼭짓점·가장자리 손잡이 — 드래그한 쪽만 움직이고 반대편은 고정. + /// sx/sy = 그 축에서 움직이는 방향 (0이면 그 축은 고정 — 원의 상하좌우 손잡이) + private func sizeHandleDrag(sx: CGFloat, sy: CGFloat) -> some Gesture { + DragGesture(minimumDistance: 1, coordinateSpace: .named(DiaryPageMetrics.coordSpace)) + .onChanged { value in + selectedID = item.uuid + applySizeHandle(sx: sx, sy: sy, translation: value.translation, commit: false) + } + .onEnded { value in + applySizeHandle(sx: sx, sy: sy, translation: value.translation, commit: true) + } + } + + private func applySizeHandle(sx: CGFloat, sy: CGFloat, translation: CGSize, commit: Bool) { + // 페이지 좌표 → 요소 로컬 축 (회전 반영) + let local = rotatedVector(translation, degrees: -item.rotationDegrees) + var w = baseWidth + var h = baseHeight + if sx != 0 { w = max(24, baseWidth + sx * local.width) } + if item.kind == .photo { + h = baseWidth > 0 ? w * (baseHeight / baseWidth) : baseHeight + } else if sy != 0 { + h = max(24, baseHeight + sy * local.height) + } + // 정사각형·정원 스냅 — 가까워지면 딱 맞추고 노란색 강조 + 햅틱 + var snapped = false + if item.kind == .rectangle, sx != 0, sy != 0, abs(w - h) <= 10 { + let side = (w + h) / 2 + w = side + h = side + snapped = true + } + if item.kind == .ellipse, abs(w - h) <= 10 { + if sx != 0 { w = h } else { h = w } + snapped = true + } + if snapped != isSnapped { + isSnapped = snapped + if snapped { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + } + // 반대편 고정: 늘어난 만큼 중심이 절반씩 따라간다 (로컬 축 → 페이지 축) + let localShift = CGSize(width: sx * (w - baseWidth) / 2, + height: (item.kind == .photo ? 1 : sy) * (h - baseHeight) / 2) + let pageShift = rotatedVector(localShift, degrees: item.rotationDegrees) + if commit { + item.widthRatio = min(max(w / pageSize.width, 0.04), 0.95) + if item.kind != .photo { + item.heightRatio = min(max(h / pageSize.width, 0.02), 1.25) + } + item.centerX = min(max(item.centerX + pageShift.width / pageSize.width, 0.02), 0.98) + item.centerY = min(max(item.centerY + pageShift.height / pageSize.height, 0.02), 0.98) + liveSize = nil + liveCenterShift = .zero + isSnapped = false + onCommit() + } else { + liveSize = CGSize(width: w, height: h) + liveCenterShift = pageShift + } + } + + /// 선·화살표 끝점 손잡이 — 반대편 끝점을 고정한 채 길이와 방향을 함께 바꾼다 + private func endpointDrag(isEnd: Bool) -> some Gesture { + DragGesture(minimumDistance: 1, coordinateSpace: .named(DiaryPageMetrics.coordSpace)) + .onChanged { value in + selectedID = item.uuid + applyEndpoint(isEnd: isEnd, translation: value.translation, commit: false) + } + .onEnded { value in + applyEndpoint(isEnd: isEnd, translation: value.translation, commit: true) + } + } + + private func applyEndpoint(isEnd: Bool, translation: CGSize, commit: Bool) { + let center = CGPoint(x: pageSize.width * item.centerX, y: pageSize.height * item.centerY) + let half = rotatedVector(CGSize(width: baseWidth / 2, height: 0), degrees: item.rotationDegrees) + let endPoint = CGPoint(x: center.x + half.width, y: center.y + half.height) + let startPoint = CGPoint(x: center.x - half.width, y: center.y - half.height) + let fixed = isEnd ? startPoint : endPoint + let movingFrom = isEnd ? endPoint : startPoint + let moving = CGPoint(x: movingFrom.x + translation.width, y: movingFrom.y + translation.height) + // 방향은 항상 시작점→끝점(화살촉 쪽) 기준 + let dx = isEnd ? moving.x - fixed.x : fixed.x - moving.x + let dy = isEnd ? moving.y - fixed.y : fixed.y - moving.y + let length = max(24, hypot(dx, dy)) + let angle = Double(atan2(dy, dx)) * 180 / .pi + let newCenter = CGPoint(x: (fixed.x + moving.x) / 2, y: (fixed.y + moving.y) / 2) + if commit { + item.widthRatio = min(max(length / pageSize.width, 0.04), 1.6) + item.rotationDegrees = angle + item.centerX = min(max(newCenter.x / pageSize.width, 0.02), 0.98) + item.centerY = min(max(newCenter.y / pageSize.height, 0.02), 0.98) + liveSize = nil + liveRotation = nil + liveCenterShift = .zero + onCommit() + } else { + liveSize = CGSize(width: length, height: baseHeight) + liveRotation = angle + liveCenterShift = CGSize(width: newCenter.x - center.x, height: newCenter.y - center.y) + } } @ViewBuilder @@ -689,19 +889,47 @@ struct DiaryItemView: View { case "trailing": (.trailing, .topTrailing) default: (.leading, .topLeading) } - return Text(item.text.isEmpty ? String(localized: "텍스트") : item.text) - .font(.system(size: size, weight: item.textBold ? .semibold : .medium)) - .multilineTextAlignment(textAlign) - .foregroundStyle(Color(hex: item.colorHex).opacity(item.text.isEmpty ? 0.4 : 1)) - .minimumScaleFactor(0.5) - .padding(item.textBorder ? 8 : 0) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: frameAlign) - .overlay { - if item.textBorder { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(Color(hex: item.colorHex).opacity(0.85), lineWidth: strokeWidth) - } + let font = Font.system(size: size, weight: item.textBold ? .semibold : .medium) + let textColor = Color(hex: item.colorHex) + let borderColor = item.borderColorHex.isEmpty + ? textColor.opacity(0.85) + : Color(hex: item.borderColorHex) + return ZStack { + if isInlineEditing { + // 제자리 입력 (워드프로세서식) — 표시용 Text와 같은 자리·글꼴이 되도록 + // UITextView 기본 여백(가로 5·세로 8)을 보정한다 + TextEditor(text: $item.text) + .font(font) + .foregroundStyle(textColor) + .multilineTextAlignment(textAlign) + .scrollContentBackground(.hidden) + .padding(.horizontal, item.textBorder ? 3 : -5) + .padding(.vertical, item.textBorder ? 0 : -8) + .focused($inlineFocused) + .onAppear { inlineFocused = true } + .onChange(of: inlineFocused) { + // 키보드가 내려가면 편집 종료 (저장은 페이지 뷰가 담당) + if !inlineFocused && isInlineEditing { + inlineEditingID.wrappedValue = nil + onCommit() + } + } + } else { + Text(item.text.isEmpty ? String(localized: "텍스트") : item.text) + .font(font) + .multilineTextAlignment(textAlign) + .foregroundStyle(textColor.opacity(item.text.isEmpty ? 0.4 : 1)) + .minimumScaleFactor(0.5) + .padding(item.textBorder ? 8 : 0) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: frameAlign) } + } + .overlay { + if item.textBorder { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(borderColor, lineWidth: strokeWidth) + } + } } /// 드래그(이동) + 핀치(균등 크기) + 두 손가락 돌리기(회전) 동시 제스처. @@ -852,14 +1080,14 @@ private struct DiaryShapeEditSheet: View { } } -// MARK: - 텍스트 상자 내용 편집 시트 +// MARK: - 텍스트 상자 스타일 편집 시트 +/// 내용은 상자를 탭해 제자리에서 입력하고, 이 시트는 스타일만 다룬다 private struct DiaryTextEditSheet: View { @Bindable var item: DiaryPageItem var onCommit: () -> Void @Environment(\.dismiss) private var dismiss - @FocusState private var focused: Bool private var fontBinding: Binding { Binding(get: { item.fontSize > 0 ? item.fontSize : 24 }, @@ -871,15 +1099,16 @@ private struct DiaryTextEditSheet: View { set: { item.lineWidth = $0 }) } + /// 빈 값 = 글자 색을 따름 — 시트에서 고르는 순간부터 독립된 테두리 색 + private var borderColorBinding: Binding { + Binding(get: { Color(hex: item.borderColorHex.isEmpty ? item.colorHex : item.borderColorHex) }, + set: { item.borderColorHex = $0.hexString }) + } + var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 14) { - TextField("내용을 입력하세요", text: $item.text, axis: .vertical) - .lineLimit(3...8) - .padding(10) - .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .focused($focused) ColorPicker("글자 색", selection: Binding( get: { Color(hex: item.colorHex) }, set: { item.colorHex = $0.hexString } @@ -900,6 +1129,7 @@ private struct DiaryTextEditSheet: View { } Toggle("테두리", isOn: $item.textBorder) if item.textBorder { + ColorPicker("테두리 색", selection: borderColorBinding, supportsOpacity: false) DiaryValueSliderRow(title: "테두리 굵기", value: borderWidthBinding, range: 1...8, step: 1, unit: "pt") } @@ -908,7 +1138,7 @@ private struct DiaryTextEditSheet: View { .padding() } .background(AppTheme.background) - .navigationTitle("텍스트 상자") + .navigationTitle("텍스트 스타일") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { @@ -920,7 +1150,6 @@ private struct DiaryTextEditSheet: View { } } .presentationDetents([.medium, .large]) - .onAppear { focused = true } } } diff --git a/myApp/HaruDanim/IOS/Views/DiaryView.swift b/myApp/HaruDanim/IOS/Views/DiaryView.swift index d6682de..f7a8ec7 100644 --- a/myApp/HaruDanim/IOS/Views/DiaryView.swift +++ b/myApp/HaruDanim/IOS/Views/DiaryView.swift @@ -330,6 +330,7 @@ struct DiaryRootView: View { textBox.centerX = 0.42; textBox.centerY = 0.62; textBox.widthRatio = 0.5 textBox.heightRatio = 0.16; textBox.fontSize = 30; textBox.textBold = true textBox.textAlignRaw = "center"; textBox.textBorder = true + textBox.borderColorHex = "#D9A621" textBox.page = page context.insert(textBox) // 펜슬 획 시드 — 확대 시 획이 유지·선명한지 검증용 (물결 곡선 2줄) diff --git a/myApp/HaruDanim/IOS/Views/HelpView.swift b/myApp/HaruDanim/IOS/Views/HelpView.swift index 8e9a71c..60cd94f 100644 --- a/myApp/HaruDanim/IOS/Views/HelpView.swift +++ b/myApp/HaruDanim/IOS/Views/HelpView.swift @@ -315,7 +315,7 @@ struct HelpView: View { HelpTopic( symbol: "applepencil", title: "펜슬 노트 페이지", - body: "페이지를 넘기면 자유 노트가 이어져요. 애플 펜슬로 쓰고 그리고, '사진'·'도형'·'텍스트'(키보드로 쓰는 글 상자)를 추가한 뒤 '배치 모드'에서 꾸며요 — 드래그로 옮기고, 선택하면 나타나는 오른쪽 아래 손잡이나 핀치로 크기를 바꾸고(도형·텍스트는 가로세로를 따로), 두 손가락 돌리기로 돌릴 수 있어요. 도형을 선택하고 '도형 수정'을 누르면 색·선 굵기·채우기·모서리 둥글기·회전을, 텍스트는 '글 수정'에서 내용과 글자 색·크기·굵게·정렬·테두리를 바꿀 수 있어요. 두 손가락 핀치로 페이지를 확대해 세밀하게 쓸 수 있고, 줄 노트를 켜면 줄 간격도 고를 수 있어요. 페이지가 여러 장이면 오른쪽 위 ⋯ 메뉴의 '페이지 순서 변경'으로 순서를 바꿀 수 있어요. 맥에서는 텍스트 입력이 기본이고, 도구 줄의 '그리기'를 켜면 마우스나 트랙패드로도 그릴 수 있어요 — 평소에는 클릭과 스크롤이 이동·버튼에 쓰여요." + body: "페이지를 넘기면 자유 노트가 이어져요. 애플 펜슬로 쓰고 그리고, '사진'·'도형'·'텍스트'를 추가한 뒤 '배치 모드'에서 꾸며요 — 드래그로 옮기고, 핀치로 크기를(모양 비율은 그대로), 두 손가락 돌리기로 회전을 바꿔요. 선택하면 나타나는 손잡이로 모양을 다듬어요: 사각형은 꼭짓점을 끌면 그 모서리만 움직이고, 원은 상하좌우 손잡이로 늘리거나 눌러 타원을 만들고, 화살표·선은 양 끝점을 끌어 길이와 방향을 바꿔요. 조절 중에 정사각형·정원이 되는 순간 손잡이가 노란색으로 변하며 살짝 붙잡아 줘요. 텍스트 상자는 선택된 상태에서 한 번 더 탭하면 바로 그 자리에서 키보드로 입력·줄바꿈이 되고, '스타일'에서 글자 색·크기·굵게·정렬·테두리(색·굵기 포함)를 바꿀 수 있어요. 도형은 '도형 수정'에서 색·선 굵기·채우기·모서리 둥글기·회전을 바꿔요. 두 손가락 핀치로 페이지를 확대해 세밀하게 쓸 수 있고, 줄 노트를 켜면 줄 간격도 고를 수 있어요. 페이지가 여러 장이면 오른쪽 위 ⋯ 메뉴의 '페이지 순서 변경'으로 순서를 바꿀 수 있어요. 맥에서는 텍스트 입력이 기본이고, 도구 줄의 '그리기'를 켜면 마우스나 트랙패드로도 그릴 수 있어요 — 평소에는 클릭과 스크롤이 이동·버튼에 쓰여요." ), HelpTopic( symbol: "doc.text.image", diff --git a/myApp/HaruDanim/Shared/DiaryModels.swift b/myApp/HaruDanim/Shared/DiaryModels.swift index bba47fa..3bf644f 100644 --- a/myApp/HaruDanim/Shared/DiaryModels.swift +++ b/myApp/HaruDanim/Shared/DiaryModels.swift @@ -190,6 +190,8 @@ final class DiaryPageItem { var textAlignRaw: String = "" /// 텍스트 상자 테두리 표시 var textBorder: Bool = false + /// 텍스트 상자 테두리 색 (hex). 빈 값 = 글자 색을 따름 + var borderColorHex: String = "" var createdAt: Date = Date() var page: DiaryPage?