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:
songyc macbook 2026-08-03 12:49:03 +09:00
parent 842293acb9
commit e6b721e368
6 changed files with 349 additions and 117 deletions

File diff suppressed because one or more lines are too long

View File

@ -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では文字入力が基本で、ツールバーの「描く」をオンにするとマウスやトラックパッドでも描けます — ふだんはクリックとスクロールが移動やボタン操作に使われます。"
}
}
}

View File

@ -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 }
}
}

View File

@ -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)

View File

@ -315,7 +315,7 @@ struct HelpView: View {
HelpTopic(
symbol: "applepencil",
title: "펜슬 노트 페이지",
body: "페이지를 넘기면 자유 노트가 이어져요. 애플 펜슬로 쓰고 그리고, '사진'·'도형'·'텍스트'(키보드로 쓰는 글 상자)를 추가한 뒤 '배치 모드'에서 꾸며요 — 드래그로 옮기고, 선택하면 나타나는 오른쪽 아래 손잡이나 핀치로 크기를 바꾸고(도형·텍스트는 가로세로를 따로), 두 손가락 돌리기로 돌릴 수 있어요. 도형을 선택하고 '도형 수정'을 누르면 색·선 굵기·채우기·모서리 둥글기·회전을, 텍스트는 '글 수정'에서 내용과 글자 색·크기·굵게·정렬·테두리를 바꿀 수 있어요. 두 손가락 핀치로 페이지를 확대해 세밀하게 쓸 수 있고, 줄 노트를 켜면 줄 간격도 고를 수 있어요. 페이지가 여러 장이면 오른쪽 위 ⋯ 메뉴의 '페이지 순서 변경'으로 순서를 바꿀 수 있어요. 맥에서는 텍스트 입력이 기본이고, 도구 줄의 '그리기'를 켜면 마우스나 트랙패드로도 그릴 수 있어요 — 평소에는 클릭과 스크롤이 이동·버튼에 쓰여요."
body: "페이지를 넘기면 자유 노트가 이어져요. 애플 펜슬로 쓰고 그리고, '사진'·'도형'·'텍스트'를 추가한 뒤 '배치 모드'에서 꾸며요 — 드래그로 옮기고, 핀치로 크기를(모양 비율은 그대로), 두 손가락 돌리기로 회전을 바꿔요. 선택하면 나타나는 손잡이로 모양을 다듬어요: 사각형은 꼭짓점을 끌면 그 모서리만 움직이고, 원은 상하좌우 손잡이로 늘리거나 눌러 타원을 만들고, 화살표·선은 양 끝점을 끌어 길이와 방향을 바꿔요. 조절 중에 정사각형·정원이 되는 순간 손잡이가 노란색으로 변하며 살짝 붙잡아 줘요. 텍스트 상자는 선택된 상태에서 한 번 더 탭하면 바로 그 자리에서 키보드로 입력·줄바꿈이 되고, '스타일'에서 글자 색·크기·굵게·정렬·테두리(색·굵기 포함)를 바꿀 수 있어요. 도형은 '도형 수정'에서 색·선 굵기·채우기·모서리 둥글기·회전을 바꿔요. 두 손가락 핀치로 페이지를 확대해 세밀하게 쓸 수 있고, 줄 노트를 켜면 줄 간격도 고를 수 있어요. 페이지가 여러 장이면 오른쪽 위 ⋯ 메뉴의 '페이지 순서 변경'으로 순서를 바꿀 수 있어요. 맥에서는 텍스트 입력이 기본이고, 도구 줄의 '그리기'를 켜면 마우스나 트랙패드로도 그릴 수 있어요 — 평소에는 클릭과 스크롤이 이동·버튼에 쓰여요."
),
HelpTopic(
symbol: "doc.text.image",

View File

@ -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?