- 획 종료 시 최신 PKDrawing만 보관, 1.2초 조용하면 백그라운드 직렬화 후 1회 저장 (세대 가드로 역전 방지) - 플러시: 비활성 전환(페이지 이탈·배치 모드·그리기 끔)·dismantleUIView·willResignActive — 유실 창 최대 1.2초 - CloudKit 트래픽도 획당 1회→묶음 1회로 감소 - -diaryStrokeTest 검증 인자: 프로그램 획 → 배달 확인(strokes=3, delivered 정상) + 재실행 복원 스크린샷 확인 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NNxA172hNDRpDsJ2qztKPf
1464 lines
62 KiB
Swift
1464 lines
62 KiB
Swift
//
|
||
// 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<Content: View>: 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<Content> 안에 중첩해 UIHostingController<Content>를 들면,
|
||
/// 하한 18 Release(-O wholemodule) 빌드에서 swift-frontend가 이 클래스의 deinit을
|
||
/// 최적화하다 크래시한다(SILPerformanceInliner의
|
||
/// isCallerAndCalleeLayoutConstraintsCompatible 무한 재귀 — 하한 26에서는 정상, 실측).
|
||
/// rootView는 원래 매 갱신 통째로 교체되므로 AnyView 소거의 동작 차이는 없다.
|
||
private final class DiaryZoomCoordinator: NSObject, UIScrollViewDelegate {
|
||
var hosting: UIHostingController<AnyView>?
|
||
|
||
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<UUID?> = .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<Double>
|
||
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<Double> {
|
||
Binding(get: { item.lineWidth > 0 ? item.lineWidth : item.kind.defaultLineWidth },
|
||
set: { item.lineWidth = $0 })
|
||
}
|
||
|
||
private var cornerBinding: Binding<Double> {
|
||
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<Double> {
|
||
Binding(get: { item.fontSize > 0 ? item.fontSize : 24 },
|
||
set: { item.fontSize = $0 })
|
||
}
|
||
|
||
private var borderWidthBinding: Binding<Double> {
|
||
Binding(get: { item.lineWidth > 0 ? item.lineWidth : item.kind.defaultLineWidth },
|
||
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) {
|
||
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
|
||
#if DEBUG
|
||
// 검증용(-diaryStrokeTest): 3초 뒤 빨간 대각선 획을 추가하고 델리게이트 경로로
|
||
// 디바운스 파이프라인을 태운 뒤, 6.5초 시점 상태를 Documents/diary-stroke-test.txt로
|
||
// 남긴다 — strokes(획 수)와 delivered(실제 저장 배달 횟수)로 판정 (§14)
|
||
if isActive, UserDefaults.standard.bool(forKey: "diaryStrokeTest") {
|
||
let coordinator = context.coordinator
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak canvas] in
|
||
guard let canvas else { return }
|
||
var drawing = canvas.drawing
|
||
let points = stride(from: 0.0, through: 1.0, by: 0.05).map { t in
|
||
PKStrokePoint(
|
||
location: CGPoint(x: 80 + 600 * t, y: 150 + 700 * t),
|
||
timeOffset: t, size: CGSize(width: 6, height: 6),
|
||
opacity: 1, force: 1, azimuth: 0, altitude: .pi / 2
|
||
)
|
||
}
|
||
drawing.strokes.append(PKStroke(
|
||
ink: PKInk(.pen, color: .systemRed),
|
||
path: PKStrokePath(controlPoints: points, creationDate: .now)
|
||
))
|
||
canvas.drawing = drawing
|
||
coordinator.canvasViewDrawingDidChange(canvas)
|
||
}
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 6.5) { [weak canvas] in
|
||
guard let canvas else { return }
|
||
let lines = [
|
||
"strokes=\(canvas.drawing.strokes.count)",
|
||
"delivered=\(coordinator.debugDeliveredCount)",
|
||
]
|
||
let url = URL.documentsDirectory.appending(path: "diary-stroke-test.txt")
|
||
try? lines.joined(separator: "\n")
|
||
.write(to: url, atomically: true, encoding: .utf8)
|
||
}
|
||
}
|
||
#endif
|
||
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)
|
||
}
|
||
|
||
static func dismantleUIView(_ uiView: SharpPencilCanvasView, coordinator: Coordinator) {
|
||
// 화면에서 떨어져 나갈 때(일기 상세 닫힘 등) 디바운스 창의 미저장 획을 저장
|
||
coordinator.flush()
|
||
}
|
||
|
||
@MainActor
|
||
final class Coordinator: NSObject, PKCanvasViewDelegate {
|
||
let onChange: (Data) -> Void
|
||
weak var canvas: SharpPencilCanvasView?
|
||
private let toolPicker = PKToolPicker()
|
||
private var observing = false
|
||
|
||
// 저장 디바운스(1.4 펜슬 최적화): 예전에는 획을 뗄 때마다 전체 드로잉을
|
||
// 직렬화(dataRepresentation)해 externalStorage 블랍을 통째로 재기록했다 —
|
||
// 획이 쌓일수록 비용이 선형 증가해 연속 필기가 버벅였다(양식 페이지의 큰
|
||
// 래스터와 겹치면 더 체감). 이제 획 종료 시엔 최신 PKDrawing(값 타입)만
|
||
// 보관하고, 1.2초 조용하면 백그라운드에서 직렬화해 1회 저장한다.
|
||
// 유실 방지 플러시 지점: 비활성 전환(페이지 이탈·배치 모드·그리기 끔)·
|
||
// 뷰 해체(dismantleUIView)·앱 비활성(willResignActive).
|
||
private var pendingDrawing: PKDrawing?
|
||
private var saveTask: Task<Void, Never>?
|
||
/// 배달 세대 — 비행 중인 옛 직렬화 결과가 더 새 결과를 덮지 않게 한다
|
||
private var deliverGeneration = 0
|
||
private var resignObserver: NSObjectProtocol?
|
||
#if DEBUG
|
||
/// 검증용(-diaryStrokeTest): onChange(실제 저장)가 몇 번 배달됐는지
|
||
var debugDeliveredCount = 0
|
||
#endif
|
||
|
||
init(onChange: @escaping (Data) -> Void) {
|
||
self.onChange = onChange
|
||
super.init()
|
||
resignObserver = NotificationCenter.default.addObserver(
|
||
forName: UIApplication.willResignActiveNotification,
|
||
object: nil, queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor in self?.flush() }
|
||
}
|
||
}
|
||
|
||
deinit {
|
||
if let resignObserver {
|
||
NotificationCenter.default.removeObserver(resignObserver)
|
||
}
|
||
}
|
||
|
||
/// 디바운스 창이 열려 있으면 지금 즉시 직렬화·저장 (페이지 이탈 직전 등)
|
||
func flush() {
|
||
saveTask?.cancel()
|
||
saveTask = nil
|
||
guard let drawing = pendingDrawing else { return }
|
||
pendingDrawing = nil
|
||
deliverGeneration += 1
|
||
#if DEBUG
|
||
debugDeliveredCount += 1
|
||
#endif
|
||
onChange(drawing.dataRepresentation())
|
||
}
|
||
|
||
private func scheduleSave() {
|
||
saveTask?.cancel()
|
||
saveTask = Task { @MainActor [weak self] in
|
||
try? await Task.sleep(for: .seconds(1.2))
|
||
guard !Task.isCancelled else { return }
|
||
self?.deliverPending()
|
||
}
|
||
}
|
||
|
||
private func deliverPending() {
|
||
guard let drawing = pendingDrawing else { return }
|
||
pendingDrawing = nil
|
||
deliverGeneration += 1
|
||
let generation = deliverGeneration
|
||
Task { @MainActor [weak self] in
|
||
// 직렬화는 페이지가 커질수록 비싸다 — 메인 밖에서 수행
|
||
let data = await Task.detached(priority: .userInitiated) {
|
||
drawing.dataRepresentation()
|
||
}.value
|
||
guard let self, generation == self.deliverGeneration else { return }
|
||
#if DEBUG
|
||
self.debugDeliveredCount += 1
|
||
#endif
|
||
self.onChange(data)
|
||
}
|
||
}
|
||
|
||
/// 양식 페이지에서는 팔레트가 고른 색도 라이트 기준으로 해석·저장되게 한다 —
|
||
/// 캔버스 트레잇만 고정하면 다크 모드에서 고른 '검정'이 흰색 획으로 저장되던
|
||
/// 반전이 그대로 남는다 (해석과 렌더를 같은 기준으로 묶는 핵심)
|
||
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 {
|
||
// 비활성 전환(페이지 이탈·배치 모드·그리기 끔) — 미저장 획 즉시 저장
|
||
flush()
|
||
if canvas.isFirstResponder {
|
||
canvas.resignFirstResponder()
|
||
}
|
||
}
|
||
}
|
||
|
||
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
|
||
pendingDrawing = canvasView.drawing
|
||
scheduleSave()
|
||
// 새 획의 렌더 레이어가 기본 해상도로 생겼을 수 있어 다시 맞춘다
|
||
(canvasView as? SharpPencilCanvasView)?.applySharpness()
|
||
}
|
||
}
|
||
}
|