PrintTextView.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import Cocoa
  2. // MARK: - Print Text Service
  3. enum PrintTextService {
  4. static func present(from window: NSWindow?) {
  5. let hostWindow = window ?? NSApp.keyWindow
  6. guard PremiumAccess.require(feature: .printText, from: hostWindow) else { return }
  7. guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
  8. viewController.presentPrintText()
  9. }
  10. }
  11. // MARK: - Overlay
  12. final class PrintTextOverlayView: NSView, AppearanceRefreshable {
  13. var onDismiss: (() -> Void)?
  14. private let backButton = PrintTextToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
  15. private let titleLabel = NSTextField(labelWithString: "Print Text")
  16. private let textContainer = NSView()
  17. private let textScrollView = NSScrollView()
  18. private let textView = NSTextView()
  19. private let printButton = PrintTextPrintButton()
  20. init() {
  21. super.init(frame: .zero)
  22. translatesAutoresizingMaskIntoConstraints = false
  23. setup()
  24. refreshAppearance()
  25. NotificationCenter.default.addObserver(
  26. self,
  27. selector: #selector(appearanceDidChange),
  28. name: .appearanceDidChange,
  29. object: nil
  30. )
  31. }
  32. deinit {
  33. NotificationCenter.default.removeObserver(self)
  34. }
  35. @objc private func appearanceDidChange() {
  36. refreshAppearance()
  37. }
  38. @available(*, unavailable)
  39. required init?(coder: NSCoder) { nil }
  40. func refreshAppearance() {
  41. layer?.backgroundColor = AppTheme.background.cgColor
  42. titleLabel.textColor = AppTheme.textPrimary
  43. textContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
  44. textContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
  45. backButton.refreshAppearance()
  46. printButton.refreshAppearance()
  47. }
  48. func present(in parent: NSView) {
  49. guard superview == nil else { return }
  50. parent.addSubview(self)
  51. NSLayoutConstraint.activate([
  52. leadingAnchor.constraint(equalTo: parent.leadingAnchor),
  53. trailingAnchor.constraint(equalTo: parent.trailingAnchor),
  54. topAnchor.constraint(equalTo: parent.topAnchor),
  55. bottomAnchor.constraint(equalTo: parent.bottomAnchor),
  56. ])
  57. alphaValue = 0
  58. NSAnimationContext.runAnimationGroup { context in
  59. context.duration = 0.2
  60. animator().alphaValue = 1
  61. }
  62. window?.makeFirstResponder(textView)
  63. }
  64. func dismiss(animated: Bool = true) {
  65. let remove = { [weak self] in
  66. self?.removeFromSuperview()
  67. self?.onDismiss?()
  68. }
  69. guard animated else {
  70. remove()
  71. return
  72. }
  73. NSAnimationContext.runAnimationGroup({ context in
  74. context.duration = 0.15
  75. animator().alphaValue = 0
  76. }, completionHandler: remove)
  77. }
  78. private func setup() {
  79. wantsLayer = true
  80. titleLabel.font = AppTheme.semiboldFont(size: 18)
  81. titleLabel.alignment = .center
  82. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  83. textContainer.wantsLayer = true
  84. textContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
  85. textContainer.layer?.borderWidth = 1.5
  86. textContainer.applyCardShadow()
  87. textContainer.translatesAutoresizingMaskIntoConstraints = false
  88. textScrollView.translatesAutoresizingMaskIntoConstraints = false
  89. textScrollView.hasVerticalScroller = true
  90. textScrollView.hasHorizontalScroller = false
  91. textScrollView.autohidesScrollers = true
  92. textScrollView.borderType = .noBorder
  93. textScrollView.drawsBackground = false
  94. textView.isRichText = false
  95. textView.isEditable = true
  96. textView.isSelectable = true
  97. textView.drawsBackground = false
  98. textView.textColor = AppTheme.textPrimary
  99. textView.font = AppTheme.regularFont(size: 14)
  100. textView.textContainerInset = NSSize(width: 16, height: 16)
  101. textView.isAutomaticQuoteSubstitutionEnabled = true
  102. textView.isAutomaticDashSubstitutionEnabled = true
  103. textView.isAutomaticTextReplacementEnabled = true
  104. textScrollView.documentView = textView
  105. backButton.translatesAutoresizingMaskIntoConstraints = false
  106. printButton.translatesAutoresizingMaskIntoConstraints = false
  107. backButton.onClick = { [weak self] in self?.dismiss() }
  108. printButton.onClick = { [weak self] in self?.printText() }
  109. addSubview(backButton)
  110. addSubview(titleLabel)
  111. addSubview(textContainer)
  112. textContainer.addSubview(textScrollView)
  113. addSubview(printButton)
  114. NSLayoutConstraint.activate([
  115. backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
  116. backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
  117. backButton.widthAnchor.constraint(equalToConstant: 40),
  118. backButton.heightAnchor.constraint(equalToConstant: 40),
  119. titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
  120. titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
  121. textContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
  122. textContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
  123. textContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
  124. textContainer.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
  125. textScrollView.leadingAnchor.constraint(equalTo: textContainer.leadingAnchor, constant: 12),
  126. textScrollView.trailingAnchor.constraint(equalTo: textContainer.trailingAnchor, constant: -12),
  127. textScrollView.topAnchor.constraint(equalTo: textContainer.topAnchor, constant: 12),
  128. textScrollView.bottomAnchor.constraint(equalTo: textContainer.bottomAnchor, constant: -12),
  129. printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
  130. printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
  131. printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
  132. ])
  133. }
  134. override func resetCursorRects() {
  135. addCursorRect(textContainer.frame, cursor: .iBeam)
  136. }
  137. private func printText() {
  138. PrintService.printText(textView.string, from: window)
  139. }
  140. }
  141. // MARK: - Toolbar Button
  142. private final class PrintTextToolbarButton: NSControl, AppearanceRefreshable {
  143. var onClick: (() -> Void)?
  144. private let symbolName: String
  145. private let iconView = NSImageView()
  146. init(symbolName: String, accessibilityLabel: String) {
  147. self.symbolName = symbolName
  148. super.init(frame: .zero)
  149. toolTip = accessibilityLabel
  150. wantsLayer = true
  151. layer?.cornerRadius = 12
  152. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
  153. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
  154. iconView.image = image.withSymbolConfiguration(config)
  155. }
  156. iconView.translatesAutoresizingMaskIntoConstraints = false
  157. addSubview(iconView)
  158. NSLayoutConstraint.activate([
  159. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  160. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  161. ])
  162. refreshAppearance()
  163. }
  164. @available(*, unavailable)
  165. required init?(coder: NSCoder) { nil }
  166. func refreshAppearance() {
  167. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  168. layer?.borderColor = AppTheme.paywallBorder.cgColor
  169. layer?.borderWidth = 1.5
  170. iconView.contentTintColor = AppTheme.textPrimary
  171. }
  172. override func mouseUp(with event: NSEvent) {
  173. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  174. onClick?()
  175. }
  176. override func resetCursorRects() {
  177. addCursorRect(bounds, cursor: .pointingHand)
  178. }
  179. }
  180. // MARK: - Print Button
  181. private final class PrintTextPrintButton: NSControl, AppearanceRefreshable {
  182. var onClick: (() -> Void)?
  183. private let titleLabel = NSTextField(labelWithString: "Print Text")
  184. private let iconView = NSImageView()
  185. private var hoverTracker: HoverTracker?
  186. private var isHovered = false
  187. init() {
  188. super.init(frame: .zero)
  189. wantsLayer = true
  190. layer?.cornerRadius = 22
  191. titleLabel.font = AppTheme.semiboldFont(size: 16)
  192. titleLabel.textColor = .white
  193. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  194. if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
  195. let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
  196. iconView.image = image.withSymbolConfiguration(config)
  197. }
  198. iconView.contentTintColor = .white
  199. iconView.translatesAutoresizingMaskIntoConstraints = false
  200. addSubview(titleLabel)
  201. addSubview(iconView)
  202. NSLayoutConstraint.activate([
  203. heightAnchor.constraint(equalToConstant: 52),
  204. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
  205. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  206. iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
  207. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  208. iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
  209. iconView.widthAnchor.constraint(equalToConstant: 20),
  210. iconView.heightAnchor.constraint(equalToConstant: 20),
  211. ])
  212. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  213. self?.setHovered(hovering)
  214. }
  215. refreshAppearance()
  216. }
  217. @available(*, unavailable)
  218. required init?(coder: NSCoder) { nil }
  219. func refreshAppearance() {
  220. let color = isHovered
  221. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  222. : AppTheme.blue
  223. layer?.backgroundColor = color.cgColor
  224. titleLabel.textColor = .white
  225. iconView.contentTintColor = .white
  226. }
  227. private func setHovered(_ hovering: Bool) {
  228. isHovered = hovering
  229. animateHover {
  230. let color = hovering
  231. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  232. : AppTheme.blue
  233. layer?.backgroundColor = color.cgColor
  234. layer?.transform = hovering
  235. ? CATransform3DMakeScale(1.03, 1.03, 1)
  236. : CATransform3DIdentity
  237. }
  238. }
  239. override func mouseUp(with event: NSEvent) {
  240. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  241. onClick?()
  242. }
  243. override func resetCursorRects() {
  244. addCursorRect(bounds, cursor: .pointingHand)
  245. }
  246. }