페이지 추가 시트의 '내 양식' 목록에서 한 쪽짜리 양식(사진·1쪽 PDF) 행이 왼쪽(썸네일·이름)만 눌리고 오른쪽 절반은 반응하지 않던 문제 수정. DiaryTemplateRowLabel이 내용물 폭만 차지해 plain 버튼의 contentShape도 그 좁은 라벨에만 적용됐던 것 — 라벨을 frame(maxWidth: .infinity)로 행 전체 폭으로 넓힌 뒤 contentShape(Rectangle())를 적용해 행 어디를 눌러도 동작하게 했다. 여러 쪽 PDF 행(NavigationLink)은 원래 전체 폭이 눌려 변경 없음. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7
618 lines
26 KiB
Swift
618 lines
26 KiB
Swift
//
|
||
// DiaryTemplates.swift
|
||
// Haru_Danim
|
||
//
|
||
// 일기 노트 페이지 양식(속지) — 외부 PDF/사진을 배경 양식으로 가져와 재사용한다 (CLAUDE.md §6.7).
|
||
//
|
||
// 구조:
|
||
// - DiaryTemplateRenderer: PDF 쪽을 페이지 논리 크기(768×1024)에 aspect-fit으로 래스터화.
|
||
// PDF는 벡터 원본을 보존하고 "현재 줌 배율"로 그때그때 다시 그려 확대해도 선명하다.
|
||
// 이미지는 가져올 때 필기 래스터 상한(3×)과 같은 해상도로 리샘플 — 필기보다 흐려질 수 없다.
|
||
// - DiaryTemplateBackground: 노트 페이지의 필기 아래에 깔리는 배경 뷰.
|
||
// DiaryZoomScrollView의 선명도 훅(assignCanvasScale)이 펜슬 캔버스와 함께 배율을 지정한다.
|
||
// - DiaryTemplateManagerView: 달력 화면에서 여는 양식 라이브러리 (가져오기·이름 변경·삭제).
|
||
// - DiaryPageChooserSheet: 페이지 추가 시 [빈 캔버스 / 줄 노트 / 내 양식] 선택.
|
||
// 여러 쪽 PDF 양식은 2단계로 쪽 썸네일을 펼쳐 원하는 쪽을 고른다.
|
||
//
|
||
// 양식 삭제 시 그 양식을 쓰던 페이지는 빈 캔버스로 폴백한다 (필기·요소는 그대로).
|
||
//
|
||
|
||
import SwiftUI
|
||
import SwiftData
|
||
import PhotosUI
|
||
import UniformTypeIdentifiers
|
||
|
||
// MARK: - 렌더러
|
||
|
||
enum DiaryTemplateRenderer {
|
||
/// PDF 가져오기 상한 (스캔본 등 거대 파일 방어 — CloudKit 동기화 부담 방지)
|
||
static let maxPDFBytes = 20 * 1024 * 1024
|
||
/// 사용 쪽 수 상한 (전자책 같은 오용 방지 — 초과분은 잘라내고 안내)
|
||
static let maxPages = 24
|
||
/// 이미지 양식 저장 해상도 상한 (긴 변 px) — 필기 래스터 상한(기기 스케일×3)과 같은 기준
|
||
static let maxImageSide: CGFloat = 3072
|
||
|
||
/// PDF 쪽 수 (열 수 없으면 nil)
|
||
static func pdfPageCount(_ data: Data) -> Int? {
|
||
guard let provider = CGDataProvider(data: data as CFData),
|
||
let document = CGPDFDocument(provider) else { return nil }
|
||
return document.numberOfPages
|
||
}
|
||
|
||
/// PDF 한 쪽을 페이지 논리 크기(768×1024)에 aspect-fit으로 래스터화.
|
||
/// 비율이 다른 PDF(A4 등)는 여백을 두고 가운데 맞춤 — 페이지 좌표계(필기·내보내기)는 불변.
|
||
/// scale = 포인트당 픽셀 배율 (기기 스케일 × 줌, 상한은 호출부가 관리)
|
||
static func renderPDFPage(data: Data, pageIndex: Int, scale: CGFloat) -> UIImage? {
|
||
guard scale > 0,
|
||
let provider = CGDataProvider(data: data as CFData),
|
||
let document = CGPDFDocument(provider),
|
||
let page = document.page(at: pageIndex + 1) else { return nil }
|
||
let pageSize = DiaryPageMetrics.size
|
||
let box = page.getBoxRect(.cropBox)
|
||
guard box.width > 0, box.height > 0 else { return nil }
|
||
let fitScale = min(pageSize.width / box.width, pageSize.height / box.height)
|
||
let drawSize = CGSize(width: box.width * fitScale, height: box.height * fitScale)
|
||
let origin = CGPoint(x: (pageSize.width - drawSize.width) / 2,
|
||
y: (pageSize.height - drawSize.height) / 2)
|
||
let format = UIGraphicsImageRendererFormat()
|
||
format.scale = scale
|
||
format.opaque = false
|
||
return UIGraphicsImageRenderer(size: pageSize, format: format).image { ctx in
|
||
let cg = ctx.cgContext
|
||
// PDF 콘텐츠는 배경이 투명한 경우가 많아 '종이' 느낌의 흰 바탕을 깐다 (다크 모드 포함)
|
||
cg.setFillColor(UIColor.white.cgColor)
|
||
cg.fill(CGRect(origin: origin, size: drawSize))
|
||
cg.saveGState()
|
||
cg.translateBy(x: origin.x, y: origin.y + drawSize.height)
|
||
cg.scaleBy(x: fitScale, y: -fitScale)
|
||
cg.translateBy(x: -box.minX, y: -box.minY)
|
||
cg.interpolationQuality = .high
|
||
cg.drawPDFPage(page)
|
||
cg.restoreGState()
|
||
}
|
||
}
|
||
|
||
/// 양식 한 쪽의 표시용 이미지 (내보내기 등 단발 렌더용)
|
||
static func image(for template: DiaryTemplate, pageIndex: Int, scale: CGFloat) -> UIImage? {
|
||
switch template.kind {
|
||
case .pdf:
|
||
return renderPDFPage(data: template.data, pageIndex: pageIndex, scale: scale)
|
||
case .image:
|
||
return UIImage(data: template.data)
|
||
}
|
||
}
|
||
|
||
/// 가져온 사진을 저장용으로 리샘플 (상한 해상도 = 필기 선명도 상한과 동일 → 체감 흐림 없음)
|
||
static func normalizedImageData(_ image: UIImage) -> Data? {
|
||
let pixelWidth = image.size.width * image.scale
|
||
let pixelHeight = image.size.height * image.scale
|
||
let longest = max(pixelWidth, pixelHeight)
|
||
guard longest > 0 else { return nil }
|
||
let ratio = longest > maxImageSide ? maxImageSide / longest : 1
|
||
let target = CGSize(width: pixelWidth * ratio, height: pixelHeight * ratio)
|
||
let format = UIGraphicsImageRendererFormat()
|
||
format.scale = 1
|
||
let resized = UIGraphicsImageRenderer(size: target, format: format).image { _ in
|
||
image.draw(in: CGRect(origin: .zero, size: target))
|
||
}
|
||
return resized.jpegData(compressionQuality: 0.85)
|
||
}
|
||
|
||
/// 라이브러리 썸네일 겸 페이지 첫 표시 플레이스홀더의 해상도(세로 px).
|
||
/// 본 렌더(백그라운드)가 도착할 때까지 잠깐 보이는 이미지라, 목록용보다 여유 있게 잡아
|
||
/// 플레이스홀더 단계의 흐릿함을 줄인다 (JPEG라 저장 부담은 수십 KB 수준).
|
||
static let thumbnailSide: CGFloat = 512
|
||
|
||
/// 라이브러리 썸네일 (첫 쪽)
|
||
static func thumbnailData(kind: DiaryTemplateKind, data: Data) -> Data? {
|
||
switch kind {
|
||
case .pdf:
|
||
return renderPDFPage(data: data, pageIndex: 0, scale: thumbnailSide / DiaryPageMetrics.size.height)?
|
||
.jpegData(compressionQuality: 0.8)
|
||
case .image:
|
||
guard let image = UIImage(data: data) else { return nil }
|
||
let longest = max(image.size.width, image.size.height)
|
||
guard longest > 0 else { return nil }
|
||
let ratio = min(thumbnailSide / longest, 1)
|
||
let target = CGSize(width: image.size.width * ratio, height: image.size.height * ratio)
|
||
let format = UIGraphicsImageRendererFormat()
|
||
format.scale = 1
|
||
return UIGraphicsImageRenderer(size: target, format: format).image { _ in
|
||
image.draw(in: CGRect(origin: .zero, size: target))
|
||
}.jpegData(compressionQuality: 0.8)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 노트 페이지 배경 뷰 (줌 배율 연동)
|
||
|
||
/// 양식 배경 이미지 뷰. PDF는 줌이 끝날 때 현재 배율로 백그라운드에서 다시 래스터해
|
||
/// 확대해도 흐려지지 않는다 (펜슬 캔버스의 선명도 규칙과 동일한 리듬).
|
||
/// 첫 표시는 저장된 썸네일을 즉시 깔고 본 렌더를 백그라운드로 돌린다 —
|
||
/// 복잡한 벡터 PDF도 페이지 넘김이 걸리지 않는다.
|
||
/// 이미지 양식은 저장 해상도가 이미 상한이라 다시 그릴 필요가 없다.
|
||
final class DiaryTemplateImageView: UIImageView {
|
||
/// 표시 중인 양식·쪽 식별자 (SwiftUI 재사용 시 불필요한 재렌더 방지)
|
||
var renderKey = ""
|
||
|
||
private var pdfData: Data?
|
||
private var pdfPageIndex = 0
|
||
private var renderedScale: CGFloat = 0
|
||
private var renderTask: Task<Void, Never>?
|
||
|
||
/// DiaryZoomScrollView가 지정하는 목표 래스터 배율 (기기 스케일 × 줌, 상한 3)
|
||
var targetContentsScale: CGFloat = 0 {
|
||
didSet { renderIfNeeded() }
|
||
}
|
||
|
||
/// 화면에 보이는(활성) 페이지만 고배율 비트맵을 유지한다 — TabView가 이웃 페이지를
|
||
/// 살려 두므로, 비활성 페이지는 2×로 강등해 양식 여러 장의 메모리를 절약한다.
|
||
var allowsHighResolution = true {
|
||
didSet {
|
||
guard oldValue != allowsHighResolution else { return }
|
||
renderIfNeeded()
|
||
}
|
||
}
|
||
|
||
/// PDF 양식 지정 + 플레이스홀더(썸네일) 즉시 표시 → 본 렌더는 백그라운드
|
||
func setPDF(data: Data, pageIndex: Int, placeholder: UIImage?) {
|
||
pdfData = data
|
||
pdfPageIndex = pageIndex
|
||
renderedScale = 0
|
||
image = placeholder
|
||
if targetContentsScale <= 0 { targetContentsScale = 2 }
|
||
renderIfNeeded()
|
||
}
|
||
|
||
/// 이미지 양식 지정 (재렌더 없음)
|
||
func setImage(data: Data) {
|
||
pdfData = nil
|
||
renderedScale = 0
|
||
image = UIImage(data: data)
|
||
}
|
||
|
||
/// 비활성 페이지는 2×로 상한 (활성 복귀 시 원래 배율로 다시 올라간다)
|
||
private var effectiveScale: CGFloat {
|
||
allowsHighResolution ? targetContentsScale : min(targetContentsScale, 2)
|
||
}
|
||
|
||
private func renderIfNeeded() {
|
||
guard pdfData != nil, effectiveScale > 0,
|
||
abs(effectiveScale - renderedScale) > 0.01 else { return }
|
||
scheduleRender()
|
||
}
|
||
|
||
private func scheduleRender() {
|
||
guard let data = pdfData else { return }
|
||
let scale = effectiveScale
|
||
let index = pdfPageIndex
|
||
renderedScale = scale
|
||
renderTask?.cancel()
|
||
renderTask = Task { [weak self] in
|
||
// CoreGraphics PDF 렌더는 백그라운드에서 안전 — 페이지 넘김·핀치 직후 프레임 드랍 방지
|
||
let image = await Task.detached(priority: .userInitiated) {
|
||
DiaryTemplateRenderer.renderPDFPage(data: data, pageIndex: index, scale: scale)
|
||
}.value
|
||
guard !Task.isCancelled else { return }
|
||
guard let image else {
|
||
// 렌더 실패(메모리 압박 등) — 배율 기록을 되돌려 다음 기회(줌/재표시)에 재시도
|
||
self?.renderedScale = 0
|
||
return
|
||
}
|
||
self?.image = image
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 노트 페이지 ZStack에서 필기 아래에 깔리는 양식 배경
|
||
struct DiaryTemplateBackground: UIViewRepresentable {
|
||
let template: DiaryTemplate
|
||
let pageIndex: Int
|
||
/// 현재 보이는 페이지인지 (비활성이면 고배율 비트맵을 내려 메모리 절약)
|
||
var isActive = true
|
||
|
||
func makeUIView(context: Context) -> DiaryTemplateImageView {
|
||
let view = DiaryTemplateImageView()
|
||
view.contentMode = .scaleAspectFit
|
||
configure(view)
|
||
return view
|
||
}
|
||
|
||
func updateUIView(_ view: DiaryTemplateImageView, context: Context) {
|
||
configure(view)
|
||
}
|
||
|
||
private func configure(_ view: DiaryTemplateImageView) {
|
||
view.allowsHighResolution = isActive
|
||
let key = "\(template.uuid.uuidString)-\(pageIndex)"
|
||
guard view.renderKey != key else { return }
|
||
view.renderKey = key
|
||
switch template.kind {
|
||
case .pdf:
|
||
view.setPDF(
|
||
data: template.data,
|
||
pageIndex: pageIndex,
|
||
placeholder: template.thumbnailData.flatMap(UIImage.init(data:))
|
||
)
|
||
case .image:
|
||
view.setImage(data: template.data)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 페이지 추가 선택
|
||
|
||
/// 새 노트 페이지의 배경 종류
|
||
enum DiaryPageStyle {
|
||
case blank
|
||
case lined
|
||
case template(DiaryTemplate, pageIndex: Int)
|
||
}
|
||
|
||
/// 페이지 추가 시트: 빈 캔버스 / 줄 노트 / 내 양식(여러 쪽이면 2단계 쪽 선택)
|
||
struct DiaryPageChooserSheet: View {
|
||
let templates: [DiaryTemplate]
|
||
let onPick: (DiaryPageStyle) -> Void
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
List {
|
||
Section {
|
||
basicRow(symbol: "square", title: "빈 캔버스") { pick(.blank) }
|
||
basicRow(symbol: "text.justify", title: "줄 노트") { pick(.lined) }
|
||
}
|
||
Section("내 양식") {
|
||
ForEach(templates, id: \.uuid) { template in
|
||
if template.pageCount > 1 {
|
||
// 여러 쪽 PDF: 쪽 썸네일을 펼쳐 고르는 2단계
|
||
NavigationLink {
|
||
DiaryTemplatePagePicker(template: template) { index in
|
||
pick(.template(template, pageIndex: index))
|
||
}
|
||
} label: {
|
||
DiaryTemplateRowLabel(template: template)
|
||
}
|
||
} else {
|
||
Button {
|
||
pick(.template(template, pageIndex: 0))
|
||
} label: {
|
||
DiaryTemplateRowLabel(template: template)
|
||
// 라벨은 내용물 폭만 차지하므로 행 전체로 넓혀야
|
||
// 오른쪽 빈 영역도 탭 판정에 들어간다
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.scrollContentBackground(.hidden)
|
||
.background(AppTheme.background)
|
||
.navigationTitle("페이지 추가")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("닫기") { dismiss() }
|
||
}
|
||
}
|
||
}
|
||
.presentationDetents([.medium, .large])
|
||
}
|
||
|
||
private func pick(_ style: DiaryPageStyle) {
|
||
onPick(style)
|
||
dismiss()
|
||
}
|
||
|
||
private func basicRow(symbol: String, title: LocalizedStringKey,
|
||
action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
HStack(spacing: 12) {
|
||
Image(systemName: symbol)
|
||
.font(.system(size: 15, weight: .semibold))
|
||
.foregroundStyle(AppTheme.green)
|
||
.frame(width: 30)
|
||
Text(title)
|
||
.foregroundStyle(.primary)
|
||
Spacer()
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
/// 양식 한 줄 표시 (썸네일 + 이름 + 종류·쪽수)
|
||
struct DiaryTemplateRowLabel: View {
|
||
let template: DiaryTemplate
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
Group {
|
||
if let data = template.thumbnailData, let image = UIImage(data: data) {
|
||
Image(uiImage: image)
|
||
.resizable()
|
||
.scaledToFit()
|
||
} else {
|
||
Image(systemName: "doc.text.image")
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
}
|
||
.frame(width: 44, height: 56)
|
||
.background(Color.white, in: RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||
.overlay(RoundedRectangle(cornerRadius: 6, style: .continuous).strokeBorder(.quaternary))
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(template.name)
|
||
.font(.subheadline.weight(.medium))
|
||
.foregroundStyle(.primary)
|
||
.lineLimit(1)
|
||
HStack(spacing: 4) {
|
||
if template.kind == .pdf {
|
||
Text(verbatim: "PDF")
|
||
} else {
|
||
Text("이미지")
|
||
}
|
||
if template.pageCount > 1 {
|
||
Text("\(template.pageCount)쪽")
|
||
}
|
||
}
|
||
.font(.caption2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 여러 쪽 PDF 양식의 쪽 선택 그리드 (썸네일은 지연 생성)
|
||
private struct DiaryTemplatePagePicker: View {
|
||
let template: DiaryTemplate
|
||
let onPick: (Int) -> Void
|
||
|
||
@State private var thumbnails: [Int: UIImage] = [:]
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 140, maximum: 190), spacing: 12)],
|
||
spacing: 14) {
|
||
ForEach(0..<template.pageCount, id: \.self) { index in
|
||
Button {
|
||
onPick(index)
|
||
} label: {
|
||
VStack(spacing: 6) {
|
||
Group {
|
||
if let image = thumbnails[index] {
|
||
Image(uiImage: image)
|
||
.resizable()
|
||
.scaledToFit()
|
||
} else {
|
||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||
.fill(.quaternary.opacity(0.4))
|
||
.overlay(ProgressView())
|
||
.aspectRatio(3 / 4, contentMode: .fit)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.background(Color.white, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||
.overlay(RoundedRectangle(cornerRadius: 8, style: .continuous).strokeBorder(.quaternary))
|
||
Text("\(index + 1)쪽")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.buttonStyle(.plain)
|
||
.task {
|
||
guard thumbnails[index] == nil else { return }
|
||
let data = template.data
|
||
let image = await Task.detached(priority: .userInitiated) {
|
||
DiaryTemplateRenderer.renderPDFPage(
|
||
data: data, pageIndex: index,
|
||
scale: 260 / DiaryPageMetrics.size.height
|
||
)
|
||
}.value
|
||
if let image { thumbnails[index] = image }
|
||
}
|
||
}
|
||
}
|
||
.padding()
|
||
}
|
||
.background(AppTheme.background)
|
||
.navigationTitle(template.name)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
}
|
||
|
||
// MARK: - 양식 관리 (달력 화면에서 진입)
|
||
|
||
struct DiaryTemplateManagerView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@Environment(\.modelContext) private var context
|
||
@Query(sort: \DiaryTemplate.createdAt) private var templates: [DiaryTemplate]
|
||
@Query private var allPages: [DiaryPage]
|
||
|
||
@State private var showingPDFImporter = false
|
||
@State private var photoSelection: PhotosPickerItem?
|
||
@State private var noticeMessage: String?
|
||
@State private var renamingTemplate: DiaryTemplate?
|
||
@State private var renameText = ""
|
||
@State private var deletingTemplate: DiaryTemplate?
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
List {
|
||
Section {
|
||
Button {
|
||
showingPDFImporter = true
|
||
} label: {
|
||
Label("PDF 가져오기", systemImage: "doc.badge.plus")
|
||
.foregroundStyle(AppTheme.green)
|
||
}
|
||
PhotosPicker(selection: $photoSelection, matching: .images) {
|
||
Label("사진에서 가져오기", systemImage: "photo.badge.plus")
|
||
.foregroundStyle(AppTheme.green)
|
||
}
|
||
} footer: {
|
||
Text("가져온 양식은 노트 페이지를 추가할 때 배경으로 고를 수 있어요. 여러 쪽 PDF는 원하는 쪽을 골라 쓸 수 있어요. (쪽은 최대 \(DiaryTemplateRenderer.maxPages)쪽, PDF 파일은 20MB까지)")
|
||
}
|
||
Section("내 양식") {
|
||
if templates.isEmpty {
|
||
Text("가져온 양식이 없어요")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
ForEach(templates, id: \.uuid) { template in
|
||
row(template)
|
||
}
|
||
}
|
||
}
|
||
.scrollContentBackground(.hidden)
|
||
.background(AppTheme.background)
|
||
.navigationTitle("양식 관리")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("닫기") { dismiss() }
|
||
}
|
||
}
|
||
}
|
||
.presentationDetents([.medium, .large])
|
||
.fileImporter(isPresented: $showingPDFImporter, allowedContentTypes: [.pdf]) { result in
|
||
importPDF(result)
|
||
}
|
||
.onChange(of: photoSelection) {
|
||
guard let photoSelection else { return }
|
||
Task {
|
||
if let data = try? await photoSelection.loadTransferable(type: Data.self),
|
||
let image = UIImage(data: data) {
|
||
importPhoto(image)
|
||
}
|
||
self.photoSelection = nil
|
||
}
|
||
}
|
||
.alert(
|
||
"양식 가져오기",
|
||
isPresented: Binding(get: { noticeMessage != nil }, set: { if !$0 { noticeMessage = nil } })
|
||
) {
|
||
Button("확인") {}
|
||
} message: {
|
||
Text(noticeMessage ?? "")
|
||
}
|
||
.alert(
|
||
"이름 변경",
|
||
isPresented: Binding(get: { renamingTemplate != nil }, set: { if !$0 { renamingTemplate = nil } }),
|
||
presenting: renamingTemplate
|
||
) { template in
|
||
TextField("양식 이름", text: $renameText)
|
||
Button("저장") {
|
||
let trimmed = renameText.trimmingCharacters(in: .whitespaces)
|
||
if !trimmed.isEmpty {
|
||
template.name = trimmed
|
||
try? context.save()
|
||
}
|
||
renamingTemplate = nil
|
||
}
|
||
Button("취소", role: .cancel) { renamingTemplate = nil }
|
||
}
|
||
.alert(
|
||
"양식 삭제",
|
||
isPresented: Binding(get: { deletingTemplate != nil }, set: { if !$0 { deletingTemplate = nil } }),
|
||
presenting: deletingTemplate
|
||
) { template in
|
||
Button("삭제", role: .destructive) {
|
||
context.delete(template)
|
||
try? context.save()
|
||
deletingTemplate = nil
|
||
}
|
||
Button("취소", role: .cancel) { deletingTemplate = nil }
|
||
} message: { template in
|
||
let usage = usageCount(template)
|
||
if usage > 0 {
|
||
Text("‘\(template.name)’ 양식을 삭제할까요? 이 양식을 쓰는 노트 페이지 \(usage)개는 빈 캔버스로 표시돼요. (필기와 요소는 그대로 남아요)")
|
||
} else {
|
||
Text("‘\(template.name)’ 양식을 삭제할까요?")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func row(_ template: DiaryTemplate) -> some View {
|
||
HStack {
|
||
DiaryTemplateRowLabel(template: template)
|
||
Spacer()
|
||
let usage = usageCounts[template.uuid.uuidString] ?? 0
|
||
if usage > 0 {
|
||
Text("페이지 \(usage)개 사용 중")
|
||
.font(.caption2)
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
}
|
||
.swipeActions {
|
||
Button("삭제", role: .destructive) {
|
||
deletingTemplate = template
|
||
}
|
||
Button("이름 변경") {
|
||
renameText = template.name
|
||
renamingTemplate = template
|
||
}
|
||
.tint(AppTheme.green)
|
||
}
|
||
}
|
||
|
||
/// 양식별 사용 중 노트 페이지 수 — 행마다 전체 페이지를 다시 훑지 않게 한 번에 집계
|
||
private var usageCounts: [String: Int] {
|
||
var counts: [String: Int] = [:]
|
||
for page in allPages where !page.templateID.isEmpty {
|
||
counts[page.templateID, default: 0] += 1
|
||
}
|
||
return counts
|
||
}
|
||
|
||
/// 이 양식을 배경으로 쓰는 노트 페이지 수 (삭제 경고용)
|
||
private func usageCount(_ template: DiaryTemplate) -> Int {
|
||
usageCounts[template.uuid.uuidString] ?? 0
|
||
}
|
||
|
||
// MARK: 가져오기
|
||
|
||
private func importPDF(_ result: Result<URL, Error>) {
|
||
guard case .success(let url) = result else { return }
|
||
let accessing = url.startAccessingSecurityScopedResource()
|
||
defer { if accessing { url.stopAccessingSecurityScopedResource() } }
|
||
guard let data = try? Data(contentsOf: url) else {
|
||
noticeMessage = String(localized: "파일을 읽지 못했어요. 다시 시도해 주세요.")
|
||
return
|
||
}
|
||
guard data.count <= DiaryTemplateRenderer.maxPDFBytes else {
|
||
noticeMessage = String(localized: "PDF가 너무 커요. 20MB 이하 파일만 가져올 수 있어요.")
|
||
return
|
||
}
|
||
guard let count = DiaryTemplateRenderer.pdfPageCount(data), count > 0 else {
|
||
noticeMessage = String(localized: "PDF를 열 수 없어요. 파일이 손상됐을 수 있어요.")
|
||
return
|
||
}
|
||
let template = DiaryTemplate(name: url.deletingPathExtension().lastPathComponent, kind: .pdf)
|
||
template.data = data
|
||
template.pageCount = min(count, DiaryTemplateRenderer.maxPages)
|
||
template.thumbnailData = DiaryTemplateRenderer.thumbnailData(kind: .pdf, data: data)
|
||
context.insert(template)
|
||
try? context.save()
|
||
if count > DiaryTemplateRenderer.maxPages {
|
||
noticeMessage = String(localized: "쪽이 많아 처음 \(DiaryTemplateRenderer.maxPages)쪽까지만 사용해요.")
|
||
}
|
||
}
|
||
|
||
private func importPhoto(_ image: UIImage) {
|
||
guard let data = DiaryTemplateRenderer.normalizedImageData(image) else {
|
||
noticeMessage = String(localized: "파일을 읽지 못했어요. 다시 시도해 주세요.")
|
||
return
|
||
}
|
||
let template = DiaryTemplate(name: String(localized: "사진 양식"), kind: .image)
|
||
template.data = data
|
||
template.pageCount = 1
|
||
template.thumbnailData = DiaryTemplateRenderer.thumbnailData(kind: .image, data: data)
|
||
context.insert(template)
|
||
try? context.save()
|
||
}
|
||
}
|