diff --git a/Memola.xcodeproj/project.pbxproj b/Memola.xcodeproj/project.pbxproj index 44b43d2..55311f8 100644 --- a/Memola.xcodeproj/project.pbxproj +++ b/Memola.xcodeproj/project.pbxproj @@ -111,6 +111,7 @@ ECD12A912C1B04EA00B96E12 /* PhotoRenderPass.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A902C1B04EA00B96E12 /* PhotoRenderPass.swift */; }; ECD12A932C1B062000B96E12 /* Photo.metal in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A922C1B062000B96E12 /* Photo.metal */; }; ECD12A952C1B1FA200B96E12 /* PhotoVertex.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A942C1B1FA200B96E12 /* PhotoVertex.swift */; }; + ECDAC07B2C318DBC0000ED77 /* ElementToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECDAC07A2C318DBC0000ED77 /* ElementToolbar.swift */; }; ECE883BD2C00AA170045C53D /* EraserStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECE883BC2C00AA170045C53D /* EraserStroke.swift */; }; ECE883BF2C00AB440045C53D /* Stroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECE883BE2C00AB440045C53D /* Stroke.swift */; }; ECE883C12C00C9CB0045C53D /* StrokeStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECE883C02C00C9CB0045C53D /* StrokeStyle.swift */; }; @@ -230,6 +231,7 @@ ECD12A902C1B04EA00B96E12 /* PhotoRenderPass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoRenderPass.swift; sourceTree = ""; }; ECD12A922C1B062000B96E12 /* Photo.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Photo.metal; sourceTree = ""; }; ECD12A942C1B1FA200B96E12 /* PhotoVertex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoVertex.swift; sourceTree = ""; }; + ECDAC07A2C318DBC0000ED77 /* ElementToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementToolbar.swift; sourceTree = ""; }; ECE883BC2C00AA170045C53D /* EraserStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EraserStroke.swift; sourceTree = ""; }; ECE883BE2C00AB440045C53D /* Stroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stroke.swift; sourceTree = ""; }; ECE883C02C00C9CB0045C53D /* StrokeStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrokeStyle.swift; sourceTree = ""; }; @@ -506,6 +508,7 @@ ECA7387B2BE5EF3500A4542E /* Memo */ = { isa = PBXGroup; children = ( + ECDAC0792C318DAF0000ED77 /* ElementToolbar */, ECBE52942C1D58F5006BDB3D /* PhotoPreview */, EC1B783B2BFA0AAC005A34E2 /* Toolbar */, EC5050082BF65D0500B4D86E /* Memo */, @@ -834,6 +837,14 @@ path = Photo; sourceTree = ""; }; + ECDAC0792C318DAF0000ED77 /* ElementToolbar */ = { + isa = PBXGroup; + children = ( + ECDAC07A2C318DBC0000ED77 /* ElementToolbar.swift */, + ); + path = ElementToolbar; + sourceTree = ""; + }; ECE883B82C009DC30045C53D /* Strokes */ = { isa = PBXGroup; children = ( @@ -1031,6 +1042,7 @@ ECA738C92BE60EF700A4542E /* GraphicContext.swift in Sources */, ECA738F62BE612B700A4542E /* MTLDevice++.swift in Sources */, EC0D14212BF79C73009BFE5F /* ToolObject.swift in Sources */, + ECDAC07B2C318DBC0000ED77 /* ElementToolbar.swift in Sources */, EC50500D2BF6674400B4D86E /* OnDragViewModifier.swift in Sources */, EC9AB09F2C1401A40076AF58 /* EraserObject.swift in Sources */, ECBE529C2C1D94A4006BDB3D /* CameraView.swift in Sources */, diff --git a/Memola/Canvas/Tool/Pen/Core/PenStyle.swift b/Memola/Canvas/Tool/Pen/Core/PenStyle.swift index 8bf961a..6a9edbd 100644 --- a/Memola/Canvas/Tool/Pen/Core/PenStyle.swift +++ b/Memola/Canvas/Tool/Pen/Core/PenStyle.swift @@ -10,6 +10,7 @@ import Foundation protocol PenStyle { var icon: (base: String, tip: String?) { get } + var compactIcon: (base: String, tip: String?) { get } var textureName: String? { get } var thickness: (min: CGFloat, max: CGFloat) { get } var thicknessSteps: [CGFloat] { get } diff --git a/Memola/Canvas/Tool/Pen/PenStyles/EraserPenStyle.swift b/Memola/Canvas/Tool/Pen/PenStyles/EraserPenStyle.swift index f4e96b6..906da73 100644 --- a/Memola/Canvas/Tool/Pen/PenStyles/EraserPenStyle.swift +++ b/Memola/Canvas/Tool/Pen/PenStyles/EraserPenStyle.swift @@ -10,6 +10,8 @@ import Foundation struct EraserPenStyle: PenStyle { var icon: (base: String, tip: String?) = ("eraser", nil) + var compactIcon: (base: String, tip: String?) = ("eraser-compact", nil) + var textureName: String? = nil var thickness: (min: CGFloat, max: CGFloat) = (0.5, 30) diff --git a/Memola/Canvas/Tool/Pen/PenStyles/MarkerPenStyle.swift b/Memola/Canvas/Tool/Pen/PenStyles/MarkerPenStyle.swift index 2e608a4..c171cac 100644 --- a/Memola/Canvas/Tool/Pen/PenStyles/MarkerPenStyle.swift +++ b/Memola/Canvas/Tool/Pen/PenStyles/MarkerPenStyle.swift @@ -10,6 +10,8 @@ import Foundation struct MarkerPenStyle: PenStyle { var icon: (base: String, tip: String?) = ("marker-base", "marker-tip") + var compactIcon: (base: String, tip: String?) = ("marker-base-compact", "marker-tip-compact") + var textureName: String? = "point-texture" var thickness: (min: CGFloat, max: CGFloat) = (0.5, 30) diff --git a/Memola/Features/Memo/ElementToolbar/ElementToolbar.swift b/Memola/Features/Memo/ElementToolbar/ElementToolbar.swift new file mode 100644 index 0000000..7db9ff4 --- /dev/null +++ b/Memola/Features/Memo/ElementToolbar/ElementToolbar.swift @@ -0,0 +1,240 @@ +// +// ElementToolbar.swift +// Memola +// +// Created by Dscyre Scotti on 6/30/24. +// + +import SwiftUI +import PhotosUI +import AVFoundation + +struct ElementToolbar: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + let size: CGFloat + @ObservedObject var tool: Tool + @ObservedObject var canvas: Canvas + + @State var opensCamera: Bool = false + @State var isCameraAccessDenied: Bool = false + @State var photosPickerItem: PhotosPickerItem? + + @Namespace var namespace + + var body: some View { + Group { + if horizontalSizeClass == .regular { + regularToolbar + } else { + ZStack(alignment: .bottomLeading) { + compactToolbar + if tool.selection == .photo { + photoOption + .background { + RoundedRectangle(cornerRadius: 8) + .fill(.regularMaterial) + } + .frame(maxWidth: .infinity) + .transition(.move(edge: .bottom).combined(with: .blurReplace)) + } + } + } + } + .fullScreenCover(isPresented: $opensCamera) { + let image: Binding = Binding { + tool.selectedPhotoItem?.image + } set: { image in + guard let image else { return } + tool.selectPhoto(image, for: canvas.canvasID) + } + CameraView(image: image, canvas: canvas) + .ignoresSafeArea() + } + .alert("Camera Access Denied", isPresented: $isCameraAccessDenied) { + Button { + if let url = URL(string: UIApplication.openSettingsURLString + "&path=CAMERA/\(String(describing: Bundle.main.bundleIdentifier))") { + UIApplication.shared.open(url) + } + } label: { + Text("Open Settings") + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Memola requires access to the camera to capture photos. Please open Settings and enable camera access.") + } + .onChange(of: photosPickerItem) { oldValue, newValue in + if newValue != nil { + Task { + tool.isLoadingPhoto = true + let data = try? await newValue?.loadTransferable(type: Data.self) + if let data, let image = UIImage(data: data) { + tool.selectPhoto(image, for: canvas.canvasID) + } + photosPickerItem = nil + } + } + } + } + + var regularToolbar: some View { + HStack(spacing: 0) { + Button { + withAnimation { + tool.selectTool(.hand) + } + } label: { + Image(systemName: "hand.draw.fill") + .fontWeight(.heavy) + .contentShape(.circle) + .frame(width: size, height: size) + .foregroundStyle(tool.selection == .hand ? Color.white : Color.accentColor) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + .background { + if tool.selection == .hand { + Color.accentColor + .clipShape(.rect(cornerRadius: 8)) + .matchedGeometryEffect(id: "element.toolbar.bg", in: namespace) + } + } + Button { + withAnimation { + tool.selectTool(.pen) + } + } label: { + Image(systemName: "pencil") + .fontWeight(.heavy) + .contentShape(.circle) + .frame(width: size, height: size) + .foregroundStyle(tool.selection == .pen ? Color.white : Color.accentColor) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + .background { + if tool.selection == .pen { + Color.accentColor + .clipShape(.rect(cornerRadius: 8)) + .matchedGeometryEffect(id: "element.toolbar.bg", in: namespace) + } + } + HStack(spacing: 0) { + Button { + withAnimation { + tool.selectTool(.photo) + } + } label: { + Image(systemName: "photo") + .contentShape(.circle) + .frame(width: size, height: size) + .foregroundStyle(tool.selection == .photo ? Color.white : Color.accentColor) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + .background { + if tool.selection == .photo { + Color.accentColor + .clipShape(.rect(cornerRadius: 8)) + .matchedGeometryEffect(id: "element.toolbar.bg", in: namespace) + } + if tool.selection != .photo { + Color.clear + .matchedGeometryEffect(id: "element.toolbar.photo.options", in: namespace) + } + } + if tool.selection == .photo { + photoOption + .matchedGeometryEffect(id: "element.toolbar.photo.options", in: namespace) + .transition(.blurReplace.animation(.easeIn(duration: 0.1))) + } + } + .background { + if tool.selection == .photo { + RoundedRectangle(cornerRadius: 8) + .fill(Color.white.tertiary) + .transition(.move(edge: .leading).combined(with: .opacity).animation(.easeIn(duration: 0.1))) + } + } + } + .background { + RoundedRectangle(cornerRadius: 8) + .fill(.regularMaterial) + } + .transition(.move(edge: .top).combined(with: .blurReplace)) + } + + var compactToolbar: some View { + HStack(spacing: 0) { + Button { + withAnimation { + tool.selectTool(.pen) + } + } label: { + Image(systemName: "pencil") + .fontWeight(.heavy) + .contentShape(.circle) + .frame(width: size, height: size) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + Button { + withAnimation { + tool.selectTool(.photo) + } + } label: { + Image(systemName: "photo") + .contentShape(.circle) + .frame(width: size, height: size) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + } + .background { + RoundedRectangle(cornerRadius: 8) + .fill(.regularMaterial) + } + .transition(.move(edge: .bottom).combined(with: .blurReplace)) + .padding(10) + } + + var photoOption: some View { + HStack(spacing: 0) { + Button { + openCamera() + } label: { + Image(systemName: "camera.fill") + .contentShape(.circle) + .frame(width: size, height: size) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + PhotosPicker(selection: $photosPickerItem, matching: .images, preferredItemEncoding: .compatible) { + Image(systemName: "photo.fill.on.rectangle.fill") + .contentShape(.circle) + .frame(width: size, height: size) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + } + } + + func openCamera() { + let status = AVCaptureDevice.authorizationStatus(for: .video) + switch status { + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { status in + withAnimation { + if status { + opensCamera = true + } else { + isCameraAccessDenied = true + } + } + } + case .authorized: + opensCamera = true + default: + isCameraAccessDenied = true + } + } +} diff --git a/Memola/Features/Memo/Memo/MemoView.swift b/Memola/Features/Memo/Memo/MemoView.swift index 4a7a384..5fd21d8 100644 --- a/Memola/Features/Memo/Memo/MemoView.swift +++ b/Memola/Features/Memo/Memo/MemoView.swift @@ -9,6 +9,8 @@ import SwiftUI import CoreData struct MemoView: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @StateObject var tool: Tool @StateObject var canvas: Canvas @StateObject var history: History @@ -28,6 +30,36 @@ struct MemoView: View { } var body: some View { + Group { + if horizontalSizeClass == .regular { + canvasView + } else { + compactCanvasView + } + } + .overlay(alignment: .top) { + Toolbar(size: size, memo: memo, tool: tool, canvas: canvas, history: history) + } + .disabled(textFieldState || tool.isLoadingPhoto) + .disabled(canvas.state == .loading || canvas.state == .closing) + .overlay { + switch canvas.state { + case .loading: + loadingIndicator("Loading memo...") + case .closing: + loadingIndicator("Saving memo...") + default: + EmptyView() + } + } + .overlay { + if tool.isLoadingPhoto { + loadingIndicator("Loading photo...") + } + } + } + + var canvasView: some View { CanvasView(tool: tool, canvas: canvas, history: history) .ignoresSafeArea() .overlay(alignment: .bottomTrailing) { @@ -47,24 +79,29 @@ struct MemoView: View { .overlay(alignment: .bottomLeading) { zoomControl } - .disabled(textFieldState || tool.isLoadingPhoto) - .overlay(alignment: .top) { - Toolbar(size: size, memo: memo, tool: tool, canvas: canvas, history: history) - } - .disabled(canvas.state == .loading || canvas.state == .closing) - .overlay { - switch canvas.state { - case .loading: - loadingIndicator("Loading memo...") - case .closing: - loadingIndicator("Saving memo...") + } + + var compactCanvasView: some View { + CanvasView(tool: tool, canvas: canvas, history: history) + .ignoresSafeArea() + .overlay(alignment: .bottom) { + switch tool.selection { + case .pen: + PenDock(tool: tool, canvas: canvas, size: size) + .transition(.move(edge: .bottom)) + case .photo: + if let photoItem = tool.selectedPhotoItem { + PhotoPreview(photoItem: photoItem, tool: tool) + .transition(.move(edge: .trailing)) + } default: EmptyView() } } - .overlay { - if tool.isLoadingPhoto { - loadingIndicator("Loading photo...") + .overlay(alignment: .bottom) { + if tool.selection == .hand { + ElementToolbar(size: size, tool: tool, canvas: canvas) + .transition(.move(edge: .bottom)) } } } diff --git a/Memola/Features/Memo/PenDock/PenDock.swift b/Memola/Features/Memo/PenDock/PenDock.swift index d9af8ca..52ddbee 100644 --- a/Memola/Features/Memo/PenDock/PenDock.swift +++ b/Memola/Features/Memo/PenDock/PenDock.swift @@ -8,41 +8,97 @@ import SwiftUI struct PenDock: View { + @Environment(\.horizontalSizeClass) var horizontalSizeClass @ObservedObject var tool: Tool @ObservedObject var canvas: Canvas let size: CGFloat - let width: CGFloat = 90 - let height: CGFloat = 30 - let factor: CGFloat = 0.9 + var width: CGFloat { + horizontalSizeClass == .compact ? 30 : 90 + } + var height: CGFloat { + horizontalSizeClass == .compact ? 90 : 30 + } + var factor: CGFloat { + horizontalSizeClass == .compact ? 0.9 : 0.9 + } @State var refreshScrollId: UUID = UUID() @State var opensColorPicker: Bool = false var body: some View { - ZStack(alignment: .bottomTrailing) { - if !canvas.locksCanvas { - VStack(alignment: .trailing) { - penPropertyTool - penItemList + if horizontalSizeClass == .regular { + ZStack(alignment: .bottomTrailing) { + if !canvas.locksCanvas { + VStack(alignment: .trailing) { + penPropertyTool + penItemList + } + .fixedSize() + .frame(maxHeight: .infinity) + .padding(10) + .transition(.move(edge: .trailing).combined(with: .blurReplace)) } - .fixedSize() - .frame(maxHeight: .infinity) - .padding(10) - .transition(.move(edge: .trailing).combined(with: .blurReplace)) + lockButton + .padding(10) + .transition(.move(edge: .trailing).combined(with: .blurReplace)) + } + } else { + ZStack(alignment: .bottomTrailing) { + if !canvas.locksCanvas { + GeometryReader { proxy in + HStack(alignment: .bottom, spacing: 10) { + newPenButton + .frame(height: height * factor - 18) + compactPenItemList + .fixedSize(horizontal: false, vertical: true) + compactPenPropertyTool + } + .padding(.horizontal, 10) + .clipped() + .background(alignment: .bottom) { + RoundedRectangle(cornerRadius: 8) + .fill(.regularMaterial) + .frame(height: height * factor - 18) + } + .padding([.horizontal, .bottom], 10) + .frame(maxWidth: min(proxy.size.height, proxy.size.width), maxHeight: .infinity, alignment: .bottom) + .frame(maxWidth: .infinity) + } + .overlay(alignment: .bottom) { + Button { + withAnimation { + tool.selectTool(.hand) + } + } label: { + Image(systemName: "chevron.compact.down") + .font(.headline) + .frame(width: 80) + .padding(10) + .background(.regularMaterial) + .clipShape(.capsule) + .contentShape(.capsule) + } + .offset(y: 5) + } + .transition(.move(edge: .bottom).combined(with: .blurReplace)) + } + lockButton + .frame(maxWidth: .infinity, alignment: .bottomTrailing) + .padding(10) + .offset(y: canvas.locksCanvas ? 0 : -(height * factor - size + 20)) + .transition(.move(edge: .trailing).combined(with: .blurReplace)) } - lockButton - .padding(10) - .transition(.move(edge: .trailing).combined(with: .blurReplace)) } } + @ViewBuilder var penItemList: some View { ScrollViewReader { proxy in ScrollView(.vertical, showsIndicators: false) { LazyVStack(spacing: 0) { ForEach(tool.pens) { pen in - penItemRow(pen) + penItem(pen) .id(pen.id) .scrollTransition { content, phase in content @@ -75,7 +131,34 @@ struct PenDock: View { } } - func penItemRow(_ pen: Pen) -> some View { + @ViewBuilder + var compactPenItemList: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 0) { + ForEach(tool.pens) { pen in + compactPenItem(pen) + .id(pen.id) + .scrollTransition { content, phase in + content + .scaleEffect(phase.isIdentity ? 1 : 0.04, anchor: .bottom) + } + } + } + .padding(.horizontal, 10) + .id(refreshScrollId) + } + .onReceive(tool.scrollPublisher) { id in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation { + proxy.scrollTo(id) + } + } + } + } + } + + func penItem(_ pen: Pen) -> some View { ZStack { penShadow(pen) if let tip = pen.style.icon.tip { @@ -88,7 +171,7 @@ struct PenDock: View { .resizable() } .frame(width: width * factor, height: height * factor) - .padding(.vertical, 5) + .padding(.horizontal, 5) .contentShape(.rect(cornerRadii: .init(topLeading: 10, bottomLeading: 10))) .onTapGesture { if tool.selectedPen !== pen { @@ -148,6 +231,79 @@ struct PenDock: View { .offset(x: tool.selectedPen === pen ? 0 : 25) } + func compactPenItem(_ pen: Pen) -> some View { + ZStack { + compactPenShadow(pen) + if let tip = pen.style.compactIcon.tip { + Image(tip) + .resizable() + .renderingMode(.template) + .foregroundStyle(Color.rgba(from: pen.rgba)) + } + Image(pen.style.compactIcon.base) + .resizable() + } + .frame(width: width * factor, height: height * factor) + .padding(.top, 5) + .contentShape(.rect(cornerRadii: .init(topLeading: 10, bottomLeading: 10))) + .onTapGesture { + if tool.selectedPen !== pen { + tool.selectPen(pen) + } + } + .padding(.horizontal, 10) + .contextMenu(if: pen.strokeStyle != .eraser) { + ControlGroup { + Button { + tool.selectPen(pen) + } label: { + Label( + title: { Text("Select") }, + icon: { Image(systemName: "pencil.tip.crop.circle") } + ) + } + Button { + let originalPen = pen + let pen = PenObject.createObject(\.viewContext, penStyle: originalPen.style) + pen.color = originalPen.rgba + pen.thickness = originalPen.thickness + pen.isSelected = true + pen.tool = tool.object + let _pen = Pen(object: pen) + tool.duplicatePen(_pen, of: originalPen) + } label: { + Label( + title: { Text("Duplicate") }, + icon: { Image(systemName: "plus.square.on.square") } + ) + } + Button(role: .destructive) { + tool.removePen(pen) + } label: { + Label( + title: { Text("Remove") }, + icon: { Image(systemName: "trash") } + ) + } + .disabled(tool.markers.count <= 1) + } + .controlGroupStyle(.menu) + } preview: { + penPreview(pen) + .drawingGroup() + .contentShape(.contextMenuPreview, .rect(cornerRadius: 10)) + } + .onDrag(if: pen.strokeStyle != .eraser) { + tool.draggedPen = pen + return NSItemProvider(contentsOf: URL(string: pen.id)) ?? NSItemProvider() + } preview: { + penPreview(pen) + .contentShape(.dragPreview, .rect(cornerRadius: 10)) + } + .onDrop(of: [.item], delegate: PenDropDelegate(id: pen.id, tool: tool, action: { refreshScrollId = UUID() })) + .offset(y: tool.selectedPen === pen ? 0 : 25) + } + @ViewBuilder var penPropertyTool: some View { if let pen = tool.selectedPen { @@ -170,6 +326,23 @@ struct PenDock: View { } } + @ViewBuilder + var compactPenPropertyTool: some View { + if let pen = tool.selectedPen { + HStack(spacing: 10) { + compactPenThicknessPicker(pen) + .frame(width: width) + .rotationEffect(.degrees(-90)) + if pen.strokeStyle == .marker { + penColorPicker(pen) + .frame(width: width) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .frame(height: height * factor - 18) + } + } + func penColorPicker(_ pen: Pen) -> some View { Button { opensColorPicker = true @@ -191,12 +364,13 @@ struct PenDock: View { } .background(baseColor) .clipShape(.rect(cornerRadius: 8)) - .frame(height: 25) + .frame(height: horizontalSizeClass == .compact ? 30 : 25) .overlay { RoundedRectangle(cornerRadius: 8) .stroke(Color.gray, lineWidth: 0.4) } .padding(0.2) + .drawingGroup() } .buttonStyle(.plain) .hoverEffect(.lift) @@ -250,19 +424,41 @@ struct PenDock: View { } } + @ViewBuilder + func compactPenThicknessPicker(_ pen: Pen) -> some View { + let minimum: CGFloat = pen.style.thickness.min + let maximum: CGFloat = pen.style.thickness.max + let start: CGFloat = 4 + let end: CGFloat = 7 + let selection = Binding( + get: { pen.thickness }, + set: { + pen.thickness = $0 + tool.objectWillChange.send() + } + ) + Picker("", selection: selection) { + ForEach(pen.style.thicknessSteps, id: \.self) { step in + let size = ((step - minimum) * (end - start) / (maximum - minimum)) + start - (0.5 / step) + Circle() + .fill(.black) + .frame(width: size, height: size) + .frame(width: size + 2, height: size + 2) + } + } + .hoverEffect(.lift) + .pickerStyle(.wheel) + .frame(width: 50, height: 30) + .onChange(of: pen.thickness) { _, _ in + withPersistence(\.viewContext) { context in + try context.saveIfNeeded() + } + } + } + var newPenButton: some View { Button { - let pen = PenObject.createObject(\.viewContext, penStyle: .marker) - var selectedPen = tool.selectedPen - selectedPen = (selectedPen?.strokeStyle == .marker ? (selectedPen ?? tool.pens.last) : tool.pens.last) - if let color = selectedPen?.rgba { - pen.color = color - } - pen.isSelected = true - pen.tool = tool.object - pen.orderIndex = Int16(tool.pens.count) - let _pen = Pen(object: pen) - tool.addPen(_pen) + createNewPen() } label: { Image(systemName: "plus.circle.fill") .font(.title2) @@ -317,6 +513,30 @@ struct PenDock: View { } } + func compactPenShadow(_ pen: Pen) -> some View { + ZStack { + Group { + if let tip = pen.style.compactIcon.tip { + Image(tip) + .resizable() + .renderingMode(.template) + } + Image(pen.style.compactIcon.base) + .resizable() + .renderingMode(.template) + } + .foregroundStyle(.black.opacity(0.2)) + .blur(radius: 3) + if let tip = pen.style.compactIcon.tip { + Image(tip) + .resizable() + .renderingMode(.template) + .foregroundStyle(Color(red: pen.rgba[0], green: pen.rgba[1], blue: pen.rgba[2])) + .blur(radius: 0.5) + } + } + } + var lockButton: some View { Button { withAnimation { @@ -332,4 +552,18 @@ struct PenDock: View { .hoverEffect(.lift) .contentTransition(.symbolEffect(.replace)) } + + func createNewPen() { + let pen = PenObject.createObject(\.viewContext, penStyle: .marker) + var selectedPen = tool.selectedPen + selectedPen = (selectedPen?.strokeStyle == .marker ? (selectedPen ?? tool.pens.last) : tool.pens.last) + if let color = selectedPen?.rgba { + pen.color = color + } + pen.isSelected = true + pen.tool = tool.object + pen.orderIndex = Int16(tool.pens.count) + let _pen = Pen(object: pen) + tool.addPen(_pen) + } } diff --git a/Memola/Features/Memo/Toolbar/Toolbar.swift b/Memola/Features/Memo/Toolbar/Toolbar.swift index bb99cac..9b9531c 100644 --- a/Memola/Features/Memo/Toolbar/Toolbar.swift +++ b/Memola/Features/Memo/Toolbar/Toolbar.swift @@ -6,12 +6,11 @@ // import SwiftUI -import PhotosUI import Foundation -import AVFoundation struct Toolbar: View { @Environment(\.dismiss) var dismiss + @Environment(\.horizontalSizeClass) var horizontalSizeClass @ObservedObject var tool: Tool @ObservedObject var canvas: Canvas @@ -19,14 +18,9 @@ struct Toolbar: View { @State var title: String @State var memo: MemoObject - @State var opensCamera: Bool = false - @State var photosPickerItem: PhotosPickerItem? - @State var isCameraAccessDenied: Bool = false @FocusState var textFieldState: Bool - @Namespace var namespace - let size: CGFloat init(size: CGFloat, memo: MemoObject, tool: Tool, canvas: Canvas, history: History) { @@ -47,8 +41,8 @@ struct Toolbar: View { } } .frame(maxWidth: .infinity, alignment: .leading) - if !canvas.locksCanvas { - elementTool + if !canvas.locksCanvas, horizontalSizeClass == .regular { + ElementToolbar(size: size, tool: tool, canvas: canvas) } HStack(spacing: 5) { if !canvas.locksCanvas { @@ -60,40 +54,6 @@ struct Toolbar: View { } .font(.subheadline) .padding(10) - .onChange(of: photosPickerItem) { oldValue, newValue in - if newValue != nil { - Task { - tool.isLoadingPhoto = true - let data = try? await newValue?.loadTransferable(type: Data.self) - if let data, let image = UIImage(data: data) { - tool.selectPhoto(image, for: canvas.canvasID) - } - photosPickerItem = nil - } - } - } - .fullScreenCover(isPresented: $opensCamera) { - let image: Binding = Binding { - tool.selectedPhotoItem?.image - } set: { image in - guard let image else { return } - tool.selectPhoto(image, for: canvas.canvasID) - } - CameraView(image: image, canvas: canvas) - .ignoresSafeArea() - } - .alert("Camera Access Denied", isPresented: $isCameraAccessDenied) { - Button { - if let url = URL(string: UIApplication.openSettingsURLString + "&path=CAMERA/\(String(describing: Bundle.main.bundleIdentifier))") { - UIApplication.shared.open(url) - } - } label: { - Text("Open Settings") - } - Button("Cancel", role: .cancel) { } - } message: { - Text("Memola requires access to the camera to capture photos. Please open Settings and enable camera access.") - } } var closeButton: some View { @@ -116,7 +76,7 @@ struct Toolbar: View { .focused($textFieldState) .textFieldStyle(.plain) .padding(.horizontal, size / 2.5) - .frame(width: 140, height: size) + .frame(width: horizontalSizeClass == .compact ? 100 : 140, height: size) .background(.regularMaterial) .clipShape(.rect(cornerRadius: 8)) .onChange(of: textFieldState) { oldValue, newValue in @@ -135,110 +95,6 @@ struct Toolbar: View { .transition(.move(edge: .top).combined(with: .blurReplace)) } - var elementTool: some View { - HStack(spacing: 0) { - Button { - withAnimation { - tool.selectTool(.hand) - } - } label: { - Image(systemName: "hand.draw.fill") - .fontWeight(.heavy) - .contentShape(.circle) - .frame(width: size, height: size) - .foregroundStyle(tool.selection == .hand ? Color.white : Color.accentColor) - .clipShape(.rect(cornerRadius: 8)) - } - .hoverEffect(.lift) - .background { - if tool.selection == .hand { - Color.accentColor - .clipShape(.rect(cornerRadius: 8)) - .matchedGeometryEffect(id: "element.toolbar.bg", in: namespace) - } - } - Button { - withAnimation { - tool.selectTool(.pen) - } - } label: { - Image(systemName: "pencil") - .fontWeight(.heavy) - .contentShape(.circle) - .frame(width: size, height: size) - .foregroundStyle(tool.selection == .pen ? Color.white : Color.accentColor) - .clipShape(.rect(cornerRadius: 8)) - } - .hoverEffect(.lift) - .background { - if tool.selection == .pen { - Color.accentColor - .clipShape(.rect(cornerRadius: 8)) - .matchedGeometryEffect(id: "element.toolbar.bg", in: namespace) - } - } - HStack(spacing: 0) { - Button { - withAnimation { - tool.selectTool(.photo) - } - } label: { - Image(systemName: "photo") - .contentShape(.circle) - .frame(width: size, height: size) - .foregroundStyle(tool.selection == .photo ? Color.white : Color.accentColor) - .clipShape(.rect(cornerRadius: 8)) - } - .hoverEffect(.lift) - .background { - if tool.selection == .photo { - Color.accentColor - .clipShape(.rect(cornerRadius: 8)) - .matchedGeometryEffect(id: "element.toolbar.bg", in: namespace) - } - if tool.selection != .photo { - Color.clear - .matchedGeometryEffect(id: "element.toolbar.photo.options", in: namespace) - } - } - if tool.selection == .photo { - HStack(spacing: 0) { - Button { - openCamera() - } label: { - Image(systemName: "camera.fill") - .contentShape(.circle) - .frame(width: size, height: size) - .clipShape(.rect(cornerRadius: 8)) - } - .hoverEffect(.lift) - PhotosPicker(selection: $photosPickerItem, matching: .images, preferredItemEncoding: .compatible) { - Image(systemName: "photo.fill.on.rectangle.fill") - .contentShape(.circle) - .frame(width: size, height: size) - .clipShape(.rect(cornerRadius: 8)) - } - .hoverEffect(.lift) - } - .matchedGeometryEffect(id: "element.toolbar.photo.options", in: namespace) - .transition(.blurReplace.animation(.easeIn(duration: 0.1))) - } - } - .background { - if tool.selection == .photo { - RoundedRectangle(cornerRadius: 8) - .fill(Color.white.tertiary) - .transition(.move(edge: .leading).animation(.easeIn(duration: 0.1))) - } - } - } - .background { - RoundedRectangle(cornerRadius: 8) - .fill(.regularMaterial) - } - .transition(.move(edge: .top).combined(with: .blurReplace)) - } - var historyControl: some View { HStack { Button { @@ -288,26 +144,7 @@ struct Toolbar: View { } .hoverEffect(.lift) .contentTransition(.symbolEffect(.replace)) - } - - func openCamera() { - let status = AVCaptureDevice.authorizationStatus(for: .video) - switch status { - case .notDetermined: - AVCaptureDevice.requestAccess(for: .video) { status in - withAnimation { - if status { - opensCamera = true - } else { - isCameraAccessDenied = true - } - } - } - case .authorized: - opensCamera = true - default: - isCameraAccessDenied = true - } + .transition(.move(edge: .top).combined(with: .blurReplace)) } func closeMemo() { diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/Contents.json b/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/Contents.json new file mode 100644 index 0000000..58541c1 --- /dev/null +++ b/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "eraser.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "eraser@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "eraser@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser.png b/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser.png new file mode 100644 index 0000000..f78d259 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser@2x.png b/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser@2x.png new file mode 100644 index 0000000..95152c5 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser@2x.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser@3x.png b/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser@3x.png new file mode 100644 index 0000000..1240b32 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/eraser/eraser-compact.imageset/eraser@3x.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/Contents.json b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/Contents.json new file mode 100644 index 0000000..d7ffdfb --- /dev/null +++ b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "marker-base.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "marker-base@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "marker-base@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base.png b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base.png new file mode 100644 index 0000000..4177713 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base@2x.png b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base@2x.png new file mode 100644 index 0000000..497653b Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base@2x.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base@3x.png b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base@3x.png new file mode 100644 index 0000000..62f9f51 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-base-compact.imageset/marker-base@3x.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/Contents.json b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/Contents.json new file mode 100644 index 0000000..69f40d1 --- /dev/null +++ b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "marker-tip.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "marker-tip@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "marker-tip@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip.png b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip.png new file mode 100644 index 0000000..1667820 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip@2x.png b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip@2x.png new file mode 100644 index 0000000..f036441 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip@2x.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip@3x.png b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip@3x.png new file mode 100644 index 0000000..2b0a33a Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/graphics/pens/marker-tip-compact.imageset/marker-tip@3x.png differ