diff --git a/Memola.xcodeproj/project.pbxproj b/Memola.xcodeproj/project.pbxproj index 83a5b4b..4eb8613 100644 --- a/Memola.xcodeproj/project.pbxproj +++ b/Memola.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ EC3565562BEFC7B300A4E0BF /* NSManagedObject++.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC3565552BEFC7B300A4E0BF /* NSManagedObject++.swift */; }; EC35655A2BF060D900A4E0BF /* Quad.metal in Sources */ = {isa = PBXBuildFile; fileRef = EC3565592BF060D900A4E0BF /* Quad.metal */; }; EC35655C2BF0712A00A4E0BF /* Float++.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC35655B2BF0712A00A4E0BF /* Float++.swift */; }; + EC37FB122C1B2DD90008D976 /* ToolSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC37FB112C1B2DD90008D976 /* ToolSelection.swift */; }; EC4538892BEBCAE000A86FEC /* Quad.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC4538882BEBCAE000A86FEC /* Quad.swift */; }; EC5050072BF65CED00B4D86E /* PenDropDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC5050062BF65CED00B4D86E /* PenDropDelegate.swift */; }; EC50500D2BF6674400B4D86E /* OnDragViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC50500C2BF6674400B4D86E /* OnDragViewModifier.swift */; }; @@ -82,6 +83,18 @@ ECA738F62BE612B700A4542E /* MTLDevice++.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA738F52BE612B700A4542E /* MTLDevice++.swift */; }; ECA738FC2BE61C5200A4542E /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA738FB2BE61C5200A4542E /* Persistence.swift */; }; ECA739082BE623F300A4542E /* PenDock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA739072BE623F300A4542E /* PenDock.swift */; }; + ECBE52962C1D5900006BDB3D /* PhotoPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBE52952C1D5900006BDB3D /* PhotoPreview.swift */; }; + ECBE529C2C1D94A4006BDB3D /* CameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBE529A2C1D94A4006BDB3D /* CameraView.swift */; }; + ECBE529E2C1DAB21006BDB3D /* UIImage++.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBE529D2C1DAB21006BDB3D /* UIImage++.swift */; }; + ECC995A32C1E8F2800B2699A /* PhotoItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC995A22C1E8F2800B2699A /* PhotoItem.swift */; }; + ECC995A52C1EB4CC00B2699A /* Data++.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC995A42C1EB4CC00B2699A /* Data++.swift */; }; + ECD12A862C19EE3900B96E12 /* ElementObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A852C19EE3900B96E12 /* ElementObject.swift */; }; + ECD12A8A2C19EFB000B96E12 /* Element.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A892C19EFB000B96E12 /* Element.swift */; }; + ECD12A8C2C1AEAA900B96E12 /* PhotoObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A8B2C1AEAA900B96E12 /* PhotoObject.swift */; }; + ECD12A8F2C1AEBA400B96E12 /* Photo.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A8E2C1AEBA400B96E12 /* Photo.swift */; }; + 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 */; }; 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 */; }; @@ -109,6 +122,7 @@ EC3565552BEFC7B300A4E0BF /* NSManagedObject++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObject++.swift"; sourceTree = ""; }; EC3565592BF060D900A4E0BF /* Quad.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Quad.metal; sourceTree = ""; }; EC35655B2BF0712A00A4E0BF /* Float++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Float++.swift"; sourceTree = ""; }; + EC37FB112C1B2DD90008D976 /* ToolSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolSelection.swift; sourceTree = ""; }; EC4538882BEBCAE000A86FEC /* Quad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quad.swift; sourceTree = ""; }; EC5050062BF65CED00B4D86E /* PenDropDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PenDropDelegate.swift; sourceTree = ""; }; EC50500C2BF6674400B4D86E /* OnDragViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnDragViewModifier.swift; sourceTree = ""; }; @@ -172,6 +186,18 @@ ECA738F52BE612B700A4542E /* MTLDevice++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MTLDevice++.swift"; sourceTree = ""; }; ECA738FB2BE61C5200A4542E /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; ECA739072BE623F300A4542E /* PenDock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PenDock.swift; sourceTree = ""; }; + ECBE52952C1D5900006BDB3D /* PhotoPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPreview.swift; sourceTree = ""; }; + ECBE529A2C1D94A4006BDB3D /* CameraView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = ""; }; + ECBE529D2C1DAB21006BDB3D /* UIImage++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage++.swift"; sourceTree = ""; }; + ECC995A22C1E8F2800B2699A /* PhotoItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoItem.swift; sourceTree = ""; }; + ECC995A42C1EB4CC00B2699A /* Data++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data++.swift"; sourceTree = ""; }; + ECD12A852C19EE3900B96E12 /* ElementObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementObject.swift; sourceTree = ""; }; + ECD12A892C19EFB000B96E12 /* Element.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Element.swift; sourceTree = ""; }; + ECD12A8B2C1AEAA900B96E12 /* PhotoObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoObject.swift; sourceTree = ""; }; + ECD12A8E2C1AEBA400B96E12 /* Photo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Photo.swift; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; @@ -214,6 +240,7 @@ EC1B783A2BF9C68C005A34E2 /* Views */ = { isa = PBXGroup; children = ( + ECBE529B2C1D94A4006BDB3D /* CameraView */, ECFC51252BF8885000D0D051 /* ColorPicker */, ); path = Views; @@ -241,6 +268,7 @@ isa = PBXGroup; children = ( ECA738BB2BE60E0300A4542E /* Tool.swift */, + EC37FB112C1B2DD90008D976 /* ToolSelection.swift */, ); path = Core; sourceTree = ""; @@ -372,6 +400,7 @@ ECA7387B2BE5EF3500A4542E /* Memo */ = { isa = PBXGroup; children = ( + ECBE52942C1D58F5006BDB3D /* PhotoPreview */, EC1B783B2BFA0AAC005A34E2 /* Toolbar */, EC5050082BF65D0500B4D86E /* Memo */, EC5050052BF65CCD00B4D86E /* PenDock */, @@ -382,8 +411,8 @@ ECA7387E2BE5FE4200A4542E /* Canvas */ = { isa = PBXGroup; children = ( + ECD12A872C19EF8700B96E12 /* Elements */, EC2BEBF22C0F5FE1005DB0AF /* RTree */, - ECA738F92BE6130000A4542E /* Geometries */, ECA738812BE5FEEE00A4542E /* Abstracts */, ECA738992BE6018900A4542E /* Buffers */, ECA738C72BE60EE200A4542E /* Contexts */, @@ -435,6 +464,7 @@ ECA738942BE6012D00A4542E /* ViewPort.metal */, ECA738962BE6014200A4542E /* Graphic.metal */, EC3565592BF060D900A4E0BF /* Quad.metal */, + ECD12A922C1B062000B96E12 /* Photo.metal */, ); path = Shaders; sourceTree = ""; @@ -463,6 +493,7 @@ ECA7389B2BE601AF00A4542E /* GridVertex.swift */, ECA7389D2BE601CB00A4542E /* QuadVertex.swift */, ECA7389F2BE601E400A4542E /* ViewPortVertex.swift */, + ECD12A942C1B1FA200B96E12 /* PhotoVertex.swift */, ); path = Vertices; sourceTree = ""; @@ -484,6 +515,8 @@ EC3565532BEFC6AD00A4E0BF /* View++.swift */, EC3565552BEFC7B300A4E0BF /* NSManagedObject++.swift */, EC35655B2BF0712A00A4E0BF /* Float++.swift */, + ECBE529D2C1DAB21006BDB3D /* UIImage++.swift */, + ECC995A42C1EB4CC00B2699A /* Data++.swift */, ); path = Extensions; sourceTree = ""; @@ -590,6 +623,7 @@ ECA738DD2BE610A000A4542E /* ViewPortRenderPass.swift */, ECA738DF2BE610B900A4542E /* EraserRenderPass.swift */, ECA738E12BE610D000A4542E /* GraphicRenderPass.swift */, + ECD12A902C1B04EA00B96E12 /* PhotoRenderPass.swift */, ); path = RenderPasses; sourceTree = ""; @@ -645,6 +679,49 @@ path = Core; sourceTree = ""; }; + ECBE52942C1D58F5006BDB3D /* PhotoPreview */ = { + isa = PBXGroup; + children = ( + ECBE52952C1D5900006BDB3D /* PhotoPreview.swift */, + ECC995A22C1E8F2800B2699A /* PhotoItem.swift */, + ); + path = PhotoPreview; + sourceTree = ""; + }; + ECBE529B2C1D94A4006BDB3D /* CameraView */ = { + isa = PBXGroup; + children = ( + ECBE529A2C1D94A4006BDB3D /* CameraView.swift */, + ); + path = CameraView; + sourceTree = ""; + }; + ECD12A872C19EF8700B96E12 /* Elements */ = { + isa = PBXGroup; + children = ( + ECD12A8D2C1AEB8000B96E12 /* Photo */, + ECD12A882C19EF9500B96E12 /* Core */, + ECA738F92BE6130000A4542E /* Geometries */, + ); + path = Elements; + sourceTree = ""; + }; + ECD12A882C19EF9500B96E12 /* Core */ = { + isa = PBXGroup; + children = ( + ECD12A892C19EFB000B96E12 /* Element.swift */, + ); + path = Core; + sourceTree = ""; + }; + ECD12A8D2C1AEB8000B96E12 /* Photo */ = { + isa = PBXGroup; + children = ( + ECD12A8E2C1AEBA400B96E12 /* Photo.swift */, + ); + path = Photo; + sourceTree = ""; + }; ECE883B82C009DC30045C53D /* Strokes */ = { isa = PBXGroup; children = ( @@ -676,6 +753,8 @@ EC0D14202BF79C73009BFE5F /* ToolObject.swift */, EC0D14252BF7A8C9009BFE5F /* PenObject.swift */, EC9AB09E2C1401A40076AF58 /* EraserObject.swift */, + ECD12A852C19EE3900B96E12 /* ElementObject.swift */, + ECD12A8B2C1AEAA900B96E12 /* PhotoObject.swift */, ); path = Objects; sourceTree = ""; @@ -763,6 +842,7 @@ buildActionMask = 2147483647; files = ( ECA738B02BE60D0B00A4542E /* CanvasViewController.swift in Sources */, + ECD12A912C1B04EA00B96E12 /* PhotoRenderPass.swift in Sources */, ECA738E42BE6110800A4542E /* Drawable.swift in Sources */, ECA738AD2BE60CC600A4542E /* DrawingView.swift in Sources */, EC1B783D2BFA0AC9005A34E2 /* Toolbar.swift in Sources */, @@ -786,11 +866,13 @@ ECA738CD2BE60F2F00A4542E /* GridContext.swift in Sources */, ECFA15202BEF21EF00455818 /* MemoObject.swift in Sources */, ECE883C12C00C9CB0045C53D /* StrokeStyle.swift in Sources */, + EC37FB122C1B2DD90008D976 /* ToolSelection.swift in Sources */, ECA738C62BE60E9D00A4542E /* EraserPenStyle.swift in Sources */, ECA738EA2BE6122E00A4542E /* CGPoint++.swift in Sources */, ECA738A62BE6023F00A4542E /* GridUniforms.swift in Sources */, ECA738D72BE60FC100A4542E /* SolidPointStrokeGenerator.swift in Sources */, EC3565542BEFC6AD00A4E0BF /* View++.swift in Sources */, + ECBE529E2C1DAB21006BDB3D /* UIImage++.swift in Sources */, EC2BEBF62C0F600D005DB0AF /* Box.swift in Sources */, ECA738832BE5FEFE00A4542E /* RenderPass.swift in Sources */, ECEC01A82BEE11BA006DA24C /* QuadShape.swift in Sources */, @@ -805,6 +887,9 @@ ECA738B82BE60DDC00A4542E /* HistoryEvent.swift in Sources */, EC0D14262BF7A8C9009BFE5F /* PenObject.swift in Sources */, ECA738952BE6012D00A4542E /* ViewPort.metal in Sources */, + ECD12A952C1B1FA200B96E12 /* PhotoVertex.swift in Sources */, + ECD12A8C2C1AEAA900B96E12 /* PhotoObject.swift in Sources */, + ECD12A862C19EE3900B96E12 /* ElementObject.swift in Sources */, EC0D14242BF79C98009BFE5F /* MemolaModel.xcdatamodeld in Sources */, ECA738F02BE6127700A4542E /* CGSize++.swift in Sources */, ECFA15242BEF223300455818 /* GraphicContextObject.swift in Sources */, @@ -817,6 +902,8 @@ ECA738F42BE612A000A4542E /* Array++.swift in Sources */, EC4538892BEBCAE000A86FEC /* Quad.swift in Sources */, ECE883BD2C00AA170045C53D /* EraserStroke.swift in Sources */, + ECD12A8F2C1AEBA400B96E12 /* Photo.swift in Sources */, + ECD12A932C1B062000B96E12 /* Photo.metal in Sources */, ECA7388F2BE600DA00A4542E /* Grid.metal in Sources */, EC2BEBF42C0F5FF7005DB0AF /* RTree.swift in Sources */, ECA738C92BE60EF700A4542E /* GraphicContext.swift in Sources */, @@ -824,6 +911,7 @@ EC0D14212BF79C73009BFE5F /* ToolObject.swift in Sources */, EC50500D2BF6674400B4D86E /* OnDragViewModifier.swift in Sources */, EC9AB09F2C1401A40076AF58 /* EraserObject.swift in Sources */, + ECBE529C2C1D94A4006BDB3D /* CameraView.swift in Sources */, ECA7389E2BE601CB00A4542E /* QuadVertex.swift in Sources */, ECA738B32BE60D9E00A4542E /* CanvasView.swift in Sources */, ECA738C42BE60E8800A4542E /* MarkerPenStyle.swift in Sources */, @@ -836,12 +924,16 @@ EC5050072BF65CED00B4D86E /* PenDropDelegate.swift in Sources */, ECA738A32BE6020A00A4542E /* CGFloat++.swift in Sources */, ECA738C12BE60E5300A4542E /* PenStyle.swift in Sources */, + ECBE52962C1D5900006BDB3D /* PhotoPreview.swift in Sources */, ECA738DE2BE610A000A4542E /* ViewPortRenderPass.swift in Sources */, EC7F6BEC2BE5E6E300A34A7B /* MemolaApp.swift in Sources */, EC2BEBF82C0F601A005DB0AF /* Node.swift in Sources */, + ECC995A52C1EB4CC00B2699A /* Data++.swift in Sources */, ECA738A02BE601E400A4542E /* ViewPortVertex.swift in Sources */, + ECD12A8A2C19EFB000B96E12 /* Element.swift in Sources */, EC0D14282BF7BF20009BFE5F /* ContextMenuViewModifier.swift in Sources */, EC2106AD2C10C2A700FBE27C /* AnyStroke.swift in Sources */, + ECC995A32C1E8F2800B2699A /* PhotoItem.swift in Sources */, ECA738BC2BE60E0300A4542E /* Tool.swift in Sources */, ECA738972BE6014200A4542E /* Graphic.metal in Sources */, ECA7388A2BE6006A00A4542E /* PipelineStates.swift in Sources */, @@ -992,6 +1084,8 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + MTLLINKER_FLAGS = ""; + MTL_COMPILER_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.example.Memola; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1025,6 +1119,8 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + MTLLINKER_FLAGS = ""; + MTL_COMPILER_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.example.Memola; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/Memola/Canvas/Buffers/Vertices/PhotoVertex.swift b/Memola/Canvas/Buffers/Vertices/PhotoVertex.swift new file mode 100644 index 0000000..8e1f118 --- /dev/null +++ b/Memola/Canvas/Buffers/Vertices/PhotoVertex.swift @@ -0,0 +1,21 @@ +// +// PhotoVertex.swift +// Memola +// +// Created by Dscyre Scotti on 6/13/24. +// + +import MetalKit +import Foundation + +struct PhotoVertex { + var position: vector_float4 + var textCoord: vector_float2 +} + +extension PhotoVertex { + init(x: CGFloat, y: CGFloat, textCoord: CGPoint) { + self.position = [x.float, y.float, 0, 1] + self.textCoord = [textCoord.x.float, textCoord.y.float] + } +} diff --git a/Memola/Canvas/Contexts/GraphicContext.swift b/Memola/Canvas/Contexts/GraphicContext.swift index 37c637c..b1f8254 100644 --- a/Memola/Canvas/Contexts/GraphicContext.swift +++ b/Memola/Canvas/Contexts/GraphicContext.swift @@ -11,12 +11,12 @@ import CoreData import Foundation final class GraphicContext: @unchecked Sendable { - var tree: RTree = RTree(maxEntries: 8) + var tree: RTree = RTree(maxEntries: 8) var eraserStrokes: Set = [] var object: GraphicContextObject? - var currentStroke: (any Stroke)? - var previousStroke: (any Stroke)? + var currentElement: Element? + var previousElement: Element? var currentPoint: CGPoint? var renderType: RenderType = .finished @@ -48,9 +48,9 @@ final class GraphicContext: @unchecked Sendable { switch stroke.style { case .marker: guard let penStroke = stroke.stroke(as: PenStroke.self) else { return } - tree.remove(penStroke.anyStroke, in: penStroke.strokeBox) + tree.remove(penStroke.element, in: penStroke.strokeBox) withPersistence(\.backgroundContext) { [weak penStroke] context in - penStroke?.object?.graphicContext = nil + penStroke?.object?.element?.graphicContext = nil try context.saveIfNeeded() context.refreshAllObjects() } @@ -69,7 +69,14 @@ final class GraphicContext: @unchecked Sendable { context.refreshAllObjects() } } - previousStroke = nil + previousElement = nil + case .photo(let photo): + tree.remove(photo.element, in: photo.photoBox) + withPersistence(\.backgroundContext) { [weak photo] context in + photo?.object?.element?.graphicContext = nil + try context.saveIfNeeded() + context.refreshAllObjects() + } } } @@ -81,9 +88,9 @@ final class GraphicContext: @unchecked Sendable { guard let penStroke = stroke.stroke(as: PenStroke.self) else { break } - tree.insert(penStroke.anyStroke, in: penStroke.strokeBox) + tree.insert(penStroke.element, in: penStroke.strokeBox) withPersistence(\.backgroundContext) { [weak self, weak penStroke] context in - penStroke?.object?.graphicContext = self?.object + penStroke?.object?.element?.graphicContext = self?.object try context.saveIfNeeded() context.refreshAllObjects() } @@ -104,7 +111,14 @@ final class GraphicContext: @unchecked Sendable { context.refreshAllObjects() } } - previousStroke = nil + previousElement = nil + case .photo(let photo): + tree.insert(photo.element, in: photo.photoBox) + withPersistence(\.backgroundContext) { [weak self, weak photo] context in + photo?.object?.element?.graphicContext = self?.object + try context.saveIfNeeded() + context.refreshAllObjects() + } } } } @@ -114,34 +128,45 @@ extension GraphicContext { guard let object else { return } let queue = OperationQueue() queue.qualityOfService = .userInteractive - object.strokes.forEach { stroke in - guard let stroke = stroke as? StrokeObject, stroke.style == 0 else { return } - let _stroke = PenStroke(object: stroke) - tree.insert(_stroke.anyStroke, in: _stroke.strokeBox) - if _stroke.isVisible(in: bounds) { - let id = stroke.objectID - queue.addOperation { [weak self] in - guard let self else { return } - withPersistenceSync(\.newBackgroundContext) { [weak _stroke] context in - guard let stroke = try? context.existingObject(with: id) as? StrokeObject else { return } - _stroke?.loadQuads(from: stroke, with: self) + object.elements.forEach { element in + guard let element = element as? ElementObject else { return } + switch element.type { + case 0: + guard let stroke = element.stroke, stroke.style == 0 else { return } + let _stroke = PenStroke(object: stroke) + tree.insert(_stroke.element, in: _stroke.strokeBox) + if _stroke.isVisible(in: bounds) { + let id = stroke.objectID + queue.addOperation { [weak self] in + guard let self else { return } + withPersistenceSync(\.newBackgroundContext) { [weak _stroke] context in + guard let stroke = try? context.existingObject(with: id) as? StrokeObject else { return } + _stroke?.loadQuads(from: stroke, with: self) + context.refreshAllObjects() + } + } + } else { + withPersistence(\.backgroundContext) { [weak self, weak _stroke] context in + guard let self else { return } + _stroke?.loadQuads(with: self) context.refreshAllObjects() } } - } else { - withPersistence(\.backgroundContext) { [weak self, weak _stroke] context in - guard let self else { return } - _stroke?.loadQuads(with: self) - context.refreshAllObjects() - } + case 1: + guard let photo = element.photo, photo.imageURL != nil else { return } + let _photo = Photo(object: photo) + tree.insert(_photo.element, in: _photo.photoBox) + default: + break } + } queue.waitUntilAllOperationsAreFinished() } func loadQuads(_ bounds: CGRect, on context: NSManagedObjectContext) { - for _stroke in self.tree.search(box: bounds.box) { - guard let stroke = _stroke.stroke(as: PenStroke.self), stroke.isEmpty else { continue } + for element in self.tree.search(box: bounds.box) { + guard let stroke = element.stroke(as: PenStroke.self), stroke.isEmpty else { continue } stroke.loadQuads(with: self) } } @@ -163,6 +188,7 @@ extension GraphicContext: Drawable { } } +// MARK: - Stroke extension GraphicContext { func beginStroke(at point: CGPoint, pen: Pen) -> any Stroke { let stroke: any Stroke @@ -185,8 +211,13 @@ extension GraphicContext { stroke.createdAt = _stroke.createdAt stroke.quads = [] stroke.erasers = .init() - stroke.graphicContext = graphicContext - graphicContext?.strokes.add(stroke) + let element = ElementObject(\.backgroundContext) + element.createdAt = _stroke.createdAt + element.type = 0 + element.graphicContext = graphicContext + stroke.element = element + element.stroke = stroke + graphicContext?.elements.add(element) _stroke.object = stroke try context.saveIfNeeded() } @@ -215,14 +246,14 @@ extension GraphicContext { } stroke = eraserStroke } - currentStroke = stroke + currentElement = .stroke(stroke.anyStroke) currentPoint = point - currentStroke?.begin(at: point) + currentElement?.stroke()?.begin(at: point) return stroke } func appendStroke(with point: CGPoint) { - guard let currentStroke else { return } + guard let currentStroke = currentElement?.stroke() else { return } guard let currentPoint, point.distance(to: currentPoint) > currentStroke.thickness * currentStroke.penStyle.stepRate else { return } @@ -231,11 +262,11 @@ extension GraphicContext { } func endStroke(at point: CGPoint) { - guard currentPoint != nil, let currentStroke = currentStroke else { return } + guard currentPoint != nil, let currentStroke = currentElement?.stroke() else { return } currentStroke.finish(at: point) if let penStroke = currentStroke.stroke(as: PenStroke.self) { penStroke.saveQuads() - tree.insert(currentStroke.anyStroke, in: currentStroke.strokeBox) + tree.insert(currentStroke.element, in: currentStroke.strokeBox) withPersistence(\.backgroundContext) { [weak penStroke] context in guard let penStroke else { return } penStroke.object?.bounds = penStroke.bounds @@ -252,21 +283,20 @@ extension GraphicContext { context.refreshAllObjects() } } - previousStroke = currentStroke - self.currentStroke = nil + previousElement = currentElement + self.currentElement = nil self.currentPoint = nil } func cancelStroke() { - if let stroke = currentStroke { + if let stroke = currentElement?.stroke() { switch stroke.style { case .marker: guard let _stroke = stroke.stroke(as: PenStroke.self) else { break } withPersistence(\.backgroundContext) { [weak graphicContext = object, weak _stroke] context in guard let _stroke else { return } - if let stroke = _stroke.object { - graphicContext?.strokes.remove(stroke) - context.delete(stroke) + if let element = _stroke.object?.element { + graphicContext?.elements.remove(element) } try context.saveIfNeeded() } @@ -281,11 +311,45 @@ extension GraphicContext { } } } - currentStroke = nil + currentElement = nil currentPoint = nil } } +// MARK: - Photo +extension GraphicContext { + func insertPhoto(at point: CGPoint, photoItem: PhotoItem) -> Photo { + let size = photoItem.dimension + let origin = point + let bounds = [origin.x - size.width / 2, origin.y - size.height / 2, origin.x + size.width / 2, origin.y + size.height / 2] + let photo = Photo(url: photoItem.id, size: size, origin: origin, bounds: bounds, createdAt: .now, bookmark: photoItem.bookmark) + tree.insert(photo.element, in: photo.photoBox) + withPersistence(\.backgroundContext) { [weak _photo = photo, weak graphicContext = object] context in + guard let _photo else { return } + let photo = PhotoObject(\.backgroundContext) + photo.imageURL = _photo.url + photo.bounds = _photo.bounds + photo.width = _photo.size.width + photo.originY = _photo.origin.y + photo.originX = _photo.origin.x + photo.height = _photo.size.height + photo.createdAt = _photo.createdAt + photo.bookmark = _photo.bookmark + let element = ElementObject(\.backgroundContext) + element.createdAt = _photo.createdAt + element.type = 1 + element.graphicContext = graphicContext + photo.element = element + element.photo = photo + graphicContext?.elements.add(element) + _photo.object = photo + try context.saveIfNeeded() + } + self.previousElement = .photo(photo) + return photo + } +} + extension GraphicContext { enum RenderType { case inProgress diff --git a/Memola/Canvas/Core/Canvas.swift b/Memola/Canvas/Core/Canvas.swift index 821d953..4189a32 100644 --- a/Memola/Canvas/Core/Canvas.swift +++ b/Memola/Canvas/Core/Canvas.swift @@ -39,8 +39,8 @@ final class Canvas: ObservableObject, Identifiable, @unchecked Sendable { } var hasValidStroke: Bool { - if let currentStroke = graphicContext.currentStroke { - return Date.now.timeIntervalSince(currentStroke.createdAt) * 1000 > 80 + if let currentElement = graphicContext.currentElement { + return Date.now.timeIntervalSince(currentElement.createdAt) * 1000 > 80 } return false } @@ -103,7 +103,7 @@ extension Canvas { } } -// MARK: - Graphic Context +// MARK: - Stroke extension Canvas { func beginTouch(at point: CGPoint, pen: Pen) -> any Stroke { graphicContext.beginStroke(at: point, pen: pen) @@ -126,6 +126,13 @@ extension Canvas { } } +// MARK: - Photo +extension Canvas { + func insertPhoto(at point: CGPoint, photoItem: PhotoItem) -> Photo { + graphicContext.insertPhoto(at: point, photoItem: photoItem) + } +} + // MARK: - Rendering extension Canvas { func renderGrid(device: MTLDevice, renderEncoder: MTLRenderCommandEncoder) { diff --git a/Memola/Canvas/Core/PipelineStates.swift b/Memola/Canvas/Core/PipelineStates.swift index 8082693..7d68936 100644 --- a/Memola/Canvas/Core/PipelineStates.swift +++ b/Memola/Canvas/Core/PipelineStates.swift @@ -93,6 +93,28 @@ struct PipelineStates { return try? device.makeRenderPipelineState(descriptor: pipelineDescriptor) } + static func createPhotoPipelineState(from renderer: Renderer, pixelFormat: MTLPixelFormat? = nil) -> MTLRenderPipelineState? { + let device = renderer.device + let library = renderer.library + let pipelineDescriptor = MTLRenderPipelineDescriptor() + + pipelineDescriptor.vertexFunction = library.makeFunction(name: "vertex_photo") + pipelineDescriptor.fragmentFunction = library.makeFunction(name: "fragment_photo") + pipelineDescriptor.colorAttachments[0].pixelFormat = pixelFormat ?? renderer.pixelFormat + pipelineDescriptor.label = "Photo Pipeline State" + + let attachment = pipelineDescriptor.colorAttachments[0] + attachment?.isBlendingEnabled = true + attachment?.rgbBlendOperation = .add + attachment?.sourceRGBBlendFactor = .sourceAlpha + attachment?.destinationRGBBlendFactor = .oneMinusSourceAlpha + attachment?.alphaBlendOperation = .add + attachment?.sourceAlphaBlendFactor = .one + attachment?.destinationAlphaBlendFactor = .oneMinusSourceAlpha + + return try? device.makeRenderPipelineState(descriptor: pipelineDescriptor) + } + static func createViewPortPipelineState(from renderer: Renderer, pixelFormat: MTLPixelFormat? = nil, isUpdate: Bool = false) -> MTLRenderPipelineState? { var label: String var vertexName: String diff --git a/Memola/Canvas/Core/Renderer.swift b/Memola/Canvas/Core/Renderer.swift index 2853673..d8cd644 100644 --- a/Memola/Canvas/Core/Renderer.swift +++ b/Memola/Canvas/Core/Renderer.swift @@ -25,6 +25,9 @@ final class Renderer { lazy var eraserRenderPass: EraserRenderPass = { EraserRenderPass(renderer: self) }() + lazy var photoRenderPass: PhotoRenderPass = { + PhotoRenderPass(renderer: self) + }() lazy var graphicRenderPass: GraphicRenderPass = { GraphicRenderPass(renderer: self) }() @@ -66,12 +69,14 @@ final class Renderer { func draw(in view: MTKView, on canvas: Canvas) { if !updatesViewPort { + graphicRenderPass.photoRenderPass = photoRenderPass graphicRenderPass.strokeRenderPass = strokeRenderPass graphicRenderPass.eraserRenderPass = eraserRenderPass graphicRenderPass.draw(on: canvas, with: self) } cacheRenderPass.clearsTexture = graphicRenderPass.clearsTexture + cacheRenderPass.photoRenderPass = photoRenderPass cacheRenderPass.strokeRenderPass = strokeRenderPass cacheRenderPass.eraserRenderPass = eraserRenderPass cacheRenderPass.graphicTexture = graphicRenderPass.graphicTexture diff --git a/Memola/Canvas/Core/Textures.swift b/Memola/Canvas/Core/Textures.swift index 57e3d8f..24a9268 100644 --- a/Memola/Canvas/Core/Textures.swift +++ b/Memola/Canvas/Core/Textures.swift @@ -20,12 +20,34 @@ class Textures { if let penTexture = penTextures[textureName] { return penTexture } + let options: [MTKTextureLoader.Option: Any] = [ + .SRGB: false, + .generateMipmaps: true, + .textureStorageMode: NSNumber(value: MTLStorageMode.private.rawValue) + ] let textureLoader = MTKTextureLoader(device: device) - let penTexture = try? textureLoader.newTexture(name: textureName, scaleFactor: 1.0, bundle: .main, options: [.SRGB: false]) + let penTexture = try? textureLoader.newTexture(name: textureName, scaleFactor: 1.0, bundle: .main, options: options) penTextures[textureName] = penTexture return penTexture } + @discardableResult + static func createPhotoTexture(for url: URL, on device: MTLDevice) -> MTLTexture? { + let textureLoader = MTKTextureLoader(device: device) + do { + let options: [MTKTextureLoader.Option: Any] = [ + .SRGB: false, + .generateMipmaps: true, + .textureStorageMode: NSNumber(value: MTLStorageMode.private.rawValue) + ] + let photoTexture = try textureLoader.newTexture(URL: url, options: options) + return photoTexture + } catch { + NSLog("[Memola] - \(error.localizedDescription)") + return nil + } + } + static func createGraphicTexture( from renderer: Renderer, size: CGSize, diff --git a/Memola/Canvas/Elements/Core/Element.swift b/Memola/Canvas/Elements/Core/Element.swift new file mode 100644 index 0000000..5d05736 --- /dev/null +++ b/Memola/Canvas/Elements/Core/Element.swift @@ -0,0 +1,56 @@ +// +// Element.swift +// Memola +// +// Created by Dscyre Scotti on 6/12/24. +// + +import Foundation + +enum Element: Equatable, Comparable { + case stroke(AnyStroke) + case photo(Photo) + + func stroke() -> (any Stroke)? { + guard case let .stroke(anyStroke) = self else { + return nil + } + return anyStroke.value + } + + func stroke(as type: S.Type) -> S? { + guard case let .stroke(anyStroke) = self else { + return nil + } + return anyStroke.stroke(as: type) + } + + func photo() -> Photo? { + guard case let .photo(photo) = self else { + return nil + } + return photo + } + + var createdAt: Date { + switch self { + case .stroke(let anyStroke): + anyStroke.value.createdAt + case .photo(let photo): + photo.createdAt + } + } + + static func < (lhs: Element, rhs: Element) -> Bool { + switch (lhs, rhs) { + case let (.stroke(leftStroke), .stroke(rightStroke)): + leftStroke < rightStroke + case let (.photo(leftPhoto), .photo(rightPhoto)): + leftPhoto < rightPhoto + case let (.photo(photo), .stroke(stroke)): + photo.createdAt < stroke.value.createdAt + case let (.stroke(stroke), .photo(photo)): + stroke.value.createdAt < photo.createdAt + } + } +} diff --git a/Memola/Canvas/Geometries/Primitives/Quad.swift b/Memola/Canvas/Elements/Geometries/Primitives/Quad.swift similarity index 100% rename from Memola/Canvas/Geometries/Primitives/Quad.swift rename to Memola/Canvas/Elements/Geometries/Primitives/Quad.swift diff --git a/Memola/Canvas/Geometries/Primitives/QuadShape.swift b/Memola/Canvas/Elements/Geometries/Primitives/QuadShape.swift similarity index 100% rename from Memola/Canvas/Geometries/Primitives/QuadShape.swift rename to Memola/Canvas/Elements/Geometries/Primitives/QuadShape.swift diff --git a/Memola/Canvas/Geometries/Stroke/Algorithms/MovingAverage.swift b/Memola/Canvas/Elements/Geometries/Stroke/Algorithms/MovingAverage.swift similarity index 100% rename from Memola/Canvas/Geometries/Stroke/Algorithms/MovingAverage.swift rename to Memola/Canvas/Elements/Geometries/Stroke/Algorithms/MovingAverage.swift diff --git a/Memola/Canvas/Geometries/Stroke/Core/AnyStroke.swift b/Memola/Canvas/Elements/Geometries/Stroke/Core/AnyStroke.swift similarity index 100% rename from Memola/Canvas/Geometries/Stroke/Core/AnyStroke.swift rename to Memola/Canvas/Elements/Geometries/Stroke/Core/AnyStroke.swift diff --git a/Memola/Canvas/Geometries/Stroke/Core/Stroke.swift b/Memola/Canvas/Elements/Geometries/Stroke/Core/Stroke.swift similarity index 98% rename from Memola/Canvas/Geometries/Stroke/Core/Stroke.swift rename to Memola/Canvas/Elements/Geometries/Stroke/Core/Stroke.swift index 6eb7526..f00857c 100644 --- a/Memola/Canvas/Geometries/Stroke/Core/Stroke.swift +++ b/Memola/Canvas/Elements/Geometries/Stroke/Core/Stroke.swift @@ -125,4 +125,8 @@ extension Stroke { var anyStroke: AnyStroke { AnyStroke(self) } + + var element: Element { + .stroke(anyStroke) + } } diff --git a/Memola/Canvas/Geometries/Stroke/Core/StrokeGenerator.swift b/Memola/Canvas/Elements/Geometries/Stroke/Core/StrokeGenerator.swift similarity index 100% rename from Memola/Canvas/Geometries/Stroke/Core/StrokeGenerator.swift rename to Memola/Canvas/Elements/Geometries/Stroke/Core/StrokeGenerator.swift diff --git a/Memola/Canvas/Geometries/Stroke/Core/StrokeStyle.swift b/Memola/Canvas/Elements/Geometries/Stroke/Core/StrokeStyle.swift similarity index 100% rename from Memola/Canvas/Geometries/Stroke/Core/StrokeStyle.swift rename to Memola/Canvas/Elements/Geometries/Stroke/Core/StrokeStyle.swift diff --git a/Memola/Canvas/Geometries/Stroke/Generators/SolidPointStrokeGenerator.swift b/Memola/Canvas/Elements/Geometries/Stroke/Generators/SolidPointStrokeGenerator.swift similarity index 100% rename from Memola/Canvas/Geometries/Stroke/Generators/SolidPointStrokeGenerator.swift rename to Memola/Canvas/Elements/Geometries/Stroke/Generators/SolidPointStrokeGenerator.swift diff --git a/Memola/Canvas/Geometries/Stroke/Strokes/EraserStroke.swift b/Memola/Canvas/Elements/Geometries/Stroke/Strokes/EraserStroke.swift similarity index 100% rename from Memola/Canvas/Geometries/Stroke/Strokes/EraserStroke.swift rename to Memola/Canvas/Elements/Geometries/Stroke/Strokes/EraserStroke.swift diff --git a/Memola/Canvas/Geometries/Stroke/Strokes/PenStroke.swift b/Memola/Canvas/Elements/Geometries/Stroke/Strokes/PenStroke.swift similarity index 100% rename from Memola/Canvas/Geometries/Stroke/Strokes/PenStroke.swift rename to Memola/Canvas/Elements/Geometries/Stroke/Strokes/PenStroke.swift diff --git a/Memola/Canvas/Elements/Photo/Photo.swift b/Memola/Canvas/Elements/Photo/Photo.swift new file mode 100644 index 0000000..3ec2741 --- /dev/null +++ b/Memola/Canvas/Elements/Photo/Photo.swift @@ -0,0 +1,117 @@ +// +// Photo.swift +// Memola +// +// Created by Dscyre Scotti on 6/13/24. +// + +import MetalKit +import Foundation + +final class Photo: @unchecked Sendable, Equatable, Comparable { + var id: UUID = UUID() + var size: CGSize + var origin: CGPoint + var image: UIImage? + var url: URL? + var bounds: [CGFloat] + var createdAt: Date + var bookmark: Data? + + var object: PhotoObject? + + var texture: MTLTexture? + var vertices: [PhotoVertex] = [] + var vertexCount: Int = 0 + var vertexBuffer: MTLBuffer? + + init(url: URL?, size: CGSize, origin: CGPoint, bounds: [CGFloat], createdAt: Date, bookmark: Data?) { + self.size = size + self.origin = origin + self.url = url + self.bounds = bounds + self.createdAt = createdAt + self.bookmark = bookmark + generateVertices() + } + + convenience init(object: PhotoObject) { + self.init( + url: object.imageURL, + size: .init(width: object.width, height: object.height), + origin: .init(x: object.originX, y: object.originY), + bounds: object.bounds, + createdAt: object.createdAt ?? .now, + bookmark: object.bookmark + ) + self.object = object + } + + func generateVertices() { + let minX = origin.x - size.width / 2 + let maxX = origin.x + size.width / 2 + let minY = origin.y - size.height / 2 + let maxY = origin.y + size.height / 2 + vertices = [ + PhotoVertex(x: minX, y: minY, textCoord: CGPoint(x: 0, y: 0)), + PhotoVertex(x: minX, y: maxY, textCoord: CGPoint(x: 0, y: 1)), + PhotoVertex(x: maxX, y: minY, textCoord: CGPoint(x: 1, y: 0)), + PhotoVertex(x: maxX, y: maxY, textCoord: CGPoint(x: 1, y: 1)), + ] + } +} + +extension Photo: Drawable { + func prepare(device: any MTLDevice) { + if vertexBuffer == nil { + vertexCount = vertices.endIndex + vertexBuffer = device.makeBuffer(bytes: vertices, length: vertexCount * MemoryLayout.stride, options: []) + } + if texture == nil, let url = bookmark?.getBookmarkURL() { + texture = Textures.createPhotoTexture(for: url, on: device) + } + } + + func draw(device: any MTLDevice, renderEncoder: any MTLRenderCommandEncoder) { + prepare(device: device) + renderEncoder.setFragmentTexture(texture, index: 0) + renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: vertices.count) + } +} + +extension Photo { + static func == (lhs: Photo, rhs: Photo) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func < (lhs: Photo, rhs: Photo) -> Bool { + lhs.createdAt < rhs.createdAt + } +} + +extension Photo { + var photoBounds: CGRect { + let x = bounds[0] + let y = bounds[1] + let width = bounds[2] - x + let height = bounds[3] - y + return CGRect(x: x, y: y, width: width, height: height) + } + + var photoBox: Box { + Box(minX: bounds[0], minY: bounds[1], maxX: bounds[2], maxY: bounds[3]) + } + + func isVisible(in bounds: CGRect) -> Bool { + bounds.contains(photoBounds) || bounds.intersects(photoBounds) + } + + var element: Element { + .photo(self) + } +} diff --git a/Memola/Canvas/History/History.swift b/Memola/Canvas/History/History.swift index 6125381..a6d152b 100644 --- a/Memola/Canvas/History/History.swift +++ b/Memola/Canvas/History/History.swift @@ -70,6 +70,20 @@ class History: ObservableObject { try context.saveIfNeeded() } } + case .photo(let _photo): + if let url = _photo.bookmark?.getBookmarkURL() { + do { + try FileManager.default.removeItem(at: url) + } catch { + NSLog("[Memola] - \(error.localizedDescription)") + } + } + withPersistence(\.backgroundContext) { context in + if let photo = _photo.object { + context.delete(photo) + } + try context.saveIfNeeded() + } } } redoStack.removeAll() diff --git a/Memola/Canvas/History/HistoryEvent.swift b/Memola/Canvas/History/HistoryEvent.swift index 4fb811f..a24d301 100644 --- a/Memola/Canvas/History/HistoryEvent.swift +++ b/Memola/Canvas/History/HistoryEvent.swift @@ -9,4 +9,5 @@ import Foundation enum HistoryEvent { case stroke(any Stroke) + case photo(Photo) } diff --git a/Memola/Canvas/RTree/RTree.swift b/Memola/Canvas/RTree/RTree.swift index 367a7ba..168520f 100644 --- a/Memola/Canvas/RTree/RTree.swift +++ b/Memola/Canvas/RTree/RTree.swift @@ -38,7 +38,11 @@ class RTree where T: Equatable & Comparable { .sorted(by: <) result = _merge(result, children) } else { - queue.append(contentsOf: node.children) + let nodes = node.children.sorted { + guard let first = $0.value, let second = $1.value else { return false } + return first < second + } + queue.append(contentsOf: nodes) } } return result diff --git a/Memola/Canvas/RenderPasses/CacheRenderPass.swift b/Memola/Canvas/RenderPasses/CacheRenderPass.swift index 20b3b27..7c5f152 100644 --- a/Memola/Canvas/RenderPasses/CacheRenderPass.swift +++ b/Memola/Canvas/RenderPasses/CacheRenderPass.swift @@ -19,6 +19,7 @@ class CacheRenderPass: RenderPass { weak var graphicTexture: MTLTexture? var cacheTexture: MTLTexture? + weak var photoRenderPass: PhotoRenderPass? weak var strokeRenderPass: StrokeRenderPass? weak var eraserRenderPass: EraserRenderPass? var clearsTexture: Bool = true @@ -34,7 +35,7 @@ class CacheRenderPass: RenderPass { } func draw(on canvas: Canvas, with renderer: Renderer) { - guard let descriptor, let strokeRenderPass, let eraserRenderPass else { return } + guard let descriptor, let strokeRenderPass, let eraserRenderPass, let photoRenderPass else { return } copyTexture(on: canvas, with: renderer) @@ -45,18 +46,26 @@ class CacheRenderPass: RenderPass { descriptor.colorAttachments[0].storeAction = .store let graphicContext = canvas.graphicContext - if let stroke = graphicContext.currentStroke { - switch stroke.style { - case .eraser: - eraserRenderPass.stroke = stroke - eraserRenderPass.descriptor = descriptor - eraserRenderPass.draw(on: canvas, with: renderer) - case .marker: - canvas.setGraphicRenderType(.inProgress) - strokeRenderPass.stroke = stroke - strokeRenderPass.graphicDescriptor = descriptor - strokeRenderPass.graphicPipelineState = graphicPipelineState - strokeRenderPass.draw(on: canvas, with: renderer) + if let element = graphicContext.currentElement { + switch element { + case .stroke(let anyStroke): + let stroke = anyStroke.value + switch stroke.style { + case .eraser: + eraserRenderPass.stroke = stroke + eraserRenderPass.descriptor = descriptor + eraserRenderPass.draw(on: canvas, with: renderer) + case .marker: + canvas.setGraphicRenderType(.inProgress) + strokeRenderPass.stroke = stroke + strokeRenderPass.graphicDescriptor = descriptor + strokeRenderPass.graphicPipelineState = graphicPipelineState + strokeRenderPass.draw(on: canvas, with: renderer) + } + case .photo(let photo): + photoRenderPass.photo = photo + photoRenderPass.descriptor = descriptor + photoRenderPass.draw(on: canvas, with: renderer) } clearsTexture = false } diff --git a/Memola/Canvas/RenderPasses/GraphicRenderPass.swift b/Memola/Canvas/RenderPasses/GraphicRenderPass.swift index aca8d71..a21ec42 100644 --- a/Memola/Canvas/RenderPasses/GraphicRenderPass.swift +++ b/Memola/Canvas/RenderPasses/GraphicRenderPass.swift @@ -15,6 +15,7 @@ class GraphicRenderPass: RenderPass { var graphicPipelineState: MTLRenderPipelineState? + weak var photoRenderPass: PhotoRenderPass? weak var strokeRenderPass: StrokeRenderPass? weak var eraserRenderPass: EraserRenderPass? @@ -32,7 +33,7 @@ class GraphicRenderPass: RenderPass { } func draw(on canvas: Canvas, with renderer: Renderer) { - guard let strokeRenderPass, let eraserRenderPass else { return } + guard let strokeRenderPass, let eraserRenderPass, let photoRenderPass else { return } guard let descriptor else { return } guard let graphicPipelineState else { return } @@ -45,21 +46,59 @@ class GraphicRenderPass: RenderPass { let graphicContext = canvas.graphicContext if renderer.redrawsGraphicRender { canvas.setGraphicRenderType(.finished) - for _stroke in graphicContext.tree.search(box: canvas.bounds.box) { - let stroke = _stroke.value - if graphicContext.previousStroke === stroke || graphicContext.currentStroke === stroke { + for _element in graphicContext.tree.search(box: canvas.bounds.box) { + if graphicContext.previousElement == _element || graphicContext.currentElement == _element { continue } - guard stroke.isVisible(in: canvas.bounds) else { continue } - descriptor.colorAttachments[0].loadAction = clearsTexture ? .clear : .load - clearsTexture = false + switch _element { + case .stroke(let _stroke): + let stroke = _stroke.value + guard stroke.isVisible(in: canvas.bounds) else { continue } + descriptor.colorAttachments[0].loadAction = clearsTexture ? .clear : .load + clearsTexture = false + switch stroke.style { + case .eraser: + eraserRenderPass.stroke = stroke + eraserRenderPass.descriptor = descriptor + eraserRenderPass.draw(on: canvas, with: renderer) + case .marker: + canvas.setGraphicRenderType(.finished) + strokeRenderPass.stroke = stroke + strokeRenderPass.graphicDescriptor = descriptor + strokeRenderPass.graphicPipelineState = graphicPipelineState + strokeRenderPass.draw(on: canvas, with: renderer) + + if let stroke = stroke as? PenStroke, !stroke.isEmptyErasedQuads { + descriptor.colorAttachments[0].loadAction = .load + eraserRenderPass.stroke = stroke + eraserRenderPass.descriptor = descriptor + eraserRenderPass.draw(on: canvas, with: renderer) + } + } + case .photo(let photo): + descriptor.colorAttachments[0].loadAction = clearsTexture ? .clear : .load + clearsTexture = false + photoRenderPass.photo = photo + photoRenderPass.descriptor = descriptor + photoRenderPass.draw(on: canvas, with: renderer) + } + } + renderer.redrawsGraphicRender = false + } + + if let element = graphicContext.previousElement { + descriptor.colorAttachments[0].loadAction = clearsTexture ? .clear : .load + clearsTexture = false + switch element { + case .stroke(let anyStroke): + let stroke = anyStroke.value switch stroke.style { case .eraser: eraserRenderPass.stroke = stroke eraserRenderPass.descriptor = descriptor eraserRenderPass.draw(on: canvas, with: renderer) case .marker: - canvas.setGraphicRenderType(.finished) + canvas.setGraphicRenderType(.newlyFinished) strokeRenderPass.stroke = stroke strokeRenderPass.graphicDescriptor = descriptor strokeRenderPass.graphicPipelineState = graphicPipelineState @@ -72,33 +111,12 @@ class GraphicRenderPass: RenderPass { eraserRenderPass.draw(on: canvas, with: renderer) } } + case .photo(let photo): + photoRenderPass.photo = photo + photoRenderPass.descriptor = descriptor + photoRenderPass.draw(on: canvas, with: renderer) } - renderer.redrawsGraphicRender = false - } - - if let stroke = graphicContext.previousStroke { - descriptor.colorAttachments[0].loadAction = clearsTexture ? .clear : .load - clearsTexture = false - switch stroke.style { - case .eraser: - eraserRenderPass.stroke = stroke - eraserRenderPass.descriptor = descriptor - eraserRenderPass.draw(on: canvas, with: renderer) - case .marker: - canvas.setGraphicRenderType(.newlyFinished) - strokeRenderPass.stroke = stroke - strokeRenderPass.graphicDescriptor = descriptor - strokeRenderPass.graphicPipelineState = graphicPipelineState - strokeRenderPass.draw(on: canvas, with: renderer) - - if let stroke = stroke as? PenStroke, !stroke.isEmptyErasedQuads { - descriptor.colorAttachments[0].loadAction = .load - eraserRenderPass.stroke = stroke - eraserRenderPass.descriptor = descriptor - eraserRenderPass.draw(on: canvas, with: renderer) - } - } - graphicContext.previousStroke = nil + graphicContext.previousElement = nil } let eraserStrokes = graphicContext.eraserStrokes diff --git a/Memola/Canvas/RenderPasses/PhotoRenderPass.swift b/Memola/Canvas/RenderPasses/PhotoRenderPass.swift new file mode 100644 index 0000000..49b9f6f --- /dev/null +++ b/Memola/Canvas/RenderPasses/PhotoRenderPass.swift @@ -0,0 +1,45 @@ +// +// PhotoRenderPass.swift +// Memola +// +// Created by Dscyre Scotti on 6/13/24. +// + +import MetalKit +import Foundation + +class PhotoRenderPass: RenderPass { + var label: String = "Photo Render Pass" + + var descriptor: MTLRenderPassDescriptor? + + var photoPipelineState: MTLRenderPipelineState? + weak var graphicTexture: MTLTexture? + + var photo: Photo? + + init(renderer: Renderer) { + photoPipelineState = PipelineStates.createPhotoPipelineState(from: renderer) + } + + func resize(on view: MTKView, to size: CGSize, with renderer: Renderer) { } + + func draw(on canvas: Canvas, with renderer: Renderer) { + guard let descriptor else { return } + + guard let commandBuffer = renderer.commandQueue.makeCommandBuffer() else { return } + commandBuffer.label = "Photo Command Buffer" + + guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { return } + renderEncoder.label = label + + guard let photoPipelineState else { return } + renderEncoder.setRenderPipelineState(photoPipelineState) + + canvas.setUniformsBuffer(device: renderer.device, renderEncoder: renderEncoder) + photo?.draw(device: renderer.device, renderEncoder: renderEncoder) + + renderEncoder.endEncoding() + commandBuffer.commit() + } +} diff --git a/Memola/Canvas/Shaders/Photo.metal b/Memola/Canvas/Shaders/Photo.metal new file mode 100644 index 0000000..f585554 --- /dev/null +++ b/Memola/Canvas/Shaders/Photo.metal @@ -0,0 +1,46 @@ +// +// Photo.metal +// Memola +// +// Created by Dscyre Scotti on 6/13/24. +// + +#include +using namespace metal; + +struct VertexIn { + float4 position [[position]]; + float2 textCoord; +}; + +struct VertexOut { + float4 position [[position]]; + float2 textCoord; +}; + +struct Uniforms { + float4x4 transform; +}; + +vertex VertexOut vertex_photo( + constant VertexIn *vertices [[buffer(0)]], + constant Uniforms &uniforms [[buffer(11)]], + uint vertexId [[vertex_id]] +) { + VertexIn in = vertices[vertexId]; + + VertexOut out; + out.position = uniforms.transform * in.position; + out.textCoord = in.textCoord; + + return out; +} + +fragment float4 fragment_photo( + VertexOut out [[stage_in]], + texture2d texture [[texture(0)]] +) { + constexpr sampler textureSampler(mag_filter::linear, min_filter::linear); + float4 color = float4(texture.sample(textureSampler, out.textCoord)); + return color; +} diff --git a/Memola/Canvas/Tool/Core/Tool.swift b/Memola/Canvas/Tool/Core/Tool.swift index 5d05c80..a60c984 100644 --- a/Memola/Canvas/Tool/Core/Tool.swift +++ b/Memola/Canvas/Tool/Core/Tool.swift @@ -14,8 +14,14 @@ public class Tool: NSObject, ObservableObject { let object: ToolObject @Published var pens: [Pen] = [] + + // MARK: - Pen @Published var selectedPen: Pen? @Published var draggedPen: Pen? + // MARK: - Photo + @Published var selectedPhotoItem: PhotoItem? + + @Published var selection: ToolSelection = .none let scrollPublisher = PassthroughSubject() var markers: [Pen] { @@ -106,4 +112,80 @@ public class Tool: NSObject, ObservableObject { } } } + + func selectPhoto(_ image: UIImage, for canvasID: NSManagedObjectID) { + guard let resizedImage = resizePhoto(of: image) else { return } + let photoItem = bookmarkPhoto(of: resizedImage, with: canvasID) + withAnimation { + selectedPhotoItem = photoItem + } + } + + private func resizePhoto(of image: UIImage) -> UIImage? { + let targetSize = CGSize(width: 768, height: 768) + let size = image.size + let widthRatio = targetSize.width / size.width + let heightRatio = targetSize.height / size.height + let newSize = CGSize( + width: size.width * min(widthRatio, heightRatio), + height: size.height * min(widthRatio, heightRatio) + ) + let rect = CGRect(origin: .zero, size: newSize) + + UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0) + image.draw(in: rect) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage + } + + private func bookmarkPhoto(of image: UIImage, with canvasID: NSManagedObjectID) -> PhotoItem? { + guard let data = image.jpegData(compressionQuality: 1) else { return nil } + let fileManager = FileManager.default + guard let directory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + return nil + } + let fileName = "\(UUID().uuidString)-\(Int(Date.now.timeIntervalSince1970))" + let folder = directory.appendingPathComponent(canvasID.uriRepresentation().lastPathComponent, conformingTo: .folder) + + if !fileManager.fileExists(atPath: folder.path()) { + do { + try fileManager.createDirectory(at: folder, withIntermediateDirectories: true) + } catch { + NSLog("[Memola] - \(error.localizedDescription)") + return nil + } + } + let file = folder.appendingPathComponent(fileName, conformingTo: .jpeg) + do { + try data.write(to: file) + } catch { + NSLog("[Memola] - \(error.localizedDescription)") + return nil + } + var photoBookmark: PhotoItem? + do { + let bookmark = try file.bookmarkData(options: .minimalBookmark) + photoBookmark = PhotoItem(id: file, image: image, bookmark: bookmark) + } catch { + NSLog("[Memola] - \(error.localizedDescription)") + } + return photoBookmark + } + + func unselectPhoto() { + guard let photoItem = selectedPhotoItem else { return } + let fileManager = FileManager.default + if let url = photoItem.bookmark.getBookmarkURL() { + do { + try fileManager.removeItem(at: url) + } catch { + NSLog("[Memola] - \(error.localizedDescription)") + } + } + withAnimation { + selectedPhotoItem = nil + } + } } diff --git a/Memola/Canvas/Tool/Core/ToolSelection.swift b/Memola/Canvas/Tool/Core/ToolSelection.swift new file mode 100644 index 0000000..76c3589 --- /dev/null +++ b/Memola/Canvas/Tool/Core/ToolSelection.swift @@ -0,0 +1,14 @@ +// +// ToolSelection.swift +// Memola +// +// Created by Dscyre Scotti on 6/13/24. +// + +import Foundation + +enum ToolSelection: Equatable { + case none + case pen + case photo +} diff --git a/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift b/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift index e2d60a4..a21f7fb 100644 --- a/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift +++ b/Memola/Canvas/View/Bridge/ViewController/CanvasViewController.swift @@ -17,6 +17,8 @@ class CanvasViewController: UIViewController { drawingView.renderView } + var photoInsertGesture: UITapGestureRecognizer? + let tool: Tool let canvas: Canvas let history: History @@ -40,6 +42,7 @@ class CanvasViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() configureViews() + configureGestures() configureListeners() } @@ -176,6 +179,11 @@ extension CanvasViewController { self?.penChanged(to: pen) } .store(in: &cancellables) + tool.$selection + .sink { [weak self] selection in + self?.toolSelectionChanged(to: selection) + } + .store(in: &cancellables) history.historyPublisher .sink { [weak self] action in @@ -216,6 +224,26 @@ extension CanvasViewController: MTKViewDelegate { } } +extension CanvasViewController { + func configureGestures() { + let photoInsertGesture = UITapGestureRecognizer(target: self, action: #selector(recognizeTapGesture)) + photoInsertGesture.numberOfTapsRequired = 1 + self.photoInsertGesture = photoInsertGesture + scrollView.addGestureRecognizer(photoInsertGesture) + } + + @objc func recognizeTapGesture(_ gesture: UITapGestureRecognizer) { + guard let photoItem = tool.selectedPhotoItem else { return } + withAnimation { + tool.selectedPhotoItem = nil + } + let point = gesture.location(in: drawingView) + let photo = canvas.insertPhoto(at: point.muliply(by: drawingView.ratio), photoItem: photoItem) + history.addUndo(.photo(photo)) + drawingView.draw() + } +} + extension CanvasViewController: UIScrollViewDelegate { func viewForZooming(in scrollView: UIScrollView) -> UIView? { drawingView @@ -300,10 +328,31 @@ extension CanvasViewController { if let pen, let device = drawingView.renderView.device { pen.style.loadTexture(on: device) } - let isPenSelected = pen != nil - scrollView.isScrollEnabled = !isPenSelected - drawingView.isUserInteractionEnabled = isPenSelected - isPenSelected ? drawingView.enableUserInteraction() : drawingView.disableUserInteraction() + } + + func toolSelectionChanged(to selection: ToolSelection) { + let enablesScrolling: Bool + let enablesDrawing: Bool + let enablesPhotoInsertion: Bool + switch selection { + case .none: + enablesScrolling = true + enablesDrawing = false + enablesPhotoInsertion = false + case .pen: + enablesScrolling = false + enablesDrawing = true + enablesPhotoInsertion = false + penChanged(to: tool.selectedPen) + case .photo: + enablesScrolling = true + enablesDrawing = false + enablesPhotoInsertion = true + } + scrollView.isScrollEnabled = enablesScrolling + drawingView.isUserInteractionEnabled = enablesDrawing + photoInsertGesture?.isEnabled = enablesPhotoInsertion + enablesDrawing ? drawingView.enableUserInteraction() : drawingView.disableUserInteraction() } } diff --git a/Memola/Canvas/View/Bridge/Views/DrawingView.swift b/Memola/Canvas/View/Bridge/Views/DrawingView.swift index 84b58d5..0bae10e 100644 --- a/Memola/Canvas/View/Bridge/Views/DrawingView.swift +++ b/Memola/Canvas/View/Bridge/Views/DrawingView.swift @@ -32,7 +32,7 @@ class DrawingView: UIView { } func updateDrawableSize(with size: CGSize) { - renderView.drawableSize = size.multiply(by: 3) + renderView.drawableSize = size.multiply(by: 2.5) } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { @@ -87,20 +87,20 @@ class DrawingView: UIView { guard !disablesUserInteraction else { return } canvas.moveTouch(to: point.muliply(by: ratio)) if canvas.hasValidStroke { - renderView.draw() + draw() } } func touchEnded(at point: CGPoint) { guard !disablesUserInteraction else { return } canvas.endTouch(at: point.muliply(by: ratio)) - renderView.draw() + draw() } func touchCancelled() { - if canvas.graphicContext.currentStroke != nil { + if canvas.graphicContext.currentElement != nil { canvas.cancelTouch() - renderView.draw() + draw() history.restoreUndo() } } @@ -114,4 +114,8 @@ class DrawingView: UIView { self?.disablesUserInteraction = false } } + + func draw() { + renderView.draw() + } } diff --git a/Memola/Components/Views/CameraView/CameraView.swift b/Memola/Components/Views/CameraView/CameraView.swift new file mode 100644 index 0000000..4be3e8b --- /dev/null +++ b/Memola/Components/Views/CameraView/CameraView.swift @@ -0,0 +1,46 @@ +// +// CameraView.swift +// Memola +// +// Created by Dscyre Scotti on 6/15/24. +// + +import SwiftUI + +struct CameraView: UIViewControllerRepresentable { + @Binding var image: UIImage? + + @ObservedObject var canvas: Canvas + + @Environment(\.dismiss) private var dismiss + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = .camera + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + let parent: CameraView + + init(_ parent: CameraView) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + parent.image = (info[.originalImage] as? UIImage)?.imageWithUpOrientation() + parent.dismiss() + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.dismiss() + } + } +} diff --git a/Memola/Config/Info.plist b/Memola/Config/Info.plist index 11d6956..5c10a78 100644 --- a/Memola/Config/Info.plist +++ b/Memola/Config/Info.plist @@ -2,6 +2,8 @@ + NSCameraUsageDescription + Memola requires access to the camera to capture photos. CFBundleShortVersionString $(MARKETING_VERSION) UISupportedInterfaceOrientations~ipad diff --git a/Memola/Extensions/Data++.swift b/Memola/Extensions/Data++.swift new file mode 100644 index 0000000..97b4182 --- /dev/null +++ b/Memola/Extensions/Data++.swift @@ -0,0 +1,18 @@ +// +// Data++.swift +// Memola +// +// Created by Dscyre Scotti on 6/16/24. +// + +import Foundation + +extension Data { + func getBookmarkURL() -> URL? { + var isStale = false + guard let bookmarkURL = try? URL(resolvingBookmarkData: self, options: .withoutUI, relativeTo: nil, bookmarkDataIsStale: &isStale) else { + return nil + } + return bookmarkURL + } +} diff --git a/Memola/Extensions/UIImage++.swift b/Memola/Extensions/UIImage++.swift new file mode 100644 index 0000000..983ad0f --- /dev/null +++ b/Memola/Extensions/UIImage++.swift @@ -0,0 +1,24 @@ +// +// UIImage++.swift +// Memola +// +// Created by Dscyre Scotti on 6/15/24. +// + +import UIKit +import Foundation + +extension UIImage { + func imageWithUpOrientation() -> UIImage? { + switch imageOrientation { + case .up: + return self + default: + UIGraphicsBeginImageContextWithOptions(size, false, scale) + draw(in: CGRect(origin: .zero, size: size)) + let result = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return result + } + } +} diff --git a/Memola/Features/Memo/Memo/MemoView.swift b/Memola/Features/Memo/Memo/MemoView.swift index b12e1ed..362bffb 100644 --- a/Memola/Features/Memo/Memo/MemoView.swift +++ b/Memola/Features/Memo/Memo/MemoView.swift @@ -29,15 +29,26 @@ struct MemoView: View { var body: some View { CanvasView(tool: tool, canvas: canvas, history: history) .ignoresSafeArea() - .overlay(alignment: .trailing) { - PenDock(tool: tool, canvas: canvas) + .overlay(alignment: .bottomTrailing) { + switch tool.selection { + case .pen: + PenDock(tool: tool, canvas: canvas) + .transition(.move(edge: .trailing)) + case .photo: + if let photoItem = tool.selectedPhotoItem { + PhotoPreview(photoItem: photoItem, tool: tool) + .transition(.move(edge: .trailing)) + } + default: + EmptyView() + } } .overlay(alignment: .bottomLeading) { zoomControl } .disabled(textFieldState) .overlay(alignment: .top) { - Toolbar(size: size, memo: memo, canvas: canvas, history: history) + Toolbar(size: size, memo: memo, tool: tool, canvas: canvas, history: history) } .disabled(canvas.state == .loading || canvas.state == .closing) .overlay { diff --git a/Memola/Features/Memo/PenDock/PenDock.swift b/Memola/Features/Memo/PenDock/PenDock.swift index bcaa02c..cbf4432 100644 --- a/Memola/Features/Memo/PenDock/PenDock.swift +++ b/Memola/Features/Memo/PenDock/PenDock.swift @@ -86,9 +86,7 @@ struct PenDock: View { .padding(.vertical, 5) .contentShape(.rect(cornerRadii: .init(topLeading: 10, bottomLeading: 10))) .onTapGesture { - if tool.selectedPen === pen { - tool.unselectPen(pen) - } else { + if tool.selectedPen !== pen { tool.selectPen(pen) } } diff --git a/Memola/Features/Memo/PhotoPreview/PhotoItem.swift b/Memola/Features/Memo/PhotoPreview/PhotoItem.swift new file mode 100644 index 0000000..fbc6875 --- /dev/null +++ b/Memola/Features/Memo/PhotoPreview/PhotoItem.swift @@ -0,0 +1,23 @@ +// +// PhotoItem.swift +// Memola +// +// Created by Dscyre Scotti on 6/16/24. +// + +import UIKit +import Foundation + +struct PhotoItem: Identifiable, Equatable { + var id: URL + let image: UIImage + let bookmark: Data + + var dimension: CGSize { + let size = image.size + let maxSize = max(size.width, size.height) + let width = size.width * 128 / maxSize + let height = size.height * 128 / maxSize + return CGSize(width: width, height: height) + } +} diff --git a/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift b/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift new file mode 100644 index 0000000..1fcf43e --- /dev/null +++ b/Memola/Features/Memo/PhotoPreview/PhotoPreview.swift @@ -0,0 +1,46 @@ +// +// PhotoPreview.swift +// Memola +// +// Created by Dscyre Scotti on 6/15/24. +// + +import SwiftUI + +struct PhotoPreview: View { + let photoItem: PhotoItem + @ObservedObject var tool: Tool + + var body: some View { + Image(uiImage: photoItem.image) + .resizable() + .scaledToFill() + .frame(width: 100, height: 100) + .cornerRadius(5) + .overlay { + RoundedRectangle(cornerRadius: 5) + .stroke(Color.gray, lineWidth: 0.2) + } + .padding(10) + .background(.regularMaterial) + .cornerRadius(5) + .overlay(alignment: .topLeading) { + Button { + tool.unselectPhoto() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .padding(1) + .contentShape(.circle) + .background { + Circle() + .fill(.white) + } + } + .foregroundStyle(.red) + .hoverEffect(.lift) + .offset(x: -12, y: -12) + } + .padding(10) + } +} diff --git a/Memola/Features/Memo/Toolbar/Toolbar.swift b/Memola/Features/Memo/Toolbar/Toolbar.swift index 24c7cba..980f2a9 100644 --- a/Memola/Features/Memo/Toolbar/Toolbar.swift +++ b/Memola/Features/Memo/Toolbar/Toolbar.swift @@ -6,23 +6,31 @@ // import SwiftUI +import PhotosUI import Foundation +import AVFoundation struct Toolbar: View { @Environment(\.dismiss) var dismiss + @ObservedObject var tool: Tool @ObservedObject var canvas: Canvas @ObservedObject var history: History - @State var memo: MemoObject @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 let size: CGFloat - init(size: CGFloat, memo: MemoObject, canvas: Canvas, history: History) { + init(size: CGFloat, memo: MemoObject, tool: Tool, canvas: Canvas, history: History) { self.size = size self.memo = memo + self.tool = tool self.canvas = canvas self.history = history self.title = memo.title @@ -30,18 +38,57 @@ struct Toolbar: View { var body: some View { HStack(spacing: 5) { - if !canvas.locksCanvas { - closeButton - titleField + HStack(spacing: 5) { + if !canvas.locksCanvas { + closeButton + titleField + } } - Spacer() - if !canvas.locksCanvas { - historyControl + .frame(maxWidth: .infinity, alignment: .leading) + elementTool + HStack(spacing: 5) { + if !canvas.locksCanvas { + historyControl + } + lockButton } - lockButton + .frame(maxWidth: .infinity, alignment: .trailing) } .font(.subheadline) .padding(10) + .onChange(of: photosPickerItem) { oldValue, newValue in + if newValue != nil { + Task { + 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 { @@ -82,13 +129,76 @@ struct Toolbar: View { .transition(.move(edge: .top).combined(with: .blurReplace)) } + var elementTool: some View { + HStack(spacing: 0) { + Button { + withAnimation { + tool.selection = tool.selection == .pen ? .none : .pen + } + } label: { + Image(systemName: "pencil") + .fontWeight(.heavy) + .contentShape(.circle) + .frame(width: size, height: size) + .background(tool.selection == .pen ? Color.accentColor : Color.clear) + .foregroundStyle(tool.selection == .pen ? Color.white : Color.accentColor) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + HStack(spacing: 0) { + Button { + withAnimation { + tool.selection = tool.selection == .photo ? .none : .photo + } + } label: { + Image(systemName: "photo") + .contentShape(.circle) + .frame(width: size, height: size) + .background(tool.selection == .photo ? Color.accentColor : Color.clear) + .foregroundStyle(tool.selection == .photo ? Color.white : Color.accentColor) + .clipShape(.rect(cornerRadius: 8)) + } + .hoverEffect(.lift) + 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) + } + } + } + .background { + if tool.selection == .photo { + RoundedRectangle(cornerRadius: 8) + .fill(Color.white.tertiary) + } + } + } + .background { + RoundedRectangle(cornerRadius: 8) + .fill(.regularMaterial) + } + } + var historyControl: some View { HStack { Button { history.historyPublisher.send(.undo) } label: { Image(systemName: "arrow.uturn.backward.circle") - .contentShape(.circle) } .hoverEffect(.lift) @@ -111,6 +221,7 @@ struct Toolbar: View { var lockButton: some View { Button { + #warning("TODO: need to revisit toggale logic") withAnimation { canvas.locksCanvas.toggle() } @@ -132,6 +243,26 @@ struct Toolbar: View { .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 + } + } + func closeMemo() { withAnimation { canvas.state = .closing diff --git a/Memola/Features/Memos/MemosView.swift b/Memola/Features/Memos/MemosView.swift index 99e87c5..1e721da 100644 --- a/Memola/Features/Memos/MemosView.swift +++ b/Memola/Features/Memos/MemosView.swift @@ -85,7 +85,7 @@ struct MemosView: View { } let graphicContextObject = GraphicContextObject(\.viewContext) - graphicContextObject.strokes = [] + graphicContextObject.elements = [] memoObject.canvas = canvasObject memoObject.tool = toolObject diff --git a/Memola/Persistence/Objects/CanvasObject.swift b/Memola/Persistence/Objects/CanvasObject.swift index 6204db1..66fc646 100644 --- a/Memola/Persistence/Objects/CanvasObject.swift +++ b/Memola/Persistence/Objects/CanvasObject.swift @@ -8,7 +8,6 @@ import CoreData import Foundation - @objc(CanvasObject) final class CanvasObject: NSManagedObject { @NSManaged var width: CGFloat diff --git a/Memola/Persistence/Objects/ElementObject.swift b/Memola/Persistence/Objects/ElementObject.swift new file mode 100644 index 0000000..45171be --- /dev/null +++ b/Memola/Persistence/Objects/ElementObject.swift @@ -0,0 +1,18 @@ +// +// ElementObject.swift +// Memola +// +// Created by Dscyre Scotti on 6/12/24. +// + +import CoreData +import Foundation + +@objc(ElementObject) +final class ElementObject: NSManagedObject { + @NSManaged var type: Int16 + @NSManaged var createdAt: Date? + @NSManaged var photo: PhotoObject? + @NSManaged var stroke: StrokeObject? + @NSManaged var graphicContext: GraphicContextObject? +} diff --git a/Memola/Persistence/Objects/GraphicContextObject.swift b/Memola/Persistence/Objects/GraphicContextObject.swift index f62a765..ea26216 100644 --- a/Memola/Persistence/Objects/GraphicContextObject.swift +++ b/Memola/Persistence/Objects/GraphicContextObject.swift @@ -11,5 +11,5 @@ import Foundation @objc(GraphicContextObject) final class GraphicContextObject: NSManagedObject { @NSManaged var canvas: CanvasObject? - @NSManaged var strokes: NSMutableOrderedSet + @NSManaged var elements: NSMutableOrderedSet } diff --git a/Memola/Persistence/Objects/PhotoObject.swift b/Memola/Persistence/Objects/PhotoObject.swift new file mode 100644 index 0000000..10283e8 --- /dev/null +++ b/Memola/Persistence/Objects/PhotoObject.swift @@ -0,0 +1,22 @@ +// +// PhotoObject.swift +// Memola +// +// Created by Dscyre Scotti on 6/13/24. +// + +import CoreData +import Foundation + +@objc(PhotoObject) +class PhotoObject: NSManagedObject { + @NSManaged var width: CGFloat + @NSManaged var originY: CGFloat + @NSManaged var originX: CGFloat + @NSManaged var height: CGFloat + @NSManaged var bounds: [CGFloat] + @NSManaged var createdAt: Date? + @NSManaged var imageURL: URL? + @NSManaged var bookmark: Data? + @NSManaged var element: ElementObject? +} diff --git a/Memola/Persistence/Objects/StrokeObject.swift b/Memola/Persistence/Objects/StrokeObject.swift index e304f46..0307920 100644 --- a/Memola/Persistence/Objects/StrokeObject.swift +++ b/Memola/Persistence/Objects/StrokeObject.swift @@ -17,5 +17,5 @@ final class StrokeObject: NSManagedObject { @NSManaged var thickness: CGFloat @NSManaged var quads: NSMutableOrderedSet @NSManaged var erasers: NSMutableSet - @NSManaged var graphicContext: GraphicContextObject? + @NSManaged var element: ElementObject? } diff --git a/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents b/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents index c9ca0bf..0388678 100644 --- a/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents +++ b/Memola/Resources/Models/MemolaModel.xcdatamodeld/MemolaModel.xcdatamodel/contents @@ -6,6 +6,13 @@ + + + + + + + @@ -17,7 +24,7 @@ - + @@ -34,6 +41,17 @@ + + + + + + + + + + + @@ -50,8 +68,8 @@ + -