From 1d5e7b7c0ed4ff1142215b031d9d46a54441ea6c Mon Sep 17 00:00:00 2001 From: jess Date: Sat, 4 Apr 2026 23:00:42 -0700 Subject: [PATCH] interactive table component with file embed chip and clickable links --- src/EditorView.swift | 416 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 405 insertions(+), 11 deletions(-) diff --git a/src/EditorView.swift b/src/EditorView.swift index f21740a..230952a 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -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.. 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.. 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.. 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 = ["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("![image](~/.swiftly/images/\(uuid).\(ext))") + markdown = "![image](~/.swiftly/images/\(uuid).\(ext))" } catch { - insertions.append("![\(url.lastPathComponent)](\(url.path))") + markdown = "![\(url.lastPathComponent)](\(url.path))" } + 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) } }