interactive table component with file embed chip and clickable links
This commit is contained in:
parent
17f01722a4
commit
1d5e7b7c0e
|
|
@ -216,6 +216,320 @@ class MarkdownLayoutManager: NSLayoutManager {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Interactive Table Component
|
||||
|
||||
enum TableAlignment {
|
||||
case left, center, right
|
||||
}
|
||||
|
||||
struct ParsedTable {
|
||||
var headers: [String]
|
||||
var alignments: [TableAlignment]
|
||||
var rows: [[String]]
|
||||
var sourceRange: NSRange
|
||||
}
|
||||
|
||||
func parseMarkdownTable(from text: NSString, range: NSRange) -> ParsedTable? {
|
||||
let tableText = text.substring(with: range)
|
||||
let lines = tableText.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
guard lines.count >= 2 else { return nil }
|
||||
|
||||
func parseCells(_ line: String) -> [String] {
|
||||
var trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.hasPrefix("|") { trimmed = String(trimmed.dropFirst()) }
|
||||
if trimmed.hasSuffix("|") { trimmed = String(trimmed.dropLast()) }
|
||||
return trimmed.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
}
|
||||
|
||||
let headers = parseCells(lines[0])
|
||||
guard headers.count > 0 else { return nil }
|
||||
|
||||
var sepIdx = 1
|
||||
var alignments: [TableAlignment] = Array(repeating: .left, count: headers.count)
|
||||
|
||||
if sepIdx < lines.count && isTableSeparator(lines[sepIdx].trimmingCharacters(in: .whitespacesAndNewlines)) {
|
||||
let sepCells = parseCells(lines[sepIdx])
|
||||
for (i, cell) in sepCells.enumerated() where i < alignments.count {
|
||||
let c = cell.trimmingCharacters(in: .whitespaces)
|
||||
if c.hasPrefix(":") && c.hasSuffix(":") {
|
||||
alignments[i] = .center
|
||||
} else if c.hasSuffix(":") {
|
||||
alignments[i] = .right
|
||||
}
|
||||
}
|
||||
sepIdx += 1
|
||||
}
|
||||
|
||||
var rows: [[String]] = []
|
||||
for i in sepIdx..<lines.count {
|
||||
let cells = parseCells(lines[i])
|
||||
var row = cells
|
||||
while row.count < headers.count { row.append("") }
|
||||
if row.count > headers.count { row = Array(row.prefix(headers.count)) }
|
||||
rows.append(row)
|
||||
}
|
||||
|
||||
return ParsedTable(headers: headers, alignments: alignments, rows: rows, sourceRange: range)
|
||||
}
|
||||
|
||||
func rebuildTableMarkdown(_ table: ParsedTable) -> String {
|
||||
let colCount = table.headers.count
|
||||
var colWidths = Array(repeating: 3, count: colCount)
|
||||
for (i, h) in table.headers.enumerated() {
|
||||
colWidths[i] = max(colWidths[i], h.count)
|
||||
}
|
||||
for row in table.rows {
|
||||
for (i, cell) in row.enumerated() where i < colCount {
|
||||
colWidths[i] = max(colWidths[i], cell.count)
|
||||
}
|
||||
}
|
||||
|
||||
func formatRow(_ cells: [String]) -> String {
|
||||
var parts: [String] = []
|
||||
for (i, cell) in cells.enumerated() where i < colCount {
|
||||
parts.append(" " + cell.padding(toLength: colWidths[i], withPad: " ", startingAt: 0) + " ")
|
||||
}
|
||||
return "|" + parts.joined(separator: "|") + "|"
|
||||
}
|
||||
|
||||
var lines: [String] = []
|
||||
lines.append(formatRow(table.headers))
|
||||
|
||||
var sepParts: [String] = []
|
||||
for i in 0..<colCount {
|
||||
let w = colWidths[i]
|
||||
var dash = String(repeating: "-", count: w)
|
||||
switch table.alignments[i] {
|
||||
case .left: dash = " " + dash + " "
|
||||
case .center: dash = ":" + dash + ":"
|
||||
case .right: dash = " " + dash + ":"
|
||||
}
|
||||
sepParts.append(dash)
|
||||
}
|
||||
lines.append("|" + sepParts.joined(separator: "|") + "|")
|
||||
|
||||
for row in table.rows {
|
||||
lines.append(formatRow(row))
|
||||
}
|
||||
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
class MarkdownTableView: NSView, NSTextFieldDelegate {
|
||||
var table: ParsedTable
|
||||
weak var textView: NSTextView?
|
||||
var onTableChanged: ((ParsedTable) -> Void)?
|
||||
|
||||
private var cellFields: [[NSTextField]] = []
|
||||
private let cellHeight: CGFloat = 26
|
||||
private let cellPadding: CGFloat = 4
|
||||
private let headerHeight: CGFloat = 28
|
||||
|
||||
init(table: ParsedTable, width: CGFloat) {
|
||||
self.table = table
|
||||
super.init(frame: .zero)
|
||||
wantsLayer = true
|
||||
layer?.backgroundColor = Theme.current.base.cgColor
|
||||
layer?.cornerRadius = 4
|
||||
layer?.borderWidth = 1
|
||||
layer?.borderColor = Theme.current.surface2.cgColor
|
||||
buildGrid(width: width)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
private func buildGrid(width: CGFloat) {
|
||||
subviews.forEach { $0.removeFromSuperview() }
|
||||
cellFields = []
|
||||
|
||||
let colCount = table.headers.count
|
||||
guard colCount > 0 else { return }
|
||||
let colWidth = (width - CGFloat(colCount + 1)) / CGFloat(colCount)
|
||||
let totalRows = 1 + table.rows.count
|
||||
let totalHeight = headerHeight + CGFloat(table.rows.count) * cellHeight
|
||||
|
||||
frame.size = NSSize(width: width, height: totalHeight)
|
||||
|
||||
// Header row
|
||||
let headerBg = NSView(frame: NSRect(x: 0, y: totalHeight - headerHeight, width: width, height: headerHeight))
|
||||
headerBg.wantsLayer = true
|
||||
headerBg.layer?.backgroundColor = Theme.current.surface0.cgColor
|
||||
addSubview(headerBg)
|
||||
|
||||
var headerFields: [NSTextField] = []
|
||||
for (col, header) in table.headers.enumerated() {
|
||||
let x = CGFloat(col) * colWidth + CGFloat(col + 1)
|
||||
let field = makeCell(text: header, frame: NSRect(x: x, y: totalHeight - headerHeight + 2, width: colWidth, height: headerHeight - 4), isHeader: true, row: -1, col: col)
|
||||
addSubview(field)
|
||||
headerFields.append(field)
|
||||
}
|
||||
cellFields.append(headerFields)
|
||||
|
||||
// Data rows
|
||||
for (rowIdx, row) in table.rows.enumerated() {
|
||||
var rowFields: [NSTextField] = []
|
||||
let y = totalHeight - headerHeight - CGFloat(rowIdx + 1) * cellHeight
|
||||
for (col, cell) in row.enumerated() where col < colCount {
|
||||
let x = CGFloat(col) * colWidth + CGFloat(col + 1)
|
||||
let field = makeCell(text: cell, frame: NSRect(x: x, y: y + 2, width: colWidth, height: cellHeight - 4), isHeader: false, row: rowIdx, col: col)
|
||||
addSubview(field)
|
||||
rowFields.append(field)
|
||||
}
|
||||
while rowFields.count < colCount {
|
||||
let col = rowFields.count
|
||||
let x = CGFloat(col) * colWidth + CGFloat(col + 1)
|
||||
let field = makeCell(text: "", frame: NSRect(x: x, y: y + 2, width: colWidth, height: cellHeight - 4), isHeader: false, row: rowIdx, col: col)
|
||||
addSubview(field)
|
||||
rowFields.append(field)
|
||||
}
|
||||
cellFields.append(rowFields)
|
||||
}
|
||||
|
||||
// Grid lines
|
||||
for i in 1..<colCount {
|
||||
let x = CGFloat(i) * (colWidth + 1)
|
||||
let line = NSView(frame: NSRect(x: x, y: 0, width: 1, height: totalHeight))
|
||||
line.wantsLayer = true
|
||||
line.layer?.backgroundColor = Theme.current.surface2.cgColor
|
||||
addSubview(line)
|
||||
}
|
||||
for i in 0..<totalRows {
|
||||
let y = totalHeight - headerHeight - CGFloat(i) * cellHeight
|
||||
let line = NSView(frame: NSRect(x: 0, y: y, width: width, height: 1))
|
||||
line.wantsLayer = true
|
||||
line.layer?.backgroundColor = Theme.current.surface2.cgColor
|
||||
addSubview(line)
|
||||
}
|
||||
}
|
||||
|
||||
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.isBordered = false
|
||||
field.drawsBackground = false
|
||||
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.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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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 actualRow = nextRow
|
||||
let fieldRow = actualRow + 1
|
||||
if fieldRow < cellFields.count, actualCol < cellFields[fieldRow].count {
|
||||
window?.makeFirstResponder(cellFields[fieldRow][actualCol])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func control(_ control: NSControl, textView tv: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
|
||||
guard let field = control as? NSTextField else { return false }
|
||||
let tag = field.tag
|
||||
let row = tag / 1000 - 1
|
||||
let col = tag % 1000
|
||||
_ = row; _ = col
|
||||
|
||||
table.rows.append(Array(repeating: "", count: table.headers.count))
|
||||
onTableChanged?(table)
|
||||
|
||||
let width = frame.width
|
||||
buildGrid(width: width)
|
||||
needsLayout = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File Embed Attachment Cell
|
||||
|
||||
class FileEmbedCell: NSTextAttachmentCell {
|
||||
let filePath: String
|
||||
let fileName: String
|
||||
private let fileIcon: NSImage
|
||||
|
||||
init(filePath: String) {
|
||||
self.filePath = filePath
|
||||
self.fileName = (filePath as NSString).lastPathComponent
|
||||
self.fileIcon = NSWorkspace.shared.icon(forFile: filePath)
|
||||
self.fileIcon.size = NSSize(width: 16, height: 16)
|
||||
super.init()
|
||||
}
|
||||
|
||||
required init(coder: NSCoder) { fatalError() }
|
||||
|
||||
override func cellSize() -> NSSize {
|
||||
let textSize = (fileName as NSString).size(withAttributes: [.font: Theme.editorFont])
|
||||
return NSSize(width: 16 + 8 + textSize.width + 16, height: max(24, textSize.height + 8))
|
||||
}
|
||||
|
||||
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {
|
||||
let path = NSBezierPath(roundedRect: cellFrame.insetBy(dx: 1, dy: 1), xRadius: 4, yRadius: 4)
|
||||
Theme.current.surface1.setFill()
|
||||
path.fill()
|
||||
Theme.current.surface2.setStroke()
|
||||
path.lineWidth = 1
|
||||
path.stroke()
|
||||
|
||||
let iconRect = NSRect(x: cellFrame.origin.x + 6, y: cellFrame.origin.y + (cellFrame.height - 16) / 2, width: 16, height: 16)
|
||||
fileIcon.draw(in: iconRect)
|
||||
|
||||
let textRect = NSRect(x: iconRect.maxX + 4, y: cellFrame.origin.y + (cellFrame.height - 16) / 2, width: cellFrame.width - 30, height: 16)
|
||||
let attrs: [NSAttributedString.Key: Any] = [
|
||||
.font: Theme.editorFont,
|
||||
.foregroundColor: Theme.current.text
|
||||
]
|
||||
fileName.draw(in: textRect, withAttributes: attrs)
|
||||
}
|
||||
|
||||
override func wantsToTrackMouse() -> Bool { true }
|
||||
|
||||
override func trackMouse(with theEvent: NSEvent, in cellFrame: NSRect, of controlView: NSView?, untilMouseUp flag: Bool) -> Bool {
|
||||
if theEvent.clickCount >= 1 {
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: filePath))
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
struct EditorView: View {
|
||||
@ObservedObject var state: AppState
|
||||
|
||||
|
|
@ -269,6 +583,7 @@ struct EditorTextView: NSViewRepresentable {
|
|||
textView.isAutomaticTextReplacementEnabled = false
|
||||
textView.isAutomaticSpellingCorrectionEnabled = false
|
||||
textView.smartInsertDeleteEnabled = false
|
||||
textView.isAutomaticLinkDetectionEnabled = false
|
||||
|
||||
textView.autoresizingMask = [.width]
|
||||
textView.isVerticallyResizable = true
|
||||
|
|
@ -307,6 +622,7 @@ struct EditorTextView: NSViewRepresentable {
|
|||
|
||||
DispatchQueue.main.async {
|
||||
context.coordinator.triggerImageUpdate()
|
||||
context.coordinator.triggerTableUpdate()
|
||||
}
|
||||
|
||||
return scrollView
|
||||
|
|
@ -341,6 +657,8 @@ struct EditorTextView: NSViewRepresentable {
|
|||
weak var textView: NSTextView?
|
||||
weak var rulerView: LineNumberRulerView?
|
||||
private var isUpdatingImages = false
|
||||
private var isUpdatingTables = false
|
||||
private var embeddedTableViews: [MarkdownTableView] = []
|
||||
|
||||
init(_ parent: EditorTextView) {
|
||||
self.parent = parent
|
||||
|
|
@ -348,7 +666,7 @@ struct EditorTextView: NSViewRepresentable {
|
|||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard let tv = textView, let ts = tv.textStorage else { return }
|
||||
if isUpdatingImages { return }
|
||||
if isUpdatingImages || isUpdatingTables { return }
|
||||
parent.text = tv.string
|
||||
let sel = tv.selectedRanges
|
||||
ts.beginEditing()
|
||||
|
|
@ -360,6 +678,7 @@ struct EditorTextView: NSViewRepresentable {
|
|||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.updateInlineImages()
|
||||
self?.updateEmbeddedTables()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -374,10 +693,79 @@ struct EditorTextView: NSViewRepresentable {
|
|||
return false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func triggerImageUpdate() {
|
||||
updateInlineImages()
|
||||
}
|
||||
|
||||
func triggerTableUpdate() {
|
||||
updateEmbeddedTables()
|
||||
}
|
||||
|
||||
// MARK: - Embedded Tables
|
||||
|
||||
private func updateEmbeddedTables() {
|
||||
guard let tv = textView, let lm = tv.layoutManager as? MarkdownLayoutManager,
|
||||
let tc = tv.textContainer else { return }
|
||||
|
||||
for tableView in embeddedTableViews {
|
||||
tableView.removeFromSuperview()
|
||||
}
|
||||
embeddedTableViews.removeAll()
|
||||
|
||||
let text = tv.string as NSString
|
||||
for block in lm.blockRanges {
|
||||
guard case .tableBlock = block.kind else { continue }
|
||||
guard let parsed = parseMarkdownTable(from: text, range: block.range) else { continue }
|
||||
|
||||
let glyphRange = lm.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
|
||||
var rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc)
|
||||
rect.origin.x += tv.textContainerInset.width
|
||||
rect.origin.y += tv.textContainerInset.height
|
||||
|
||||
let tableWidth = tc.containerSize.width - 8
|
||||
let tableView = MarkdownTableView(table: parsed, width: tableWidth)
|
||||
tableView.frame.origin = NSPoint(x: rect.origin.x + 4, y: rect.origin.y)
|
||||
tableView.textView = tv
|
||||
|
||||
tableView.onTableChanged = { [weak self] updatedTable in
|
||||
self?.applyTableEdit(updatedTable)
|
||||
}
|
||||
|
||||
tv.addSubview(tableView)
|
||||
embeddedTableViews.append(tableView)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyTableEdit(_ table: ParsedTable) {
|
||||
guard let tv = textView, let ts = tv.textStorage else { return }
|
||||
let newMarkdown = rebuildTableMarkdown(table)
|
||||
let range = table.sourceRange
|
||||
guard NSMaxRange(range) <= ts.length else { return }
|
||||
|
||||
isUpdatingTables = true
|
||||
let sel = tv.selectedRanges
|
||||
ts.beginEditing()
|
||||
ts.replaceCharacters(in: range, with: newMarkdown)
|
||||
applySyntaxHighlighting(to: ts)
|
||||
ts.endEditing()
|
||||
tv.selectedRanges = sel
|
||||
parent.text = tv.string
|
||||
updateBlockRanges(for: tv)
|
||||
isUpdatingTables = false
|
||||
}
|
||||
|
||||
private static let imageRegex: NSRegularExpression? = {
|
||||
try? NSRegularExpression(pattern: "!\\[[^\\]]*\\]\\(([^)]+)\\)")
|
||||
}()
|
||||
|
|
@ -1359,30 +1747,36 @@ class LineNumberTextView: NSTextView {
|
|||
}
|
||||
|
||||
let imageExts: Set<String> = ["png", "jpg", "jpeg", "gif", "bmp", "tiff", "webp"]
|
||||
var insertions: [String] = []
|
||||
guard let ts = textStorage else { return false }
|
||||
|
||||
var didInsert = false
|
||||
for url in urls {
|
||||
let ext = url.pathExtension.lowercased()
|
||||
if imageExts.contains(ext) {
|
||||
let uuid = UUID().uuidString
|
||||
let dest = imageCacheDir.appendingPathComponent("\(uuid).\(ext)")
|
||||
let markdown: String
|
||||
do {
|
||||
try FileManager.default.copyItem(at: url, to: dest)
|
||||
insertions.append(".\(ext))")
|
||||
markdown = ".\(ext))"
|
||||
} catch {
|
||||
insertions.append(")")
|
||||
markdown = ")"
|
||||
}
|
||||
insertText(markdown, replacementRange: selectedRange())
|
||||
didInsert = true
|
||||
} else {
|
||||
insertions.append("[\(url.lastPathComponent)](\(url.absoluteString))")
|
||||
let attachment = NSTextAttachment()
|
||||
let cell = FileEmbedCell(filePath: url.path)
|
||||
attachment.attachmentCell = cell
|
||||
let attachStr = NSMutableAttributedString(string: "\n")
|
||||
attachStr.append(NSAttributedString(attachment: attachment))
|
||||
let insertAt = min(selectedRange().location, ts.length)
|
||||
ts.insert(attachStr, at: insertAt)
|
||||
didInsert = true
|
||||
}
|
||||
}
|
||||
|
||||
if !insertions.isEmpty {
|
||||
let text = insertions.joined(separator: "\n")
|
||||
insertText(text, replacementRange: selectedRange())
|
||||
return true
|
||||
}
|
||||
return super.performDragOperation(sender)
|
||||
return didInsert || super.performDragOperation(sender)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue