diff --git a/Memola.xcodeproj/project.pbxproj b/Memola.xcodeproj/project.pbxproj index e6dc41e..5e08e45 100644 --- a/Memola.xcodeproj/project.pbxproj +++ b/Memola.xcodeproj/project.pbxproj @@ -81,6 +81,8 @@ ECFA15242BEF223300455818 /* GraphicContextObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECFA15232BEF223300455818 /* GraphicContextObject.swift */; }; ECFA15262BEF224900455818 /* StrokeObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECFA15252BEF224900455818 /* StrokeObject.swift */; }; ECFA15282BEF225000455818 /* QuadObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECFA15272BEF225000455818 /* QuadObject.swift */; }; + ECFC51272BF8885700D0D051 /* ColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECFC51262BF8885700D0D051 /* ColorPicker.swift */; }; + ECFC512A2BF8BBD800D0D051 /* Triangle.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECFC51292BF8BBD800D0D051 /* Triangle.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -160,6 +162,8 @@ ECFA15232BEF223300455818 /* GraphicContextObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphicContextObject.swift; sourceTree = ""; }; ECFA15252BEF224900455818 /* StrokeObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrokeObject.swift; sourceTree = ""; }; ECFA15272BEF225000455818 /* QuadObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuadObject.swift; sourceTree = ""; }; + ECFC51262BF8885700D0D051 /* ColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPicker.swift; sourceTree = ""; }; + ECFC51292BF8BBD800D0D051 /* Triangle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Triangle.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -225,6 +229,7 @@ EC50500A2BF6672000B4D86E /* Components */ = { isa = PBXGroup; children = ( + ECFC51282BF8BBD000D0D051 /* Shapes */, EC50500B2BF6673300B4D86E /* ViewModifiers */, ); path = Components; @@ -315,6 +320,7 @@ ECA7387B2BE5EF3500A4542E /* Memo */ = { isa = PBXGroup; children = ( + ECFC51252BF8885000D0D051 /* ColorPicker */, EC5050082BF65D0500B4D86E /* Memo */, EC5050052BF65CCD00B4D86E /* PenDock */, ); @@ -599,6 +605,22 @@ path = Objects; sourceTree = ""; }; + ECFC51252BF8885000D0D051 /* ColorPicker */ = { + isa = PBXGroup; + children = ( + ECFC51262BF8885700D0D051 /* ColorPicker.swift */, + ); + path = ColorPicker; + sourceTree = ""; + }; + ECFC51282BF8BBD000D0D051 /* Shapes */ = { + isa = PBXGroup; + children = ( + ECFC51292BF8BBD800D0D051 /* Triangle.swift */, + ); + path = Shapes; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -672,6 +694,7 @@ ECA738B02BE60D0B00A4542E /* CanvasViewController.swift in Sources */, ECA738E42BE6110800A4542E /* Drawable.swift in Sources */, ECA738AD2BE60CC600A4542E /* DrawingView.swift in Sources */, + ECFC512A2BF8BBD800D0D051 /* Triangle.swift in Sources */, ECA738E02BE610B900A4542E /* EraserRenderPass.swift in Sources */, EC35655A2BF060D900A4E0BF /* Quad.metal in Sources */, ECA738912BE600F500A4542E /* Cache.metal in Sources */, @@ -704,6 +727,7 @@ ECA738CB2BE60F1900A4542E /* ViewPortContext.swift in Sources */, ECA738EE2BE6125D00A4542E /* simd_float4x4++.swift in Sources */, ECA7388C2BE6009600A4542E /* Textures.swift in Sources */, + ECFC51272BF8885700D0D051 /* ColorPicker.swift in Sources */, ECA738B82BE60DDC00A4542E /* HistoryEvent.swift in Sources */, EC0D14262BF7A8C9009BFE5F /* PenObject.swift in Sources */, ECA738952BE6012D00A4542E /* ViewPort.metal in Sources */, diff --git a/Memola/Canvas/Contexts/GraphicContext.swift b/Memola/Canvas/Contexts/GraphicContext.swift index cf6f565..fa6b910 100644 --- a/Memola/Canvas/Contexts/GraphicContext.swift +++ b/Memola/Canvas/Contexts/GraphicContext.swift @@ -117,7 +117,7 @@ extension GraphicContext { func beginStroke(at point: CGPoint, pen: Pen) -> Stroke { let stroke = Stroke( bounds: [point.x - pen.thickness, point.y - pen.thickness, point.x + pen.thickness, point.y + pen.thickness], - color: pen.color, + color: pen.rgba, style: pen.strokeStyle.rawValue, createdAt: .now, thickness: pen.thickness diff --git a/Memola/Canvas/Tool/Core/Tool.swift b/Memola/Canvas/Tool/Core/Tool.swift index 609530a..1e0377d 100644 --- a/Memola/Canvas/Tool/Core/Tool.swift +++ b/Memola/Canvas/Tool/Core/Tool.swift @@ -16,6 +16,7 @@ public class Tool: NSObject, ObservableObject { @Published var pens: [Pen] = [] @Published var selectedPen: Pen? @Published var draggedPen: Pen? + @Published var opensColorPicker: Bool = false let scrollPublisher = PassthroughSubject() @@ -32,6 +33,7 @@ public class Tool: NSObject, ObservableObject { } if let selectedPen = pens.first(where: { $0.isSelected }) { selectPen(selectedPen) + scrollPublisher.send(selectedPen.id) } } } diff --git a/Memola/Canvas/Tool/Pen/Core/Pen.swift b/Memola/Canvas/Tool/Pen/Core/Pen.swift index 01a6b8a..4b453cf 100644 --- a/Memola/Canvas/Tool/Pen/Core/Pen.swift +++ b/Memola/Canvas/Tool/Pen/Core/Pen.swift @@ -18,9 +18,9 @@ class Pen: NSObject, ObservableObject, Identifiable { object?.style = strokeStyle.rawValue } } - @Published var color: [CGFloat] { + @Published var rgba: [CGFloat] { didSet { - object?.color = color + object?.color = rgba } } @Published var thickness: CGFloat { @@ -33,12 +33,18 @@ class Pen: NSObject, ObservableObject, Identifiable { object?.isSelected = isSelected } } + var color: Color { + get { Color.rgba(from: rgba) } + set { + rgba = newValue.components + } + } init(object: PenObject) { self.object = object self.id = object.objectID.uriRepresentation().absoluteString self.style = (Stroke.Style(rawValue: object.style) ?? .marker).anyPenStyle - self.color = object.color + self.rgba = object.color self.thickness = object.thickness self.isSelected = object.isSelected super.init() diff --git a/Memola/Components/Shapes/Triangle.swift b/Memola/Components/Shapes/Triangle.swift new file mode 100644 index 0000000..d9ce850 --- /dev/null +++ b/Memola/Components/Shapes/Triangle.swift @@ -0,0 +1,20 @@ +// +// Triangle.swift +// Memola +// +// Created by Dscyre Scotti on 5/18/24. +// + +import SwiftUI +import Foundation + +struct Triangle: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + return path + } +} diff --git a/Memola/Extensions/Color++.swift b/Memola/Extensions/Color++.swift index b0e9aa6..332db55 100644 --- a/Memola/Extensions/Color++.swift +++ b/Memola/Extensions/Color++.swift @@ -18,6 +18,18 @@ extension Color { } } +extension Color { + var hsba: (hue: Double, saturation: Double, brightness: Double, alpha: Double) { + let uiColor = UIColor(self) + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + uiColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + return (hue, saturation, brightness, alpha) + } +} + extension UIColor { var components: [CGFloat] { let uiColor: UIColor = self diff --git a/Memola/Features/Memo/ColorPicker/ColorPicker.swift b/Memola/Features/Memo/ColorPicker/ColorPicker.swift new file mode 100644 index 0000000..1bd4151 --- /dev/null +++ b/Memola/Features/Memo/ColorPicker/ColorPicker.swift @@ -0,0 +1,184 @@ +// +// ColorPicker.swift +// Memola +// +// Created by Dscyre Scotti on 5/18/24. +// + +import SwiftUI +import Foundation + +struct ColorPicker: View { + @ObservedObject var pen: Pen + + @State var hue: Double = 1 + @State var saturation: Double = 0 + @State var brightness: Double = 1 + @State var alpha: Double = 1 + + let size: CGFloat = 15 + + var body: some View { + VStack(spacing: 10) { + colorPicker + .frame(width: 180, height: 180) + HStack(spacing: 10) { + Color(hue: hue, saturation: saturation, brightness: brightness) + .overlay { + Image("transparent-grid-square") + .resizable() + .scaleEffect(1.8) + .aspectRatio(contentMode: .fill) + .opacity(0.5) + .overlay { + pen.color + } + .clipShape(Triangle()) + } + .frame(width: size * 2 + 10) + .cornerRadius(5) + VStack(spacing: 10) { + hueSlider + alphaSlider + } + } + } + .padding(10) + .background { + Rectangle() + .fill(.regularMaterial) + .ignoresSafeArea(.all) + } + .onAppear { + let hsba = pen.color.hsba + hue = hsba.hue + saturation = hsba.saturation + brightness = hsba.brightness + alpha = hsba.alpha * 1.43 - 0.43 + } + } + + @ViewBuilder + var colorPicker: some View { + GeometryReader { proxy in + ZStack { + Color(hue: hue, saturation: 1, brightness: 1) + LinearGradient( + colors: [.white, .clear], + startPoint: .leading, + endPoint: .trailing + ) + LinearGradient( + colors: [.black, .clear], + startPoint: .bottom, + endPoint: .top + ) + } + .cornerRadius(5) + .drawingGroup() + .overlay(alignment: .bottomLeading) { + Color(hue: hue, saturation: saturation, brightness: brightness) + .frame(width: size, height: size) + .clipShape(Circle()) + .overlay { + Circle() + .strokeBorder(.white, lineWidth: 2) + } + .offset(x: -size + 5, y: size - 5) + .offset(x: max(proxy.size.width * saturation, size - 10), y: min(proxy.size.height * -brightness, -size + 10)) + } + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + saturation = min(1, max(value.location.x / proxy.size.width, 0)) + brightness = 1 - min(1, max(value.location.y / proxy.size.height, 0)) + updateBaseColor() + } + ) + } + } + + @ViewBuilder + var hueSlider: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + LinearGradient( + colors: (0...10).map { Color(hue: Double($0) * 0.1, saturation: 1, brightness: 1) }, + startPoint: .leading, + endPoint: .trailing + ) + Color(hue: hue, saturation: 1, brightness: 1) + .frame(width: size, height: size) + .overlay { + Circle() + .strokeBorder(.white, lineWidth: 2) + } + .clipShape(Circle()) + .offset(x: -size) + .offset(x: max(size, proxy.size.width * hue)) + } + .frame(width: proxy.size.width, height: proxy.size.height) + .clipShape(Capsule()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + hue = min(1, max(value.location.x / proxy.size.width, 0)) + updateBaseColor() + } + .onEnded { value in + hue = min(1, max(value.location.x / proxy.size.width, 0)) + updateBaseColor() + } + ) + } + .frame(height: size) + } + + @ViewBuilder + var alphaSlider: some View { + GeometryReader { proxy in + let color = Color(hue: hue, saturation: saturation, brightness: brightness) + ZStack(alignment: .leading) { + LinearGradient( + colors: (3...10).map { color.opacity(0.1 * CGFloat($0)) }, + startPoint: .leading, + endPoint: .trailing + ) + .background { + Image("transparent-grid-rect") + .resizable() + .aspectRatio(contentMode: .fill) + .opacity(0.5) + .background(.white) + } + color + .frame(width: size, height: size) + .overlay { + Circle() + .strokeBorder(.white, lineWidth: 2) + } + .clipShape(Circle()) + .offset(x: -size) + .offset(x: max(size, proxy.size.width * alpha)) + } + .frame(width: proxy.size.width, height: proxy.size.height) + .clipShape(Capsule()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + alpha = min(1, max(value.location.x / proxy.size.width, 0)) + updateBaseColor() + } + .onEnded { value in + alpha = min(1, max(value.location.x / proxy.size.width, 0)) + updateBaseColor() + } + ) + } + .frame(height: size) + } + + func updateBaseColor() { + pen.color = Color(hue: hue, saturation: saturation, brightness: brightness).opacity(0.7 * alpha + 0.3) + } +} diff --git a/Memola/Features/Memo/PenDock/PenDockView.swift b/Memola/Features/Memo/PenDock/PenDockView.swift index 4b9140c..ee376c9 100644 --- a/Memola/Features/Memo/PenDock/PenDockView.swift +++ b/Memola/Features/Memo/PenDock/PenDockView.swift @@ -15,41 +15,85 @@ struct PenDockView: View { let factor: CGFloat = 0.95 var body: some View { - ScrollViewReader { proxy in - ScrollView(.vertical, showsIndicators: false) { - LazyVStack(spacing: 0) { - ForEach(tool.pens) { pen in - penView(pen) - .id(pen.id) - .scrollTransition { content, phase in - content - .scaleEffect(phase.isIdentity ? 1 : 0.04, anchor: .trailing) + VStack(alignment: .trailing) { + if let pen = tool.selectedPen { + VStack(spacing: 10) { + Button { + tool.opensColorPicker = true + } label: { + let hsba = pen.color.hsba + Color(hue: hsba.hue, saturation: hsba.saturation, brightness: hsba.brightness) + .overlay { + Image("transparent-grid-square") + .resizable() + .scaleEffect(1.8) + .aspectRatio(contentMode: .fill) + .opacity(0.5) + .overlay { + pen.color + } + .clipShape(Triangle()) } + .clipShape(Capsule()) + .frame(height: 20) + .drawingGroup() } + .hoverEffect(.lift) + .popover(isPresented: $tool.opensColorPicker) { + ColorPicker(pen: pen) + .presentationCompactAdaptation(.popover) + } + Capsule() + .frame(height: 20) } - .padding(.vertical, 10) - .padding(.leading, 40) + .padding() + .frame(width: width * factor - 18) + .background { + RoundedRectangle(cornerRadius: 20) + .fill(.regularMaterial) + } + .transition(.move(edge: .trailing).combined(with: .opacity)) + } else { + Color.clear + .frame(width: width * factor - 18, height: 50) } - .onReceive(tool.scrollPublisher) { id in - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - withAnimation { - proxy.scrollTo(id) + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + LazyVStack(spacing: 0) { + ForEach(tool.pens) { pen in + penView(pen) + .id(pen.id) + .scrollTransition { content, phase in + content + .scaleEffect(phase.isIdentity ? 1 : 0.04, anchor: .trailing) + } + } + } + .padding(.vertical, 10) + .padding(.leading, 40) + } + .onReceive(tool.scrollPublisher) { id in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation { + proxy.scrollTo(id) + } } } } + .frame(maxHeight:( (height * factor + 10) * 6) + 20) + .fixedSize() + .background(alignment: .trailing) { + RoundedRectangle(cornerRadius: 20) + .fill(.regularMaterial) + .frame(width: width * factor - 18) + } + .clipShape(.rect(cornerRadii: .init(bottomTrailing: 20, topTrailing: 20))) + .overlay(alignment: .bottomLeading) { + newPenButton + .offset(x: 60, y: 10) + } } - .frame(maxHeight:( (height * factor + 10) * 7) + 20) .fixedSize() - .background(alignment: .trailing) { - RoundedRectangle(cornerRadius: 20) - .fill(.regularMaterial) - .frame(width: width * factor - 15) - } - .clipShape(.rect(cornerRadii: .init(bottomTrailing: 20, topTrailing: 20))) - .overlay(alignment: .bottomLeading) { - newPenButton - .offset(x: 60, y: 10) - } } @ViewBuilder @@ -59,7 +103,7 @@ struct PenDockView: View { Image(tip) .resizable() .renderingMode(.template) - .foregroundStyle(Color.rgba(from: pen.color)) + .foregroundStyle(Color.rgba(from: pen.rgba)) } Image(pen.style.icon.base) .resizable() @@ -74,12 +118,13 @@ struct PenDockView: View { tool.selectPen(pen) } } + .padding(.leading, 10) .contextMenu(if: pen.strokeStyle != .eraser) { ControlGroup { Button { let originalPen = pen let pen = PenObject.createObject(\.viewContext, penStyle: originalPen.style) - pen.color = originalPen.color + pen.color = originalPen.rgba pen.isSelected = true pen.tool = tool.object let _pen = Pen(object: pen) @@ -109,7 +154,6 @@ struct PenDockView: View { .contentShape(.dragPreview, .rect(cornerRadius: 10)) } .onDrop(of: [.item], delegate: PenDropDelegate(id: pen.id, tool: tool)) - .padding(.leading, 10) .offset(x: tool.selectedPen === pen ? 0 : 25) } @@ -142,7 +186,7 @@ struct PenDockView: View { Image(tip) .resizable() .renderingMode(.template) - .foregroundStyle(Color.rgba(from: pen.color)) + .foregroundStyle(Color.rgba(from: pen.rgba)) } Image(pen.style.icon.base) .resizable() diff --git a/Memola/Resources/Assets/Assets.xcassets/backgrounds/Contents.json b/Memola/Resources/Assets/Assets.xcassets/backgrounds/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Memola/Resources/Assets/Assets.xcassets/backgrounds/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-rect.imageset/Contents.json b/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-rect.imageset/Contents.json new file mode 100644 index 0000000..a8d92bf --- /dev/null +++ b/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-rect.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "transparency_grid 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "transparency_grid 1@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "transparency_grid 1@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-rect.imageset/transparency_grid 1.png b/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-rect.imageset/transparency_grid 1.png new file mode 100644 index 0000000..a3e4149 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-rect.imageset/transparency_grid 1.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-rect.imageset/transparency_grid 1@2x.png b/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-rect.imageset/transparency_grid 1@2x.png new file mode 100644 index 0000000..af558fb Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-rect.imageset/transparency_grid 1@2x.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-rect.imageset/transparency_grid 1@3x.png b/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-rect.imageset/transparency_grid 1@3x.png new file mode 100644 index 0000000..948bb49 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-rect.imageset/transparency_grid 1@3x.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-square.imageset/Contents.json b/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-square.imageset/Contents.json new file mode 100644 index 0000000..a8d92bf --- /dev/null +++ b/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-square.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "transparency_grid 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "transparency_grid 1@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "transparency_grid 1@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-square.imageset/transparency_grid 1.png b/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-square.imageset/transparency_grid 1.png new file mode 100644 index 0000000..b340c38 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-square.imageset/transparency_grid 1.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-square.imageset/transparency_grid 1@2x.png b/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-square.imageset/transparency_grid 1@2x.png new file mode 100644 index 0000000..c060271 Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-square.imageset/transparency_grid 1@2x.png differ diff --git a/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-square.imageset/transparency_grid 1@3x.png b/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-square.imageset/transparency_grid 1@3x.png new file mode 100644 index 0000000..27938df Binary files /dev/null and b/Memola/Resources/Assets/Assets.xcassets/backgrounds/transparent-grid-square.imageset/transparency_grid 1@3x.png differ