// // 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 @State private var arranging = false @State private var selectedItemID: UUID? @State private var photoSelection: PhotosPickerItem? var body: some View { VStack(spacing: 10) { pageToolbar GeometryReader { geo in let scale = min(geo.size.width / DiaryPageMetrics.size.width, geo.size.height / DiaryPageMetrics.size.height) pageContent .frame(width: DiaryPageMetrics.size.width, height: DiaryPageMetrics.size.height) .scaleEffect(scale) .frame(width: geo.size.width, height: geo.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 } } } // 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() } 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") } Spacer() if arranging, let selectedItemID, let item = page.items.first(where: { $0.uuid == selectedItemID }) { 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() .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 ) { data in page.drawingData = data save() } .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .allowsHitTesting(!arranging) 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 deleteItem(_ item: DiaryPageItem) { context.delete(item) selectedItemID = nil save() } private func save() { page.entry?.updatedAt = .now try? context.save() } } // MARK: - 줄 노트 배경 struct DiaryLinedPaper: View { var body: some View { Canvas { canvasContext, size in 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 += DiaryPageMetrics.lineSpacing } } } } // 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)) } } /// 드래그(이동) + 핀치(크기) 동시 제스처. 끝났을 때만 모델에 반영한다. 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) } } 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 캔버스 struct DiaryPencilCanvas: UIViewRepresentable { let drawingData: Data /// 퍼스트 리스폰더(펜슬 도구 팔레트) 보유 여부 — 보이는 페이지 & 필기 모드일 때만 let isActive: Bool let onChange: (Data) -> Void func makeCoordinator() -> Coordinator { Coordinator(onChange: onChange) } func makeUIView(context: Context) -> PKCanvasView { let canvas = PKCanvasView() canvas.backgroundColor = .clear canvas.isOpaque = false // 시뮬레이터·손가락 입력도 허용 (실기기에서 펜슬 우선은 시스템이 처리) canvas.drawingPolicy = .anyInput 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: PKCanvasView, context: Context) { context.coordinator.setActive(isActive) } @MainActor final class Coordinator: NSObject, PKCanvasViewDelegate { let onChange: (Data) -> Void weak var canvas: PKCanvasView? 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()) } } }