feat: implement photo dock

This commit is contained in:
dscyrescotti
2024-07-20 17:32:45 +07:00
parent a260518602
commit d9ef99bc22
18 changed files with 230 additions and 76 deletions

View File

@@ -103,6 +103,7 @@
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 */; };
ECC4F38C2C4B9B63007EC227 /* PhotoFileObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC4F38B2C4B9B63007EC227 /* PhotoFileObject.swift */; };
ECC995A32C1E8F2800B2699A /* PhotoItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECC995A22C1E8F2800B2699A /* PhotoItem.swift */; };
ECD12A862C19EE3900B96E12 /* ElementObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A852C19EE3900B96E12 /* ElementObject.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>"; };
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>"; };
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>"; };
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>"; };
@@ -1039,6 +1041,7 @@
EC9AB09E2C1401A40076AF58 /* EraserObject.swift */,
ECD12A852C19EE3900B96E12 /* ElementObject.swift */,
ECD12A8B2C1AEAA900B96E12 /* PhotoObject.swift */,
ECC4F38B2C4B9B63007EC227 /* PhotoFileObject.swift */,
);
path = Objects;
sourceTree = "<group>";
@@ -1162,6 +1165,7 @@
ECA738DA2BE60FF100A4542E /* CacheRenderPass.swift in Sources */,
ECF7B2DA2C39169C004D2C57 /* Float++.swift in Sources */,
ECA738CD2BE60F2F00A4542E /* PointGridContext.swift in Sources */,
ECC4F38C2C4B9B63007EC227 /* PhotoFileObject.swift in Sources */,
EC6E3BDE2C43D5A500DD20F3 /* SidebarVisibility.swift in Sources */,
ECFA15202BEF21EF00455818 /* MemoObject.swift in Sources */,
ECE883C12C00C9CB0045C53D /* StrokeStyle.swift in Sources */,

View File

@@ -153,7 +153,7 @@ extension GraphicContext {
}
}
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)
tree.insert(_photo.element, in: _photo.photoBox)
default:
@@ -312,23 +312,26 @@ extension GraphicContext {
// MARK: - Photo
extension GraphicContext {
func insertPhoto(at point: CGPoint, photoItem: PhotoItem) -> Photo {
let size = photoItem.getDimension()
func insertPhoto(at point: CGPoint, photoFile: PhotoFileObject) -> Photo {
let size = photoFile.photoDimension()
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)
let photo = Photo(url: photoFile.imageURL, size: size, origin: origin, bounds: bounds, createdAt: .now, bookmark: photoFile.bookmark)
tree.insert(photo.element, in: photo.photoBox)
let photoFileID = photoFile.objectID
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)
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
photo.file = photoFile
photoFile.photos?.add(photo)
let element = ElementObject(\.backgroundContext)
element.createdAt = _photo.createdAt
element.type = 1

View File

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

View File

@@ -37,12 +37,12 @@ final class Photo: @unchecked Sendable, Equatable {
convenience init(object: PhotoObject) {
self.init(
url: object.imageURL,
url: object.file?.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
bookmark: object.file?.bookmark
)
self.object = object
}

View File

@@ -19,7 +19,7 @@ final class Tool: NSObject, ObservableObject {
@Published var selectedPen: Pen?
@Published var draggedPen: Pen?
// MARK: - Photo
@Published var selectedPhotoItem: PhotoItem?
@Published var selectedPhotoFile: PhotoFileObject?
@Published var isLoadingPhoto: Bool = false
@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 }
let photoItem = bookmarkPhoto(of: resizedImage, and: image, in: dimension, with: canvasID)
withAnimation {
selectedPhotoItem = photoItem
isLoadingPhoto = false
guard let photoItem = bookmarkPhoto(of: resizedImage, and: image, in: dimension, with: canvas.objectID) else { return }
let _dimension = photoItem.dimension
let graphicContext = canvas.graphicContext
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)? {
let targetSize = CGSize(width: 512, height: 512)
let size = image.size
@@ -198,25 +215,10 @@ final class Tool: NSObject, ObservableObject {
var photoBookmark: PhotoItem?
do {
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 {
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
}
}
}

View File

@@ -329,17 +329,15 @@ extension CanvasViewController {
}
@objc private func recognizeTapGesture(_ gesture: Platform.TapGestureRecognizer) {
guard let photoItem = tool.selectedPhotoItem else { return }
withAnimation {
tool.selectedPhotoItem = nil
}
guard let photoFile = tool.selectedPhotoFile else { return }
tool.selectedPhotoFile = nil
#if os(macOS)
let pointInLeftBottomOrigin = gesture.location(in: drawingView)
let point = CGPoint(x: pointInLeftBottomOrigin.x, y: drawingView.bounds.height - pointInLeftBottomOrigin.y)
#else
let point = gesture.location(in: drawingView)
#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))
drawingView.draw()
}

View File

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

View File

@@ -167,6 +167,4 @@ struct ElementToolbar: View {
.frame(maxWidth: .infinity)
.transition(.move(edge: .bottom).combined(with: .blurReplace))
}
}

View File

@@ -75,14 +75,7 @@ struct MemoView: View {
case .pen:
PenDock(tool: tool, canvas: canvas)
case .photo:
ZStack(alignment: .bottomTrailing) {
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)
PhotoDock(memo: memo, tool: tool, canvas: canvas)
default:
EmptyView()
}
@@ -99,13 +92,6 @@ struct MemoView: View {
switch tool.selection {
case .pen:
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:
EmptyView()
}

View File

@@ -38,7 +38,7 @@ struct PenDock: View {
VStack(alignment: .trailing, spacing: 5) {
penPropertyTool
penItemList
.frame(maxWidth: proxy.size.width * 0.4)
.frame(maxHeight: proxy.size.height * 0.4)
}
.fixedSize()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
@@ -187,6 +187,40 @@ struct PenDock: View {
.padding(.leading, 10)
.contentShape(.rect)
.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 {
Button {
tool.selectPen(pen)
@@ -222,12 +256,12 @@ struct PenDock: View {
.disabled(tool.markers.count <= 1)
}
.controlGroupStyle(.menu)
#endif
} preview: {
penPreview(pen)
.drawingGroup()
#if os(iOS)
.contentShape(.contextMenuPreview, .rect(cornerRadius: 10))
#else
#endif
}
.onDrag(if: pen.strokeStyle != .eraser) {

View File

@@ -11,6 +11,9 @@ import PhotosUI
struct PhotoDock: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@FetchRequest private var fileObjects: FetchedResults<PhotoFileObject>
private let memo: MemoObject
private let size: CGFloat = 40
@ObservedObject private var tool: Tool
@@ -20,15 +23,30 @@ struct PhotoDock: View {
@State private var isCameraAccessDenied: Bool = false
@State private var photosPickerItem: PhotosPickerItem?
init(tool: Tool, canvas: Canvas) {
init(memo: MemoObject, tool: Tool, canvas: Canvas) {
self.memo = memo
self.tool = tool
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 {
Group {
#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
if horizontalSizeClass == .regular {
photoOption
@@ -41,7 +59,7 @@ struct PhotoDock: View {
#if os(iOS)
.fullScreenCover(isPresented: $opensCamera) {
let image: Binding<UIImage?> = Binding {
tool.selectedPhotoItem?.image
tool.selectedPhotoFile?.image
} set: { image in
guard let image else { return }
tool.selectPhoto(image, for: canvas.canvasID)
@@ -63,21 +81,19 @@ struct PhotoDock: View {
}
#endif
.onChange(of: photosPickerItem) { oldValue, newValue in
if newValue != nil {
if let photoItem = newValue {
Task {
tool.isLoadingPhoto = true
let data = try? await newValue?.loadTransferable(type: Data.self)
if let data, let image = Platform.Image(data: data) {
tool.selectPhoto(image, for: canvas.canvasID)
}
await createFile(for: photoItem)
photosPickerItem = nil
tool.isLoadingPhoto = false
}
}
}
}
private var photoOption: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
#if os(iOS)
Button {
openCamera()
@@ -91,7 +107,11 @@ struct PhotoDock: View {
#endif
PhotosPicker(selection: $photosPickerItem, matching: .images, preferredItemEncoding: .compatible) {
Image(systemName: "photo.fill.on.rectangle.fill")
#if os(macOS)
.frame(width: size * 2, height: size)
#else
.frame(width: size, height: size)
#endif
.clipShape(.rect(cornerRadius: 8))
.contentShape(.rect(cornerRadius: 8))
}
@@ -126,9 +146,6 @@ struct PhotoDock: View {
RoundedRectangle(cornerRadius: 8)
.fill(.regularMaterial)
}
.padding(.trailing, 10)
.frame(maxHeight: .infinity)
.transition(.move(edge: .trailing).combined(with: .blurReplace))
}
private var compactPhotoOption: some View {
@@ -186,6 +203,49 @@ struct PhotoDock: View {
.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() {
let status = AVCaptureDevice.authorizationStatus(for: .video)
switch status {
@@ -205,4 +265,11 @@ struct PhotoDock: View {
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)
}
}
}

View File

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

View File

@@ -13,16 +13,23 @@ struct PhotoPreview: View {
private let photoItem: PhotoItem
@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) {
self.photoItem = photoItem
self.tool = tool
}
var body: some View {
Image(image: photoItem.previewImage)
Image(image: photoItem.image)
.resizable()
.scaledToFit()
.frame(width: horizontalSizeClass == .compact ? 80 : nil, height: horizontalSizeClass == .compact ? nil : 100)
.frame(width: previewWidth, height: previewHeight)
.cornerRadius(5)
.overlay {
RoundedRectangle(cornerRadius: 5)
@@ -33,7 +40,7 @@ struct PhotoPreview: View {
.cornerRadius(5)
.overlay(alignment: .topLeading) {
Button {
tool.unselectPhoto()
// tool.unselectPhoto()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)

View File

@@ -12,4 +12,5 @@ import Foundation
final class GraphicContextObject: NSManagedObject {
@NSManaged var canvas: CanvasObject?
@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 tool: ToolObject
@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 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 bounds: [CGFloat]
@NSManaged var createdAt: Date?
@NSManaged var imageURL: URL?
@NSManaged var bookmark: Data?
@NSManaged var file: PhotoFileObject?
@NSManaged var element: ElementObject?
}

View File

@@ -26,6 +26,7 @@
<entity name="GraphicContextObject" representedClassName="GraphicContextObject" syncable="YES">
<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="files" optional="YES" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="PhotoFileObject" inverseName="graphicContext" inverseEntity="PhotoFileObject"/>
</entity>
<entity name="MemoObject" representedClassName="MemoObject" syncable="YES">
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
@@ -46,6 +47,14 @@
<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"/>
</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">
<attribute name="bookmark" optional="YES" attributeType="Binary"/>
<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="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="file" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PhotoFileObject" inverseName="photos" inverseEntity="PhotoFileObject"/>
</entity>
<entity name="QuadObject" representedClassName="QuadObject" syncable="YES">
<attribute name="color" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer" customClassName="[CGFloat]"/>