feat: add memo preview generation

This commit is contained in:
dscyrescotti
2024-07-05 17:49:11 +07:00
parent 1309e4a9dc
commit c46424ba87
21 changed files with 377 additions and 49 deletions

View File

@@ -112,6 +112,8 @@
ECD12A932C1B062000B96E12 /* Photo.metal in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A922C1B062000B96E12 /* Photo.metal */; };
ECD12A952C1B1FA200B96E12 /* PhotoVertex.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD12A942C1B1FA200B96E12 /* PhotoVertex.swift */; };
ECDAC07B2C318DBC0000ED77 /* ElementToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECDAC07A2C318DBC0000ED77 /* ElementToolbar.swift */; };
ECDDD40D2C366B3B00DF9D5E /* PreviewRenderPass.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECDDD40C2C366B3B00DF9D5E /* PreviewRenderPass.swift */; };
ECDDD40F2C368B2700DF9D5E /* MTLTexture++.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECDDD40E2C368B2700DF9D5E /* MTLTexture++.swift */; };
ECE883BD2C00AA170045C53D /* EraserStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECE883BC2C00AA170045C53D /* EraserStroke.swift */; };
ECE883BF2C00AB440045C53D /* Stroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECE883BE2C00AB440045C53D /* Stroke.swift */; };
ECE883C12C00C9CB0045C53D /* StrokeStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECE883C02C00C9CB0045C53D /* StrokeStyle.swift */; };
@@ -232,6 +234,8 @@
ECD12A922C1B062000B96E12 /* Photo.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Photo.metal; sourceTree = "<group>"; };
ECD12A942C1B1FA200B96E12 /* PhotoVertex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoVertex.swift; sourceTree = "<group>"; };
ECDAC07A2C318DBC0000ED77 /* ElementToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementToolbar.swift; sourceTree = "<group>"; };
ECDDD40C2C366B3B00DF9D5E /* PreviewRenderPass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewRenderPass.swift; sourceTree = "<group>"; };
ECDDD40E2C368B2700DF9D5E /* MTLTexture++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MTLTexture++.swift"; sourceTree = "<group>"; };
ECE883BC2C00AA170045C53D /* EraserStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EraserStroke.swift; sourceTree = "<group>"; };
ECE883BE2C00AB440045C53D /* Stroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Stroke.swift; sourceTree = "<group>"; };
ECE883C02C00C9CB0045C53D /* StrokeStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrokeStyle.swift; sourceTree = "<group>"; };
@@ -629,6 +633,7 @@
ECBE529D2C1DAB21006BDB3D /* UIImage++.swift */,
ECC995A42C1EB4CC00B2699A /* Data++.swift */,
EC18150E2C2DB13200541369 /* Date++.swift */,
ECDDD40E2C368B2700DF9D5E /* MTLTexture++.swift */,
);
path = Extensions;
sourceTree = "<group>";
@@ -738,6 +743,7 @@
ECA738E12BE610D000A4542E /* GraphicRenderPass.swift */,
ECD12A902C1B04EA00B96E12 /* PhotoRenderPass.swift */,
EC5D40802C21CE270067F090 /* PhotoBackgroundRenderPass.swift */,
ECDDD40C2C366B3B00DF9D5E /* PreviewRenderPass.swift */,
);
path = RenderPasses;
sourceTree = "<group>";
@@ -991,6 +997,7 @@
ECA738AA2BE6026D00A4542E /* Uniforms.swift in Sources */,
EC1815082C2D980B00541369 /* Sort.swift in Sources */,
ECA7387D2BE5EF4B00A4542E /* MemoView.swift in Sources */,
ECDDD40D2C366B3B00DF9D5E /* PreviewRenderPass.swift in Sources */,
ECA738DA2BE60FF100A4542E /* CacheRenderPass.swift in Sources */,
ECA738CD2BE60F2F00A4542E /* PointGridContext.swift in Sources */,
ECFA15202BEF21EF00455818 /* MemoObject.swift in Sources */,
@@ -1067,6 +1074,7 @@
EC8F54AC2C2ACDA8001C7C74 /* GridMode.swift in Sources */,
EC7F6BEC2BE5E6E300A34A7B /* MemolaApp.swift in Sources */,
EC2BEBF82C0F601A005DB0AF /* Node.swift in Sources */,
ECDDD40F2C368B2700DF9D5E /* MTLTexture++.swift in Sources */,
ECC995A52C1EB4CC00B2699A /* Data++.swift in Sources */,
ECA738A02BE601E400A4542E /* ViewPortVertex.swift in Sources */,
ECD12A8A2C19EFB000B96E12 /* Element.swift in Sources */,

View File

@@ -25,6 +25,7 @@ final class Canvas: ObservableObject, Identifiable, @unchecked Sendable {
let defaultZoomScale: CGFloat = 20
var transform: simd_float4x4 = .init()
var previewTransform: simd_float4x4 = .init()
var clipBounds: CGRect = .zero
var bounds: CGRect = .zero
var uniformsBuffer: MTLBuffer?
@@ -37,6 +38,8 @@ final class Canvas: ObservableObject, Identifiable, @unchecked Sendable {
let zoomPublisher = PassthroughSubject<CGFloat, Never>()
weak var renderer: Renderer?
init(size: CGSize, canvasID: NSManagedObjectID, gridMode: Int16) {
self.size = size
self.canvasID = canvasID
@@ -78,6 +81,23 @@ extension Canvas {
context.refreshAllObjects()
}
}
func save(for memoObject: MemoObject, completion: @escaping () -> Void) {
state = .closing
let previewImage = renderer?.drawPreview(on: self)
memoObject.preview = previewImage?.jpegData(compressionQuality: 0.8)
withPersistenceSync(\.viewContext) { context in
try context.saveIfNeeded()
}
withPersistence(\.backgroundContext) { [weak self] context in
try context.saveIfNeeded()
context.refreshAllObjects()
DispatchQueue.main.async { [weak self] in
self?.state = .closed
completion()
}
}
}
}
// MARK: - Dimension
@@ -92,6 +112,29 @@ extension Canvas {
self.transform = simd_float4x4(transform1 * transform2 * transform3)
}
func updatePreviewTransform(to targetRect: CGRect) {
let bounds = CGRect(origin: .zero, size: size)
let translationTransform = CGAffineTransform(translationX: -targetRect.origin.x, y: -targetRect.origin.y)
let scaleX = bounds.width / targetRect.width
let scaleY = bounds.height / targetRect.height
let scalingTransform = CGAffineTransform(scaleX: scaleX, y: scaleY)
let combinedTransform = translationTransform.concatenating(scalingTransform)
let normalizeX = CGAffineTransform(scaleX: 1.0 / bounds.width, y: 1.0)
let normalizeY = CGAffineTransform(scaleX: 1.0, y: 1.0 / bounds.height)
let normalizeTransform = normalizeX.concatenating(normalizeY)
let normalizedTransform = combinedTransform.concatenating(normalizeTransform)
let renderScale = CGAffineTransform(scaleX: 2.0, y: 2.0)
let renderTranslation = CGAffineTransform(translationX: -1.0, y: -1.0)
let transform = normalizedTransform.concatenating(renderScale).concatenating(renderTranslation)
self.previewTransform = simd_float4x4(transform)
}
func updateClipBounds(_ scrollView: UIScrollView, on drawingView: DrawingView) {
let ratio = drawingView.ratio
let bounds = scrollView.convert(scrollView.bounds, to: drawingView)
@@ -197,6 +240,12 @@ extension Canvas {
uniformsBuffer = device.makeBuffer(bytes: &uniforms, length: MemoryLayout<Uniforms>.size)
renderEncoder.setVertexBuffer(uniformsBuffer, offset: 0, index: 11)
}
func setPreviewUniformsBuffer(device: MTLDevice, renderEncoder: MTLRenderCommandEncoder) {
var uniforms = Uniforms(transform: previewTransform)
uniformsBuffer = device.makeBuffer(bytes: &uniforms, length: MemoryLayout<Uniforms>.size)
renderEncoder.setVertexBuffer(uniformsBuffer, offset: 0, index: 11)
}
}
// MARK: - State

View File

@@ -40,6 +40,9 @@ final class Renderer {
lazy var photoBackgroundRenderPass: PhotoBackgroundRenderPass = {
PhotoBackgroundRenderPass(renderer: self)
}()
lazy var previewRenderPass: PreviewRenderPass = {
PreviewRenderPass(renderer: self)
}()
init(canvasView: MTKView) {
guard let device = MTLCreateSystemDefaultDevice() else {
@@ -106,4 +109,21 @@ final class Renderer {
viewPortRenderPass.cacheTexture = cacheRenderPass.cacheTexture
viewPortRenderPass.draw(into: commandBuffer, on: canvas, with: self)
}
func drawPreview(on canvas: Canvas) -> UIImage? {
guard let commandBuffer = commandQueue.makeCommandBuffer() else {
NSLog("[Memola] - Unable to create command buffer")
return nil
}
strokeRenderPass.eraserRenderPass = eraserRenderPass
previewRenderPass.photoRenderPass = photoRenderPass
previewRenderPass.strokeRenderPass = strokeRenderPass
previewRenderPass.eraserRenderPass = eraserRenderPass
previewRenderPass.draw(into: commandBuffer, on: canvas, with: self)
guard let cgImage = previewRenderPass.previewTexture?.getImage() else {
return nil
}
return UIImage(cgImage: cgImage, scale: 1.0, orientation: .downMirrored)
}
}

View File

@@ -139,4 +139,27 @@ class Textures {
texture.label = "Photo Background Texture"
return texture
}
static func createPreviewTexture(
from renderer: Renderer,
size: CGSize,
pixelFormat: MTLPixelFormat? = nil
) -> MTLTexture? {
let width = Int(size.width)
let height = Int(size.height)
guard width > 0, height > 0 else { return nil }
let descriptor = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: pixelFormat ?? renderer.pixelFormat,
width: width,
height: height,
mipmapped: false
)
descriptor.storageMode = .shared
descriptor.usage = [.shaderRead, .renderTarget, .shaderWrite]
guard let texture = renderer.device.makeTexture(descriptor: descriptor) else {
return nil
}
texture.label = "Preview Texture"
return texture
}
}

View File

@@ -41,6 +41,15 @@ enum Element: Equatable, Comparable {
}
}
var box: Box {
switch self {
case .stroke(let anyStroke):
anyStroke.value.strokeBox
case .photo(let photo):
photo.photoBox
}
}
var elementGroupType: ElementGroup.ElementGroupType {
switch self {
case .stroke(let anyStroke):

View File

@@ -28,6 +28,15 @@ class EraserRenderPass: RenderPass {
@discardableResult
func draw(into commandBuffer: any MTLCommandBuffer, on canvas: Canvas, with renderer: Renderer) -> Bool {
draw(into: commandBuffer, on: canvas, with: renderer, isPreview: false)
}
@discardableResult
func drawPreview(into commandBuffer: any MTLCommandBuffer, on canvas: Canvas, with renderer: Renderer) -> Bool {
draw(into: commandBuffer, on: canvas, with: renderer, isPreview: true)
}
private func draw(into commandBuffer: any MTLCommandBuffer, on canvas: Canvas, with renderer: Renderer, isPreview: Bool) -> Bool {
guard let elementGroup else { return false }
guard let descriptor else { return false }
@@ -65,7 +74,11 @@ class EraserRenderPass: RenderPass {
guard let eraserPipelineState else { return false }
renderEncoder.setRenderPipelineState(eraserPipelineState)
canvas.setUniformsBuffer(device: renderer.device, renderEncoder: renderEncoder)
if isPreview {
canvas.setPreviewUniformsBuffer(device: renderer.device, renderEncoder: renderEncoder)
} else {
canvas.setUniformsBuffer(device: renderer.device, renderEncoder: renderEncoder)
}
if let indexBuffer {
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)

View File

@@ -69,7 +69,7 @@ class GraphicRenderPass: RenderPass {
draw(for: elementGroup, into: commandBuffer, on: canvas, with: renderer)
}
let end = Date.now.timeIntervalSince1970 * 1000
NSLog("[Memola] - duration: \(end - start)")
NSLog("[Memola] - graphic duration: \(end - start)")
renderer.redrawsGraphicRender = false
}
if let element = graphicContext.previousElement {

View File

@@ -26,6 +26,15 @@ class PhotoRenderPass: RenderPass {
@discardableResult
func draw(into commandBuffer: any MTLCommandBuffer, on canvas: Canvas, with renderer: Renderer) -> Bool {
draw(into: commandBuffer, on: canvas, with: renderer, isPreview: false)
}
@discardableResult
func drawPreview(into commandBuffer: any MTLCommandBuffer, on canvas: Canvas, with renderer: Renderer) -> Bool {
draw(into: commandBuffer, on: canvas, with: renderer, isPreview: true)
}
private func draw(into commandBuffer: any MTLCommandBuffer, on canvas: Canvas, with renderer: Renderer, isPreview: Bool) -> Bool {
guard let elementGroup else { return false }
guard let descriptor else { return false }
@@ -42,7 +51,11 @@ class PhotoRenderPass: RenderPass {
guard let photoPipelineState else { return false }
renderEncoder.setRenderPipelineState(photoPipelineState)
canvas.setUniformsBuffer(device: renderer.device, renderEncoder: renderEncoder)
if isPreview {
canvas.setPreviewUniformsBuffer(device: renderer.device, renderEncoder: renderEncoder)
} else {
canvas.setUniformsBuffer(device: renderer.device, renderEncoder: renderEncoder)
}
for photo in photos {
photo.draw(device: renderer.device, renderEncoder: renderEncoder)

View File

@@ -0,0 +1,118 @@
//
// PreviewRenderPass.swift
// Memola
//
// Created by Dscyre Scotti on 7/4/24.
//
import MetalKit
import Foundation
final class PreviewRenderPass: RenderPass {
var label: String = "Preview Render Pass"
var descriptor: MTLRenderPassDescriptor?
var previewPipelineState: MTLRenderPipelineState?
var previewTexture: MTLTexture?
weak var photoRenderPass: PhotoRenderPass?
weak var strokeRenderPass: StrokeRenderPass?
weak var eraserRenderPass: EraserRenderPass?
init(renderer: Renderer) {
descriptor = MTLRenderPassDescriptor()
previewPipelineState = renderer.graphicRenderPass.graphicPipelineState
}
func resize(on view: MTKView, to size: CGSize, with renderer: Renderer) { }
@discardableResult
func draw(into commandBuffer: any MTLCommandBuffer, on canvas: Canvas, with renderer: Renderer) -> Bool {
let tree = canvas.graphicContext.tree
if !tree.isEmpty {
var elementGroups: [ElementGroup] = []
let start = Date.now.timeIntervalSince1970 * 1000
var bounds: [CGFloat] = []
for _element in tree.traverse() {
if bounds.isEmpty {
bounds = [
_element.box.minX,
_element.box.minY,
_element.box.maxX,
_element.box.maxY
]
} else {
bounds = [
min(_element.box.minX, bounds[0]),
min(_element.box.minY, bounds[1]),
max(_element.box.maxX, bounds[2]),
max(_element.box.maxY, bounds[3])
]
}
if elementGroups.isEmpty {
let _elementGroup = ElementGroup(_element)
elementGroups.append(_elementGroup)
} else {
guard let _elementGroup = elementGroups.last else { continue }
if _elementGroup.isSameElement(_element) {
_elementGroup.add(_element)
} else {
let _elementGroup = ElementGroup(_element)
elementGroups.append(_elementGroup)
}
}
}
let origin = CGPoint(x: bounds[0], y: bounds[1])
let size = CGSize(width: bounds[2] - bounds[0], height: bounds[3] - bounds[1])
previewTexture = createPreviewTexture(for: size, with: renderer)
descriptor?.colorAttachments[0].texture = previewTexture
descriptor?.colorAttachments[0].clearColor = MTLClearColor(red: 1, green: 1, blue: 1, alpha: 0)
descriptor?.colorAttachments[0].storeAction = .store
descriptor?.colorAttachments[0].loadAction = .clear
canvas.updatePreviewTransform(to: CGRect(origin: origin, size: size))
for elementGroup in elementGroups {
draw(for: elementGroup, into: commandBuffer, on: canvas, with: renderer)
descriptor?.colorAttachments[0].loadAction = .load
}
let end = Date.now.timeIntervalSince1970 * 1000
NSLog("[Memola] - preview duration: \(end - start)")
}
commandBuffer.commit()
commandBuffer.waitUntilCompleted()
return true
}
private func createPreviewTexture(for size: CGSize, with renderer: Renderer) -> MTLTexture? {
let ratio = size.width / size.height
let dimension: CGFloat = 800
let width: CGFloat
let height: CGFloat
if dimension * ratio > dimension {
height = dimension
width = dimension * ratio
} else {
height = dimension / ratio
width = dimension
}
return Textures.createPreviewTexture(from: renderer, size: CGSize(width: width, height: height))
}
private func draw(for elementGroup: ElementGroup, into commandBuffer: MTLCommandBuffer, on canvas: Canvas, with renderer: Renderer) {
switch elementGroup.type {
case .stroke:
strokeRenderPass?.elementGroup = elementGroup
strokeRenderPass?.graphicDescriptor = descriptor
strokeRenderPass?.graphicPipelineState = previewPipelineState
strokeRenderPass?.drawPreview(into: commandBuffer, on: canvas, with: renderer)
case .eraser:
eraserRenderPass?.elementGroup = elementGroup
eraserRenderPass?.descriptor = descriptor
eraserRenderPass?.drawPreview(into: commandBuffer, on: canvas, with: renderer)
case .photo:
photoRenderPass?.elementGroup = elementGroup
photoRenderPass?.descriptor = descriptor
photoRenderPass?.drawPreview(into: commandBuffer, on: canvas, with: renderer)
}
}
}

View File

@@ -36,6 +36,15 @@ class StrokeRenderPass: RenderPass {
@discardableResult
func draw(into commandBuffer: any MTLCommandBuffer, on canvas: Canvas, with renderer: Renderer) -> Bool {
draw(into: commandBuffer, on: canvas, with: renderer, isPreview: false)
}
@discardableResult
func drawPreview(into commandBuffer: any MTLCommandBuffer, on canvas: Canvas, with renderer: Renderer) -> Bool {
draw(into: commandBuffer, on: canvas, with: renderer, isPreview: true)
}
private func draw(into commandBuffer: any MTLCommandBuffer, on canvas: Canvas, with renderer: Renderer, isPreview: Bool) -> Bool {
guard let elementGroup else { return false }
guard let descriptor else { return false }
@@ -81,7 +90,11 @@ class StrokeRenderPass: RenderPass {
guard let strokePipelineState else { return false }
renderEncoder.setRenderPipelineState(strokePipelineState)
canvas.setUniformsBuffer(device: renderer.device, renderEncoder: renderEncoder)
if isPreview {
canvas.setPreviewUniformsBuffer(device: renderer.device, renderEncoder: renderEncoder)
} else {
canvas.setUniformsBuffer(device: renderer.device, renderEncoder: renderEncoder)
}
if let penStyle = penStroke?.penStyle, let indexBuffer {
if penStyle.textureName != nil {
@@ -126,7 +139,12 @@ class StrokeRenderPass: RenderPass {
renderEncoder.setRenderPipelineState(eraserPipelineState)
canvas.setUniformsBuffer(device: renderer.device, renderEncoder: renderEncoder)
if isPreview {
canvas.setPreviewUniformsBuffer(device: renderer.device, renderEncoder: renderEncoder)
} else {
canvas.setUniformsBuffer(device: renderer.device, renderEncoder: renderEncoder)
}
if let erasedIndexBuffer {
renderEncoder.setVertexBuffer(erasedVertexBuffer, offset: 0, index: 0)
renderEncoder.drawIndexedPrimitives(

View File

@@ -33,6 +33,7 @@ class CanvasViewController: UIViewController {
self.drawingView = DrawingView(tool: tool, canvas: canvas, history: history)
self.renderer = Renderer(canvasView: drawingView.renderView)
super.init(nibName: nil, bundle: nil)
self.canvas.renderer = renderer
}
required init?(coder: NSCoder) {

View File

@@ -0,0 +1,48 @@
//
// MTLTexture++.swift
// Memola
//
// Created by Dscyre Scotti on 7/4/24.
//
import MetalKit
import Foundation
extension MTLTexture {
private func bytes() -> UnsafeMutableRawPointer {
let width = self.width
let height = self.height
let rowBytes = self.width * 4
let p = malloc(width * height * 4)!
getBytes(p, bytesPerRow: rowBytes, from: MTLRegionMake2D(0, 0, width, height), mipmapLevel: 0)
return p
}
func getImage() -> CGImage? {
let bytes = self.bytes()
let pColorSpace = CGColorSpaceCreateDeviceRGB()
let rawBitmapInfo = CGImageAlphaInfo.noneSkipFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue
let bitmapInfo = CGBitmapInfo(rawValue: rawBitmapInfo)
let selftureSize = self.width * self.height * 4
let rowBytes = self.width * 4
if let provider = CGDataProvider(dataInfo: nil, data: bytes, size: selftureSize, releaseData: { _, p, _ in
p.deallocate()
}) {
return CGImage(
width: width,
height: height,
bitsPerComponent: 8,
bitsPerPixel: 32,
bytesPerRow: rowBytes,
space: pColorSpace,
bitmapInfo: bitmapInfo,
provider: provider,
decode: nil,
shouldInterpolate: true,
intent: CGColorRenderingIntent.defaultIntent
)
}
return nil
}
}

View File

@@ -42,8 +42,8 @@ struct MemosView: View {
}
var body: some View {
MemoGrid(memoObjects: memoObjects, placeholder: placeholder) { memoObject in
memoCard(memoObject)
MemoGrid(memoObjects: memoObjects, placeholder: placeholder) { memoObject, cellWidth in
memoCard(memoObject, cellWidth)
}
.navigationTitle(horizontalSizeClass == .compact ? "Memos" : "")
.navigationBarTitleDisplayMode(.inline)
@@ -130,8 +130,8 @@ struct MemosView: View {
}
}
func memoCard(_ memoObject: MemoObject) -> some View {
MemoCard(memoObject: memoObject) { card in
func memoCard(_ memoObject: MemoObject, _ cellWidth: CGFloat) -> some View {
MemoCard(memoObject: memoObject, cellWidth: cellWidth) { card in
card
.contextMenu {
Button {

View File

@@ -9,11 +9,13 @@ import SwiftUI
struct MemoCard<Preview: View, Detail: View>: View {
let memoObject: MemoObject
let cellWidth: CGFloat
let modifyPreview: ((MemoPreview) -> Preview)?
let details: () -> Detail
init(memoObject: MemoObject, @ViewBuilder modifyPreview: @escaping (MemoPreview) -> Preview, @ViewBuilder details: @escaping () -> Detail) {
init(memoObject: MemoObject, cellWidth: CGFloat, @ViewBuilder modifyPreview: @escaping (MemoPreview) -> Preview, @ViewBuilder details: @escaping () -> Detail) {
self.memoObject = memoObject
self.cellWidth = cellWidth
self.modifyPreview = modifyPreview
self.details = details
}
@@ -21,9 +23,9 @@ struct MemoCard<Preview: View, Detail: View>: View {
var body: some View {
VStack(alignment: .leading, spacing: 5) {
if let modifyPreview {
modifyPreview(MemoPreview())
modifyPreview(MemoPreview(preview: memoObject.preview, cellWidth: cellWidth))
} else {
MemoPreview()
MemoPreview(preview: memoObject.preview, cellWidth: cellWidth)
}
VStack(alignment: .leading, spacing: 2) {
Text(memoObject.title)

View File

@@ -11,9 +11,9 @@ struct MemoGrid<Card: View>: View {
@Environment(\.horizontalSizeClass) var horizontalSizeClass
let memoObjects: FetchedResults<MemoObject>
let placeholder: Placeholder.Info
@ViewBuilder let card: (MemoObject) -> Card
@ViewBuilder let card: (MemoObject, CGFloat) -> Card
var cellWidth: CGFloat {
var maxCellWidth: CGFloat {
if horizontalSizeClass == .compact {
return 180
}
@@ -21,21 +21,27 @@ struct MemoGrid<Card: View>: View {
}
var body: some View {
if memoObjects.isEmpty {
Placeholder(info: placeholder)
} else {
GeometryReader { proxy in
let count = Int(proxy.size.width / cellWidth)
let columns: [GridItem] = .init(repeating: GridItem(.flexible(), spacing: 15), count: count)
ScrollView {
LazyVGrid(columns: columns, spacing: 15) {
ForEach(memoObjects) { memoObject in
card(memoObject)
Group {
if memoObjects.isEmpty {
Placeholder(info: placeholder)
} else {
GeometryReader { proxy in
let spacing: CGFloat = 15
let padding: CGFloat = 20
let count = Int(proxy.size.width / maxCellWidth)
let cellWidth = (proxy.size.width - spacing * CGFloat(count - 2) - padding * 2.0) / CGFloat(count)
let columns: [GridItem] = .init(repeating: GridItem(.flexible(), spacing: spacing), count: count)
ScrollView {
LazyVGrid(columns: columns, spacing: spacing) {
ForEach(memoObjects) { memoObject in
card(memoObject, cellWidth)
}
}
.padding(padding)
}
.padding()
}
}
}
.background(Color(uiColor: .secondarySystemBackground))
}
}

View File

@@ -10,6 +10,8 @@ import SwiftUI
struct MemoPreview: View {
@Environment(\.horizontalSizeClass) var horizontalSizeClass
let preview: Data?
let cellWidth: CGFloat
var cellHeight: CGFloat {
if horizontalSizeClass == .compact {
return 120
@@ -18,8 +20,17 @@ struct MemoPreview: View {
}
var body: some View {
Rectangle()
.frame(height: cellHeight)
.clipShape(RoundedRectangle(cornerRadius: 10))
Group {
if let preview, let previewImage = UIImage(data: preview) {
Image(uiImage: previewImage)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Rectangle()
.fill(.white)
}
}
.frame(width: cellWidth, height: cellHeight)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}

View File

@@ -42,9 +42,9 @@ struct TrashView: View {
} set: { _ in
deletedMemo = nil
}
MemoGrid(memoObjects: memoObjects, placeholder: placeholder) { memoObject in
memoCard(memoObject)
}
MemoGrid(memoObjects: memoObjects, placeholder: placeholder) { memoObject, cellWidth in
memoCard(memoObject, cellWidth)
}
.navigationTitle(horizontalSizeClass == .compact ? "Trash" : "")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $query, placement: .toolbar, prompt: Text("Search"))
@@ -87,8 +87,8 @@ struct TrashView: View {
}
}
func memoCard(_ memoObject: MemoObject) -> some View {
MemoCard(memoObject: memoObject) { card in
func memoCard(_ memoObject: MemoObject, _ cellWidth: CGFloat) -> some View {
MemoCard(memoObject: memoObject, cellWidth: cellWidth) { card in
card
.contextMenu {
Button {

View File

@@ -38,7 +38,7 @@ struct Sidebar: View {
.listStyle(.sidebar)
.navigationTitle(horizontalSizeClass == .compact ? "Memola" : "")
.scrollContentBackground(.hidden)
.background(Color(uiColor: .secondarySystemFill))
.background(Color(uiColor: .secondarySystemBackground))
.navigationSplitViewColumnWidth(min: 250, ideal: 250, max: 250)
.navigationBarTitleDisplayMode(horizontalSizeClass == .compact ? .automatic : .inline)
}

View File

@@ -148,21 +148,8 @@ struct Toolbar: View {
}
func closeMemo() {
withAnimation {
canvas.state = .closing
}
withPersistenceSync(\.viewContext) { context in
try context.saveIfNeeded()
}
withPersistence(\.backgroundContext) { context in
try context.saveIfNeeded()
context.refreshAllObjects()
DispatchQueue.main.async {
withAnimation {
canvas.state = .closed
}
dismiss()
}
canvas.save(for: memo) {
dismiss()
}
}
}

View File

@@ -17,6 +17,7 @@ final class MemoObject: NSManagedObject, Identifiable {
@NSManaged var deletedAt: Date?
@NSManaged var isFavorite: Bool
@NSManaged var isTrash: Bool
@NSManaged var preview: Data?
@NSManaged var tool: ToolObject
@NSManaged var canvas: CanvasObject
}

View File

@@ -32,6 +32,7 @@
<attribute name="deletedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="isFavorite" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="isTrash" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="preview" optional="YES" attributeType="Binary" allowsExternalBinaryDataStorage="YES"/>
<attribute name="title" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="canvas" maxCount="1" deletionRule="Cascade" destinationEntity="CanvasObject" inverseName="memo" inverseEntity="CanvasObject"/>