mirror of
https://github.com/dscyrescotti/Memola.git
synced 2026-05-17 21:27:09 +02:00
316 lines
11 KiB
Swift
316 lines
11 KiB
Swift
//
|
|
// PenDock.swift
|
|
// Memola
|
|
//
|
|
// Created by Dscyre Scotti on 5/4/24.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct PenDock: View {
|
|
@ObservedObject var tool: Tool
|
|
@ObservedObject var canvas: Canvas
|
|
|
|
let width: CGFloat = 90
|
|
let height: CGFloat = 30
|
|
let factor: CGFloat = 0.9
|
|
|
|
@State var refreshScrollId: UUID = UUID()
|
|
@State var opensColorPicker: Bool = false
|
|
|
|
var body: some View {
|
|
if !canvas.locksCanvas {
|
|
VStack(alignment: .trailing) {
|
|
penPropertyTool
|
|
penItemList
|
|
}
|
|
.fixedSize()
|
|
.frame(maxHeight: .infinity)
|
|
.padding(10)
|
|
.transition(.move(edge: .trailing).combined(with: .blurReplace))
|
|
}
|
|
}
|
|
|
|
var penItemList: some View {
|
|
ScrollViewReader { proxy in
|
|
ScrollView(.vertical, showsIndicators: false) {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(tool.pens) { pen in
|
|
penItemRow(pen)
|
|
.id(pen.id)
|
|
.scrollTransition { content, phase in
|
|
content
|
|
.scaleEffect(phase.isIdentity ? 1 : 0.04, anchor: .trailing)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 10)
|
|
.padding(.leading, 40)
|
|
.id(refreshScrollId)
|
|
}
|
|
.onReceive(tool.scrollPublisher) { id in
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
withAnimation {
|
|
proxy.scrollTo(id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(maxHeight:( (height * factor + 10) * 6) + 20)
|
|
.fixedSize()
|
|
.background(alignment: .trailing) {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(.regularMaterial)
|
|
.frame(width: width * factor - 18)
|
|
}
|
|
.clipShape(.rect(cornerRadii: .init(bottomTrailing: 8, topTrailing: 8)))
|
|
.overlay(alignment: .bottomLeading) {
|
|
newPenButton
|
|
.offset(x: 60, y: 10)
|
|
}
|
|
}
|
|
|
|
func penItemRow(_ pen: Pen) -> some View {
|
|
ZStack {
|
|
penShadow(pen)
|
|
if let tip = pen.style.icon.tip {
|
|
Image(tip)
|
|
.resizable()
|
|
.renderingMode(.template)
|
|
.foregroundStyle(Color.rgba(from: pen.rgba))
|
|
}
|
|
Image(pen.style.icon.base)
|
|
.resizable()
|
|
}
|
|
.frame(width: width * factor, height: height * factor)
|
|
.padding(.vertical, 5)
|
|
.contentShape(.rect(cornerRadii: .init(topLeading: 10, bottomLeading: 10)))
|
|
.onTapGesture {
|
|
if tool.selectedPen === pen {
|
|
tool.unselectPen(pen)
|
|
} else {
|
|
tool.selectPen(pen)
|
|
}
|
|
}
|
|
.padding(.leading, 10)
|
|
.contextMenu(if: pen.strokeStyle != .eraser) {
|
|
ControlGroup {
|
|
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)
|
|
}
|
|
.controlGroupStyle(.menu)
|
|
} preview: {
|
|
penPreview(pen)
|
|
.drawingGroup()
|
|
.contentShape(.contextMenuPreview, .rect(cornerRadius: 10))
|
|
}
|
|
.onDrag(if: pen.strokeStyle != .eraser) {
|
|
tool.draggedPen = pen
|
|
return NSItemProvider(contentsOf: URL(string: pen.id)) ?? NSItemProvider()
|
|
} preview: {
|
|
penPreview(pen)
|
|
.contentShape(.dragPreview, .rect(cornerRadius: 10))
|
|
}
|
|
.onDrop(of: [.item], delegate: PenDropDelegate(id: pen.id, tool: tool, action: { refreshScrollId = UUID() }))
|
|
.offset(x: tool.selectedPen === pen ? 0 : 25)
|
|
}
|
|
|
|
@ViewBuilder
|
|
var penPropertyTool: some View {
|
|
if let pen = tool.selectedPen {
|
|
VStack(spacing: 5) {
|
|
if pen.strokeStyle == .marker {
|
|
penColorPicker(pen)
|
|
}
|
|
penThicknessPicker(pen)
|
|
}
|
|
.padding(10)
|
|
.frame(width: width * factor - 18)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(.regularMaterial)
|
|
}
|
|
.transition(.move(edge: .trailing).combined(with: .blurReplace))
|
|
} else {
|
|
Color.clear
|
|
.frame(width: width * factor - 18, height: 50)
|
|
}
|
|
}
|
|
|
|
func penColorPicker(_ pen: Pen) -> some View {
|
|
Button {
|
|
opensColorPicker = true
|
|
} label: {
|
|
let hsba = pen.color.hsba
|
|
let baseColor = Color(hue: hsba.hue, saturation: hsba.saturation, brightness: hsba.brightness)
|
|
GeometryReader { proxy in
|
|
HStack(spacing: 0) {
|
|
baseColor
|
|
.frame(width: proxy.size.width / 2)
|
|
Image("transparent-grid-square")
|
|
.resizable()
|
|
.scaleEffect(3)
|
|
.aspectRatio(contentMode: .fill)
|
|
.opacity(1 - hsba.alpha)
|
|
.frame(width: proxy.size.width / 2)
|
|
.clipped()
|
|
}
|
|
}
|
|
.background(baseColor)
|
|
.clipShape(.rect(cornerRadius: 8))
|
|
.frame(height: 25)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(Color.gray, lineWidth: 0.4)
|
|
}
|
|
.padding(0.2)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.hoverEffect(.lift)
|
|
.popover(isPresented: $opensColorPicker) {
|
|
let color = Binding(
|
|
get: { pen.color },
|
|
set: {
|
|
pen.color = $0
|
|
tool.objectWillChange.send()
|
|
}
|
|
)
|
|
ColorPicker(color: color)
|
|
.presentationCompactAdaptation(.popover)
|
|
.onDisappear {
|
|
withPersistence(\.viewContext) { context in
|
|
try context.saveIfNeeded()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
func penThicknessPicker(_ pen: Pen) -> some View {
|
|
let minimum: CGFloat = pen.style.thickness.min
|
|
let maximum: CGFloat = pen.style.thickness.max
|
|
let start: CGFloat = 4
|
|
let end: CGFloat = 10
|
|
let selection = Binding(
|
|
get: { pen.thickness },
|
|
set: {
|
|
pen.thickness = $0
|
|
tool.objectWillChange.send()
|
|
}
|
|
)
|
|
Picker("", selection: selection) {
|
|
ForEach(pen.style.thicknessSteps, id: \.self) { step in
|
|
let size = ((step - minimum) * (end - start) / (maximum - minimum)) + start - (0.5 / step)
|
|
Circle()
|
|
.fill(.black)
|
|
.frame(width: size, height: size)
|
|
.frame(width: size + 2, height: size + 2)
|
|
}
|
|
}
|
|
.pickerStyle(.wheel)
|
|
.frame(width: width * factor - 18, height: 35)
|
|
.onChange(of: pen.thickness) { _, _ in
|
|
withPersistence(\.viewContext) { context in
|
|
try context.saveIfNeeded()
|
|
}
|
|
}
|
|
}
|
|
|
|
var newPenButton: some View {
|
|
Button {
|
|
let pen = PenObject.createObject(\.viewContext, penStyle: .marker)
|
|
var selectedPen = tool.selectedPen
|
|
selectedPen = (selectedPen?.strokeStyle == .marker ? (selectedPen ?? tool.pens.last) : tool.pens.last)
|
|
if let color = selectedPen?.rgba {
|
|
pen.color = color
|
|
}
|
|
pen.isSelected = true
|
|
pen.tool = tool.object
|
|
pen.orderIndex = Int16(tool.pens.count)
|
|
let _pen = Pen(object: pen)
|
|
tool.addPen(_pen)
|
|
} label: {
|
|
Image(systemName: "plus.circle.fill")
|
|
.font(.title2)
|
|
.padding(1)
|
|
.contentShape(.circle)
|
|
.background {
|
|
Circle()
|
|
.fill(.white)
|
|
}
|
|
}
|
|
.foregroundStyle(.green)
|
|
.hoverEffect(.lift)
|
|
}
|
|
|
|
func penPreview(_ pen: Pen) -> some View {
|
|
ZStack {
|
|
if let tip = pen.style.icon.tip {
|
|
Image(tip)
|
|
.resizable()
|
|
.renderingMode(.template)
|
|
.foregroundStyle(Color.rgba(from: pen.rgba))
|
|
}
|
|
Image(pen.style.icon.base)
|
|
.resizable()
|
|
}
|
|
.frame(width: width * factor, height: height * factor)
|
|
.padding(.vertical, 5)
|
|
.padding(.leading, 10)
|
|
}
|
|
|
|
func penShadow(_ pen: Pen) -> some View {
|
|
ZStack {
|
|
Group {
|
|
if let tip = pen.style.icon.tip {
|
|
Image(tip)
|
|
.resizable()
|
|
.renderingMode(.template)
|
|
}
|
|
Image(pen.style.icon.base)
|
|
.resizable()
|
|
.renderingMode(.template)
|
|
}
|
|
.foregroundStyle(.black.opacity(0.2))
|
|
.blur(radius: 3)
|
|
if let tip = pen.style.icon.tip {
|
|
Image(tip)
|
|
.resizable()
|
|
.renderingMode(.template)
|
|
.foregroundStyle(Color(red: pen.rgba[0], green: pen.rgba[1], blue: pen.rgba[2]))
|
|
.blur(radius: 0.5)
|
|
}
|
|
}
|
|
}
|
|
}
|