content-width tables, add-column button, column resize handles
This commit is contained in:
parent
405772ee6b
commit
3e52866825
|
|
@ -358,6 +358,125 @@ 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 {
|
||||
|
|
@ -366,13 +485,15 @@ class TableBlock: NSObject, CompositorBlock, NSTextFieldDelegate {
|
|||
var sourceRange: NSRange
|
||||
var onTableChanged: ((ParsedTable, NSRange) -> Void)?
|
||||
|
||||
private let containerView: NSView
|
||||
private var cellFields: [[NSTextField]] = []
|
||||
private var columnWidths: [CGFloat] = []
|
||||
private let containerView: TableBlockView
|
||||
fileprivate var cellFields: [[NSTextField]] = []
|
||||
fileprivate var columnWidths: [CGFloat] = []
|
||||
private var customWidths: [Int: CGFloat] = [:]
|
||||
private var rowHeights: [CGFloat] = []
|
||||
private var gridWidth: CGFloat = 0
|
||||
|
||||
private let minColWidth: CGFloat = 40
|
||||
let minColWidth: CGFloat = 60
|
||||
let maxColWidth: CGFloat = 300
|
||||
private let cellPadding: CGFloat = 16
|
||||
private let defaultHeaderHeight: CGFloat = 28
|
||||
private let defaultCellHeight: CGFloat = 26
|
||||
|
||||
|
|
@ -382,49 +503,92 @@ class TableBlock: NSObject, CompositorBlock, NSTextFieldDelegate {
|
|||
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
|
||||
self.gridWidth = width
|
||||
containerView = FlippedBlockView(frame: .zero)
|
||||
containerView = TableBlockView(frame: .zero)
|
||||
containerView.wantsLayer = true
|
||||
containerView.layer?.backgroundColor = Theme.current.base.cgColor
|
||||
containerView.layer?.cornerRadius = 4
|
||||
super.init()
|
||||
initSizes(width: width)
|
||||
containerView.tableBlock = self
|
||||
initSizes()
|
||||
buildGrid()
|
||||
}
|
||||
|
||||
private func initSizes(width: CGFloat) {
|
||||
// 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 }
|
||||
let available = width - CGFloat(colCount + 1)
|
||||
let colW = available / CGFloat(colCount)
|
||||
columnWidths = Array(repeating: max(colW, minColWidth), count: colCount)
|
||||
columnWidths = (0..<colCount).map { col in
|
||||
customWidths[col] ?? measureColumnWidth(col)
|
||||
}
|
||||
rowHeights = [defaultHeaderHeight]
|
||||
for _ in 0..<table.rows.count {
|
||||
rowHeights.append(defaultCellHeight)
|
||||
}
|
||||
}
|
||||
|
||||
private func columnX(for col: Int) -> CGFloat {
|
||||
func columnX(for col: Int) -> CGFloat {
|
||||
var x: CGFloat = 1
|
||||
for i in 0..<col { x += columnWidths[i] + 1 }
|
||||
return x
|
||||
}
|
||||
|
||||
func layoutBlock(width: CGFloat) {
|
||||
if abs(gridWidth - width) > 1 {
|
||||
gridWidth = width
|
||||
initSizes(width: width)
|
||||
buildGrid()
|
||||
}
|
||||
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 = []
|
||||
|
|
@ -432,10 +596,11 @@ class TableBlock: NSObject, CompositorBlock, NSTextFieldDelegate {
|
|||
let colCount = table.headers.count
|
||||
guard colCount > 0 else { return }
|
||||
let th = rowHeights.reduce(0, +)
|
||||
let totalGridWidth = columnX(for: colCount) + 1
|
||||
containerView.frame.size = NSSize(width: max(gridWidth, totalGridWidth), height: th + 2)
|
||||
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: totalGridWidth, height: rowHeights[0]))
|
||||
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)
|
||||
|
|
@ -467,7 +632,6 @@ class TableBlock: NSObject, CompositorBlock, NSTextFieldDelegate {
|
|||
yOffset += h
|
||||
}
|
||||
|
||||
// Vertical dividers
|
||||
for i in 1..<colCount {
|
||||
let x = columnX(for: i) - 1
|
||||
let line = NSView(frame: NSRect(x: x, y: 0, width: 1, height: th))
|
||||
|
|
@ -476,15 +640,17 @@ class TableBlock: NSObject, CompositorBlock, NSTextFieldDelegate {
|
|||
containerView.addSubview(line)
|
||||
}
|
||||
|
||||
// Horizontal dividers
|
||||
var divY: CGFloat = rowHeights[0]
|
||||
for i in 1..<rowHeights.count {
|
||||
let line = NSView(frame: NSRect(x: 0, y: divY, width: totalGridWidth, height: 1))
|
||||
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 {
|
||||
|
|
@ -534,11 +700,28 @@ class TableBlock: NSObject, CompositorBlock, NSTextFieldDelegate {
|
|||
|
||||
if let movement = obj.userInfo?["NSTextMovement"] as? Int, movement == NSTabTextMovement {
|
||||
let nextCol = col + 1
|
||||
let nextRow = row + (nextCol >= table.headers.count ? 1 : 0)
|
||||
let actualCol = nextCol % table.headers.count
|
||||
let fieldRow = nextRow + 1
|
||||
if fieldRow < cellFields.count, actualCol < cellFields[fieldRow].count {
|
||||
containerView.window?.makeFirstResponder(cellFields[fieldRow][actualCol])
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1106,8 +1289,7 @@ struct CompositorRepresentable: NSViewRepresentable {
|
|||
switch block.kind {
|
||||
case .tableBlock:
|
||||
guard let parsed = parseMarkdownTable(from: text, range: block.range) else { continue }
|
||||
let tableWidth = tc.containerSize.width - 8
|
||||
let tb = TableBlock(table: parsed, width: tableWidth)
|
||||
let tb = TableBlock(table: parsed, width: 0)
|
||||
tb.onTableChanged = { [weak self] updatedTable, range in
|
||||
self?.applyTableEdit(updatedTable, sourceRange: range)
|
||||
}
|
||||
|
|
@ -1164,7 +1346,6 @@ struct CompositorRepresentable: NSViewRepresentable {
|
|||
for block in lm.blockRanges {
|
||||
let glyphRange = lm.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
|
||||
let rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc)
|
||||
let w = tc.containerSize.width - 8
|
||||
let x = origin.x + 4
|
||||
|
||||
switch block.kind {
|
||||
|
|
@ -1172,14 +1353,15 @@ struct CompositorRepresentable: NSViewRepresentable {
|
|||
guard tableIdx < tableBlocks.count else { continue }
|
||||
let tb = tableBlocks[tableIdx]
|
||||
tableIdx += 1
|
||||
tb.layoutBlock(width: w)
|
||||
tb.view.frame = NSRect(x: x, y: rect.origin.y + origin.y, width: w, height: tb.blockHeight)
|
||||
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)
|
||||
|
||||
|
|
@ -1199,6 +1381,32 @@ struct CompositorRepresentable: NSViewRepresentable {
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue