feat(diary): 도형·텍스트 상자 UX 2차 개편 — 종류별 모양 손잡이·스냅·제자리 텍스트 입력
실사용 보고: 도형이 고정 비율 손잡이 하나뿐이라 모양을 다듬을 수 없고, 텍스트는 시트에 입력하는 방식이 일반 노트 앱과 동떨어져 있었다. - 종류별 모양 손잡이 (드래그는 페이지 좌표계로 받아 실시간 회전에도 안정): · 사각형: 꼭짓점 4개 — 끈 모서리만 움직이고 반대편 고정 · 원: 상하좌우 4개 — 늘리고 눌러 타원 만들기 · 선·화살표: 양 끝점 — 반대 끝 고정한 채 길이+방향 동시 조절 · 사진·텍스트: 우하단 1개 (사진은 원본 비율 유지) - 정사각형·정원 스냅: |가로-세로| ≤ 10pt면 딱 맞추고 손잡이·테두리 노란색 강조 + 햅틱. 핀치는 모양 비율 그대로 균등 스케일 - 텍스트 상자 제자리 입력: 선택된 상자를 탭하면 그 자리에서 키보드 입력·줄바꿈 (생성 직후에도 바로 입력). '글 수정' 시트는 '스타일' 시트로 재편 — 테두리 켬/끔 + 독립 테두리 색(borderColorHex, 빈 값 = 글자 색) + 굵기 - borderColorHex 필드 추가 (기본값 — CloudKit·구버전 안전) - 검증 인자 -diaryTextEdit 추가, 도움말 갱신 + en/ja 번역 - 검증: Debug/Store 빌드, 아이패드 심 손잡이 배치·회전 정합·제자리 편집 렌더 확인, 카탈로그 missing·stale 0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
This commit is contained in:
parent
842293acb9
commit
e6b721e368
File diff suppressed because one or more lines are too long
@ -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では文字入力が基本で、ツールバーの「描く」をオンにするとマウスやトラックパッドでも描けます — ふだんはクリックとスクロールが移動やボタン操作に使われます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<UUID?> = .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<Double> {
|
||||
Binding(get: { item.fontSize > 0 ? item.fontSize : 24 },
|
||||
@ -871,15 +1099,16 @@ private struct DiaryTextEditSheet: View {
|
||||
set: { item.lineWidth = $0 })
|
||||
}
|
||||
|
||||
/// 빈 값 = 글자 색을 따름 — 시트에서 고르는 순간부터 독립된 테두리 색
|
||||
private var borderColorBinding: Binding<Color> {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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줄)
|
||||
|
||||
@ -315,7 +315,7 @@ struct HelpView: View {
|
||||
HelpTopic(
|
||||
symbol: "applepencil",
|
||||
title: "펜슬 노트 페이지",
|
||||
body: "페이지를 넘기면 자유 노트가 이어져요. 애플 펜슬로 쓰고 그리고, '사진'·'도형'·'텍스트'(키보드로 쓰는 글 상자)를 추가한 뒤 '배치 모드'에서 꾸며요 — 드래그로 옮기고, 선택하면 나타나는 오른쪽 아래 손잡이나 핀치로 크기를 바꾸고(도형·텍스트는 가로세로를 따로), 두 손가락 돌리기로 돌릴 수 있어요. 도형을 선택하고 '도형 수정'을 누르면 색·선 굵기·채우기·모서리 둥글기·회전을, 텍스트는 '글 수정'에서 내용과 글자 색·크기·굵게·정렬·테두리를 바꿀 수 있어요. 두 손가락 핀치로 페이지를 확대해 세밀하게 쓸 수 있고, 줄 노트를 켜면 줄 간격도 고를 수 있어요. 페이지가 여러 장이면 오른쪽 위 ⋯ 메뉴의 '페이지 순서 변경'으로 순서를 바꿀 수 있어요. 맥에서는 텍스트 입력이 기본이고, 도구 줄의 '그리기'를 켜면 마우스나 트랙패드로도 그릴 수 있어요 — 평소에는 클릭과 스크롤이 이동·버튼에 쓰여요."
|
||||
body: "페이지를 넘기면 자유 노트가 이어져요. 애플 펜슬로 쓰고 그리고, '사진'·'도형'·'텍스트'를 추가한 뒤 '배치 모드'에서 꾸며요 — 드래그로 옮기고, 핀치로 크기를(모양 비율은 그대로), 두 손가락 돌리기로 회전을 바꿔요. 선택하면 나타나는 손잡이로 모양을 다듬어요: 사각형은 꼭짓점을 끌면 그 모서리만 움직이고, 원은 상하좌우 손잡이로 늘리거나 눌러 타원을 만들고, 화살표·선은 양 끝점을 끌어 길이와 방향을 바꿔요. 조절 중에 정사각형·정원이 되는 순간 손잡이가 노란색으로 변하며 살짝 붙잡아 줘요. 텍스트 상자는 선택된 상태에서 한 번 더 탭하면 바로 그 자리에서 키보드로 입력·줄바꿈이 되고, '스타일'에서 글자 색·크기·굵게·정렬·테두리(색·굵기 포함)를 바꿀 수 있어요. 도형은 '도형 수정'에서 색·선 굵기·채우기·모서리 둥글기·회전을 바꿔요. 두 손가락 핀치로 페이지를 확대해 세밀하게 쓸 수 있고, 줄 노트를 켜면 줄 간격도 고를 수 있어요. 페이지가 여러 장이면 오른쪽 위 ⋯ 메뉴의 '페이지 순서 변경'으로 순서를 바꿀 수 있어요. 맥에서는 텍스트 입력이 기본이고, 도구 줄의 '그리기'를 켜면 마우스나 트랙패드로도 그릴 수 있어요 — 평소에는 클릭과 스크롤이 이동·버튼에 쓰여요."
|
||||
),
|
||||
HelpTopic(
|
||||
symbol: "doc.text.image",
|
||||
|
||||
@ -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?
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user