// // DiaryNotePage.swift // Haru_Danim // // 일기 노트 페이지 — 애플 펜슬 자유 필기 캔버스 + 사진/도형 배치. // // 좌표계: 페이지는 논리 크기 768×1024pt로 고정하고 화면 크기에 맞춰 scaleEffect로 축소/확대한다. // → 필기(PKDrawing 절대 좌표)와 배치 요소(비율 좌표)가 기기·창 크기와 무관하게 항상 같은 // 자리에 놓이고, 내보내기(같은 768pt 기준 렌더)와도 픽셀 단위로 일치한다. // // 모드: 기본은 필기 모드(캔버스가 터치를 받음), '배치 모드'를 켜면 캔버스 히트테스트를 끄고 // 사진/도형을 드래그·핀치로 옮기고 크기를 바꿀 수 있다 (선택 시 삭제 버튼 표시). // import SwiftUI import SwiftData import PencilKit import PhotosUI /// 페이지 논리 크기 (내보내기 렌더와 공유). 순수 상수라 격리 불필요 — /// 백그라운드 PDF 렌더(DiaryTemplateRenderer)에서도 참조한다. 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 { @Bindable var page: DiaryPage /// 현재 보이는 페이지일 때만 캔버스가 퍼스트 리스폰더(펜슬 도구 팔레트)를 가진다 let isActive: Bool @Environment(\.modelContext) private var context @Environment(\.diaryReadOnly) private var readOnly @Query private var allTemplates: [DiaryTemplate] @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·-diaryTextEdit 소비 여부 (활성 페이지에서 1회만) @State private var debugShapeEditDone = false @State private var debugTextEditDone = false #endif /// 필기 입력 게이트. 맥(Designed for iPad)은 마우스가 이동·버튼 조작과 겸용이라 /// 도구 줄의 '그리기' 토글을 켠 동안만 캔버스가 입력을 받는다 (텍스트 우선 UX, §6.7). /// 아이패드는 항상 true(토글 미노출) — 펜슬 필기 동작이 기존과 완전히 동일하다. @State private var drawMode: Bool = { #if DEBUG // 검증·촬영용: -diaryDrawMode YES → 맥 분기에서도 그리기 모드로 시작 if UserDefaults.standard.bool(forKey: "diaryDrawMode") { return true } #endif return !DeviceLayout.isMac }() /// 이 페이지의 배경 양식 (삭제됐거나 아직 동기화 전이면 nil → 빈 캔버스 폴백) private var template: DiaryTemplate? { guard !page.templateID.isEmpty else { return nil } return allTemplates.first { $0.uuid.uuidString == page.templateID } } 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) } .sheet(item: $editingShapeItem) { item in DiaryShapeEditSheet(item: item, onCommit: save) } // 인라인 입력 종료의 단일 정리 지점 — 어떤 경로(빈 곳 탭·키보드 내림·다른 요소 // 선택·배치 모드 종료·삭제)로 끝나든 여기서 빈 상자 정리+저장이 한 번 일어난다 .onChange(of: inlineEditingTextID) { oldValue, _ in if let oldValue { finalizeInlineText(oldValue) } } // 다른 요소를 선택하면 진행 중이던 인라인 입력을 닫는다 (편집 상태 잔존 방지) .onChange(of: selectedItemID) { if let editing = inlineEditingTextID, selectedItemID != editing { inlineEditingTextID = nil } } .onAppear { debugOpenShapeEditIfNeeded() debugOpenTextEditIfNeeded() } .onChange(of: isActive) { debugOpenShapeEditIfNeeded() debugOpenTextEditIfNeeded() } } /// 검증·촬영용: -diaryShapeEdit YES → 활성 페이지의 첫 도형을 선택하고 '도형 수정' 시트 표시 private func debugOpenShapeEditIfNeeded() { #if DEBUG guard !debugShapeEditDone, isActive, UserDefaults.standard.bool(forKey: "diaryShapeEdit"), let shape = page.items.first(where: { $0.kind != .photo && $0.kind != .text }) else { return } debugShapeEditDone = true arranging = true selectedItemID = shape.uuid editingShapeItem = shape #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: 페이지 도구 줄 /// 아이패드: 기존 순서 그대로 (펜슬이 메인이라 필기 토글 없음). /// 맥: 텍스트 입력이 메인 — '텍스트'를 맨 앞으로, 필기는 '그리기' 토글로만 켠다. /// 맥은 전역 xLarge 보정 + 사이드바로 폭이 좁아질 수 있어 가로 스크롤 도구 줄로 /// 라벨을 온전히 유지한다 (트랙패드 스크롤 — 창이 넓으면 스크롤 없이 다 보임). @ViewBuilder private var pageToolbar: some View { Group { if DeviceLayout.isMac { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { textAddButton photoPickerButton shapeMenu linedControls drawModeToggle selectionButtons arrangeToggle } .padding(.horizontal, 4) } } else { HStack(spacing: 12) { linedControls photoPickerButton shapeMenu textAddButton Spacer() selectionButtons arrangeToggle } .padding(.horizontal, 4) } } .font(.subheadline) .lineLimit(1) .tint(AppTheme.green) .frame(maxWidth: DiaryPageMetrics.size.width) } /// 줄 노트 토글 + 줄 간격 (양식이 깔린 페이지에서는 양식에 가려지므로 숨김 — 상호 배타) @ViewBuilder private var linedControls: some View { if template == nil { 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") } } } } private var photoPickerButton: some View { PhotosPicker(selection: $photoSelection, matching: .images) { Label("사진", systemImage: "photo.badge.plus") } } private var shapeMenu: some View { 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") } } private var textAddButton: some View { Button { addText() } label: { Label("텍스트", systemImage: "textformat") } } /// 맥 전용 필기 토글 — 켜면 배치 모드를 끄고 캔버스가 마우스 입력을 받는다 private var drawModeToggle: some View { Toggle(isOn: $drawMode.animation()) { Label("그리기", systemImage: "pencil.tip") } .toggleStyle(.button) .onChange(of: drawMode) { if drawMode { arranging = false } } } @ViewBuilder private var selectionButtons: some View { if arranging, let selectedItemID, let item = page.items.first(where: { $0.uuid == selectedItemID }) { if item.kind == .text { Button { endInlineTextEditing() editingTextItem = item } label: { Label("스타일", systemImage: "textformat") } } else if item.kind != .photo { Button { editingShapeItem = item } label: { Label("도형 수정", systemImage: "slider.horizontal.3") } } Button(role: .destructive) { deleteItem(item) } label: { Label("삭제", systemImage: "trash") } } } private var arrangeToggle: some View { Toggle(isOn: $arranging.animation()) { Label("배치 모드", systemImage: "hand.draw") } .toggleStyle(.button) .onChange(of: arranging) { if !arranging { selectedItemID = nil endInlineTextEditing() } // 맥: 배치와 그리기는 상호 배타 — 마우스 입력이 어디로 가는지 항상 명확하게 if arranging && DeviceLayout.isMac { drawMode = false } } } 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, template == nil { DiaryLinedPaper(spacing: page.lineSpacing) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) } // 배경 양식 (PDF/이미지) — 필기·배치 요소 아래에 깔린다 if let template { DiaryTemplateBackground(template: template, pageIndex: page.templatePageIndex, isActive: isActive) .allowsHitTesting(false) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) } // 배치 요소 (사진/도형) — 필기 아래에 깔린다 ForEach(page.items, id: \.uuid) { item in DiaryItemView( item: item, pageSize: DiaryPageMetrics.size, arranging: arranging, selectedID: $selectedItemID, inlineEditingID: $inlineEditingTextID, onCommit: save ) } // 필기 캔버스 — 항상 최상단. 배치 모드에서는 터치를 요소들에게 넘기고, // 열람 전용(프리미엄 만료)에서는 필기를 받지 않는다. // drawMode는 맥에서만 꺼질 수 있다(아이패드 항상 true — 기존 동작 불변, §6.7) DiaryPencilCanvas( drawingData: page.drawingData, isActive: isActive && !arranging && !readOnly && drawMode, fixedLightInk: template != nil ) { data in page.drawingData = data save() } .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) .allowsHitTesting(!arranging && !readOnly && drawMode) if arranging { RoundedRectangle(cornerRadius: 18, style: .continuous) .strokeBorder(AppTheme.yellow, style: StrokeStyle(lineWidth: 2, dash: [7, 5])) } } .contentShape(Rectangle()) .coordinateSpace(name: DiaryPageMetrics.coordSpace) .onTapGesture { if arranging { selectedItemID = nil endInlineTextEditing() } } } /// 인라인 텍스트 입력을 끝낸다 — 정리·저장은 onChange(of: inlineEditingTextID)의 finalize가 담당 /// (키보드 내림으로 DiaryItemView가 직접 바인딩을 지우는 경로와 단일화) private func endInlineTextEditing() { guard inlineEditingTextID != nil else { return } inlineEditingTextID = nil } /// 인라인 입력이 끝난 상자 정리: 내용이 빈 상자는 지운다(플레이스홀더가 내보내기에 /// 인쇄되는 것 방지). 단, '스타일' 시트로 넘어가는 중이면 꾸민 뒤 쓸 수 있게 유지. private func finalizeInlineText(_ id: UUID) { if let item = page.items.first(where: { $0.uuid == id }), item.kind == .text, item.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, editingTextItem?.uuid != id { context.delete(item) if selectedItemID == id { selectedItemID = nil } } save() } // 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() inlineEditingTextID = item.uuid } private func deleteItem(_ item: DiaryPageItem) { context.delete(item) selectedItemID = nil // 인라인 입력 중이던 상자를 지웠으면 편집 상태도 정리 (죽은 uuid 잔존 방지 — // 삭제를 먼저 했으므로 finalize는 저장만 한다) if inlineEditingTextID == item.uuid { inlineEditingTextID = 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() -> DiaryZoomCoordinator { DiaryZoomCoordinator() } 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: AnyView(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 #if DEBUG // 검증용: -diaryZoomScale 2 → 2.5초 뒤 fit 대비 N배로 프로그램 줌 // (핀치 없이 확대 시 필기 획 유지·선명도를 스크린샷으로 확인) let zoomFactor = UserDefaults.standard.double(forKey: "diaryZoomScale") if zoomFactor > 0 { DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak scroll] in guard let scroll else { return } scroll.setZoomScale(scroll.minimumZoomScale * zoomFactor, animated: false) scroll.delegate?.scrollViewDidEndZooming?( scroll, with: scroll.zoomedView, atScale: scroll.zoomScale ) } } #endif return scroll } func updateUIView(_ scroll: DiaryZoomScrollView, context: Context) { context.coordinator.hosting?.rootView = AnyView(content()) scroll.pinchGestureRecognizer?.isEnabled = zoomEnabled // 배치 모드에서는 한 손가락 드래그를 요소 이동에 양보 (페이지 이동은 두 손가락) scroll.panGestureRecognizer.minimumNumberOfTouches = zoomEnabled ? 1 : 2 } } /// ⚠️ 비제네릭·톱레벨 + AnyView 소거 유지(1.4 하한 18 컴파일러 우회): 예전처럼 /// DiaryZoomContainer 안에 중첩해 UIHostingController를 들면, /// 하한 18 Release(-O wholemodule) 빌드에서 swift-frontend가 이 클래스의 deinit을 /// 최적화하다 크래시한다(SILPerformanceInliner의 /// isCallerAndCalleeLayoutConstraintsCompatible 무한 재귀 — 하한 26에서는 정상, 실측). /// rootView는 원래 매 갱신 통째로 교체되므로 AnyView 소거의 동작 차이는 없다. private final class DiaryZoomCoordinator: 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) } /// 서브트리에서 펜슬 캔버스·양식 배경을 찾아 목표 래스터 배율을 지정한다 /// (PDF 양식도 줌에 맞춰 다시 래스터해 확대 시 흐려지지 않게 — 캔버스와 같은 규칙) private static func assignCanvasScale(_ scale: CGFloat, in view: UIView) { if let canvas = view as? SharpPencilCanvasView { canvas.targetContentsScale = scale } else if let templateView = view as? DiaryTemplateImageView { templateView.targetContentsScale = scale } 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 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 /// 손잡이 드래그 중의 실시간 크기 (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 } private var baseHeight: CGFloat { if item.kind == .photo, let data = item.imageData, let image = UIImage(data: data), image.size.width > 0 { return baseWidth * image.size.height / image.size.width } let ratio = item.heightRatio > 0 ? item.heightRatio : item.widthRatio * item.kind.defaultAspect return pageSize.width * ratio } private var width: CGFloat { 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) } var body: some View { content .frame(width: width, height: height) .overlay { if arranging && isSelected { RoundedRectangle(cornerRadius: 6) .strokeBorder(isSnapped ? AppTheme.yellow : AppTheme.green, lineWidth: 2) } } // 손잡이는 rotationEffect보다 안쪽에 붙어 요소와 함께 회전한다. // 드래그 값은 페이지 좌표계(.named)로 받아 실시간 회전에도 흔들리지 않는다. .overlay { if arranging && isSelected && !isInlineEditing { handles } } .rotationEffect(.degrees(liveRotation ?? item.rotationDegrees) + rotationDelta) .position( x: pageSize.width * item.centerX + dragTranslation.width + liveCenterShift.width, y: pageSize.height * item.centerY + dragTranslation.height + liveCenterShift.height ) .allowsHitTesting(arranging) .onTapGesture { // 텍스트: 선택된 상자를 한 번 더 탭하면 제자리 입력 시작 (워드프로세서식) if item.kind == .text, isSelected { inlineEditingID.wrappedValue = item.uuid } selectedID = item.uuid } .gesture(arranging && !isInlineEditing ? moveResizeRotate : nil) } // 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)) } } 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("크기 조절")) } /// 페이지 좌표 벡터 회전 (요소 로컬 축 ↔ 페이지 축 변환) 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 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: let radius = item.cornerRadius >= 0 ? item.cornerRadius : 10 let rect = RoundedRectangle(cornerRadius: radius, style: .continuous) if item.filled { rect.fill(Color(hex: item.colorHex)) } else { rect.strokeBorder(Color(hex: item.colorHex), lineWidth: strokeWidth) } case .ellipse: if item.filled { Ellipse().fill(Color(hex: item.colorHex)) } else { Ellipse().strokeBorder(Color(hex: item.colorHex), lineWidth: strokeWidth) } case .arrow: DiaryArrowShape() .stroke(Color(hex: item.colorHex), style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round, lineJoin: .round)) case .line: DiaryLineShape() .stroke(Color(hex: item.colorHex), style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round)) case .text: textContent } } /// 선 굵기 — 0(미지정)이면 종류별 기본값 (기존 하드코딩과 동일한 룩) private var strokeWidth: CGFloat { item.lineWidth > 0 ? item.lineWidth : item.kind.defaultLineWidth } private var textContent: some View { let size = item.fontSize > 0 ? item.fontSize : 24 let (textAlign, frameAlign): (TextAlignment, Alignment) = switch item.textAlignRaw { case "center": (.center, .top) case "trailing": (.trailing, .topTrailing) default: (.leading, .topLeading) } 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) } } } /// 드래그(이동) + 핀치(균등 크기) + 두 손가락 돌리기(회전) 동시 제스처. /// 끝났을 때만 모델에 반영한다. 정밀 조절은 크기 손잡이·'도형 수정'/'글 수정' 시트로. private var moveResizeRotate: 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 // 선·화살표는 끝점 드래그로 페이지 폭 1.6배까지 늘어날 수 있으므로 // 핀치 커밋 상한도 같게 — 0.95로 두면 긴 선이 핀치 순간 잘려 짧아진다 let maxWidthRatio: Double = (item.kind == .arrow || item.kind == .line) ? 1.6 : 0.95 item.widthRatio = min(max(item.widthRatio * value, 0.08), maxWidthRatio) // 자유 비율로 조절해 둔 높이도 같은 배율로 — 핀치는 항상 균등 스케일 if item.kind != .photo, item.heightRatio > 0 { item.heightRatio = min(max(item.heightRatio * value, 0.02), 1.25) } pinchScale = 1 onCommit() } let rotate = RotationGesture() .onChanged { value in selectedID = item.uuid rotationDelta = value } .onEnded { value in item.rotationDegrees += value.degrees rotationDelta = .zero onCommit() } return drag.simultaneously(with: pinch).simultaneously(with: rotate) } } // MARK: - 편집 시트 공용 행 /// 값 라벨이 붙은 슬라이더 행 (도형·텍스트 편집 시트 공용). /// 모델에 직접 바인딩되므로 시트 밖 페이지에서 실시간 미리보기가 된다. private struct DiaryValueSliderRow: View { let title: LocalizedStringKey @Binding var value: Double let range: ClosedRange let step: Double var unit: String = "" var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(title) Spacer() Text(verbatim: "\(Int(value))\(unit)") .font(.callout.monospacedDigit()) .foregroundStyle(.secondary) } Slider(value: $value, in: range, step: step) { Text(title) } } } } /// 회전 슬라이더 행 — 도형·텍스트 공용. 두 손가락 돌리기 제스처의 정밀·마우스 대체 수단 private struct DiaryRotationRow: View { @Bindable var item: DiaryPageItem var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { Text("회전") Spacer() Text(verbatim: "\(Int(item.rotationDegrees))°") .font(.callout.monospacedDigit()) .foregroundStyle(.secondary) } Slider(value: $item.rotationDegrees, in: -180...180, step: 5) { Text("회전") } } } } // MARK: - 도형 스타일 편집 시트 private struct DiaryShapeEditSheet: View { @Bindable var item: DiaryPageItem var onCommit: () -> Void @Environment(\.dismiss) private var dismiss private var isClosedShape: Bool { item.kind == .rectangle || item.kind == .ellipse } private var lineWidthBinding: Binding { Binding(get: { item.lineWidth > 0 ? item.lineWidth : item.kind.defaultLineWidth }, set: { item.lineWidth = $0 }) } private var cornerBinding: Binding { Binding(get: { item.cornerRadius >= 0 ? item.cornerRadius : 10 }, set: { item.cornerRadius = $0 }) } var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 16) { ColorPicker("도형 색", selection: Binding( get: { Color(hex: item.colorHex) }, set: { item.colorHex = $0.hexString } ), supportsOpacity: false) if isClosedShape { Toggle("채우기", isOn: $item.filled) } if !(isClosedShape && item.filled) { DiaryValueSliderRow(title: "선 굵기", value: lineWidthBinding, range: 1...14, step: 1, unit: "pt") } if item.kind == .rectangle { DiaryValueSliderRow(title: "모서리 둥글기", value: cornerBinding, range: 0...40, step: 2, unit: "pt") } DiaryRotationRow(item: item) } .padding() } .background(AppTheme.background) .navigationTitle("도형 수정") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("완료") { onCommit() dismiss() } } } } .presentationDetents([.medium, .large]) } } // MARK: - 텍스트 상자 스타일 편집 시트 /// 내용은 상자를 탭해 제자리에서 입력하고, 이 시트는 스타일만 다룬다 private struct DiaryTextEditSheet: View { @Bindable var item: DiaryPageItem var onCommit: () -> Void @Environment(\.dismiss) private var dismiss private var fontBinding: Binding { Binding(get: { item.fontSize > 0 ? item.fontSize : 24 }, set: { item.fontSize = $0 }) } private var borderWidthBinding: Binding { Binding(get: { item.lineWidth > 0 ? item.lineWidth : item.kind.defaultLineWidth }, 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) { ColorPicker("글자 색", selection: Binding( get: { Color(hex: item.colorHex) }, set: { item.colorHex = $0.hexString } ), supportsOpacity: false) DiaryValueSliderRow(title: "글자 크기", value: fontBinding, range: 12...64, step: 1, unit: "pt") Toggle("굵게", isOn: $item.textBold) HStack { Text("정렬") Spacer() Picker("정렬", selection: $item.textAlignRaw) { Text("왼쪽").tag("") Text("가운데").tag("center") Text("오른쪽").tag("trailing") } .pickerStyle(.segmented) .frame(maxWidth: 280) } Toggle("테두리", isOn: $item.textBorder) if item.textBorder { ColorPicker("테두리 색", selection: borderColorBinding, supportsOpacity: false) DiaryValueSliderRow(title: "테두리 굵기", value: borderWidthBinding, range: 1...8, step: 1, unit: "pt") } DiaryRotationRow(item: item) } .padding() } .background(AppTheme.background) .navigationTitle("텍스트 스타일") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("완료") { onCommit() dismiss() } } } } .presentationDetents([.medium, .large]) } } 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) } /// ⚠️ 뷰의 contentScaleFactor만 조정한다 — 예전처럼 서브'레이어'의 contentsScale을 /// 직접 바꾸고 setNeedsDisplay()를 호출하면, PencilKit이 렌더러로 직접 contents를 /// 채우는 획 타일 레이어의 픽셀이 지워진 채 다시 그려지지 않아 확대할 때마다 /// 필기가 사라지는 버그가 된다 (그리는 동안엔 라이브 렌더로 보였다가 획을 떼면 /// 다시 지워짐). 뷰 수준 배율만 지정하면 PencilKit이 자기 타이밍에 새 배율로 /// 다시 렌더링하므로 획이 유지되면서 선명해진다. private static func apply(_ scale: CGFloat, view: UIView) { if abs(view.contentScaleFactor - scale) > 0.01 { view.contentScaleFactor = scale } for subview in view.subviews { apply(scale, view: subview) } } } struct DiaryPencilCanvas: UIViewRepresentable { let drawingData: Data /// 퍼스트 리스폰더(펜슬 도구 팔레트) 보유 여부 — 보이는 페이지 & 필기 모드일 때만 let isActive: Bool /// 양식(PDF·사진) 페이지는 펜 색을 라이트 모드 기준으로 고정한다. /// PencilKit은 기본적으로 획 색을 다크/라이트에 맞춰 자동 반전하는데, 양식은 항상 /// 밝은 배경(흰 PDF 등)이라 다크 모드 기기에서 쓴 획이 라이트 모드 기기에서 /// 흰색으로 반전돼 보이지 않는 문제가 있었다(맥·아이패드 모드가 다를 때 실사용 보고). /// 색 고정은 캔버스 트레잇 + 도구 팔레트 색 해석을 함께 묶어야 WYSIWYG가 된다. let fixedLightInk: 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 canvas.overrideUserInterfaceStyle = fixedLightInk ? .light : .unspecified #if targetEnvironment(simulator) // 시뮬레이터에는 펜슬이 없으므로 마우스(손가락) 입력 허용 canvas.drawingPolicy = .anyInput #else // 실기기: 아이패드는 펜슬 전용 — 손가락은 페이지 이동/확대에만 쓰인다. // 맥(Designed for iPad)은 펜슬이 없어 마우스·트랙패드 입력 허용 — // 입력 게이트는 노트 도구 줄의 '그리기' 토글(drawMode)이 담당한다 (§6.7) canvas.drawingPolicy = DeviceLayout.isMac ? .anyInput : .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) { // 양식이 늦게 동기화로 도착하거나 삭제되면 페이지가 살아 있는 채로 값이 바뀔 수 있다 let style: UIUserInterfaceStyle = fixedLightInk ? .light : .unspecified if canvas.overrideUserInterfaceStyle != style { canvas.overrideUserInterfaceStyle = style } context.coordinator.setFixedLightInk(fixedLightInk) 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 setFixedLightInk(_ fixed: Bool) { let style: UIUserInterfaceStyle = fixed ? .light : .unspecified if toolPicker.colorUserInterfaceStyle != style { toolPicker.colorUserInterfaceStyle = style } } 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() } } }