mirror of
https://github.com/dscyrescotti/Memola.git
synced 2026-04-20 07:41:26 +02:00
feat: implement photo dock
This commit is contained in:
@@ -103,6 +103,7 @@
|
|||||||
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 */; };
|
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 */; };
|
||||||
@@ -244,6 +245,7 @@
|
|||||||
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>"; };
|
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>"; };
|
||||||
@@ -1039,6 +1041,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 +1165,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 */,
|
||||||
|
|||||||
@@ -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 = context.object(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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,32 @@ 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 photoItem = bookmarkPhoto(of: resizedImage, and: image, in: dimension, with: canvas.objectID) else { return }
|
||||||
withAnimation {
|
let _dimension = photoItem.dimension
|
||||||
selectedPhotoItem = photoItem
|
let graphicContext = canvas.graphicContext
|
||||||
isLoadingPhoto = false
|
withPersistence(\.viewContext) { [weak graphicContext = graphicContext] context in
|
||||||
|
let file = PhotoFileObject(\.viewContext)
|
||||||
|
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)
|
||||||
|
try context.saveIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 +215,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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -167,6 +167,4 @@ struct ElementToolbar: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.transition(.move(edge: .bottom).combined(with: .blurReplace))
|
.transition(.move(edge: .bottom).combined(with: .blurReplace))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(memo: memo, 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ 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 memo: MemoObject
|
||||||
private let size: CGFloat = 40
|
private let size: CGFloat = 40
|
||||||
|
|
||||||
@ObservedObject private var tool: Tool
|
@ObservedObject private var tool: Tool
|
||||||
@@ -20,15 +23,30 @@ struct PhotoDock: View {
|
|||||||
@State private var isCameraAccessDenied: Bool = false
|
@State private var isCameraAccessDenied: Bool = false
|
||||||
@State private var photosPickerItem: PhotosPickerItem?
|
@State private var photosPickerItem: PhotosPickerItem?
|
||||||
|
|
||||||
init(tool: Tool, canvas: Canvas) {
|
init(memo: MemoObject, tool: Tool, canvas: Canvas) {
|
||||||
|
self.memo = memo
|
||||||
self.tool = tool
|
self.tool = tool
|
||||||
self.canvas = canvas
|
self.canvas = canvas
|
||||||
|
|
||||||
|
let predicate: NSPredicate = NSPredicate(format: "graphicContext = %@", memo.canvas.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
|
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))
|
||||||
#else
|
#else
|
||||||
if horizontalSizeClass == .regular {
|
if horizontalSizeClass == .regular {
|
||||||
photoOption
|
photoOption
|
||||||
@@ -41,7 +59,7 @@ 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
|
tool.selectedPhotoFile?.image
|
||||||
} set: { image in
|
} set: { image in
|
||||||
guard let image else { return }
|
guard let image else { return }
|
||||||
tool.selectPhoto(image, for: canvas.canvasID)
|
tool.selectPhoto(image, for: canvas.canvasID)
|
||||||
@@ -63,21 +81,19 @@ struct PhotoDock: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
.onChange(of: photosPickerItem) { oldValue, newValue in
|
.onChange(of: photosPickerItem) { oldValue, newValue in
|
||||||
if newValue != nil {
|
if let photoItem = newValue {
|
||||||
Task {
|
Task {
|
||||||
tool.isLoadingPhoto = true
|
tool.isLoadingPhoto = true
|
||||||
let data = try? await newValue?.loadTransferable(type: Data.self)
|
await createFile(for: photoItem)
|
||||||
if let data, let image = Platform.Image(data: data) {
|
|
||||||
tool.selectPhoto(image, for: canvas.canvasID)
|
|
||||||
}
|
|
||||||
photosPickerItem = nil
|
photosPickerItem = nil
|
||||||
|
tool.isLoadingPhoto = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
@@ -91,7 +107,11 @@ struct PhotoDock: View {
|
|||||||
#endif
|
#endif
|
||||||
PhotosPicker(selection: $photosPickerItem, matching: .images, preferredItemEncoding: .compatible) {
|
PhotosPicker(selection: $photosPickerItem, 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 +146,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 {
|
||||||
@@ -186,6 +203,49 @@ struct PhotoDock: View {
|
|||||||
.transition(.move(edge: .bottom).combined(with: .blurReplace))
|
.transition(.move(edge: .bottom).combined(with: .blurReplace))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
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 {
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(.regularMaterial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func openCamera() {
|
private func openCamera() {
|
||||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||||
switch status {
|
switch status {
|
||||||
@@ -205,4 +265,11 @@ 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: memo.canvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -13,16 +13,23 @@ struct PhotoPreview: View {
|
|||||||
private let photoItem: PhotoItem
|
private let photoItem: PhotoItem
|
||||||
@ObservedObject private var tool: Tool
|
@ObservedObject private var tool: Tool
|
||||||
|
|
||||||
|
private var previewWidth: CGFloat? {
|
||||||
|
horizontalSizeClass == .compact ? 80 : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var previewHeight: CGFloat? {
|
||||||
|
horizontalSizeClass == .compact ? nil : 100
|
||||||
|
}
|
||||||
|
|
||||||
init(photoItem: PhotoItem, tool: Tool) {
|
init(photoItem: PhotoItem, tool: Tool) {
|
||||||
self.photoItem = photoItem
|
self.photoItem = photoItem
|
||||||
self.tool = tool
|
self.tool = tool
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Image(image: photoItem.previewImage)
|
Image(image: photoItem.image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.frame(width: previewWidth, height: previewHeight)
|
||||||
.frame(width: horizontalSizeClass == .compact ? 80 : nil, height: horizontalSizeClass == .compact ? nil : 100)
|
|
||||||
.cornerRadius(5)
|
.cornerRadius(5)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 5)
|
RoundedRectangle(cornerRadius: 5)
|
||||||
@@ -33,7 +40,7 @@ struct PhotoPreview: View {
|
|||||||
.cornerRadius(5)
|
.cornerRadius(5)
|
||||||
.overlay(alignment: .topLeading) {
|
.overlay(alignment: .topLeading) {
|
||||||
Button {
|
Button {
|
||||||
tool.unselectPhoto()
|
// tool.unselectPhoto()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "xmark.circle.fill")
|
Image(systemName: "xmark.circle.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
Memola/Persistence/Objects/PhotoFileObject.swift
Normal file
40
Memola/Persistence/Objects/PhotoFileObject.swift
Normal 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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user