1413 lines
51 KiB
Swift
1413 lines
51 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
|
|
// MARK: - Block Protocol
|
|
|
|
protocol CompositorBlock: AnyObject {
|
|
var view: NSView { get }
|
|
var blockHeight: CGFloat { get }
|
|
func layoutBlock(width: CGFloat)
|
|
func becomeActiveBlock()
|
|
func resignActiveBlock()
|
|
}
|
|
|
|
// MARK: - CompositorView
|
|
|
|
class CompositorView: NSView {
|
|
|
|
var blocks: [CompositorBlock] = []
|
|
weak var scrollView: NSScrollView?
|
|
|
|
var onContentHeightChanged: (() -> Void)?
|
|
|
|
private var activeBlockIndex: Int? = nil
|
|
|
|
override var isFlipped: Bool { true }
|
|
|
|
func setBlocks(_ newBlocks: [CompositorBlock]) {
|
|
for block in blocks {
|
|
block.view.removeFromSuperview()
|
|
}
|
|
blocks = newBlocks
|
|
for block in blocks {
|
|
addSubview(block.view)
|
|
}
|
|
layoutAllBlocks()
|
|
}
|
|
|
|
func insertBlock(_ block: CompositorBlock, at index: Int) {
|
|
let clamped = min(index, blocks.count)
|
|
blocks.insert(block, at: clamped)
|
|
addSubview(block.view)
|
|
layoutBlocks(from: clamped)
|
|
}
|
|
|
|
func removeBlock(at index: Int) {
|
|
guard index < blocks.count else { return }
|
|
let block = blocks[index]
|
|
block.view.removeFromSuperview()
|
|
blocks.remove(at: index)
|
|
if activeBlockIndex == index {
|
|
activeBlockIndex = nil
|
|
} else if let active = activeBlockIndex, active > index {
|
|
activeBlockIndex = active - 1
|
|
}
|
|
layoutBlocks(from: index)
|
|
}
|
|
|
|
func layoutAllBlocks() {
|
|
layoutBlocks(from: 0)
|
|
}
|
|
|
|
func layoutBlocks(from startIndex: Int) {
|
|
let width = bounds.width
|
|
var y: CGFloat = startIndex > 0
|
|
? blocks[startIndex - 1].view.frame.maxY
|
|
: 0
|
|
|
|
for i in startIndex..<blocks.count {
|
|
let block = blocks[i]
|
|
block.layoutBlock(width: width)
|
|
block.view.frame = NSRect(x: 0, y: y, width: width, height: block.blockHeight)
|
|
y += block.blockHeight
|
|
}
|
|
|
|
let totalHeight = y
|
|
if frame.height != totalHeight {
|
|
frame.size.height = totalHeight
|
|
onContentHeightChanged?()
|
|
}
|
|
}
|
|
|
|
func blockDidResize(_ block: CompositorBlock) {
|
|
guard let idx = blocks.firstIndex(where: { $0 === block }) else { return }
|
|
layoutBlocks(from: idx)
|
|
}
|
|
|
|
override func resizeSubviews(withOldSize oldSize: NSSize) {
|
|
super.resizeSubviews(withOldSize: oldSize)
|
|
if oldSize.width != bounds.width {
|
|
layoutAllBlocks()
|
|
}
|
|
}
|
|
|
|
// MARK: - Focus Routing
|
|
|
|
func activateBlock(at index: Int) {
|
|
guard index >= 0, index < blocks.count else { return }
|
|
if let prev = activeBlockIndex, prev < blocks.count {
|
|
blocks[prev].resignActiveBlock()
|
|
}
|
|
activeBlockIndex = index
|
|
blocks[index].becomeActiveBlock()
|
|
}
|
|
|
|
func activateNextBlock() {
|
|
let next = (activeBlockIndex ?? -1) + 1
|
|
if next < blocks.count {
|
|
activateBlock(at: next)
|
|
}
|
|
}
|
|
|
|
func activatePreviousBlock() {
|
|
let prev = (activeBlockIndex ?? blocks.count) - 1
|
|
if prev >= 0 {
|
|
activateBlock(at: prev)
|
|
}
|
|
}
|
|
|
|
var contentHeight: CGFloat {
|
|
blocks.last.map { $0.view.frame.maxY } ?? 0
|
|
}
|
|
}
|
|
|
|
// MARK: - TextBlock
|
|
|
|
class TextBlock: NSObject, CompositorBlock, NSTextViewDelegate {
|
|
|
|
let textView: NSTextView
|
|
private let textContainer: NSTextContainer
|
|
private let layoutManager: NSLayoutManager
|
|
private let textStorage: NSTextStorage
|
|
|
|
weak var compositor: CompositorView?
|
|
|
|
var view: NSView { textView }
|
|
|
|
var blockHeight: CGFloat {
|
|
layoutManager.ensureLayout(for: textContainer)
|
|
let usedRect = layoutManager.usedRect(for: textContainer)
|
|
return max(usedRect.height + textView.textContainerInset.height * 2, 24)
|
|
}
|
|
|
|
var text: String {
|
|
get { textView.string }
|
|
set { textView.string = newValue }
|
|
}
|
|
|
|
override init() {
|
|
textStorage = NSTextStorage()
|
|
layoutManager = NSLayoutManager()
|
|
textStorage.addLayoutManager(layoutManager)
|
|
|
|
textContainer = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
|
|
textContainer.widthTracksTextView = true
|
|
layoutManager.addTextContainer(textContainer)
|
|
|
|
textView = NSTextView(frame: .zero, textContainer: textContainer)
|
|
textView.isEditable = true
|
|
textView.isSelectable = true
|
|
textView.allowsUndo = true
|
|
textView.isRichText = false
|
|
textView.isVerticallyResizable = false
|
|
textView.isHorizontallyResizable = false
|
|
textView.font = Theme.editorFont
|
|
textView.textColor = Theme.current.text
|
|
textView.backgroundColor = .clear
|
|
textView.drawsBackground = false
|
|
textView.insertionPointColor = Theme.current.text
|
|
textView.isAutomaticQuoteSubstitutionEnabled = false
|
|
textView.isAutomaticDashSubstitutionEnabled = false
|
|
textView.isAutomaticTextReplacementEnabled = false
|
|
textView.isAutomaticSpellingCorrectionEnabled = false
|
|
textView.smartInsertDeleteEnabled = false
|
|
textView.isAutomaticLinkDetectionEnabled = false
|
|
textView.textContainerInset = NSSize(width: 4, height: 4)
|
|
|
|
super.init()
|
|
textView.delegate = self
|
|
}
|
|
|
|
convenience init(text: String) {
|
|
self.init()
|
|
textView.string = text
|
|
}
|
|
|
|
func layoutBlock(width: CGFloat) {
|
|
textContainer.size = NSSize(width: max(width - 8, 1), height: CGFloat.greatestFiniteMagnitude)
|
|
layoutManager.ensureLayout(for: textContainer)
|
|
}
|
|
|
|
func becomeActiveBlock() {
|
|
textView.window?.makeFirstResponder(textView)
|
|
}
|
|
|
|
func resignActiveBlock() {
|
|
if textView.window?.firstResponder === textView {
|
|
textView.window?.makeFirstResponder(nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - NSTextViewDelegate
|
|
|
|
func textDidChange(_ notification: Notification) {
|
|
compositor?.blockDidResize(self)
|
|
}
|
|
}
|
|
|
|
// MARK: - Document Model
|
|
|
|
enum BlockType {
|
|
case text
|
|
case table
|
|
case horizontalRule
|
|
}
|
|
|
|
struct BlockDescriptor {
|
|
let type: BlockType
|
|
let content: String
|
|
}
|
|
|
|
func parseDocument(_ markdown: String) -> [BlockDescriptor] {
|
|
let lines = markdown.components(separatedBy: "\n")
|
|
var descriptors: [BlockDescriptor] = []
|
|
var currentTextLines: [String] = []
|
|
|
|
func flushText() {
|
|
if !currentTextLines.isEmpty {
|
|
let content = currentTextLines.joined(separator: "\n")
|
|
descriptors.append(BlockDescriptor(type: .text, content: content))
|
|
currentTextLines = []
|
|
}
|
|
}
|
|
|
|
var i = 0
|
|
while i < lines.count {
|
|
let line = lines[i]
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
// Horizontal rule: line is only dashes/spaces, at least 3 dashes
|
|
if isHorizontalRuleLine(trimmed) {
|
|
flushText()
|
|
descriptors.append(BlockDescriptor(type: .horizontalRule, content: line))
|
|
i += 1
|
|
continue
|
|
}
|
|
|
|
// Table: starts with | and next line is a separator row
|
|
if trimmed.hasPrefix("|") && i + 1 < lines.count {
|
|
let nextTrimmed = lines[i + 1].trimmingCharacters(in: .whitespaces)
|
|
if isDocTableSeparator(nextTrimmed) {
|
|
flushText()
|
|
var tableLines: [String] = [line, lines[i + 1]]
|
|
var j = i + 2
|
|
while j < lines.count {
|
|
let rowTrimmed = lines[j].trimmingCharacters(in: .whitespaces)
|
|
if rowTrimmed.hasPrefix("|") && rowTrimmed.hasSuffix("|") {
|
|
tableLines.append(lines[j])
|
|
j += 1
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
let content = tableLines.joined(separator: "\n")
|
|
descriptors.append(BlockDescriptor(type: .table, content: content))
|
|
i = j
|
|
continue
|
|
}
|
|
}
|
|
|
|
currentTextLines.append(line)
|
|
i += 1
|
|
}
|
|
|
|
flushText()
|
|
|
|
if descriptors.isEmpty {
|
|
descriptors.append(BlockDescriptor(type: .text, content: ""))
|
|
}
|
|
|
|
return descriptors
|
|
}
|
|
|
|
func serializeDocument(_ blocks: [CompositorBlock]) -> String {
|
|
var parts: [String] = []
|
|
for block in blocks {
|
|
if let textBlock = block as? TextBlock {
|
|
parts.append(textBlock.text)
|
|
} else if let hrBlock = block as? HRBlock {
|
|
parts.append(hrBlock.sourceText)
|
|
} else if let tableBlock = block as? TableBlock {
|
|
parts.append(rebuildTableMarkdown(tableBlock.table))
|
|
} else {
|
|
parts.append("")
|
|
}
|
|
}
|
|
return parts.joined(separator: "\n")
|
|
}
|
|
|
|
private func isHorizontalRuleLine(_ trimmed: String) -> Bool {
|
|
guard !trimmed.isEmpty else { return false }
|
|
let stripped = trimmed.replacingOccurrences(of: " ", with: "")
|
|
guard stripped.count >= 3 else { return false }
|
|
return stripped.allSatisfy { $0 == "-" }
|
|
}
|
|
|
|
private func isDocTableSeparator(_ trimmed: String) -> Bool {
|
|
guard trimmed.hasPrefix("|") else { return false }
|
|
let inner = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "| "))
|
|
guard !inner.isEmpty else { return false }
|
|
let cells = inner.components(separatedBy: "|")
|
|
return cells.allSatisfy { cell in
|
|
let c = cell.trimmingCharacters(in: .whitespaces)
|
|
return c.allSatisfy { $0 == "-" || $0 == ":" } && c.contains("-")
|
|
}
|
|
}
|
|
|
|
// MARK: - HRBlock
|
|
|
|
class HRDrawingView: NSView {
|
|
override var isFlipped: Bool { true }
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
super.draw(dirtyRect)
|
|
let y = bounds.midY
|
|
let path = NSBezierPath()
|
|
path.move(to: NSPoint(x: 16, y: y))
|
|
path.line(to: NSPoint(x: bounds.width - 16, y: y))
|
|
path.lineWidth = 1
|
|
Theme.current.overlay0.setStroke()
|
|
path.stroke()
|
|
}
|
|
}
|
|
|
|
class HRBlock: NSObject, CompositorBlock {
|
|
|
|
let sourceText: String
|
|
var sourceRange: NSRange
|
|
private let hrView: HRDrawingView
|
|
|
|
var view: NSView { hrView }
|
|
|
|
var blockHeight: CGFloat { 20 }
|
|
|
|
init(sourceText: String = "---", sourceRange: NSRange = NSRange(location: 0, length: 0)) {
|
|
self.sourceText = sourceText
|
|
self.sourceRange = sourceRange
|
|
hrView = HRDrawingView(frame: NSRect(x: 0, y: 0, width: 100, height: 20))
|
|
super.init()
|
|
}
|
|
|
|
func layoutBlock(width: CGFloat) {}
|
|
|
|
func becomeActiveBlock() {}
|
|
|
|
func resignActiveBlock() {}
|
|
}
|
|
|
|
class FlippedBlockView: NSView {
|
|
override var isFlipped: Bool { true }
|
|
}
|
|
|
|
// MARK: - TableBlockView
|
|
|
|
class TableBlockView: FlippedBlockView {
|
|
|
|
weak var tableBlock: TableBlock?
|
|
private var trackingArea: NSTrackingArea?
|
|
private var addColumnButton: NSButton?
|
|
|
|
override var isFlipped: Bool { true }
|
|
|
|
private enum DragMode { case none, column(Int) }
|
|
private var dragMode: DragMode = .none
|
|
private var dragStartX: CGFloat = 0
|
|
private var dragStartWidth: CGFloat = 0
|
|
|
|
private let dividerHitZone: CGFloat = 4
|
|
|
|
func setupTracking() {
|
|
if let old = trackingArea { removeTrackingArea(old) }
|
|
let area = NSTrackingArea(
|
|
rect: bounds,
|
|
options: [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect],
|
|
owner: self,
|
|
userInfo: nil
|
|
)
|
|
addTrackingArea(area)
|
|
trackingArea = area
|
|
}
|
|
|
|
func updateAddButton() {
|
|
addColumnButton?.removeFromSuperview()
|
|
guard let tb = tableBlock else { return }
|
|
let gridW = tb.totalGridWidth
|
|
let th = tb.totalGridHeight
|
|
let btn = NSButton(frame: NSRect(x: gridW + 2, y: 0, width: 20, height: min(th, 28)))
|
|
btn.title = "+"
|
|
btn.bezelStyle = .inline
|
|
btn.font = NSFont.systemFont(ofSize: 12, weight: .medium)
|
|
btn.isBordered = false
|
|
btn.wantsLayer = true
|
|
btn.layer?.backgroundColor = Theme.current.surface0.cgColor
|
|
btn.layer?.cornerRadius = 3
|
|
btn.contentTintColor = Theme.current.overlay2
|
|
btn.target = self
|
|
btn.action = #selector(addColumnClicked)
|
|
btn.alphaValue = 0
|
|
addSubview(btn)
|
|
addColumnButton = btn
|
|
}
|
|
|
|
@objc private func addColumnClicked() {
|
|
tableBlock?.addColumn()
|
|
}
|
|
|
|
override func mouseEntered(with event: NSEvent) {
|
|
NSAnimationContext.runAnimationGroup { ctx in
|
|
ctx.duration = 0.15
|
|
addColumnButton?.animator().alphaValue = 1
|
|
}
|
|
}
|
|
|
|
override func mouseExited(with event: NSEvent) {
|
|
NSCursor.arrow.set()
|
|
NSAnimationContext.runAnimationGroup { ctx in
|
|
ctx.duration = 0.15
|
|
addColumnButton?.animator().alphaValue = 0
|
|
}
|
|
dragMode = .none
|
|
}
|
|
|
|
override func mouseMoved(with event: NSEvent) {
|
|
let pt = convert(event.locationInWindow, from: nil)
|
|
if dividerColumn(at: pt) != nil {
|
|
NSCursor.resizeLeftRight.set()
|
|
} else {
|
|
NSCursor.arrow.set()
|
|
}
|
|
}
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
let pt = convert(event.locationInWindow, from: nil)
|
|
if let col = dividerColumn(at: pt), let tb = tableBlock {
|
|
dragMode = .column(col)
|
|
dragStartX = pt.x
|
|
dragStartWidth = tb.columnWidths[col]
|
|
return
|
|
}
|
|
dragMode = .none
|
|
super.mouseDown(with: event)
|
|
}
|
|
|
|
override func mouseDragged(with event: NSEvent) {
|
|
let pt = convert(event.locationInWindow, from: nil)
|
|
switch dragMode {
|
|
case .column(let col):
|
|
guard let tb = tableBlock else { return }
|
|
let delta = pt.x - dragStartX
|
|
let newWidth = max(tb.minColWidth, min(dragStartWidth + delta, tb.maxColWidth))
|
|
tb.setColumnWidth(col, to: newWidth)
|
|
case .none:
|
|
super.mouseDragged(with: event)
|
|
}
|
|
}
|
|
|
|
override func mouseUp(with event: NSEvent) {
|
|
dragMode = .none
|
|
}
|
|
|
|
private func dividerColumn(at pt: NSPoint) -> Int? {
|
|
guard let tb = tableBlock else { return nil }
|
|
let colCount = tb.table.headers.count
|
|
for i in 1...colCount {
|
|
let divX = tb.columnX(for: i) - 1
|
|
if abs(pt.x - divX) <= dividerHitZone { return i - 1 }
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - TableBlock
|
|
|
|
class TableBlock: NSObject, CompositorBlock, NSTextFieldDelegate {
|
|
|
|
var table: ParsedTable
|
|
var sourceRange: NSRange
|
|
var onTableChanged: ((ParsedTable, NSRange) -> Void)?
|
|
|
|
private let containerView: TableBlockView
|
|
fileprivate var cellFields: [[NSTextField]] = []
|
|
fileprivate var columnWidths: [CGFloat] = []
|
|
private var customWidths: [Int: CGFloat] = [:]
|
|
private var rowHeights: [CGFloat] = []
|
|
|
|
let minColWidth: CGFloat = 60
|
|
let maxColWidth: CGFloat = 300
|
|
private let cellPadding: CGFloat = 16
|
|
private let defaultHeaderHeight: CGFloat = 28
|
|
private let defaultCellHeight: CGFloat = 26
|
|
|
|
var view: NSView { containerView }
|
|
|
|
var blockHeight: CGFloat {
|
|
rowHeights.reduce(0, +) + 2
|
|
}
|
|
|
|
var totalGridWidth: CGFloat {
|
|
columnX(for: table.headers.count) + 1
|
|
}
|
|
|
|
var totalGridHeight: CGFloat {
|
|
rowHeights.reduce(0, +)
|
|
}
|
|
|
|
init(table: ParsedTable, width: CGFloat) {
|
|
self.table = table
|
|
self.sourceRange = table.sourceRange
|
|
containerView = TableBlockView(frame: .zero)
|
|
containerView.wantsLayer = true
|
|
containerView.layer?.backgroundColor = Theme.current.base.cgColor
|
|
containerView.layer?.cornerRadius = 4
|
|
super.init()
|
|
containerView.tableBlock = self
|
|
initSizes()
|
|
buildGrid()
|
|
}
|
|
|
|
// MARK: - Sizing
|
|
|
|
private func measureColumnWidth(_ col: Int) -> CGFloat {
|
|
let headerFont = NSFontManager.shared.convert(Theme.editorFont, toHaveTrait: .boldFontMask)
|
|
let cellFont = Theme.editorFont
|
|
|
|
var maxW: CGFloat = 0
|
|
if col < table.headers.count {
|
|
let w = (table.headers[col] as NSString).size(withAttributes: [.font: headerFont]).width
|
|
maxW = max(maxW, w)
|
|
}
|
|
for row in table.rows {
|
|
guard col < row.count else { continue }
|
|
let w = (row[col] as NSString).size(withAttributes: [.font: cellFont]).width
|
|
maxW = max(maxW, w)
|
|
}
|
|
return min(max(maxW + cellPadding * 2, minColWidth), maxColWidth)
|
|
}
|
|
|
|
private func initSizes() {
|
|
let colCount = table.headers.count
|
|
guard colCount > 0 else { return }
|
|
columnWidths = (0..<colCount).map { col in
|
|
customWidths[col] ?? measureColumnWidth(col)
|
|
}
|
|
rowHeights = [defaultHeaderHeight]
|
|
for _ in 0..<table.rows.count {
|
|
rowHeights.append(defaultCellHeight)
|
|
}
|
|
}
|
|
|
|
func columnX(for col: Int) -> CGFloat {
|
|
var x: CGFloat = 1
|
|
for i in 0..<col { x += columnWidths[i] + 1 }
|
|
return x
|
|
}
|
|
|
|
func setColumnWidth(_ col: Int, to width: CGFloat) {
|
|
guard col >= 0, col < columnWidths.count else { return }
|
|
columnWidths[col] = width
|
|
customWidths[col] = width
|
|
buildGrid()
|
|
}
|
|
|
|
func layoutBlock(width: CGFloat) {}
|
|
|
|
func becomeActiveBlock() {}
|
|
|
|
func resignActiveBlock() {}
|
|
|
|
// MARK: - Add column
|
|
|
|
func addColumn() {
|
|
table.headers.append("Header")
|
|
table.alignments.append(.left)
|
|
for i in 0..<table.rows.count {
|
|
table.rows[i].append("")
|
|
}
|
|
initSizes()
|
|
buildGrid()
|
|
onTableChanged?(table, sourceRange)
|
|
}
|
|
|
|
// MARK: - Grid
|
|
|
|
private func buildGrid() {
|
|
containerView.subviews.forEach { $0.removeFromSuperview() }
|
|
cellFields = []
|
|
|
|
let colCount = table.headers.count
|
|
guard colCount > 0 else { return }
|
|
let th = rowHeights.reduce(0, +)
|
|
let gridW = totalGridWidth
|
|
let addBtnSpace: CGFloat = 24
|
|
containerView.frame.size = NSSize(width: gridW + addBtnSpace, height: th + 2)
|
|
|
|
let headerBg = NSView(frame: NSRect(x: 0, y: 0, width: gridW, height: rowHeights[0]))
|
|
headerBg.wantsLayer = true
|
|
headerBg.layer?.backgroundColor = Theme.current.surface0.cgColor
|
|
containerView.addSubview(headerBg)
|
|
|
|
var headerFields: [NSTextField] = []
|
|
for (col, header) in table.headers.enumerated() {
|
|
let x = columnX(for: col)
|
|
let h = rowHeights[0]
|
|
let field = makeCell(text: header, frame: NSRect(x: x, y: 2, width: columnWidths[col], height: h - 4),
|
|
isHeader: true, row: -1, col: col)
|
|
containerView.addSubview(field)
|
|
headerFields.append(field)
|
|
}
|
|
cellFields.append(headerFields)
|
|
|
|
var yOffset = rowHeights[0]
|
|
for (rowIdx, row) in table.rows.enumerated() {
|
|
var rowFields: [NSTextField] = []
|
|
let h = rowHeights[rowIdx + 1]
|
|
for col in 0..<colCount {
|
|
let cellText = col < row.count ? row[col] : ""
|
|
let x = columnX(for: col)
|
|
let field = makeCell(text: cellText, frame: NSRect(x: x, y: yOffset + 2, width: columnWidths[col], height: h - 4),
|
|
isHeader: false, row: rowIdx, col: col)
|
|
containerView.addSubview(field)
|
|
rowFields.append(field)
|
|
}
|
|
cellFields.append(rowFields)
|
|
yOffset += h
|
|
}
|
|
|
|
for i in 1..<colCount {
|
|
let x = columnX(for: i) - 1
|
|
let line = NSView(frame: NSRect(x: x, y: 0, width: 1, height: th))
|
|
line.wantsLayer = true
|
|
line.layer?.backgroundColor = Theme.current.surface2.cgColor
|
|
containerView.addSubview(line)
|
|
}
|
|
|
|
var divY: CGFloat = rowHeights[0]
|
|
for i in 1..<rowHeights.count {
|
|
let line = NSView(frame: NSRect(x: 0, y: divY, width: gridW, height: 1))
|
|
line.wantsLayer = true
|
|
line.layer?.backgroundColor = Theme.current.surface2.cgColor
|
|
containerView.addSubview(line)
|
|
divY += rowHeights[i]
|
|
}
|
|
|
|
containerView.setupTracking()
|
|
containerView.updateAddButton()
|
|
}
|
|
|
|
private func makeCell(text: String, frame: NSRect, isHeader: Bool, row: Int, col: Int) -> NSTextField {
|
|
let field = NSTextField(frame: frame)
|
|
field.stringValue = text
|
|
field.isEditable = true
|
|
field.isSelectable = true
|
|
field.isBordered = false
|
|
field.isBezeled = false
|
|
field.drawsBackground = false
|
|
field.wantsLayer = true
|
|
field.font = isHeader
|
|
? NSFontManager.shared.convert(Theme.editorFont, toHaveTrait: .boldFontMask)
|
|
: Theme.editorFont
|
|
field.textColor = Theme.current.text
|
|
field.focusRingType = .none
|
|
field.cell?.truncatesLastVisibleLine = true
|
|
field.cell?.usesSingleLineMode = true
|
|
field.tag = (row + 1) * 1000 + col
|
|
field.delegate = self
|
|
if let align = table.alignments[safe: col] {
|
|
switch align {
|
|
case .left: field.alignment = .left
|
|
case .center: field.alignment = .center
|
|
case .right: field.alignment = .right
|
|
}
|
|
}
|
|
return field
|
|
}
|
|
|
|
// MARK: - Cell editing
|
|
|
|
func controlTextDidEndEditing(_ obj: Notification) {
|
|
guard let field = obj.object as? NSTextField else { return }
|
|
let tag = field.tag
|
|
let row = tag / 1000 - 1
|
|
let col = tag % 1000
|
|
|
|
if row == -1 {
|
|
guard col < table.headers.count else { return }
|
|
table.headers[col] = field.stringValue
|
|
} else {
|
|
guard row < table.rows.count, col < table.rows[row].count else { return }
|
|
table.rows[row][col] = field.stringValue
|
|
}
|
|
onTableChanged?(table, sourceRange)
|
|
|
|
if let movement = obj.userInfo?["NSTextMovement"] as? Int, movement == NSTabTextMovement {
|
|
let nextCol = col + 1
|
|
if nextCol >= table.headers.count {
|
|
let isLastRow = (row == table.rows.count - 1) || (row == -1 && table.rows.isEmpty)
|
|
if isLastRow {
|
|
addColumn()
|
|
let fieldRow = row + 1
|
|
if fieldRow < cellFields.count {
|
|
let newCol = table.headers.count - 1
|
|
if newCol < cellFields[fieldRow].count {
|
|
containerView.window?.makeFirstResponder(cellFields[fieldRow][newCol])
|
|
}
|
|
}
|
|
} else {
|
|
let fieldRow = row + 2
|
|
if fieldRow < cellFields.count, !cellFields[fieldRow].isEmpty {
|
|
containerView.window?.makeFirstResponder(cellFields[fieldRow][0])
|
|
}
|
|
}
|
|
return
|
|
}
|
|
let fieldRow = row + 1
|
|
if fieldRow < cellFields.count, nextCol < cellFields[fieldRow].count {
|
|
containerView.window?.makeFirstResponder(cellFields[fieldRow][nextCol])
|
|
}
|
|
}
|
|
}
|
|
|
|
func control(_ control: NSControl, textView tv: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
|
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
|
|
table.rows.append(Array(repeating: "", count: table.headers.count))
|
|
rowHeights.append(defaultCellHeight)
|
|
buildGrid()
|
|
onTableChanged?(table, sourceRange)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
}
|
|
|
|
private extension Array {
|
|
subscript(safe index: Int) -> Element? {
|
|
indices.contains(index) ? self[index] : nil
|
|
}
|
|
}
|
|
|
|
// MARK: - CompositorRepresentable
|
|
|
|
struct CompositorRepresentable: NSViewRepresentable {
|
|
@Binding var text: String
|
|
var evalResults: [Int: EvalEntry]
|
|
var fileFormat: FileFormat
|
|
var onEvaluate: () -> Void
|
|
var onBackspaceAtStart: (() -> Void)?
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(self)
|
|
}
|
|
|
|
func makeNSView(context: Context) -> NSScrollView {
|
|
let scrollView = NSTextView.scrollableTextView()
|
|
let defaultTV = scrollView.documentView as! NSTextView
|
|
|
|
let tc = defaultTV.textContainer!
|
|
tc.replaceLayoutManager(MarkdownLayoutManager())
|
|
|
|
let textView = LineNumberTextView(frame: defaultTV.frame, textContainer: tc)
|
|
textView.minSize = defaultTV.minSize
|
|
textView.maxSize = defaultTV.maxSize
|
|
textView.isVerticallyResizable = defaultTV.isVerticallyResizable
|
|
textView.isHorizontallyResizable = defaultTV.isHorizontallyResizable
|
|
textView.autoresizingMask = defaultTV.autoresizingMask
|
|
|
|
textView.isEditable = true
|
|
textView.isSelectable = true
|
|
textView.allowsUndo = true
|
|
textView.isRichText = true
|
|
textView.usesFindBar = true
|
|
textView.isIncrementalSearchingEnabled = true
|
|
textView.font = Theme.editorFont
|
|
textView.textColor = Theme.current.text
|
|
textView.backgroundColor = Theme.current.base
|
|
textView.insertionPointColor = Theme.current.text
|
|
textView.selectedTextAttributes = [
|
|
.backgroundColor: Theme.current.surface1
|
|
]
|
|
textView.isAutomaticQuoteSubstitutionEnabled = false
|
|
textView.isAutomaticDashSubstitutionEnabled = false
|
|
textView.isAutomaticTextReplacementEnabled = false
|
|
textView.isAutomaticSpellingCorrectionEnabled = false
|
|
textView.smartInsertDeleteEnabled = false
|
|
textView.isAutomaticLinkDetectionEnabled = false
|
|
|
|
textView.textContainerInset = NSSize(width: 4, height: 8)
|
|
textView.textContainer?.widthTracksTextView = false
|
|
textView.registerForDraggedTypes([.fileURL])
|
|
|
|
scrollView.documentView = textView
|
|
|
|
textView.string = text
|
|
textView.evalResults = evalResults
|
|
textView.delegate = context.coordinator
|
|
context.coordinator.textView = textView
|
|
|
|
if let ts = textView.textStorage {
|
|
ts.beginEditing()
|
|
applySyntaxHighlighting(to: ts, format: fileFormat)
|
|
ts.endEditing()
|
|
}
|
|
textView.applyEvalSpacing()
|
|
textView.typingAttributes = [
|
|
.font: Theme.editorFont,
|
|
.foregroundColor: Theme.current.text
|
|
]
|
|
updateBlockRanges(for: textView)
|
|
|
|
context.coordinator.triggerImageUpdate()
|
|
context.coordinator.triggerTableUpdate()
|
|
|
|
return scrollView
|
|
}
|
|
|
|
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
|
guard let textView = scrollView.documentView as? LineNumberTextView else { return }
|
|
if textView.string != text {
|
|
let selectedRanges = textView.selectedRanges
|
|
textView.string = text
|
|
if let ts = textView.textStorage {
|
|
ts.beginEditing()
|
|
applySyntaxHighlighting(to: ts, format: fileFormat)
|
|
ts.endEditing()
|
|
}
|
|
textView.selectedRanges = selectedRanges
|
|
updateBlockRanges(for: textView)
|
|
context.coordinator.triggerTableUpdate()
|
|
}
|
|
textView.backgroundColor = Theme.current.base
|
|
textView.insertionPointColor = Theme.current.text
|
|
textView.typingAttributes = [
|
|
.font: Theme.editorFont,
|
|
.foregroundColor: Theme.current.text
|
|
]
|
|
|
|
textView.evalResults = evalResults
|
|
textView.needsDisplay = true
|
|
}
|
|
|
|
class Coordinator: NSObject, NSTextViewDelegate {
|
|
var parent: CompositorRepresentable
|
|
weak var textView: NSTextView?
|
|
private var isUpdatingImages = false
|
|
private var isUpdatingTables = false
|
|
private var tableBlocks: [TableBlock] = []
|
|
private var hrBlocks: [HRBlock] = []
|
|
private var observers: [NSObjectProtocol] = []
|
|
|
|
init(_ parent: CompositorRepresentable) {
|
|
self.parent = parent
|
|
super.init()
|
|
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .focusEditor, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
guard let tv = self?.textView else { return }
|
|
tv.window?.makeFirstResponder(tv)
|
|
tv.setSelectedRange(NSRange(location: 0, length: 0))
|
|
})
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .formatDocument, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
self?.formatCurrentDocument()
|
|
})
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .settingsChanged, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
guard let tv = self?.textView, let ts = tv.textStorage else { return }
|
|
let palette = Theme.current
|
|
tv.backgroundColor = palette.base
|
|
tv.insertionPointColor = palette.text
|
|
tv.selectedTextAttributes = [.backgroundColor: palette.surface1]
|
|
tv.typingAttributes = [
|
|
.font: Theme.editorFont,
|
|
.foregroundColor: palette.text
|
|
]
|
|
ts.beginEditing()
|
|
applySyntaxHighlighting(to: ts, format: self?.parent.fileFormat ?? .markdown)
|
|
ts.endEditing()
|
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
|
tv.needsDisplay = true
|
|
})
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .boldSelection, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
self?.wrapSelection(with: "**")
|
|
})
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .italicizeSelection, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
self?.wrapSelection(with: "*")
|
|
})
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .insertTable, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
self?.insertBlankTable()
|
|
})
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .smartEval, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
self?.performSmartEval()
|
|
})
|
|
}
|
|
|
|
deinit {
|
|
for obs in observers {
|
|
NotificationCenter.default.removeObserver(obs)
|
|
}
|
|
}
|
|
|
|
func textDidChange(_ notification: Notification) {
|
|
guard let tv = textView, let ts = tv.textStorage else { return }
|
|
if isUpdatingImages || isUpdatingTables { return }
|
|
parent.text = tv.string
|
|
let sel = tv.selectedRanges
|
|
ts.beginEditing()
|
|
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
|
|
ts.endEditing()
|
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
|
tv.typingAttributes = [
|
|
.font: Theme.editorFont,
|
|
.foregroundColor: Theme.current.text
|
|
]
|
|
tv.selectedRanges = sel
|
|
updateBlockRanges(for: tv)
|
|
tv.needsDisplay = true
|
|
|
|
updateInlineImages()
|
|
updateEmbeddedTables()
|
|
}
|
|
|
|
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
|
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
|
|
insertNewlineWithAutoIndent(textView)
|
|
parent.onEvaluate()
|
|
return true
|
|
}
|
|
if commandSelector == #selector(NSResponder.deleteBackward(_:)) {
|
|
if textView.selectedRange().location == 0 && textView.selectedRange().length == 0 {
|
|
parent.onBackspaceAtStart?()
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func textView(_ textView: NSTextView, shouldChangeTextIn range: NSRange, replacementString text: String?) -> Bool {
|
|
guard let text = text, text.count == 1 else { return true }
|
|
let ch = text.first!
|
|
let hasSelection = range.length > 0
|
|
|
|
if !hasSelection {
|
|
let closerChars: Set<Character> = ["}", ")", "]", "\"", "'"]
|
|
if closerChars.contains(ch) {
|
|
let str = textView.string as NSString
|
|
if range.location < str.length {
|
|
let next = Character(UnicodeScalar(str.character(at: range.location))!)
|
|
if next == ch {
|
|
textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let pairClosers: [Character: Character] = ["{": "}", "(": ")", "[": "]"]
|
|
if let close = pairClosers[ch] {
|
|
if hasSelection {
|
|
let selected = (textView.string as NSString).substring(with: range)
|
|
textView.insertText(String(ch) + selected + String(close), replacementRange: range)
|
|
textView.setSelectedRange(NSRange(location: range.location + 1, length: selected.count))
|
|
} else {
|
|
textView.insertText(String(ch) + String(close), replacementRange: range)
|
|
textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
|
|
}
|
|
return false
|
|
}
|
|
|
|
if ch == "\"" || ch == "'" {
|
|
if hasSelection {
|
|
let selected = (textView.string as NSString).substring(with: range)
|
|
textView.insertText(String(ch) + selected + String(ch), replacementRange: range)
|
|
textView.setSelectedRange(NSRange(location: range.location + 1, length: selected.count))
|
|
} else {
|
|
textView.insertText(String(ch) + String(ch), replacementRange: range)
|
|
textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
|
|
}
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
|
|
var urlString: String?
|
|
if let url = link as? URL {
|
|
urlString = url.absoluteString
|
|
} else if let str = link as? String {
|
|
urlString = str
|
|
}
|
|
guard let str = urlString, let url = URL(string: str) else { return false }
|
|
NSWorkspace.shared.open(url)
|
|
return true
|
|
}
|
|
|
|
// MARK: - Editing helpers
|
|
|
|
private func formatCurrentDocument() {
|
|
guard let tv = textView else { return }
|
|
let format = parent.fileFormat
|
|
let text = tv.string
|
|
|
|
var formatted: String?
|
|
switch format {
|
|
case .json:
|
|
formatted = formatJSON(text)
|
|
default:
|
|
if format.isCode {
|
|
formatted = normalizeIndentation(text)
|
|
}
|
|
}
|
|
|
|
if let result = formatted, result != text {
|
|
let sel = tv.selectedRanges
|
|
tv.string = result
|
|
parent.text = result
|
|
if let ts = tv.textStorage {
|
|
ts.beginEditing()
|
|
applySyntaxHighlighting(to: ts, format: format)
|
|
ts.endEditing()
|
|
}
|
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
|
tv.selectedRanges = sel
|
|
tv.needsDisplay = true
|
|
}
|
|
}
|
|
|
|
private func formatJSON(_ text: String) -> String? {
|
|
guard let data = text.data(using: .utf8),
|
|
let obj = try? JSONSerialization.jsonObject(with: data),
|
|
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
|
|
let result = String(data: pretty, encoding: .utf8) else { return nil }
|
|
return result
|
|
}
|
|
|
|
private func normalizeIndentation(_ text: String) -> String {
|
|
let lines = text.components(separatedBy: "\n")
|
|
var result: [String] = []
|
|
var depth = 0
|
|
|
|
for line in lines {
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
if trimmed.isEmpty {
|
|
result.append("")
|
|
continue
|
|
}
|
|
|
|
let opens = trimmed.filter { "{([".contains($0) }.count
|
|
let closes = trimmed.filter { "})]".contains($0) }.count
|
|
let delta = opens - closes
|
|
|
|
if delta < 0 { depth = max(0, depth + delta) }
|
|
let indent = String(repeating: " ", count: depth)
|
|
result.append(indent + trimmed)
|
|
if delta > 0 { depth += delta }
|
|
}
|
|
|
|
return result.joined(separator: "\n")
|
|
}
|
|
|
|
private func insertNewlineWithAutoIndent(_ textView: NSTextView) {
|
|
let str = textView.string as NSString
|
|
let cursor = textView.selectedRange().location
|
|
let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0))
|
|
let currentLine = str.substring(with: lineRange)
|
|
|
|
var indent = ""
|
|
for c in currentLine {
|
|
if c == " " || c == "\t" { indent.append(c) }
|
|
else { break }
|
|
}
|
|
|
|
let trimmed = currentLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let shouldIndent = trimmed.hasSuffix("{") || trimmed.hasSuffix(":") ||
|
|
trimmed.hasSuffix("do") || trimmed.hasSuffix("then") ||
|
|
trimmed.hasSuffix("(") || trimmed.hasSuffix("[")
|
|
|
|
if shouldIndent {
|
|
indent += " "
|
|
}
|
|
|
|
let charBeforeCursor: Character? = cursor > 0 ? Character(UnicodeScalar(str.character(at: cursor - 1))!) : nil
|
|
let charAtCursor: Character? = cursor < str.length ? Character(UnicodeScalar(str.character(at: cursor))!) : nil
|
|
|
|
if let before = charBeforeCursor, let after = charAtCursor,
|
|
(before == "{" && after == "}") || (before == "(" && after == ")") || (before == "[" && after == "]") {
|
|
let baseIndent = indent.count >= 4 ? String(indent.dropLast(4)) : ""
|
|
let insertion = "\n" + indent + "\n" + baseIndent
|
|
textView.insertText(insertion, replacementRange: textView.selectedRange())
|
|
textView.setSelectedRange(NSRange(location: cursor + 1 + indent.count, length: 0))
|
|
return
|
|
}
|
|
|
|
textView.insertText("\n" + indent, replacementRange: textView.selectedRange())
|
|
}
|
|
|
|
private func wrapSelection(with wrapper: String) {
|
|
guard let tv = textView else { return }
|
|
let sel = tv.selectedRange()
|
|
guard sel.length > 0 else { return }
|
|
let str = tv.string as NSString
|
|
let selected = str.substring(with: sel)
|
|
let wrapped = wrapper + selected + wrapper
|
|
tv.insertText(wrapped, replacementRange: sel)
|
|
tv.setSelectedRange(NSRange(location: sel.location + wrapper.count, length: selected.count))
|
|
}
|
|
|
|
private func insertBlankTable() {
|
|
guard let tv = textView else { return }
|
|
let table = "| Column 1 | Column 2 | Column 3 |\n| --- | --- | --- |\n| | | |\n"
|
|
tv.insertText(table, replacementRange: tv.selectedRange())
|
|
}
|
|
|
|
private func performSmartEval() {
|
|
guard let tv = textView else { return }
|
|
let str = tv.string as NSString
|
|
let cursor = tv.selectedRange().location
|
|
let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0))
|
|
let line = str.substring(with: lineRange).trimmingCharacters(in: .newlines)
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
if trimmed.hasPrefix("let ") {
|
|
if let eqIdx = trimmed.firstIndex(of: "="), eqIdx > trimmed.startIndex {
|
|
let afterLet = trimmed[trimmed.index(trimmed.startIndex, offsetBy: 4)..<eqIdx]
|
|
.trimmingCharacters(in: .whitespaces)
|
|
let insertion = "\n/= \(afterLet)\n"
|
|
let endOfLine = NSMaxRange(lineRange)
|
|
tv.insertText(insertion, replacementRange: NSRange(location: endOfLine, length: 0))
|
|
}
|
|
} else if !trimmed.isEmpty {
|
|
let lineStart = lineRange.location
|
|
let whitespacePrefix = line.prefix(while: { $0 == " " || $0 == "\t" })
|
|
let insertLoc = lineStart + whitespacePrefix.count
|
|
tv.insertText("/= ", replacementRange: NSRange(location: insertLoc, length: 0))
|
|
}
|
|
}
|
|
|
|
// MARK: - Images
|
|
|
|
func triggerImageUpdate() {
|
|
updateInlineImages()
|
|
}
|
|
|
|
func triggerTableUpdate() {
|
|
updateEmbeddedTables()
|
|
}
|
|
|
|
private static let imageRegex: NSRegularExpression? = {
|
|
try? NSRegularExpression(pattern: "!\\[[^\\]]*\\]\\(([^)]+)\\)")
|
|
}()
|
|
|
|
private func updateInlineImages() {
|
|
guard let tv = textView, let ts = tv.textStorage else { return }
|
|
guard let regex = Coordinator.imageRegex else { return }
|
|
|
|
let text = ts.string
|
|
let nsText = text as NSString
|
|
let fullRange = NSRange(location: 0, length: nsText.length)
|
|
|
|
let existingAttachmentRanges = findExistingImageAttachments(in: ts)
|
|
let matches = regex.matches(in: text, range: fullRange)
|
|
|
|
var resolvedPaths: [String] = []
|
|
for match in matches {
|
|
let urlRange = match.range(at: 1)
|
|
let rawPath = nsText.substring(with: urlRange)
|
|
if let resolved = resolveLocalImagePath(rawPath), FileManager.default.fileExists(atPath: resolved) {
|
|
resolvedPaths.append(resolved)
|
|
}
|
|
}
|
|
|
|
let neededSet = Set(resolvedPaths)
|
|
let existingSet = Set(existingAttachmentRanges.map { $0.1 })
|
|
if neededSet == existingSet { return }
|
|
|
|
isUpdatingImages = true
|
|
ts.beginEditing()
|
|
|
|
for (range, _) in existingAttachmentRanges.reversed() {
|
|
ts.deleteCharacters(in: range)
|
|
}
|
|
|
|
let recalcText = ts.string
|
|
let recalcNS = recalcText as NSString
|
|
let recalcFull = NSRange(location: 0, length: recalcNS.length)
|
|
let recalcMatches = regex.matches(in: recalcText, range: recalcFull)
|
|
|
|
var offset = 0
|
|
for match in recalcMatches {
|
|
let urlRange = NSRange(location: match.range(at: 1).location + offset, length: match.range(at: 1).length)
|
|
let rawPath = recalcNS.substring(with: NSRange(location: urlRange.location, length: urlRange.length))
|
|
guard let resolved = resolveLocalImagePath(rawPath),
|
|
FileManager.default.fileExists(atPath: resolved),
|
|
let image = NSImage(contentsOfFile: resolved) else { continue }
|
|
|
|
let maxWidth: CGFloat = min(600, tv.bounds.width - 40)
|
|
let ratio = image.size.width > maxWidth ? maxWidth / image.size.width : 1.0
|
|
let displaySize = NSSize(
|
|
width: image.size.width * ratio,
|
|
height: image.size.height * ratio
|
|
)
|
|
image.size = displaySize
|
|
|
|
let attachment = NSTextAttachment()
|
|
let cell = NSTextAttachmentCell(imageCell: image)
|
|
attachment.attachmentCell = cell
|
|
|
|
let attachStr = NSMutableAttributedString(string: "\n")
|
|
attachStr.append(NSAttributedString(attachment: attachment))
|
|
attachStr.addAttribute(.toolTip, value: resolved, range: NSRange(location: 1, length: 1))
|
|
|
|
let lineEnd = NSMaxRange(match.range) + offset
|
|
let insertAt = min(lineEnd, ts.length)
|
|
ts.insert(attachStr, at: insertAt)
|
|
offset += attachStr.length
|
|
}
|
|
|
|
ts.endEditing()
|
|
isUpdatingImages = false
|
|
}
|
|
|
|
private func findExistingImageAttachments(in textStorage: NSTextStorage) -> [(NSRange, String)] {
|
|
var results: [(NSRange, String)] = []
|
|
let fullRange = NSRange(location: 0, length: textStorage.length)
|
|
textStorage.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
|
|
if value is NSTextAttachment {
|
|
var extRange = range
|
|
if extRange.location > 0 {
|
|
let prev = NSRange(location: extRange.location - 1, length: 1)
|
|
let ch = (textStorage.string as NSString).substring(with: prev)
|
|
if ch == "\n" {
|
|
extRange = NSRange(location: prev.location, length: extRange.length + 1)
|
|
}
|
|
}
|
|
let tip = textStorage.attribute(.toolTip, at: range.location, effectiveRange: nil) as? String ?? ""
|
|
results.append((extRange, tip))
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
// MARK: - Block positioning
|
|
|
|
private func updateEmbeddedTables() {
|
|
guard let tv = textView, let lm = tv.layoutManager as? MarkdownLayoutManager,
|
|
let tc = tv.textContainer, let ts = tv.textStorage else { return }
|
|
|
|
for tb in tableBlocks { tb.view.removeFromSuperview() }
|
|
for hb in hrBlocks { hb.view.removeFromSuperview() }
|
|
tableBlocks.removeAll()
|
|
hrBlocks.removeAll()
|
|
|
|
let origin = tv.textContainerOrigin
|
|
let text = tv.string as NSString
|
|
guard text.length > 0 else { return }
|
|
|
|
lm.ensureLayout(for: tc)
|
|
|
|
var blockSpacings: [(NSRange, CGFloat)] = []
|
|
|
|
for block in lm.blockRanges {
|
|
switch block.kind {
|
|
case .tableBlock:
|
|
guard let parsed = parseMarkdownTable(from: text, range: block.range) else { continue }
|
|
let tb = TableBlock(table: parsed, width: 0)
|
|
tb.onTableChanged = { [weak self] updatedTable, range in
|
|
self?.applyTableEdit(updatedTable, sourceRange: range)
|
|
}
|
|
blockSpacings.append((block.range, tb.blockHeight))
|
|
tableBlocks.append(tb)
|
|
|
|
case .horizontalRule:
|
|
let src = text.substring(with: block.range)
|
|
let hb = HRBlock(sourceText: src, sourceRange: block.range)
|
|
blockSpacings.append((block.range, hb.blockHeight))
|
|
hrBlocks.append(hb)
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// Collapse source lines and create gaps via paragraph spacing
|
|
isUpdatingTables = true
|
|
let tinyFont = NSFont.systemFont(ofSize: 0.1)
|
|
ts.beginEditing()
|
|
for (range, height) in blockSpacings {
|
|
// Make source text invisible: tiny font, zero foreground alpha
|
|
ts.addAttribute(.font, value: tinyFont, range: range)
|
|
ts.addAttribute(.foregroundColor, value: NSColor.clear, range: range)
|
|
|
|
// Collapse line heights and add spacing for the block view
|
|
let para = NSMutableParagraphStyle()
|
|
para.minimumLineHeight = 0.1
|
|
para.maximumLineHeight = 0.1
|
|
para.lineSpacing = 0
|
|
para.paragraphSpacing = 0
|
|
para.paragraphSpacingBefore = 0
|
|
ts.addAttribute(.paragraphStyle, value: para, range: range)
|
|
|
|
// On the last line, add paragraph spacing equal to block height
|
|
let lastLineRange = text.lineRange(for: NSRange(location: max(0, NSMaxRange(range) - 1), length: 0))
|
|
let lastPara = NSMutableParagraphStyle()
|
|
lastPara.minimumLineHeight = 0.1
|
|
lastPara.maximumLineHeight = 0.1
|
|
lastPara.lineSpacing = 0
|
|
lastPara.paragraphSpacing = height
|
|
lastPara.paragraphSpacingBefore = 0
|
|
ts.addAttribute(.paragraphStyle, value: lastPara, range: lastLineRange)
|
|
}
|
|
ts.endEditing()
|
|
isUpdatingTables = false
|
|
|
|
lm.ensureLayout(for: tc)
|
|
|
|
// Position blocks at the origin of their collapsed source text
|
|
var tableIdx = 0
|
|
var hrIdx = 0
|
|
for block in lm.blockRanges {
|
|
let glyphRange = lm.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
|
|
let rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc)
|
|
let x = origin.x + 4
|
|
|
|
switch block.kind {
|
|
case .tableBlock:
|
|
guard tableIdx < tableBlocks.count else { continue }
|
|
let tb = tableBlocks[tableIdx]
|
|
tableIdx += 1
|
|
let tableW = tb.view.frame.width
|
|
tb.view.frame = NSRect(x: x, y: rect.origin.y + origin.y, width: tableW, height: tb.blockHeight)
|
|
tv.addSubview(tb.view)
|
|
|
|
case .horizontalRule:
|
|
guard hrIdx < hrBlocks.count else { continue }
|
|
let hb = hrBlocks[hrIdx]
|
|
hrIdx += 1
|
|
let w = tc.containerSize.width - 8
|
|
hb.view.frame = NSRect(x: x, y: rect.origin.y + origin.y, width: w, height: hb.blockHeight)
|
|
tv.addSubview(hb.view)
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
private func applyTableEdit(_ table: ParsedTable, sourceRange: NSRange) {
|
|
guard let tv = textView, let ts = tv.textStorage else { return }
|
|
let newMarkdown = rebuildTableMarkdown(table)
|
|
guard NSMaxRange(sourceRange) <= ts.length else { return }
|
|
|
|
isUpdatingTables = true
|
|
let sel = tv.selectedRanges
|
|
ts.beginEditing()
|
|
ts.replaceCharacters(in: sourceRange, with: newMarkdown)
|
|
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
|
|
|
|
// Collapse new source text and set paragraph spacing for updated table height
|
|
let newRange = NSRange(location: sourceRange.location, length: (newMarkdown as NSString).length)
|
|
let blockHeight: CGFloat = 28 + CGFloat(table.rows.count) * 26 + 2
|
|
let tinyFont = NSFont.systemFont(ofSize: 0.1)
|
|
ts.addAttribute(.font, value: tinyFont, range: newRange)
|
|
ts.addAttribute(.foregroundColor, value: NSColor.clear, range: newRange)
|
|
|
|
let collapsePara = NSMutableParagraphStyle()
|
|
collapsePara.minimumLineHeight = 0.1
|
|
collapsePara.maximumLineHeight = 0.1
|
|
collapsePara.lineSpacing = 0
|
|
collapsePara.paragraphSpacing = 0
|
|
collapsePara.paragraphSpacingBefore = 0
|
|
ts.addAttribute(.paragraphStyle, value: collapsePara, range: newRange)
|
|
|
|
let text = ts.string as NSString
|
|
let lastLineRange = text.lineRange(for: NSRange(location: max(0, NSMaxRange(newRange) - 1), length: 0))
|
|
let lastPara = NSMutableParagraphStyle()
|
|
lastPara.minimumLineHeight = 0.1
|
|
lastPara.maximumLineHeight = 0.1
|
|
lastPara.lineSpacing = 0
|
|
lastPara.paragraphSpacing = blockHeight
|
|
lastPara.paragraphSpacingBefore = 0
|
|
ts.addAttribute(.paragraphStyle, value: lastPara, range: lastLineRange)
|
|
|
|
ts.endEditing()
|
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
|
tv.selectedRanges = sel
|
|
parent.text = tv.string
|
|
updateBlockRanges(for: tv)
|
|
isUpdatingTables = false
|
|
|
|
updateEmbeddedTables()
|
|
}
|
|
}
|
|
}
|