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 {
|
struct EditorView: View {
|
||||||
@ObservedObject var state: AppState
|
@ObservedObject var state: AppState
|
||||||
|
|
||||||
|
|
@ -269,6 +583,7 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
textView.isAutomaticTextReplacementEnabled = false
|
textView.isAutomaticTextReplacementEnabled = false
|
||||||
textView.isAutomaticSpellingCorrectionEnabled = false
|
textView.isAutomaticSpellingCorrectionEnabled = false
|
||||||
textView.smartInsertDeleteEnabled = false
|
textView.smartInsertDeleteEnabled = false
|
||||||
|
textView.isAutomaticLinkDetectionEnabled = false
|
||||||
|
|
||||||
textView.autoresizingMask = [.width]
|
textView.autoresizingMask = [.width]
|
||||||
textView.isVerticallyResizable = true
|
textView.isVerticallyResizable = true
|
||||||
|
|
@ -307,6 +622,7 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
context.coordinator.triggerImageUpdate()
|
context.coordinator.triggerImageUpdate()
|
||||||
|
context.coordinator.triggerTableUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
return scrollView
|
return scrollView
|
||||||
|
|
@ -341,6 +657,8 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
weak var textView: NSTextView?
|
weak var textView: NSTextView?
|
||||||
weak var rulerView: LineNumberRulerView?
|
weak var rulerView: LineNumberRulerView?
|
||||||
private var isUpdatingImages = false
|
private var isUpdatingImages = false
|
||||||
|
private var isUpdatingTables = false
|
||||||
|
private var embeddedTableViews: [MarkdownTableView] = []
|
||||||
|
|
||||||
init(_ parent: EditorTextView) {
|
init(_ parent: EditorTextView) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
|
|
@ -348,7 +666,7 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
|
|
||||||
func textDidChange(_ notification: Notification) {
|
func textDidChange(_ notification: Notification) {
|
||||||
guard let tv = textView, let ts = tv.textStorage else { return }
|
guard let tv = textView, let ts = tv.textStorage else { return }
|
||||||
if isUpdatingImages { return }
|
if isUpdatingImages || isUpdatingTables { return }
|
||||||
parent.text = tv.string
|
parent.text = tv.string
|
||||||
let sel = tv.selectedRanges
|
let sel = tv.selectedRanges
|
||||||
ts.beginEditing()
|
ts.beginEditing()
|
||||||
|
|
@ -360,6 +678,7 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.updateInlineImages()
|
self?.updateInlineImages()
|
||||||
|
self?.updateEmbeddedTables()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -374,10 +693,79 @@ struct EditorTextView: NSViewRepresentable {
|
||||||
return false
|
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() {
|
func triggerImageUpdate() {
|
||||||
updateInlineImages()
|
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? = {
|
private static let imageRegex: NSRegularExpression? = {
|
||||||
try? NSRegularExpression(pattern: "!\\[[^\\]]*\\]\\(([^)]+)\\)")
|
try? NSRegularExpression(pattern: "!\\[[^\\]]*\\]\\(([^)]+)\\)")
|
||||||
}()
|
}()
|
||||||
|
|
@ -1359,30 +1747,36 @@ class LineNumberTextView: NSTextView {
|
||||||
}
|
}
|
||||||
|
|
||||||
let imageExts: Set<String> = ["png", "jpg", "jpeg", "gif", "bmp", "tiff", "webp"]
|
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 {
|
for url in urls {
|
||||||
let ext = url.pathExtension.lowercased()
|
let ext = url.pathExtension.lowercased()
|
||||||
if imageExts.contains(ext) {
|
if imageExts.contains(ext) {
|
||||||
let uuid = UUID().uuidString
|
let uuid = UUID().uuidString
|
||||||
let dest = imageCacheDir.appendingPathComponent("\(uuid).\(ext)")
|
let dest = imageCacheDir.appendingPathComponent("\(uuid).\(ext)")
|
||||||
|
let markdown: String
|
||||||
do {
|
do {
|
||||||
try FileManager.default.copyItem(at: url, to: dest)
|
try FileManager.default.copyItem(at: url, to: dest)
|
||||||
insertions.append(".\(ext))")
|
markdown = ".\(ext))"
|
||||||
} catch {
|
} catch {
|
||||||
insertions.append(")")
|
markdown = ")"
|
||||||
}
|
}
|
||||||
|
insertText(markdown, replacementRange: selectedRange())
|
||||||
|
didInsert = true
|
||||||
} else {
|
} 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 {
|
return didInsert || super.performDragOperation(sender)
|
||||||
let text = insertions.joined(separator: "\n")
|
|
||||||
insertText(text, replacementRange: selectedRange())
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return super.performDragOperation(sender)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue