Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions Sources/Snapok/BackgroundEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ final class BackgroundEditorController: NSWindowController, NSWindowDelegate, NS
private var toolButtons: [NSButton] = []
private let strokePicker = NSPopUpButton()
private let stepStylePicker = NSPopUpButton()
private let mosaicStylePicker = NSPopUpButton()
private let numberRect = NSButton(checkboxWithTitle: L("Number boxes", "画框自动编号"), target: nil, action: nil)
private let textControls = AnnotationTextControls()
private let strokeControls = NSStackView()
Expand Down Expand Up @@ -311,7 +312,12 @@ final class BackgroundEditorController: NSWindowController, NSWindowDelegate, NS
stepStylePicker.setAccessibilityLabel(L("Number style", "编号样式"))
numberRect.target = self
numberRect.action = #selector(changeNumberRect)
let numberOptions = NSStackView(views: [numberRect, stepStylePicker])
mosaicStylePicker.addItems(withTitles: MosaicStyle.allCases.map(\.title))
mosaicStylePicker.target = self
mosaicStylePicker.action = #selector(changeMosaicStyle)
mosaicStylePicker.setAccessibilityLabel(L("Mosaic style", "马赛克样式"))
mosaicStylePicker.toolTip = L("Box styles: drag out a rectangle · Brush: paint freely", "框选样式:拖出一个矩形 · 涂抹:按住随意涂")
let numberOptions = NSStackView(views: [mosaicStylePicker, numberRect, stepStylePicker])
numberOptions.spacing = 10
let options = NSStackView(views: [strokeControls, textControls, label(L("Color", "颜色"), size: 11), annotationColor])
options.spacing = 10
Expand Down Expand Up @@ -384,6 +390,12 @@ final class BackgroundEditorController: NSWindowController, NSWindowDelegate, NS
preview.stepStyle = StepMarkerStyle.allCases[stepStylePicker.indexOfSelectedItem]
preview.applyStyle()
}
@objc private func changeMosaicStyle() {
let style = MosaicStyle.allCases[max(0, mosaicStylePicker.indexOfSelectedItem)]
preview.mosaicStyle = style
MosaicStyle.current = style
preview.applyStyle()
}
@objc private func changeNumberRect() {
preview.setNumberRect(numberRect.state == .on)
}
Expand All @@ -397,7 +409,9 @@ final class BackgroundEditorController: NSWindowController, NSWindowDelegate, NS
stepStylePicker.isHidden = !preview.usesStepStyle
numberRect.isHidden = !preview.usesRectStyle
numberRect.state = preview.autoNumberRect ? .on : .off
toolbarHeight?.constant = preview.usesStepStyle || preview.usesRectStyle ? 154 : 126
mosaicStylePicker.isHidden = !preview.usesMosaicStyle
mosaicStylePicker.selectItem(at: MosaicStyle.allCases.firstIndex(of: preview.mosaicStyle) ?? 0)
toolbarHeight?.constant = preview.usesStepStyle || preview.usesRectStyle || preview.usesMosaicStyle ? 154 : 126
stepStylePicker.selectItem(at: StepMarkerStyle.allCases.firstIndex(of: preview.stepStyle) ?? 0)
textControls.update(preview.textStyle, pixelScale: preview.pixelScale)
for (index, button) in toolButtons.enumerated() {
Expand Down
12 changes: 9 additions & 3 deletions Sources/Snapok/EditorCanvas.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ final class BackgroundPreview: NSView, NSTextFieldDelegate {
|| selectedIndex.map { annotations[$0].kind == .step || annotations[$0].stepNumber != nil } == true
}
var selectedColor: NSColor = Style.colors[0]
var mosaicStyle = MosaicStyle.current
var usesMosaicStyle: Bool { selectedTool == .mosaic || selectedIndex.map { annotations[$0].kind == .mosaic } == true }
var textStyle = AnnotationTextStyle()
var usesTextStyle: Bool { selectedTool == .text || selectedIndex.map { annotations[$0].kind == .text } == true }
var sizeLevel = 1 { didSet { if oldValue != sizeLevel { textStyle.pointSize = Style.lineWidth(for: .text, level: sizeLevel) } } }
Expand Down Expand Up @@ -182,6 +184,7 @@ final class BackgroundPreview: NSView, NSTextFieldDelegate {
record()
annotations[index].color = selectedColor
annotations[index].sizeLevel = sizeLevel
if annotations[index].kind == .mosaic { annotations[index].restyleMosaic(mosaicStyle) }
if annotations[index].kind == .step || annotations[index].stepNumber != nil {
annotations[index].stepStyle = stepStyle
if annotations[index].kind == .step { StepMarkers.resize(&annotations[index]) }
Expand Down Expand Up @@ -229,6 +232,7 @@ final class BackgroundPreview: NSView, NSTextFieldDelegate {
stepStyle = annotations[hit].stepStyle ?? .filledCircle
}
if annotations[hit].kind == .text { textStyle = annotations[hit].effectiveTextStyle }
if annotations[hit].kind == .mosaic { mosaicStyle = annotations[hit].effectiveMosaicStyle }
if annotations[hit].kind == .text, event.clickCount == 2 {
editText(at: point, index: hit)
} else { record(); lastPoint = point }
Expand All @@ -246,6 +250,7 @@ final class BackgroundPreview: NSView, NSTextFieldDelegate {
if tool == .text { editText(at: point, index: nil); return }
start = point
draft = Annotation(kind: tool, rect: CGRect(origin: point, size: .zero), points: [point, point], color: selectedColor, sizeLevel: sizeLevel)
if tool == .mosaic { draft?.mosaicStyle = mosaicStyle }
changed()
}

Expand All @@ -256,18 +261,19 @@ final class BackgroundPreview: NSView, NSTextFieldDelegate {
annotations[index] = annotations[index].offsetBy(dx: point.x - lastPoint.x, dy: point.y - lastPoint.y)
self.lastPoint = point
} else if let start, var draft {
if draft.kind == .pen || draft.kind == .mosaic { draft.points.append(point) }
let isStroke = draft.kind == .pen || (draft.kind == .mosaic && !draft.isBoxMosaic)
if isStroke { draft.points.append(point) }
else { draft.points = [start, point] }
let xs = draft.points.map(\.x), ys = draft.points.map(\.y)
draft.rect = CGRect(x: xs.min()!, y: ys.min()!, width: xs.max()! - xs.min()!, height: ys.max()! - ys.min()!)
if draft.kind == .pen || draft.kind == .mosaic { draft.rect = draft.rect.insetBy(dx: -draft.lineWidth / 2, dy: -draft.lineWidth / 2) }
if isStroke { draft.rect = draft.rect.insetBy(dx: -draft.lineWidth / 2, dy: -draft.lineWidth / 2) }
self.draft = draft
}
changed()
}

override func mouseUp(with event: NSEvent) {
if var draft, draft.rect.width + draft.rect.height >= 2 {
if var draft, draft.isBoxMosaic ? draft.rect.width >= 3 && draft.rect.height >= 3 : draft.rect.width + draft.rect.height >= 2 {
if draft.kind == .rect, autoNumberRect {
draft.stepNumber = String(StepMarkers.nextNumber(in: annotations))
draft.stepStyle = stepStyle
Expand Down
7 changes: 5 additions & 2 deletions Sources/Snapok/HistoryStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ enum Keychain {

extension Annotation: Codable {
private enum CodingKeys: String, CodingKey {
case kind, rect, points, text, color, sizeLevel, textStyle, stepStyle, stepNumber
case kind, rect, points, text, color, sizeLevel, textStyle, stepStyle, stepNumber, mosaicStyle
}

init(from decoder: Decoder) throws {
Expand All @@ -92,7 +92,9 @@ extension Annotation: Codable {
sizeLevel: try container.decode(Int.self, forKey: .sizeLevel),
textStyle: try container.decodeIfPresent(AnnotationTextStyle.self, forKey: .textStyle),
stepStyle: try container.decodeIfPresent(StepMarkerStyle.self, forKey: .stepStyle),
stepNumber: try container.decodeIfPresent(String.self, forKey: .stepNumber)
stepNumber: try container.decodeIfPresent(String.self, forKey: .stepNumber),
// Unknown styles (from a newer version) fall back to the brush rather than failing the whole item.
mosaicStyle: (try? container.decodeIfPresent(MosaicStyle.self, forKey: .mosaicStyle)) ?? nil
)
}

Expand All @@ -108,6 +110,7 @@ extension Annotation: Codable {
try container.encodeIfPresent(textStyle, forKey: .textStyle)
try container.encodeIfPresent(stepStyle, forKey: .stepStyle)
try container.encodeIfPresent(stepNumber, forKey: .stepNumber)
try container.encodeIfPresent(mosaicStyle, forKey: .mosaicStyle)
}
}

Expand Down
124 changes: 124 additions & 0 deletions Sources/Snapok/MosaicStyle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import AppKit
import CoreImage

/// How a mosaic annotation hides what is underneath.
/// `brush` follows the pointer like a marker; the other styles cover a dragged rectangle.
enum MosaicStyle: String, CaseIterable, Codable, Sendable {
case pixelBox, blurBox, frostedBox, solidBox, brush

var title: String {
switch self {
case .pixelBox: return L("Box · Pixelate", "框选 · 像素马赛克")
case .blurBox: return L("Box · Blur", "框选 · 高斯模糊")
case .frostedBox: return L("Box · Frosted Glass", "框选 · 毛玻璃")
case .solidBox: return L("Box · Solid Color", "框选 · 色块遮挡")
case .brush: return L("Brush · Pixelate", "涂抹 · 像素马赛克")
}
}

/// Box styles are drawn by dragging out a rectangle instead of painting a stroke.
var isBox: Bool { self != .brush }

/// Pixel block edge, in image points, for each size level.
nonisolated static func blockSize(level: Int) -> CGFloat {
[8, 13, 20][min(max(level, 0), 2)]
}

/// Gaussian blur radius (sigma), in image points, for each size level.
nonisolated static func blurRadius(_ style: MosaicStyle, level: Int) -> CGFloat {
let level = min(max(level, 0), 2)
return style == .frostedBox ? [10, 16, 24][level] : [5, 9, 15][level]
}

/// The style picked last time, so the choice survives between captures.
static var current: MosaicStyle {
get { UserDefaults.standard.string(forKey: "editor.mosaicStyle").flatMap(MosaicStyle.init(rawValue:)) ?? .pixelBox }
set { UserDefaults.standard.set(newValue.rawValue, forKey: "editor.mosaicStyle") }
}
}

extension Annotation {
/// Older annotations (and automatic redaction strokes) carry no style and stay brush strokes.
var effectiveMosaicStyle: MosaicStyle { mosaicStyle ?? .brush }
var isBoxMosaic: Bool { kind == .mosaic && effectiveMosaicStyle.isBox }

/// Switches a selected mosaic to another style, keeping the area it covers.
/// A brush stroke becomes a box over its bounds; a box becomes a zigzag brush stroke over the box.
@MainActor
mutating func restyleMosaic(_ style: MosaicStyle) {
guard kind == .mosaic, style != effectiveMosaicStyle else { return }
if !style.isBox {
let stroke = SensitiveDetector.mosaic(covering: rect.insetBy(dx: 3, dy: 3))
points = stroke.points
rect = stroke.rect
sizeLevel = stroke.sizeLevel
} else if !effectiveMosaicStyle.isBox {
points = [CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.maxX, y: rect.maxY)]
}
mosaicStyle = style
}
}

/// Full-size filtered copies of the screenshot, shared by the capture overlay and the editor.
/// Filtering the whole image once keeps dragging smooth and makes boxes that touch blend seamlessly.
@MainActor
enum MosaicCache {
private static var sourceID: ObjectIdentifier?
private static var images: [String: NSImage] = [:]
private static let context = CIContext(options: [.cacheIntermediates: false])

/// The classic brush mosaic (fixed 10 pt blocks, unchanged from earlier versions).
static func pixelated(for source: NSImage) -> NSImage? {
filtered(source, key: "brush") { $0.pixelated(blockSize: 10) }
}

/// The filtered image used by a mosaic annotation, or nil for solid boxes (they need no source).
static func image(for annotation: Annotation, source: NSImage) -> NSImage? {
let style = annotation.effectiveMosaicStyle
let level = min(max(annotation.sizeLevel, 0), Style.sizeLevels - 1)
switch style {
case .brush:
return pixelated(for: source)
case .pixelBox:
let block = MosaicStyle.blockSize(level: level)
return filtered(source, key: "pixel-\(level)") { $0.pixelated(blockSize: block) }
case .blurBox, .frostedBox:
let radius = MosaicStyle.blurRadius(style, level: level)
return filtered(source, key: "blur-\(radius)") { blurred($0, radius: radius) }
case .solidBox:
return nil
}
}

static func clear() {
sourceID = nil
images = [:]
}

private static func filtered(_ source: NSImage, key: String, make: (NSImage) -> NSImage?) -> NSImage? {
if sourceID != ObjectIdentifier(source) {
sourceID = ObjectIdentifier(source)
images = [:]
}
if let image = images[key] { return image }
let image = make(source)
images[key] = image
return image
}

/// Blurs with clamped edges so the border of the screenshot does not fade to transparent.
static func blurred(_ source: NSImage, radius: CGFloat) -> NSImage? {
guard let cgImage = source.cgImage(forProposedRect: nil, context: nil, hints: nil), source.size.width > 0 else { return nil }
let scale = CGFloat(cgImage.width) / source.size.width
let input = CIImage(cgImage: cgImage)
let output = input.clampedToExtent().applyingGaussianBlur(sigma: Double(radius * scale)).cropped(to: input.extent)
guard let result = context.createCGImage(output, from: input.extent, format: .RGBA8,
colorSpace: CGColorSpace(name: CGColorSpace.sRGB)) else { return nil }
return NSImage(cgImage: result, size: source.size)
}

/// Corner radius of a box mosaic: crisp for pixel blocks, softly rounded for the others.
static func cornerRadius(for annotation: Annotation) -> CGFloat {
annotation.effectiveMosaicStyle == .pixelBox ? 0 : min(6, min(annotation.rect.width, annotation.rect.height) / 4)
}
}
Loading