Merge pull request #69 from dscyrescotti/feature/photo-dock

Implement photo dock
This commit is contained in:
Aye Chan
2024-07-21 16:54:01 +08:00
committed by GitHub
19 changed files with 300 additions and 154 deletions

View File

@@ -101,8 +101,8 @@
ECA738E42BE6110800A4542E /* Drawable.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA738E32BE6110800A4542E /* Drawable.swift */; }; ECA738E42BE6110800A4542E /* Drawable.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA738E32BE6110800A4542E /* Drawable.swift */; };
ECA738FC2BE61C5200A4542E /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA738FB2BE61C5200A4542E /* Persistence.swift */; }; ECA738FC2BE61C5200A4542E /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA738FB2BE61C5200A4542E /* Persistence.swift */; };
ECA739082BE623F300A4542E /* PenDock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA739072BE623F300A4542E /* PenDock.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 */; }; ECBE529C2C1D94A4006BDB3D /* CameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBE529A2C1D94A4006BDB3D /* CameraView.swift */; };
ECC4F38C2C4B9B63007EC227 /* PhotoFileObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC4F38B2C4B9B63007EC227 /* PhotoFileObject.swift */; };
ECC995A32C1E8F2800B2699A /* PhotoItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC995A22C1E8F2800B2699A /* PhotoItem.swift */; }; ECC995A32C1E8F2800B2699A /* PhotoItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC995A22C1E8F2800B2699A /* PhotoItem.swift */; };
ECD12A862C19EE3900B96E12 /* ElementObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A852C19EE3900B96E12 /* ElementObject.swift */; }; ECD12A862C19EE3900B96E12 /* ElementObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A852C19EE3900B96E12 /* ElementObject.swift */; };
ECD12A8A2C19EFB000B96E12 /* Element.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A892C19EFB000B96E12 /* Element.swift */; }; ECD12A8A2C19EFB000B96E12 /* Element.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A892C19EFB000B96E12 /* Element.swift */; };
@@ -242,8 +242,8 @@
ECA738E32BE6110800A4542E /* Drawable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Drawable.swift; sourceTree = "<group>"; }; ECA738E32BE6110800A4542E /* Drawable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Drawable.swift; sourceTree = "<group>"; };
ECA738FB2BE61C5200A4542E /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = "<group>"; }; ECA738FB2BE61C5200A4542E /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = "<group>"; };
ECA739072BE623F300A4542E /* PenDock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PenDock.swift; sourceTree = "<group>"; }; ECA739072BE623F300A4542E /* PenDock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PenDock.swift; sourceTree = "<group>"; };
ECBE52952C1D5900006BDB3D /* PhotoPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPreview.swift; sourceTree = "<group>"; };
ECBE529A2C1D94A4006BDB3D /* CameraView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = "<group>"; }; ECBE529A2C1D94A4006BDB3D /* CameraView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = "<group>"; };
ECC4F38B2C4B9B63007EC227 /* PhotoFileObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoFileObject.swift; sourceTree = "<group>"; };
ECC995A22C1E8F2800B2699A /* PhotoItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoItem.swift; sourceTree = "<group>"; }; ECC995A22C1E8F2800B2699A /* PhotoItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoItem.swift; sourceTree = "<group>"; };
ECD12A852C19EE3900B96E12 /* ElementObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementObject.swift; sourceTree = "<group>"; }; ECD12A852C19EE3900B96E12 /* ElementObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementObject.swift; sourceTree = "<group>"; };
ECD12A892C19EFB000B96E12 /* Element.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Element.swift; sourceTree = "<group>"; }; ECD12A892C19EFB000B96E12 /* Element.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Element.swift; sourceTree = "<group>"; };
@@ -589,6 +589,7 @@
EC86C5802C4010BE00C07D21 /* PhotoDock */ = { EC86C5802C4010BE00C07D21 /* PhotoDock */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
ECC995A22C1E8F2800B2699A /* PhotoItem.swift */,
EC86C5812C4010CC00C07D21 /* PhotoDock.swift */, EC86C5812C4010CC00C07D21 /* PhotoDock.swift */,
); );
path = PhotoDock; path = PhotoDock;
@@ -645,7 +646,6 @@
children = ( children = (
EC86C5802C4010BE00C07D21 /* PhotoDock */, EC86C5802C4010BE00C07D21 /* PhotoDock */,
ECDAC0792C318DAF0000ED77 /* ElementToolbar */, ECDAC0792C318DAF0000ED77 /* ElementToolbar */,
ECBE52942C1D58F5006BDB3D /* PhotoPreview */,
EC1B783B2BFA0AAC005A34E2 /* Toolbar */, EC1B783B2BFA0AAC005A34E2 /* Toolbar */,
EC5050082BF65D0500B4D86E /* Memo */, EC5050082BF65D0500B4D86E /* Memo */,
EC5050052BF65CCD00B4D86E /* PenDock */, EC5050052BF65CCD00B4D86E /* PenDock */,
@@ -907,15 +907,6 @@
path = Core; path = Core;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
ECBE52942C1D58F5006BDB3D /* PhotoPreview */ = {
isa = PBXGroup;
children = (
ECBE52952C1D5900006BDB3D /* PhotoPreview.swift */,
ECC995A22C1E8F2800B2699A /* PhotoItem.swift */,
);
path = PhotoPreview;
sourceTree = "<group>";
};
ECBE529B2C1D94A4006BDB3D /* CameraView */ = { ECBE529B2C1D94A4006BDB3D /* CameraView */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -1039,6 +1030,7 @@
EC9AB09E2C1401A40076AF58 /* EraserObject.swift */, EC9AB09E2C1401A40076AF58 /* EraserObject.swift */,
ECD12A852C19EE3900B96E12 /* ElementObject.swift */, ECD12A852C19EE3900B96E12 /* ElementObject.swift */,
ECD12A8B2C1AEAA900B96E12 /* PhotoObject.swift */, ECD12A8B2C1AEAA900B96E12 /* PhotoObject.swift */,
ECC4F38B2C4B9B63007EC227 /* PhotoFileObject.swift */,
); );
path = Objects; path = Objects;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -1162,6 +1154,7 @@
ECA738DA2BE60FF100A4542E /* CacheRenderPass.swift in Sources */, ECA738DA2BE60FF100A4542E /* CacheRenderPass.swift in Sources */,
ECF7B2DA2C39169C004D2C57 /* Float++.swift in Sources */, ECF7B2DA2C39169C004D2C57 /* Float++.swift in Sources */,
ECA738CD2BE60F2F00A4542E /* PointGridContext.swift in Sources */, ECA738CD2BE60F2F00A4542E /* PointGridContext.swift in Sources */,
ECC4F38C2C4B9B63007EC227 /* PhotoFileObject.swift in Sources */,
EC6E3BDE2C43D5A500DD20F3 /* SidebarVisibility.swift in Sources */, EC6E3BDE2C43D5A500DD20F3 /* SidebarVisibility.swift in Sources */,
ECFA15202BEF21EF00455818 /* MemoObject.swift in Sources */, ECFA15202BEF21EF00455818 /* MemoObject.swift in Sources */,
ECE883C12C00C9CB0045C53D /* StrokeStyle.swift in Sources */, ECE883C12C00C9CB0045C53D /* StrokeStyle.swift in Sources */,
@@ -1240,7 +1233,6 @@
EC2002D72C4160EF002EBD5F /* EditCommands.swift in Sources */, EC2002D72C4160EF002EBD5F /* EditCommands.swift in Sources */,
ECF7B2DF2C39169C004D2C57 /* simd_float4x4++.swift in Sources */, ECF7B2DF2C39169C004D2C57 /* simd_float4x4++.swift in Sources */,
ECF7B2D02C39169C004D2C57 /* Array++.swift in Sources */, ECF7B2D02C39169C004D2C57 /* Array++.swift in Sources */,
ECBE52962C1D5900006BDB3D /* PhotoPreview.swift in Sources */,
ECA738DE2BE610A000A4542E /* ViewPortRenderPass.swift in Sources */, ECA738DE2BE610A000A4542E /* ViewPortRenderPass.swift in Sources */,
EC8F54AC2C2ACDA8001C7C74 /* GridMode.swift in Sources */, EC8F54AC2C2ACDA8001C7C74 /* GridMode.swift in Sources */,
EC2002D92C4161ED002EBD5F /* ViewCommands.swift in Sources */, EC2002D92C4161ED002EBD5F /* ViewCommands.swift in Sources */,

View File

@@ -153,7 +153,7 @@ extension GraphicContext {
} }
} }
case 1: case 1:
guard let photo = element.photo, photo.imageURL != nil else { return } guard let photo = element.photo, photo.file?.imageURL != nil else { return }
let _photo = Photo(object: photo) let _photo = Photo(object: photo)
tree.insert(_photo.element, in: _photo.photoBox) tree.insert(_photo.element, in: _photo.photoBox)
default: default:
@@ -312,23 +312,26 @@ extension GraphicContext {
// MARK: - Photo // MARK: - Photo
extension GraphicContext { extension GraphicContext {
func insertPhoto(at point: CGPoint, photoItem: PhotoItem) -> Photo { func insertPhoto(at point: CGPoint, photoFile: PhotoFileObject) -> Photo {
let size = photoItem.getDimension() let size = photoFile.photoDimension()
let origin = point 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 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) let photo = Photo(url: photoFile.imageURL, size: size, origin: origin, bounds: bounds, createdAt: .now, bookmark: photoFile.bookmark)
tree.insert(photo.element, in: photo.photoBox) tree.insert(photo.element, in: photo.photoBox)
let photoFileID = photoFile.objectID
withPersistence(\.backgroundContext) { [weak _photo = photo, weak graphicContext = object] context in withPersistence(\.backgroundContext) { [weak _photo = photo, weak graphicContext = object] context in
guard let _photo else { return } guard let _photo, let photoFile = try context.existingObject(with: photoFileID) as? PhotoFileObject else {
return
}
let photo = PhotoObject(\.backgroundContext) let photo = PhotoObject(\.backgroundContext)
photo.imageURL = _photo.url
photo.bounds = _photo.bounds photo.bounds = _photo.bounds
photo.width = _photo.size.width photo.width = _photo.size.width
photo.originY = _photo.origin.y photo.originY = _photo.origin.y
photo.originX = _photo.origin.x photo.originX = _photo.origin.x
photo.height = _photo.size.height photo.height = _photo.size.height
photo.createdAt = _photo.createdAt photo.createdAt = _photo.createdAt
photo.bookmark = _photo.bookmark photo.file = photoFile
photoFile.photos?.add(photo)
let element = ElementObject(\.backgroundContext) let element = ElementObject(\.backgroundContext)
element.createdAt = _photo.createdAt element.createdAt = _photo.createdAt
element.type = 1 element.type = 1

View File

@@ -221,8 +221,8 @@ extension Canvas {
// MARK: - Photo // MARK: - Photo
extension Canvas { extension Canvas {
func insertPhoto(at point: CGPoint, photoItem: PhotoItem) -> Photo { func insertPhoto(at point: CGPoint, photoFile: PhotoFileObject) -> Photo {
graphicContext.insertPhoto(at: point, photoItem: photoItem) graphicContext.insertPhoto(at: point, photoFile: photoFile)
} }
} }

View File

@@ -37,12 +37,12 @@ final class Photo: @unchecked Sendable, Equatable {
convenience init(object: PhotoObject) { convenience init(object: PhotoObject) {
self.init( self.init(
url: object.imageURL, url: object.file?.imageURL,
size: .init(width: object.width, height: object.height), size: .init(width: object.width, height: object.height),
origin: .init(x: object.originX, y: object.originY), origin: .init(x: object.originX, y: object.originY),
bounds: object.bounds, bounds: object.bounds,
createdAt: object.createdAt ?? .now, createdAt: object.createdAt ?? .now,
bookmark: object.bookmark bookmark: object.file?.bookmark
) )
self.object = object self.object = object
} }

View File

@@ -19,7 +19,7 @@ final class Tool: NSObject, ObservableObject {
@Published var selectedPen: Pen? @Published var selectedPen: Pen?
@Published var draggedPen: Pen? @Published var draggedPen: Pen?
// MARK: - Photo // MARK: - Photo
@Published var selectedPhotoItem: PhotoItem? @Published var selectedPhotoFile: PhotoFileObject?
@Published var isLoadingPhoto: Bool = false @Published var isLoadingPhoto: Bool = false
@Published var selection: ToolSelection = .hand @Published var selection: ToolSelection = .hand
@@ -128,15 +128,31 @@ final class Tool: NSObject, ObservableObject {
} }
} }
func selectPhoto(_ image: Platform.Image, for canvasID: NSManagedObjectID) { func createFile(_ image: Platform.Image, with canvas: CanvasObject?) {
guard let (resizedImage, dimension) = resizePhoto(of: image) else { return } guard let (resizedImage, dimension) = resizePhoto(of: image) else { return }
let photoItem = bookmarkPhoto(of: resizedImage, and: image, in: dimension, with: canvasID) guard let objectID = canvas?.objectID, let photoItem = bookmarkPhoto(of: resizedImage, and: image, in: dimension, with: objectID) else { return }
withAnimation { let _dimension = photoItem.dimension
selectedPhotoItem = photoItem let graphicContext = canvas?.graphicContext
isLoadingPhoto = false withPersistenceSync(\.backgroundContext) { [weak graphicContext = graphicContext] context in
let file = PhotoFileObject(\.backgroundContext)
file.imageURL = photoItem.id
file.bookmark = photoItem.bookmark
file.dimension = [_dimension.width, _dimension.height]
file.createdAt = .now
file.photos = []
file.graphicContext = graphicContext
graphicContext?.files.add(file)
} }
} }
func selectPhoto(_ photoFile: PhotoFileObject) {
selectedPhotoFile = photoFile
}
func unselectPhoto() {
selectedPhotoFile = nil
}
private func resizePhoto(of image: Platform.Image) -> (Platform.Image, CGSize)? { private func resizePhoto(of image: Platform.Image) -> (Platform.Image, CGSize)? {
let targetSize = CGSize(width: 512, height: 512) let targetSize = CGSize(width: 512, height: 512)
let size = image.size let size = image.size
@@ -198,25 +214,10 @@ final class Tool: NSObject, ObservableObject {
var photoBookmark: PhotoItem? var photoBookmark: PhotoItem?
do { do {
let bookmark = try file.bookmarkData(options: .minimalBookmark) let bookmark = try file.bookmarkData(options: .minimalBookmark)
photoBookmark = PhotoItem(id: file, image: image, previewImage: previewImage, dimension: dimension, bookmark: bookmark) photoBookmark = PhotoItem(id: file, image: image, dimension: dimension, bookmark: bookmark)
} catch { } catch {
NSLog("[Memola] - \(error.localizedDescription)") NSLog("[Memola] - \(error.localizedDescription)")
} }
return photoBookmark 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
}
}
} }

View File

@@ -329,17 +329,15 @@ extension CanvasViewController {
} }
@objc private func recognizeTapGesture(_ gesture: Platform.TapGestureRecognizer) { @objc private func recognizeTapGesture(_ gesture: Platform.TapGestureRecognizer) {
guard let photoItem = tool.selectedPhotoItem else { return } guard let photoFile = tool.selectedPhotoFile else { return }
withAnimation { tool.selectedPhotoFile = nil
tool.selectedPhotoItem = nil
}
#if os(macOS) #if os(macOS)
let pointInLeftBottomOrigin = gesture.location(in: drawingView) let pointInLeftBottomOrigin = gesture.location(in: drawingView)
let point = CGPoint(x: pointInLeftBottomOrigin.x, y: drawingView.bounds.height - pointInLeftBottomOrigin.y) let point = CGPoint(x: pointInLeftBottomOrigin.x, y: drawingView.bounds.height - pointInLeftBottomOrigin.y)
#else #else
let point = gesture.location(in: drawingView) let point = gesture.location(in: drawingView)
#endif #endif
let photo = canvas.insertPhoto(at: point.muliply(by: drawingView.ratio), photoItem: photoItem) let photo = canvas.insertPhoto(at: point.muliply(by: drawingView.ratio), photoFile: photoFile)
history.addUndo(.photo(photo)) history.addUndo(.photo(photo))
drawingView.draw() drawingView.draw()
} }

View File

@@ -237,6 +237,7 @@ struct MemosView: View {
markerPenObjects.first?.isSelected = true markerPenObjects.first?.isSelected = true
let graphicContextObject = GraphicContextObject(\.viewContext) let graphicContextObject = GraphicContextObject(\.viewContext)
graphicContextObject.files = []
graphicContextObject.elements = [] graphicContextObject.elements = []
memoObject.canvas = canvasObject memoObject.canvas = canvasObject

View File

@@ -33,9 +33,6 @@ struct ElementToolbar: View {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
if tool.selection == .photo { if tool.selection == .photo {
PhotoDock(tool: tool, canvas: canvas) PhotoDock(tool: tool, canvas: canvas)
.padding(.bottom, 10)
.frame(maxWidth: .infinity)
.transition(.move(edge: .bottom).combined(with: .blurReplace))
} else { } else {
compactToolbar compactToolbar
} }
@@ -164,9 +161,7 @@ struct ElementToolbar: View {
.fill(.regularMaterial) .fill(.regularMaterial)
} }
.padding(10) .padding(10)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.transition(.move(edge: .bottom).combined(with: .blurReplace)) .transition(.move(edge: .bottom).combined(with: .blurReplace))
} }
} }

View File

@@ -75,14 +75,7 @@ struct MemoView: View {
case .pen: case .pen:
PenDock(tool: tool, canvas: canvas) PenDock(tool: tool, canvas: canvas)
case .photo: case .photo:
ZStack(alignment: .bottomTrailing) { PhotoDock(tool: tool, canvas: canvas)
PhotoDock(tool: tool, canvas: canvas)
if let photoItem = tool.selectedPhotoItem {
PhotoPreview(photoItem: photoItem, tool: tool)
.transition(.move(edge: .trailing).combined(with: .blurReplace))
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
default: default:
EmptyView() EmptyView()
} }
@@ -99,13 +92,6 @@ struct MemoView: View {
switch tool.selection { switch tool.selection {
case .pen: case .pen:
PenDock(tool: tool, canvas: canvas) PenDock(tool: tool, canvas: canvas)
.transition(.move(edge: .bottom).combined(with: .blurReplace))
case .photo:
if let photoItem = tool.selectedPhotoItem {
PhotoPreview(photoItem: photoItem, tool: tool)
.frame(maxWidth: .infinity, alignment: .trailing)
.transition(.move(edge: .trailing))
}
default: default:
EmptyView() EmptyView()
} }

View File

@@ -38,7 +38,7 @@ struct PenDock: View {
VStack(alignment: .trailing, spacing: 5) { VStack(alignment: .trailing, spacing: 5) {
penPropertyTool penPropertyTool
penItemList penItemList
.frame(maxWidth: proxy.size.width * 0.4) .frame(maxHeight: proxy.size.height * 0.4)
} }
.fixedSize() .fixedSize()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
@@ -187,6 +187,40 @@ struct PenDock: View {
.padding(.leading, 10) .padding(.leading, 10)
.contentShape(.rect) .contentShape(.rect)
.contextMenu(if: pen.strokeStyle != .eraser) { .contextMenu(if: pen.strokeStyle != .eraser) {
#if os(macOS)
Button {
tool.selectPen(pen)
} label: {
Label(
title: { Text("Select") },
icon: { Image(systemName: "pencil.tip.crop.circle") }
)
}
Button {
let originalPen = pen
let pen = PenObject.createObject(\.viewContext, penStyle: originalPen.style)
pen.color = originalPen.rgba
pen.thickness = originalPen.thickness
pen.isSelected = true
pen.tool = tool.object
let _pen = Pen(object: pen)
tool.duplicatePen(_pen, of: originalPen)
} label: {
Label(
title: { Text("Duplicate") },
icon: { Image(systemName: "plus.square.on.square") }
)
}
Button(role: .destructive) {
tool.removePen(pen)
} label: {
Label(
title: { Text("Remove") },
icon: { Image(systemName: "trash") }
)
}
.disabled(tool.markers.count <= 1)
#else
ControlGroup { ControlGroup {
Button { Button {
tool.selectPen(pen) tool.selectPen(pen)
@@ -222,12 +256,12 @@ struct PenDock: View {
.disabled(tool.markers.count <= 1) .disabled(tool.markers.count <= 1)
} }
.controlGroupStyle(.menu) .controlGroupStyle(.menu)
#endif
} preview: { } preview: {
penPreview(pen) penPreview(pen)
.drawingGroup() .drawingGroup()
#if os(iOS) #if os(iOS)
.contentShape(.contextMenuPreview, .rect(cornerRadius: 10)) .contentShape(.contextMenuPreview, .rect(cornerRadius: 10))
#else
#endif #endif
} }
.onDrag(if: pen.strokeStyle != .eraser) { .onDrag(if: pen.strokeStyle != .eraser) {

View File

@@ -11,6 +11,8 @@ import PhotosUI
struct PhotoDock: View { struct PhotoDock: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@FetchRequest private var fileObjects: FetchedResults<PhotoFileObject>
private let size: CGFloat = 40 private let size: CGFloat = 40
@ObservedObject private var tool: Tool @ObservedObject private var tool: Tool
@@ -18,22 +20,29 @@ struct PhotoDock: View {
@State private var opensCamera: Bool = false @State private var opensCamera: Bool = false
@State private var isCameraAccessDenied: Bool = false @State private var isCameraAccessDenied: Bool = false
@State private var photosPickerItem: PhotosPickerItem? @State private var photosPickerItems: [PhotosPickerItem] = []
init(tool: Tool, canvas: Canvas) { init(tool: Tool, canvas: Canvas) {
self.tool = tool self.tool = tool
self.canvas = canvas self.canvas = canvas
var predicate: NSPredicate?
if let canvasObject = canvas.object {
predicate = NSPredicate(format: "graphicContext = %@", canvasObject.graphicContext)
}
let descriptors: [SortDescriptor<PhotoFileObject>] = [SortDescriptor(\.createdAt)]
self._fileObjects = FetchRequest(sortDescriptors: descriptors, predicate: predicate)
} }
var body: some View { var body: some View {
Group { Group {
#if os(macOS) #if os(macOS)
photoOption photoDock
#else #else
if horizontalSizeClass == .regular { if horizontalSizeClass == .regular {
photoOption photoDock
} else { } else {
compactPhotoOption compactPhotoDock
} }
#endif #endif
} }
@@ -41,10 +50,13 @@ struct PhotoDock: View {
#if os(iOS) #if os(iOS)
.fullScreenCover(isPresented: $opensCamera) { .fullScreenCover(isPresented: $opensCamera) {
let image: Binding<UIImage?> = Binding { let image: Binding<UIImage?> = Binding {
tool.selectedPhotoItem?.image .none
} set: { image in } set: { image in
guard let image else { return } guard let image else { return }
tool.selectPhoto(image, for: canvas.canvasID) tool.isLoadingPhoto = true
tool.createFile(image, with: canvas.object)
saveFile()
tool.isLoadingPhoto = false
} }
CameraView(image: image, canvas: canvas) CameraView(image: image, canvas: canvas)
.ignoresSafeArea() .ignoresSafeArea()
@@ -62,22 +74,55 @@ struct PhotoDock: View {
Text("Memola requires access to the camera to capture photos. Please open Settings and enable camera access.") Text("Memola requires access to the camera to capture photos. Please open Settings and enable camera access.")
} }
#endif #endif
.onChange(of: photosPickerItem) { oldValue, newValue in .onChange(of: photosPickerItems) { oldValue, newValue in
if newValue != nil { if !newValue.isEmpty {
Task { Task {
tool.isLoadingPhoto = true tool.isLoadingPhoto = true
let data = try? await newValue?.loadTransferable(type: Data.self) for photoItem in newValue {
if let data, let image = Platform.Image(data: data) { await createFile(for: photoItem)
tool.selectPhoto(image, for: canvas.canvasID)
} }
photosPickerItem = nil saveFile()
photosPickerItems = []
tool.isLoadingPhoto = false
} }
} }
} }
} }
private var photoDock: some View {
GeometryReader { proxy in
VStack(alignment: .trailing, spacing: 5) {
photoOption
photoItemGrid
.frame(minHeight: proxy.size.height * 0.2, maxHeight: proxy.size.height * 0.4)
}
.fixedSize()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
}
.padding(10)
.transition(.move(edge: .trailing).combined(with: .blurReplace))
}
private var compactPhotoDock: some View {
GeometryReader { proxy in
HStack(spacing: 0) {
compactPhotoItemList
compactPhotoOption
}
.fixedSize(horizontal: false, vertical: true)
.background {
RoundedRectangle(cornerRadius: 8)
.fill(.regularMaterial)
}
.frame(maxWidth: min(proxy.size.height, proxy.size.width), maxHeight: .infinity, alignment: .bottom)
.frame(maxWidth: .infinity)
}
.padding(10)
.transition(.move(edge: .bottom).combined(with: .blurReplace))
}
private var photoOption: some View { private var photoOption: some View {
VStack(spacing: 0) { HStack(spacing: 0) {
#if os(iOS) #if os(iOS)
Button { Button {
openCamera() openCamera()
@@ -89,9 +134,13 @@ struct PhotoDock: View {
} }
.hoverEffect(.lift) .hoverEffect(.lift)
#endif #endif
PhotosPicker(selection: $photosPickerItem, matching: .images, preferredItemEncoding: .compatible) { PhotosPicker(selection: $photosPickerItems, matching: .images, preferredItemEncoding: .compatible) {
Image(systemName: "photo.fill.on.rectangle.fill") Image(systemName: "photo.fill.on.rectangle.fill")
#if os(macOS)
.frame(width: size * 2, height: size)
#else
.frame(width: size, height: size) .frame(width: size, height: size)
#endif
.clipShape(.rect(cornerRadius: 8)) .clipShape(.rect(cornerRadius: 8))
.contentShape(.rect(cornerRadius: 8)) .contentShape(.rect(cornerRadius: 8))
} }
@@ -126,9 +175,6 @@ struct PhotoDock: View {
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill(.regularMaterial) .fill(.regularMaterial)
} }
.padding(.trailing, 10)
.frame(maxHeight: .infinity)
.transition(.move(edge: .trailing).combined(with: .blurReplace))
} }
private var compactPhotoOption: some View { private var compactPhotoOption: some View {
@@ -144,7 +190,7 @@ struct PhotoDock: View {
} }
.hoverEffect(.lift) .hoverEffect(.lift)
#endif #endif
PhotosPicker(selection: $photosPickerItem, matching: .images, preferredItemEncoding: .compatible) { PhotosPicker(selection: $photosPickerItems, matching: .images, preferredItemEncoding: .compatible) {
Image(systemName: "photo.fill.on.rectangle.fill") Image(systemName: "photo.fill.on.rectangle.fill")
.frame(width: size, height: size) .frame(width: size, height: size)
.clipShape(.rect(cornerRadius: 8)) .clipShape(.rect(cornerRadius: 8))
@@ -177,13 +223,87 @@ struct PhotoDock: View {
#endif #endif
} }
} }
}
@ViewBuilder
private var photoItemGrid: some View {
let padding: CGFloat = 5
let size = (self.size * 2 - (5 + padding * 2)) / 2
let columns: [GridItem] = .init(repeating: GridItem(.flexible(), spacing: 5), count: 2)
ScrollView(showsIndicators: false) {
LazyVGrid(columns: columns, spacing: 5) {
ForEach(fileObjects) { file in
Group {
let previewSize = file.previewSize(size)
if let previewImage = file.previewImage {
Image(image: previewImage)
.resizable()
.frame(width: previewSize.width, height: previewSize.height)
.onTapGesture {
if tool.selectedPhotoFile == file {
tool.unselectPhoto()
} else {
tool.selectPhoto(file)
}
}
} else {
Color.gray.opacity(0.5)
}
}
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 5))
.overlay {
if tool.selectedPhotoFile == file {
RoundedRectangle(cornerRadius: 5)
.stroke(Color.accentColor, lineWidth: 2.5)
}
}
}
}
.padding(padding)
}
.background { .background {
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.fill(.regularMaterial) .fill(.regularMaterial)
} }
.padding(.bottom, 10) }
.frame(maxWidth: .infinity)
.transition(.move(edge: .bottom).combined(with: .blurReplace)) @ViewBuilder
private var compactPhotoItemList: some View {
let padding: CGFloat = 5
let size = self.size - padding * 2
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 5) {
ForEach(fileObjects) { file in
Group {
let previewSize = file.previewSize(size)
if let previewImage = file.previewImage {
Image(image: previewImage)
.resizable()
.frame(width: previewSize.width, height: previewSize.height)
.onTapGesture {
if tool.selectedPhotoFile == file {
tool.unselectPhoto()
} else {
tool.selectPhoto(file)
}
}
} else {
Color.gray.opacity(0.5)
}
}
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 5))
.overlay {
if tool.selectedPhotoFile == file {
RoundedRectangle(cornerRadius: 5)
.stroke(Color.accentColor, lineWidth: 2.5)
}
}
}
}
.padding(padding)
}
} }
private func openCamera() { private func openCamera() {
@@ -205,4 +325,20 @@ struct PhotoDock: View {
isCameraAccessDenied = true isCameraAccessDenied = true
} }
} }
private func createFile(for photoItem: PhotosPickerItem) async {
let data = try? await photoItem.loadTransferable(type: Data.self)
if let data, let image = Platform.Image(data: data) {
tool.createFile(image, with: canvas.object)
}
}
private func saveFile() {
withPersistenceSync(\.backgroundContext) { context in
try context.saveIfNeeded()
withPersistenceSync(\.viewContext) { context in
try context.saveIfNeeded()
}
}
}
} }

View File

@@ -11,7 +11,6 @@ import Foundation
struct PhotoItem: Identifiable, Equatable { struct PhotoItem: Identifiable, Equatable {
var id: URL var id: URL
let image: Platform.Image let image: Platform.Image
let previewImage: Platform.Image
let dimension: CGSize let dimension: CGSize
let bookmark: Data let bookmark: Data

View File

@@ -1,55 +0,0 @@
//
// PhotoPreview.swift
// Memola
//
// Created by Dscyre Scotti on 6/15/24.
//
import SwiftUI
struct PhotoPreview: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
private let photoItem: PhotoItem
@ObservedObject private var tool: Tool
init(photoItem: PhotoItem, tool: Tool) {
self.photoItem = photoItem
self.tool = tool
}
var body: some View {
Image(image: photoItem.previewImage)
.resizable()
.scaledToFit()
.frame(width: horizontalSizeClass == .compact ? 80 : nil, height: horizontalSizeClass == .compact ? nil : 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)
#if os(iOS)
.hoverEffect(.lift)
#endif
.offset(x: -12, y: -12)
}
.padding(10)
}
}

View File

@@ -20,7 +20,8 @@ final class Persistence {
}() }()
lazy var backgroundContext: NSManagedObjectContext = { lazy var backgroundContext: NSManagedObjectContext = {
let context = persistentContainer.newBackgroundContext() let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.parent = viewContext
context.undoManager = nil context.undoManager = nil
context.automaticallyMergesChangesFromParent = true context.automaticallyMergesChangesFromParent = true
return context return context

View File

@@ -12,4 +12,5 @@ import Foundation
final class GraphicContextObject: NSManagedObject { final class GraphicContextObject: NSManagedObject {
@NSManaged var canvas: CanvasObject? @NSManaged var canvas: CanvasObject?
@NSManaged var elements: NSMutableOrderedSet @NSManaged var elements: NSMutableOrderedSet
@NSManaged var files: NSMutableOrderedSet
} }

View File

@@ -20,4 +20,8 @@ final class MemoObject: NSManagedObject, Identifiable {
@NSManaged var preview: Data? @NSManaged var preview: Data?
@NSManaged var tool: ToolObject @NSManaged var tool: ToolObject
@NSManaged var canvas: CanvasObject @NSManaged var canvas: CanvasObject
var files: [PhotoFileObject] {
canvas.graphicContext.files.compactMap { $0 as? PhotoFileObject }
}
} }

View File

@@ -0,0 +1,40 @@
//
// PhotoFileObject.swift
// Memola
//
// Created by Dscyre Scotti on 7/20/24.
//
import CoreData
import Foundation
@objc(PhotoFileObject)
final class PhotoFileObject: NSManagedObject, Identifiable {
@NSManaged var createdAt: Date?
@NSManaged var imageURL: URL?
@NSManaged var bookmark: Data?
@NSManaged var dimension: [CGFloat]
@NSManaged var photos: NSMutableSet?
@NSManaged var graphicContext: GraphicContextObject?
var previewImage: Platform.Image? {
guard let imageURL = bookmark?.getBookmarkURL() else { return nil }
guard let data = try? Data(contentsOf: imageURL, options: []) else { return nil }
return Platform.Image(data: data)
}
func previewSize(_ size: CGFloat) -> (width: CGFloat, height: CGFloat) {
let minDimension = min(dimension[0], dimension[1])
let width = size * dimension[0] / minDimension
let height = size * dimension[1] / minDimension
return (width, height)
}
func photoDimension() -> CGSize {
let maxSize = max(dimension[0], dimension[1])
let width = dimension[0] * 100 / maxSize
let height = dimension[1] * 100 / maxSize
return CGSize(width: width, height: height)
}
}

View File

@@ -16,7 +16,7 @@ final class PhotoObject: NSManagedObject {
@NSManaged var height: CGFloat @NSManaged var height: CGFloat
@NSManaged var bounds: [CGFloat] @NSManaged var bounds: [CGFloat]
@NSManaged var createdAt: Date? @NSManaged var createdAt: Date?
@NSManaged var imageURL: URL?
@NSManaged var bookmark: Data? @NSManaged var file: PhotoFileObject?
@NSManaged var element: ElementObject? @NSManaged var element: ElementObject?
} }

View File

@@ -26,6 +26,7 @@
<entity name="GraphicContextObject" representedClassName="GraphicContextObject" syncable="YES"> <entity name="GraphicContextObject" representedClassName="GraphicContextObject" syncable="YES">
<relationship name="canvas" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CanvasObject" inverseName="graphicContext" inverseEntity="CanvasObject"/> <relationship name="canvas" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CanvasObject" inverseName="graphicContext" inverseEntity="CanvasObject"/>
<relationship name="elements" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="ElementObject" inverseName="graphicContext" inverseEntity="ElementObject"/> <relationship name="elements" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="ElementObject" inverseName="graphicContext" inverseEntity="ElementObject"/>
<relationship name="files" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PhotoFileObject" inverseName="graphicContext" inverseEntity="PhotoFileObject"/>
</entity> </entity>
<entity name="MemoObject" representedClassName="MemoObject" syncable="YES"> <entity name="MemoObject" representedClassName="MemoObject" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
@@ -46,6 +47,14 @@
<attribute name="thickness" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> <attribute name="thickness" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<relationship name="tool" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ToolObject" inverseName="pens" inverseEntity="ToolObject"/> <relationship name="tool" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ToolObject" inverseName="pens" inverseEntity="ToolObject"/>
</entity> </entity>
<entity name="PhotoFileObject" representedClassName="PhotoFileObject" syncable="YES">
<attribute name="bookmark" optional="YES" attributeType="Binary"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="dimension" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[CGFloat]"/>
<attribute name="imageURL" optional="YES" attributeType="URI"/>
<relationship name="graphicContext" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="GraphicContextObject" inverseName="files" inverseEntity="GraphicContextObject"/>
<relationship name="photos" toMany="YES" deletionRule="Cascade" destinationEntity="PhotoObject" inverseName="file" inverseEntity="PhotoObject"/>
</entity>
<entity name="PhotoObject" representedClassName="PhotoObject" syncable="YES"> <entity name="PhotoObject" representedClassName="PhotoObject" syncable="YES">
<attribute name="bookmark" optional="YES" attributeType="Binary"/> <attribute name="bookmark" optional="YES" attributeType="Binary"/>
<attribute name="bounds" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[CGFloat]"/> <attribute name="bounds" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[CGFloat]"/>
@@ -56,6 +65,7 @@
<attribute name="originY" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> <attribute name="originY" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="width" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> <attribute name="width" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<relationship name="element" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ElementObject" inverseName="photo" inverseEntity="ElementObject"/> <relationship name="element" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ElementObject" inverseName="photo" inverseEntity="ElementObject"/>
<relationship name="file" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PhotoFileObject" inverseName="photos" inverseEntity="PhotoFileObject"/>
</entity> </entity>
<entity name="QuadObject" representedClassName="QuadObject" syncable="YES"> <entity name="QuadObject" representedClassName="QuadObject" syncable="YES">
<attribute name="color" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[CGFloat]"/> <attribute name="color" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[CGFloat]"/>