// // DiaryNotePage.swift // Haru_Danim // // 일기 노트 페이지 — 애플 펜슬 자유 필기 캔버스 + 사진/도형 배치. // // 좌표계: 페이지는 논리 크기 768×1024pt로 고정하고 화면 크기에 맞춰 scaleEffect로 축소/확대한다. // → 필기(PKDrawing 절대 좌표)와 배치 요소(비율 좌표)가 기기·창 크기와 무관하게 항상 같은 // 자리에 놓이고, 내보내기(같은 768pt 기준 렌더)와도 픽셀 단위로 일치한다. // // 모드: 기본은 필기 모드(캔버스가 터치를 받음), '배치 모드'를 켜면 캔버스 히트테스트를 끄고 // 사진/도형을 드래그·핀치로 옮기고 크기를 바꿀 수 있다 (선택 시 삭제 버튼 표시). // import SwiftUI import SwiftData import PencilKit import PhotosUI /// 페이지 논리 크기 (내보내기 렌더와 공유) enum DiaryPageMetrics { static let size = CGSize(width: 768, height: 1024) static let lineSpacing: CGFloat = 44 static let lineTopInset: CGFloat = 72 } struct DiaryNotePageView: View { @Bindable var page: DiaryPage /// 현재 보이는 페이지일 때만 캔버스가 퍼스트 리스폰더(펜슬 도구 팔레트)를 가진다 let isActive: Bool @Environment(\.modelContext) private var context @Environment(\.diaryReadOnly) private var readOnly @State private var arranging = false @State private var selectedItemID: UUID? @State private var photoSelection: PhotosPickerItem? /// 텍스트 상자 내용 편집 대상 @State private var editingTextItem: DiaryPageItem? var body: some View { VStack(spacing: 10) { if !readOnly { pageToolbar } // 두 손가락 핀치로 화면에 꽉 차게 확대(최대 4배)하고, 확대 중엔 두 손가락으로 이동. // 한 손가락/펜슬은 그대로 필기·배치에 쓰인다. 배치 모드에서는 요소 핀치와 // 충돌하지 않도록 페이지 줌을 잠근다. DiaryZoomContainer(pageSize: DiaryPageMetrics.size, zoomEnabled: !arranging) { pageContent .frame(width: DiaryPageMetrics.size.width, height: DiaryPageMetrics.size.height) } } .padding(.horizontal, 24) .padding(.bottom, 34) .onChange(of: photoSelection) { guard let photoSelection else { return } Task { if let data = try? await photoSelection.loadTransferable(type: Data.self), let image = UIImage(data: data) { addPhoto(image) } self.photoSelection = nil } } .sheet(item: $editingTextItem) { item in DiaryTextEditSheet(item: item, onCommit: save) } } // MARK: 페이지 도구 줄 private var pageToolbar: some View { HStack(spacing: 12) { Toggle(isOn: $page.lined.animation()) { Label("줄 노트", systemImage: "text.justify") } .toggleStyle(.button) .onChange(of: page.lined) { save() } if page.lined { Menu { Picker("줄 간격", selection: Binding( get: { page.lineSpacing }, set: { page.lineSpacing = $0; save() } )) { Text("좁게").tag(34.0) Text("보통").tag(44.0) Text("넓게").tag(56.0) Text("아주 넓게").tag(70.0) } } label: { Label("줄 간격", systemImage: "arrow.up.and.down.text.horizontal") } } PhotosPicker(selection: $photoSelection, matching: .images) { Label("사진", systemImage: "photo.badge.plus") } Menu { shapeButton(.rectangle, label: String(localized: "사각형"), symbol: "rectangle") shapeButton(.ellipse, label: String(localized: "원"), symbol: "circle") shapeButton(.arrow, label: String(localized: "화살표"), symbol: "arrow.right") shapeButton(.line, label: String(localized: "선"), symbol: "minus") } label: { Label("도형", systemImage: "square.on.circle") } Button { addText() } label: { Label("텍스트", systemImage: "textformat") } Spacer() if arranging, let selectedItemID, let item = page.items.first(where: { $0.uuid == selectedItemID }) { if item.kind == .text { Button { editingTextItem = item } label: { Label("글 수정", systemImage: "square.and.pencil") } } Button(role: .destructive) { deleteItem(item) } label: { Label("삭제", systemImage: "trash") } } Toggle(isOn: $arranging.animation()) { Label("배치 모드", systemImage: "hand.draw") } .toggleStyle(.button) .onChange(of: arranging) { if !arranging { selectedItemID = nil } } } .font(.subheadline) .tint(AppTheme.green) .padding(.horizontal, 4) .frame(maxWidth: DiaryPageMetrics.size.width) } private func shapeButton(_ kind: DiaryItemKind, label: String, symbol: String) -> some View { Button { addShape(kind) } label: { Label(label, systemImage: symbol) } } // MARK: 페이지 본체 (768×1024 고정 좌표) private var pageContent: some View { ZStack { // 종이 RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(AppTheme.surface) .shadow(color: .black.opacity(0.10), radius: 14, y: 6) if page.lined { DiaryLinedPaper(spacing: page.lineSpacing) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) } // 배치 요소 (사진/도형) — 필기 아래에 깔린다 ForEach(page.items, id: \.uuid) { item in DiaryItemView( item: item, pageSize: DiaryPageMetrics.size, arranging: arranging, selectedID: $selectedItemID, onCommit: save ) } // 필기 캔버스 — 항상 최상단. 배치 모드에서는 터치를 요소들에게 넘기고, // 열람 전용(프리미엄 만료)에서는 필기를 받지 않는다. DiaryPencilCanvas( drawingData: page.drawingData, isActive: isActive && !arranging && !readOnly ) { data in page.drawingData = data save() } .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .allowsHitTesting(!arranging && !readOnly) if arranging { RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(AppTheme.yellow, style: StrokeStyle(lineWidth: 2, dash: [7, 5])) } } .contentShape(Rectangle()) .onTapGesture { if arranging { selectedItemID = nil } } } // MARK: 요소 추가/삭제 private func addPhoto(_ image: UIImage) { let item = DiaryPageItem(kind: .photo) item.imageData = image.diaryCompressed() item.page = page context.insert(item) arranging = true selectedItemID = item.uuid save() } private func addShape(_ kind: DiaryItemKind) { let item = DiaryPageItem(kind: kind) item.colorHex = AppTheme.tagPresets.randomElement() ?? "#2F6B4F" item.page = page context.insert(item) arranging = true selectedItemID = item.uuid save() } /// 텍스트 상자 추가 — 만들자마자 내용 입력 시트를 띄운다 private func addText() { let item = DiaryPageItem(kind: .text) item.colorHex = "#3A3A3A" item.widthRatio = 0.45 item.page = page context.insert(item) arranging = true selectedItemID = item.uuid save() editingTextItem = item } private func deleteItem(_ item: DiaryPageItem) { context.delete(item) selectedItemID = nil save() } private func save() { page.entry?.updatedAt = .now try? context.save() } } // MARK: - 줄 노트 배경 struct DiaryLinedPaper: View { /// 줄 간격 (페이지별 설정, 기본 44pt) var spacing: Double = DiaryPageMetrics.lineSpacing var body: some View { Canvas { canvasContext, size in let step = max(CGFloat(spacing), 20) var y = DiaryPageMetrics.lineTopInset while y < size.height - 24 { var path = Path() path.move(to: CGPoint(x: 32, y: y)) path.addLine(to: CGPoint(x: size.width - 32, y: y)) canvasContext.stroke(path, with: .color(.primary.opacity(0.10)), lineWidth: 1) y += step } } } } // MARK: - 핀치줌 컨테이너 (UIScrollView 기반) /// 노트 페이지를 감싸는 확대/축소 스크롤 뷰. /// - 처음엔 페이지가 화면에 딱 맞게(fit) 표시되고, 두 손가락 핀치로 최대 4배까지 확대 /// - 확대 상태에서 두 손가락 드래그로 이동 (한 손가락·펜슬은 안쪽 필기 캔버스가 그대로 받는다) /// - 배치 모드에서는 zoomEnabled=false로 페이지 줌을 잠가 요소 핀치와 충돌하지 않게 한다 private struct DiaryZoomContainer: UIViewRepresentable { let pageSize: CGSize let zoomEnabled: Bool @ViewBuilder let content: () -> Content func makeCoordinator() -> Coordinator { Coordinator() } func makeUIView(context: Context) -> DiaryZoomScrollView { let scroll = DiaryZoomScrollView() scroll.pageSize = pageSize scroll.delegate = context.coordinator scroll.showsVerticalScrollIndicator = false scroll.showsHorizontalScrollIndicator = false scroll.contentInsetAdjustmentBehavior = .never scroll.scrollsToTop = false scroll.bounces = false scroll.bouncesZoom = true scroll.backgroundColor = .clear // 이동/확대는 손가락 전용 — 펜슬 터치는 스크롤이 가로채지 않고 캔버스가 받아 필기한다. // 손가락 한 개로도 (확대 상태에서) 페이지를 상하좌우로 이동할 수 있다. scroll.panGestureRecognizer.allowedTouchTypes = [ NSNumber(value: UITouch.TouchType.direct.rawValue) ] scroll.panGestureRecognizer.minimumNumberOfTouches = 1 scroll.pinchGestureRecognizer?.allowedTouchTypes = [ NSNumber(value: UITouch.TouchType.direct.rawValue) ] let hosting = UIHostingController(rootView: content()) hosting.view.backgroundColor = .clear hosting.safeAreaRegions = [] hosting.view.frame = CGRect(origin: .zero, size: pageSize) scroll.addSubview(hosting.view) scroll.contentSize = pageSize scroll.zoomedView = hosting.view context.coordinator.hosting = hosting return scroll } func updateUIView(_ scroll: DiaryZoomScrollView, context: Context) { context.coordinator.hosting?.rootView = content() scroll.pinchGestureRecognizer?.isEnabled = zoomEnabled // 배치 모드에서는 한 손가락 드래그를 요소 이동에 양보 (페이지 이동은 두 손가락) scroll.panGestureRecognizer.minimumNumberOfTouches = zoomEnabled ? 1 : 2 } final class Coordinator: NSObject, UIScrollViewDelegate { var hosting: UIHostingController? func viewForZooming(in scrollView: UIScrollView) -> UIView? { (scrollView as? DiaryZoomScrollView)?.zoomedView } func scrollViewDidZoom(_ scrollView: UIScrollView) { (scrollView as? DiaryZoomScrollView)?.centerZoomedView() } func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { // 핀치가 끝난 배율에 맞춰 캔버스 래스터 해상도를 다시 맞춘다 (확대 시 픽셀 번짐 방지) (scrollView as? DiaryZoomScrollView)?.updateCanvasSharpness() } } } /// 레이아웃(회전·창 크기 변경)에 맞춰 fit 배율을 다시 계산하고 콘텐츠를 가운데 정렬하는 스크롤 뷰 final class DiaryZoomScrollView: UIScrollView { var pageSize: CGSize = DiaryPageMetrics.size weak var zoomedView: UIView? private var didSetInitialZoom = false private var lastCanvasScale: CGFloat = 0 override func layoutSubviews() { super.layoutSubviews() guard bounds.width > 0, bounds.height > 0 else { return } let fit = min(bounds.width / pageSize.width, bounds.height / pageSize.height) let wasAtMinimum = zoomScale <= minimumZoomScale + 0.0001 if abs(minimumZoomScale - fit) > 0.0001 { minimumZoomScale = fit maximumZoomScale = max(fit * 4, 1) // 처음이거나 fit 상태로 보고 있었다면 새 크기에 맞춰 다시 fit if !didSetInitialZoom || wasAtMinimum || zoomScale < fit { zoomScale = fit didSetInitialZoom = true } } else if !didSetInitialZoom { zoomScale = fit didSetInitialZoom = true } centerZoomedView() updateCanvasSharpness() } /// 콘텐츠가 화면보다 작을 때 가운데 정렬 func centerZoomedView() { guard let zoomedView else { return } let dx = max((bounds.width - contentSize.width) / 2, 0) let dy = max((bounds.height - contentSize.height) / 2, 0) zoomedView.center = CGPoint(x: contentSize.width / 2 + dx, y: contentSize.height / 2 + dy) } /// 펜슬 캔버스의 래스터 해상도를 현재 줌 배율에 정확히 맞춘다. /// 페이지는 논리 768×1024pt 고정 + 스케일 변환이라, 캔버스 래스터가 화면 픽셀과 /// 1:1로 일치(contentsScale = 기기 스케일 × 줌)해야 가장 선명하다 — 남거나(다운샘플 소프트닝) /// 모자라면(업스케일 픽셀) 흐려 보인다. 상한은 메모리 보호용 (기기 스케일 × 3). /// 실제 적용·유지는 SharpPencilCanvasView가 담당한다 (획을 그릴 때마다 재적용). func updateCanvasSharpness() { guard let zoomedView else { return } let display = max(traitCollection.displayScale, 1) let target = display * min(max(zoomScale, 0.7), 3) guard abs(target - lastCanvasScale) > 0.01 else { return } lastCanvasScale = target Self.assignCanvasScale(target, in: zoomedView) } /// 서브트리에서 펜슬 캔버스를 찾아 목표 래스터 배율을 지정한다 private static func assignCanvasScale(_ scale: CGFloat, in view: UIView) { if let canvas = view as? SharpPencilCanvasView { canvas.targetContentsScale = scale return } for subview in view.subviews { assignCanvasScale(scale, in: subview) } } } // MARK: - 배치 요소 (사진/도형) struct DiaryItemView: View { @Bindable var item: DiaryPageItem let pageSize: CGSize let arranging: Bool @Binding var selectedID: UUID? var onCommit: () -> Void = {} @State private var dragTranslation: CGSize = .zero @State private var pinchScale: CGFloat = 1 private var isSelected: Bool { selectedID == item.uuid } private var width: CGFloat { pageSize.width * item.widthRatio * pinchScale } private var height: CGFloat { if item.kind == .photo, let data = item.imageData, let image = UIImage(data: data), image.size.width > 0 { return width * image.size.height / image.size.width } return width * item.kind.defaultAspect } var body: some View { content .frame(width: width, height: height) .overlay { if arranging && isSelected { RoundedRectangle(cornerRadius: 6) .strokeBorder(AppTheme.green, lineWidth: 2) } } .rotationEffect(.degrees(item.rotationDegrees)) .position( x: pageSize.width * item.centerX + dragTranslation.width, y: pageSize.height * item.centerY + dragTranslation.height ) .allowsHitTesting(arranging) .onTapGesture { selectedID = item.uuid } .gesture(arranging ? moveAndResize : nil) } @ViewBuilder private var content: some View { switch item.kind { case .photo: if let data = item.imageData, let image = UIImage(data: data) { Image(uiImage: image) .resizable() .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .shadow(color: .black.opacity(0.12), radius: 5, y: 2) } else { RoundedRectangle(cornerRadius: 10) .fill(Color.gray.opacity(0.2)) } case .rectangle: RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(Color(hex: item.colorHex), lineWidth: 5) case .ellipse: Ellipse() .strokeBorder(Color(hex: item.colorHex), lineWidth: 5) case .arrow: DiaryArrowShape() .stroke(Color(hex: item.colorHex), style: StrokeStyle(lineWidth: 6, lineCap: .round, lineJoin: .round)) case .line: DiaryLineShape() .stroke(Color(hex: item.colorHex), style: StrokeStyle(lineWidth: 5, lineCap: .round)) case .text: Text(item.text.isEmpty ? String(localized: "텍스트") : item.text) .font(.system(size: 24, weight: .medium)) .foregroundStyle(Color(hex: item.colorHex).opacity(item.text.isEmpty ? 0.4 : 1)) .minimumScaleFactor(0.3) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } /// 드래그(이동) + 핀치(크기) 동시 제스처. 끝났을 때만 모델에 반영한다. private var moveAndResize: some Gesture { let drag = DragGesture() .onChanged { value in selectedID = item.uuid dragTranslation = value.translation } .onEnded { value in item.centerX = min(max(item.centerX + value.translation.width / pageSize.width, 0.02), 0.98) item.centerY = min(max(item.centerY + value.translation.height / pageSize.height, 0.02), 0.98) dragTranslation = .zero onCommit() } let pinch = MagnificationGesture() .onChanged { value in selectedID = item.uuid pinchScale = value } .onEnded { value in item.widthRatio = min(max(item.widthRatio * value, 0.08), 0.95) pinchScale = 1 onCommit() } return drag.simultaneously(with: pinch) } } // MARK: - 텍스트 상자 내용 편집 시트 private struct DiaryTextEditSheet: View { @Bindable var item: DiaryPageItem var onCommit: () -> Void @Environment(\.dismiss) private var dismiss @FocusState private var focused: Bool var body: some View { NavigationStack { VStack(alignment: .leading, spacing: 12) { 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 } ), supportsOpacity: false) Spacer() } .padding() .background(AppTheme.background) .navigationTitle("텍스트 상자") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("완료") { onCommit() dismiss() } } } } .presentationDetents([.height(300)]) .onAppear { focused = true } } } struct DiaryArrowShape: Shape { func path(in rect: CGRect) -> Path { var path = Path() let midY = rect.midY path.move(to: CGPoint(x: rect.minX, y: midY)) path.addLine(to: CGPoint(x: rect.maxX, y: midY)) let head = min(rect.width * 0.22, rect.height * 0.9) path.move(to: CGPoint(x: rect.maxX - head, y: midY - head * 0.6)) path.addLine(to: CGPoint(x: rect.maxX, y: midY)) path.addLine(to: CGPoint(x: rect.maxX - head, y: midY + head * 0.6)) return path } } struct DiaryLineShape: Shape { func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: rect.minX, y: rect.midY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY)) return path } } // MARK: - PencilKit 캔버스 /// 래스터 해상도를 스스로 유지하는 PKCanvasView. /// 획을 그리거나 내부 레이아웃이 갱신될 때 PencilKit이 렌더 레이어를 새로 만들며 /// contentsScale이 기본값으로 돌아가는 것을 매번 다시 맞춘다. final class SharpPencilCanvasView: PKCanvasView { /// 바깥 줌 스크롤이 지정하는 목표 래스터 배율 (기기 스케일 × 현재 줌) var targetContentsScale: CGFloat = 0 { didSet { if abs(targetContentsScale - oldValue) > 0.01 { applySharpness() } } } override func layoutSubviews() { super.layoutSubviews() applySharpness() } func applySharpness() { guard targetContentsScale > 0 else { return } Self.apply(targetContentsScale, view: self) } private static func apply(_ scale: CGFloat, view: UIView) { if abs(view.contentScaleFactor - scale) > 0.01 { view.contentScaleFactor = scale } apply(scale, layer: view.layer) for subview in view.subviews { apply(scale, view: subview) } } private static func apply(_ scale: CGFloat, layer: CALayer) { if abs(layer.contentsScale - scale) > 0.01 { layer.contentsScale = scale layer.setNeedsDisplay() } for sublayer in layer.sublayers ?? [] { apply(scale, layer: sublayer) } } } struct DiaryPencilCanvas: UIViewRepresentable { let drawingData: Data /// 퍼스트 리스폰더(펜슬 도구 팔레트) 보유 여부 — 보이는 페이지 & 필기 모드일 때만 let isActive: Bool let onChange: (Data) -> Void func makeCoordinator() -> Coordinator { Coordinator(onChange: onChange) } func makeUIView(context: Context) -> SharpPencilCanvasView { let canvas = SharpPencilCanvasView() canvas.backgroundColor = .clear canvas.isOpaque = false #if targetEnvironment(simulator) // 시뮬레이터에는 펜슬이 없으므로 마우스(손가락) 입력 허용 canvas.drawingPolicy = .anyInput #else // 실기기: 필기는 펜슬 전용 — 손가락은 페이지 이동/확대에만 쓰인다 canvas.drawingPolicy = .pencilOnly #endif canvas.delegate = context.coordinator if let drawing = try? PKDrawing(data: drawingData), !drawingData.isEmpty { canvas.drawing = drawing } context.coordinator.canvas = canvas return canvas } func updateUIView(_ canvas: SharpPencilCanvasView, context: Context) { context.coordinator.setActive(isActive) } @MainActor final class Coordinator: NSObject, PKCanvasViewDelegate { let onChange: (Data) -> Void weak var canvas: SharpPencilCanvasView? private let toolPicker = PKToolPicker() private var observing = false init(onChange: @escaping (Data) -> Void) { self.onChange = onChange } func setActive(_ active: Bool) { guard let canvas else { return } if active { if !observing { toolPicker.addObserver(canvas) observing = true } toolPicker.setVisible(true, forFirstResponder: canvas) // 창에 붙기 전엔 실패하므로 다음 런루프에서 재시도 DispatchQueue.main.async { if canvas.window != nil { canvas.becomeFirstResponder() } } } else if canvas.isFirstResponder { canvas.resignFirstResponder() } } func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) { onChange(canvasView.drawing.dataRepresentation()) // 새 획의 렌더 레이어가 기본 해상도로 생겼을 수 있어 다시 맞춘다 (canvasView as? SharpPencilCanvasView)?.applySharpness() } } }