565 lines
22 KiB
Swift
565 lines
22 KiB
Swift
// EditorWindowController.swift
|
|
// GreenshotMac
|
|
//
|
|
// Port of Greenshot's ImageEditorForm, laid out like a classic image editor:
|
|
// a vertical tool rail on the left, a contextual options bar across the top.
|
|
|
|
import AppKit
|
|
|
|
@MainActor
|
|
final class EditorWindowController: NSWindowController, NSWindowDelegate,
|
|
EditorCanvasDelegate, StyleBarDelegate {
|
|
|
|
private static var openEditors: [EditorWindowController] = []
|
|
|
|
/// The one-click palette at the foot of the tool rail.
|
|
private static let palette: [NSColor] = [
|
|
.systemRed, .systemOrange, .systemYellow, .systemGreen,
|
|
.systemBlue, .systemPurple, .black, .white
|
|
]
|
|
|
|
let surface: Surface
|
|
private let canvas: EditorCanvasView
|
|
private let scrollView = NSScrollView()
|
|
private let optionsBar = NSStackView()
|
|
private let styleBar = StyleBarView()
|
|
private var toolButtons: [ToolKind: NSButton] = [:]
|
|
private var deleteButton: NSButton?
|
|
private let currentColorWell = NSColorWell()
|
|
private let fillColorWell = NSColorWell()
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
@discardableResult
|
|
static func open(with capture: Capture) -> EditorWindowController {
|
|
let controller = EditorWindowController(capture: capture)
|
|
openEditors.append(controller)
|
|
controller.showWindow(nil)
|
|
controller.window?.makeKeyAndOrderFront(nil)
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
return controller
|
|
}
|
|
|
|
init(capture: Capture) {
|
|
surface = Surface(capture: capture)
|
|
canvas = EditorCanvasView(surface: surface)
|
|
|
|
let size = surface.imageSize
|
|
let maxSize = (NSScreen.main?.visibleFrame.size ?? CGSize(width: 1400, height: 900))
|
|
let contentWidth = min(max(760, size.width + 120), maxSize.width - 80)
|
|
let contentHeight = min(max(520, size.height + 96), maxSize.height - 80)
|
|
|
|
let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight),
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
backing: .buffered,
|
|
defer: false)
|
|
window.title = capture.details.title.isEmpty
|
|
? "Screenshot"
|
|
: "Screenshot — \(capture.details.title)"
|
|
window.center()
|
|
window.isReleasedWhenClosed = false
|
|
Theme.styleWindowChrome(window)
|
|
super.init(window: window)
|
|
|
|
window.delegate = self
|
|
buildInterface()
|
|
canvas.delegate = self
|
|
select(tool: .select)
|
|
}
|
|
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
|
|
|
|
func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
|
|
surface.undoManager
|
|
}
|
|
|
|
func windowWillClose(_ notification: Notification) {
|
|
canvas.endTextEditing()
|
|
EditorWindowController.openEditors.removeAll { $0 === self }
|
|
}
|
|
|
|
// MARK: - Interface
|
|
|
|
private func buildInterface() {
|
|
guard let window, let contentView = window.contentView else { return }
|
|
|
|
let chrome = buildOptionsChrome()
|
|
let rail = buildToolRail()
|
|
|
|
scrollView.documentView = canvas
|
|
scrollView.hasVerticalScroller = true
|
|
scrollView.hasHorizontalScroller = true
|
|
scrollView.allowsMagnification = false
|
|
scrollView.borderType = .noBorder
|
|
scrollView.backgroundColor = .underPageBackgroundColor
|
|
scrollView.drawsBackground = true
|
|
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let railDivider = Theme.divider(axis: .vertical)
|
|
let chromeDivider = Theme.divider(axis: .horizontal)
|
|
|
|
contentView.addSubview(chrome)
|
|
contentView.addSubview(chromeDivider)
|
|
contentView.addSubview(rail)
|
|
contentView.addSubview(railDivider)
|
|
contentView.addSubview(scrollView)
|
|
|
|
NSLayoutConstraint.activate([
|
|
// Options chrome spans the top.
|
|
chrome.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
chrome.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
chrome.topAnchor.constraint(equalTo: contentView.topAnchor),
|
|
|
|
chromeDivider.topAnchor.constraint(equalTo: chrome.bottomAnchor),
|
|
chromeDivider.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
chromeDivider.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
|
|
// Tool rail down the left.
|
|
rail.topAnchor.constraint(equalTo: chromeDivider.bottomAnchor),
|
|
rail.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
rail.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
|
|
|
railDivider.topAnchor.constraint(equalTo: rail.topAnchor),
|
|
railDivider.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
|
railDivider.leadingAnchor.constraint(equalTo: rail.trailingAnchor),
|
|
|
|
// Canvas fills what is left.
|
|
scrollView.topAnchor.constraint(equalTo: chromeDivider.bottomAnchor),
|
|
scrollView.leadingAnchor.constraint(equalTo: railDivider.trailingAnchor),
|
|
scrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
scrollView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
|
|
])
|
|
|
|
canvas.refreshFrame()
|
|
window.makeFirstResponder(canvas)
|
|
}
|
|
|
|
/// Destinations and zoom on one row, the contextual style bar beneath: an options bar.
|
|
private func buildOptionsChrome() -> NSView {
|
|
optionsBar.orientation = .horizontal
|
|
optionsBar.spacing = 4
|
|
optionsBar.alignment = .centerY
|
|
optionsBar.edgeInsets = NSEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
|
|
optionsBar.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
for destination in [DestinationID.file, .fileWithDialog, .clipboard, .email, .printer, .share] {
|
|
optionsBar.addArrangedSubview(destinationButton(for: destination))
|
|
}
|
|
|
|
let spacer = NSView()
|
|
spacer.translatesAutoresizingMaskIntoConstraints = false
|
|
spacer.setContentHuggingPriority(.init(1), for: .horizontal)
|
|
optionsBar.addArrangedSubview(spacer)
|
|
|
|
let zoomControls: [(String, String, Selector)] = [
|
|
("minus.magnifyingglass", "Zoom out (⌘−)", #selector(zoomOut(_:))),
|
|
("plus.magnifyingglass", "Zoom in (⌘+)", #selector(zoomIn(_:))),
|
|
("1.magnifyingglass", "Actual size (⌘0)", #selector(actualSize(_:))),
|
|
("arrow.up.left.and.down.right.magnifyingglass", "Fit to window", #selector(fitToWindow(_:)))
|
|
]
|
|
for (symbol, tip, action) in zoomControls {
|
|
let button = NSButton()
|
|
EditorWindowController.configure(button, symbol: symbol, fallbackTitle: tip)
|
|
Theme.styleButton(button)
|
|
button.toolTip = tip
|
|
button.target = self
|
|
button.action = action
|
|
optionsBar.addArrangedSubview(button)
|
|
}
|
|
|
|
styleBar.delegate = self
|
|
|
|
let stack = NSStackView(views: [optionsBar, styleBar])
|
|
stack.orientation = .vertical
|
|
stack.spacing = 0
|
|
stack.alignment = .leading
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let chrome = Theme.backdrop(material: .headerView)
|
|
Theme.fill(stack, in: chrome)
|
|
NSLayoutConstraint.activate([
|
|
optionsBar.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
|
optionsBar.trailingAnchor.constraint(equalTo: stack.trailingAnchor),
|
|
styleBar.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
|
styleBar.trailingAnchor.constraint(equalTo: stack.trailingAnchor)
|
|
])
|
|
return chrome
|
|
}
|
|
|
|
/// The tool palette: two columns of tools, then delete, then the colour swatches.
|
|
private func buildToolRail() -> NSView {
|
|
var toolRows: [[NSView]] = []
|
|
var pair: [NSView] = []
|
|
for tool in ToolKind.allCases {
|
|
let button = makeToolButton(tool)
|
|
toolButtons[tool] = button
|
|
pair.append(button)
|
|
if pair.count == 2 {
|
|
toolRows.append(pair)
|
|
pair = []
|
|
}
|
|
}
|
|
if pair.count == 1 {
|
|
pair.append(NSView())
|
|
toolRows.append(pair)
|
|
}
|
|
|
|
let toolGrid = NSGridView(views: toolRows)
|
|
toolGrid.rowSpacing = 4
|
|
toolGrid.columnSpacing = 4
|
|
toolGrid.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let delete = NSButton()
|
|
EditorWindowController.configure(delete, symbol: "trash", fallbackTitle: "Delete")
|
|
Theme.styleButton(delete)
|
|
delete.contentTintColor = .systemRed
|
|
delete.toolTip = "Delete the selected element (⌫)"
|
|
delete.target = self
|
|
delete.action = #selector(deleteSelection(_:))
|
|
delete.isEnabled = false
|
|
delete.translatesAutoresizingMaskIntoConstraints = false
|
|
delete.widthAnchor.constraint(equalToConstant: 32).isActive = true
|
|
delete.heightAnchor.constraint(equalToConstant: 28).isActive = true
|
|
deleteButton = delete
|
|
|
|
// Line and fill colour, the way an editor shows foreground and background swatches.
|
|
currentColorWell.color = Configuration.shared.editorLineColor
|
|
currentColorWell.target = self
|
|
currentColorWell.action = #selector(currentColorChanged)
|
|
currentColorWell.toolTip = "Line colour"
|
|
currentColorWell.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
fillColorWell.color = Configuration.shared.editorFillColor
|
|
fillColorWell.target = self
|
|
fillColorWell.action = #selector(fillColorChanged)
|
|
fillColorWell.toolTip = "Fill colour"
|
|
fillColorWell.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
for well in [currentColorWell, fillColorWell] {
|
|
NSLayoutConstraint.activate([
|
|
well.widthAnchor.constraint(equalToConstant: 26),
|
|
well.heightAnchor.constraint(equalToConstant: 24)
|
|
])
|
|
}
|
|
|
|
let wells = NSStackView(views: [currentColorWell, fillColorWell])
|
|
wells.orientation = .horizontal
|
|
wells.spacing = 4
|
|
wells.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
var swatchRows: [[NSView]] = []
|
|
var swatchPair: [NSView] = []
|
|
for (index, color) in EditorWindowController.palette.enumerated() {
|
|
swatchPair.append(makeSwatchButton(color, index: index))
|
|
if swatchPair.count == 2 {
|
|
swatchRows.append(swatchPair)
|
|
swatchPair = []
|
|
}
|
|
}
|
|
if swatchPair.count == 1 {
|
|
swatchPair.append(NSView())
|
|
swatchRows.append(swatchPair)
|
|
}
|
|
let swatchGrid = NSGridView(views: swatchRows)
|
|
swatchGrid.rowSpacing = 4
|
|
swatchGrid.columnSpacing = 4
|
|
swatchGrid.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let spacer = NSView()
|
|
spacer.translatesAutoresizingMaskIntoConstraints = false
|
|
spacer.setContentHuggingPriority(.init(1), for: .vertical)
|
|
spacer.setContentCompressionResistancePriority(.init(1), for: .vertical)
|
|
|
|
let stack = NSStackView(views: [toolGrid,
|
|
EditorWindowController.railDivider(),
|
|
delete,
|
|
EditorWindowController.railDivider(),
|
|
wells,
|
|
swatchGrid,
|
|
spacer])
|
|
stack.orientation = .vertical
|
|
stack.spacing = 8
|
|
stack.alignment = .centerX
|
|
stack.distribution = .fill
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
stack.setHuggingPriority(.defaultHigh, for: .vertical)
|
|
|
|
let rail = Theme.backdrop(material: .sidebar)
|
|
rail.addSubview(stack)
|
|
NSLayoutConstraint.activate([
|
|
stack.topAnchor.constraint(equalTo: rail.topAnchor, constant: 10),
|
|
stack.leadingAnchor.constraint(equalTo: rail.leadingAnchor, constant: 8),
|
|
stack.trailingAnchor.constraint(equalTo: rail.trailingAnchor, constant: -8),
|
|
stack.bottomAnchor.constraint(equalTo: rail.bottomAnchor, constant: -10)
|
|
])
|
|
return rail
|
|
}
|
|
|
|
/// A separator sized for the tool rail; an unconstrained one would collapse
|
|
/// to zero width inside a centre-aligned vertical stack.
|
|
private static func railDivider() -> NSBox {
|
|
let divider = Theme.divider(axis: .horizontal)
|
|
divider.widthAnchor.constraint(equalToConstant: 56).isActive = true
|
|
return divider
|
|
}
|
|
|
|
private func makeToolButton(_ tool: ToolKind) -> NSButton {
|
|
let button = NSButton()
|
|
EditorWindowController.configure(button, symbol: tool.symbolName, fallbackTitle: tool.title)
|
|
Theme.styleButton(button)
|
|
button.setButtonType(.pushOnPushOff)
|
|
button.toolTip = "\(tool.title) (\(tool.keyEquivalent.uppercased()))"
|
|
button.target = self
|
|
button.action = #selector(toolButtonClicked(_:))
|
|
button.tag = ToolKind.allCases.firstIndex(of: tool) ?? 0
|
|
button.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
button.widthAnchor.constraint(equalToConstant: 32),
|
|
button.heightAnchor.constraint(equalToConstant: 28)
|
|
])
|
|
return button
|
|
}
|
|
|
|
private func makeSwatchButton(_ color: NSColor, index: Int) -> NSButton {
|
|
let swatch = NSButton()
|
|
swatch.image = EditorWindowController.swatchImage(color)
|
|
swatch.imagePosition = .imageOnly
|
|
swatch.isBordered = false
|
|
swatch.toolTip = "Set colour"
|
|
swatch.target = self
|
|
swatch.action = #selector(colorSwatchClicked(_:))
|
|
swatch.tag = index
|
|
swatch.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
swatch.widthAnchor.constraint(equalToConstant: 20),
|
|
swatch.heightAnchor.constraint(equalToConstant: 20)
|
|
])
|
|
return swatch
|
|
}
|
|
|
|
private func destinationButton(for destination: DestinationID) -> NSButton {
|
|
let button = NSButton()
|
|
EditorWindowController.configure(button, symbol: destination.symbolName, fallbackTitle: destination.pickerTitle)
|
|
Theme.styleButton(button)
|
|
button.toolTip = destination.title
|
|
button.target = self
|
|
button.action = #selector(destinationButtonClicked(_:))
|
|
button.tag = DestinationID.allCases.firstIndex(of: destination) ?? 0
|
|
return button
|
|
}
|
|
|
|
/// Uses the SF Symbol when the running system has it, and a short text label otherwise,
|
|
/// so a missing symbol never leaves an invisible button.
|
|
private static func configure(_ button: NSButton, symbol: String, fallbackTitle: String) {
|
|
let configuration = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular)
|
|
if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: fallbackTitle)?
|
|
.withSymbolConfiguration(configuration) {
|
|
button.image = image
|
|
button.imagePosition = .imageOnly
|
|
} else {
|
|
button.title = fallbackTitle
|
|
button.imagePosition = .noImage
|
|
button.font = NSFont.systemFont(ofSize: 11)
|
|
}
|
|
}
|
|
|
|
private static func swatchImage(_ color: NSColor) -> NSImage {
|
|
let size = NSSize(width: 16, height: 16)
|
|
let image = NSImage(size: size)
|
|
image.lockFocus()
|
|
let path = NSBezierPath(roundedRect: NSRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5),
|
|
xRadius: 4, yRadius: 4)
|
|
color.setFill()
|
|
path.fill()
|
|
NSColor.separatorColor.setStroke()
|
|
path.lineWidth = 1
|
|
path.stroke()
|
|
image.unlockFocus()
|
|
return image
|
|
}
|
|
|
|
// MARK: - Tools
|
|
|
|
func select(tool: ToolKind) {
|
|
canvas.tool = tool
|
|
for (kind, button) in toolButtons {
|
|
button.state = kind == tool ? .on : .off
|
|
}
|
|
updateStyleBar()
|
|
}
|
|
|
|
@objc private func toolButtonClicked(_ sender: NSButton) {
|
|
let tool = ToolKind.allCases[min(sender.tag, ToolKind.allCases.count - 1)]
|
|
select(tool: tool)
|
|
}
|
|
|
|
@objc private func destinationButtonClicked(_ sender: NSButton) {
|
|
let destination = DestinationID.allCases[min(sender.tag, DestinationID.allCases.count - 1)]
|
|
export(to: destination, anchor: sender)
|
|
}
|
|
|
|
@objc private func colorSwatchClicked(_ sender: NSButton) {
|
|
guard sender.tag >= 0, sender.tag < EditorWindowController.palette.count else { return }
|
|
apply(color: EditorWindowController.palette[sender.tag])
|
|
}
|
|
|
|
@objc private func currentColorChanged() {
|
|
apply(color: currentColorWell.color)
|
|
}
|
|
|
|
@objc private func fillColorChanged() {
|
|
let color = fillColorWell.color
|
|
Configuration.shared.editorFillColor = color
|
|
canvas.applyStyleToSelection { $0.fillColor = color }
|
|
}
|
|
|
|
private func apply(color: NSColor) {
|
|
// The highlighter has its own colour field; everything else uses the line colour.
|
|
let isHighlight = canvas.tool == .highlight || surface.selection is HighlightElement
|
|
canvas.applyStyleToSelection { style in
|
|
if isHighlight {
|
|
style.highlightColor = color
|
|
} else {
|
|
style.lineColor = color
|
|
}
|
|
}
|
|
if isHighlight {
|
|
Configuration.shared.editorHighlightColor = color
|
|
} else {
|
|
Configuration.shared.editorLineColor = color
|
|
}
|
|
currentColorWell.color = color
|
|
updateStyleBar()
|
|
}
|
|
|
|
private func updateStyleBar() {
|
|
let capabilities: StyleCapabilities
|
|
let style: ElementStyle
|
|
if let selection = surface.selection, !(selection is CropElement) {
|
|
capabilities = selection.capabilities
|
|
style = selection.style
|
|
} else {
|
|
capabilities = capabilitiesForTool(canvas.tool)
|
|
style = canvas.style
|
|
}
|
|
styleBar.update(capabilities: capabilities,
|
|
style: style,
|
|
showCrop: surface.cropElement != nil,
|
|
toolName: canvas.tool.title)
|
|
deleteButton?.isEnabled = surface.selection != nil
|
|
currentColorWell.color = capabilities.contains(.highlightColor) ? style.highlightColor : style.lineColor
|
|
fillColorWell.color = style.fillColor
|
|
fillColorWell.isEnabled = capabilities.contains(.fillColor)
|
|
}
|
|
|
|
private func capabilitiesForTool(_ tool: ToolKind) -> StyleCapabilities {
|
|
switch tool {
|
|
case .select: return []
|
|
case .rectangle, .ellipse: return [.lineColor, .fillColor, .lineThickness, .shadow]
|
|
case .line, .arrow, .freehand: return [.lineColor, .lineThickness, .shadow]
|
|
case .text: return [.lineColor, .fillColor, .font, .shadow]
|
|
case .counter: return [.lineColor, .fillColor, .font, .shadow]
|
|
case .highlight: return [.highlightColor]
|
|
case .obfuscate: return [.obfuscation]
|
|
case .dropper: return []
|
|
case .crop: return []
|
|
}
|
|
}
|
|
|
|
// MARK: - Canvas delegate
|
|
|
|
func canvasDidChangeSelection(_ canvas: EditorCanvasView) {
|
|
updateStyleBar()
|
|
}
|
|
|
|
func canvasDidChangeContent(_ canvas: EditorCanvasView) {
|
|
canvas.invalidateIntrinsicContentSize()
|
|
window?.isDocumentEdited = surface.hasUnsavedChanges
|
|
updateStyleBar()
|
|
}
|
|
|
|
func canvasDidRequestToolChange(_ canvas: EditorCanvasView, to tool: ToolKind) {
|
|
select(tool: tool)
|
|
}
|
|
|
|
func canvasDidPickColor(_ canvas: EditorCanvasView, color: NSColor) {
|
|
apply(color: color)
|
|
}
|
|
|
|
// MARK: - Style bar delegate
|
|
|
|
func styleBar(_ bar: StyleBarView, didChange mutate: @escaping (inout ElementStyle) -> Void) {
|
|
canvas.applyStyleToSelection(mutate)
|
|
}
|
|
|
|
func styleBarDidApplyCrop(_ bar: StyleBarView) {
|
|
applyCrop(nil)
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
@IBAction func applyCrop(_ sender: Any?) {
|
|
guard let crop = surface.cropElement else { return }
|
|
surface.applyCrop(to: crop.normalizedFrame)
|
|
canvas.refreshFrame()
|
|
select(tool: .select)
|
|
}
|
|
|
|
func export(to destination: DestinationID, anchor: NSView? = nil) {
|
|
canvas.endTextEditing()
|
|
let capture = surface.renderedCapture()
|
|
Destinations.export(capture, to: destination, anchor: anchor ?? canvas)
|
|
if destination == .file || destination == .fileWithDialog {
|
|
surface.hasUnsavedChanges = false
|
|
window?.isDocumentEdited = false
|
|
}
|
|
}
|
|
|
|
@IBAction func saveDocument(_ sender: Any?) { export(to: .file) }
|
|
@IBAction func saveDocumentAs(_ sender: Any?) { export(to: .fileWithDialog) }
|
|
@IBAction func copy(_ sender: Any?) { export(to: .clipboard) }
|
|
@IBAction func printDocument(_ sender: Any?) { export(to: .printer) }
|
|
@IBAction func shareDocument(_ sender: Any?) { export(to: .share, anchor: canvas) }
|
|
@IBAction func sendAsEmail(_ sender: Any?) { export(to: .email) }
|
|
|
|
@IBAction func zoomIn(_ sender: Any?) { canvas.zoom = min(8, canvas.zoom * 1.25) }
|
|
@IBAction func zoomOut(_ sender: Any?) { canvas.zoom = max(0.1, canvas.zoom / 1.25) }
|
|
@IBAction func actualSize(_ sender: Any?) { canvas.zoom = 1 }
|
|
|
|
@IBAction func fitToWindow(_ sender: Any?) {
|
|
let available = scrollView.contentView.bounds.size
|
|
guard available.width > 1, available.height > 1,
|
|
surface.imageSize.width > 1, surface.imageSize.height > 1 else { return }
|
|
let fit = min(available.width / surface.imageSize.width,
|
|
available.height / surface.imageSize.height)
|
|
canvas.zoom = min(1, max(0.1, fit))
|
|
}
|
|
|
|
@IBAction func selectTool(_ sender: NSMenuItem) {
|
|
guard let raw = sender.representedObject as? String, let tool = ToolKind(rawValue: raw) else { return }
|
|
select(tool: tool)
|
|
}
|
|
|
|
@IBAction func deleteSelection(_ sender: Any?) {
|
|
surface.removeSelection()
|
|
canvas.needsDisplay = true
|
|
updateStyleBar()
|
|
}
|
|
|
|
@IBAction func duplicateSelection(_ sender: Any?) {
|
|
guard let selection = surface.selection else { return }
|
|
surface.duplicate(selection)
|
|
canvas.needsDisplay = true
|
|
}
|
|
|
|
@IBAction func bringToFront(_ sender: Any?) {
|
|
guard let selection = surface.selection else { return }
|
|
surface.bringToFront(selection)
|
|
}
|
|
|
|
@IBAction func sendToBack(_ sender: Any?) {
|
|
guard let selection = surface.selection else { return }
|
|
surface.sendToBack(selection)
|
|
}
|
|
}
|