add block compositor foundation: protocol, view, text block, document model
This commit is contained in:
parent
de350b9d45
commit
b6a007490e
|
|
@ -0,0 +1,434 @@
|
||||||
|
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 {
|
||||||
|
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 (placeholder for Stage 3, minimal for compilation)
|
||||||
|
|
||||||
|
class HRDrawingView: NSView {
|
||||||
|
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
|
||||||
|
private let hrView: HRDrawingView
|
||||||
|
|
||||||
|
var view: NSView { hrView }
|
||||||
|
|
||||||
|
var blockHeight: CGFloat { 20 }
|
||||||
|
|
||||||
|
init(sourceText: String = "---") {
|
||||||
|
self.sourceText = sourceText
|
||||||
|
hrView = HRDrawingView(frame: NSRect(x: 0, y: 0, width: 100, height: 20))
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func layoutBlock(width: CGFloat) {}
|
||||||
|
|
||||||
|
func becomeActiveBlock() {}
|
||||||
|
|
||||||
|
func resignActiveBlock() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CompositorRepresentable
|
||||||
|
|
||||||
|
struct CompositorRepresentable: NSViewRepresentable {
|
||||||
|
@Binding var text: String
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSScrollView {
|
||||||
|
let scrollView = NSScrollView()
|
||||||
|
scrollView.hasVerticalScroller = true
|
||||||
|
scrollView.hasHorizontalScroller = false
|
||||||
|
scrollView.autohidesScrollers = true
|
||||||
|
scrollView.drawsBackground = true
|
||||||
|
scrollView.backgroundColor = Theme.current.base
|
||||||
|
|
||||||
|
let compositor = CompositorView(frame: scrollView.contentView.bounds)
|
||||||
|
compositor.scrollView = scrollView
|
||||||
|
compositor.autoresizingMask = [.width]
|
||||||
|
compositor.onContentHeightChanged = {
|
||||||
|
compositor.frame.size.height = compositor.contentHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollView.documentView = compositor
|
||||||
|
|
||||||
|
context.coordinator.compositor = compositor
|
||||||
|
context.coordinator.loadDocument(text)
|
||||||
|
|
||||||
|
return scrollView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||||
|
let coord = context.coordinator
|
||||||
|
if coord.lastSyncedText != text {
|
||||||
|
coord.loadDocument(text)
|
||||||
|
}
|
||||||
|
scrollView.backgroundColor = Theme.current.base
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator {
|
||||||
|
var parent: CompositorRepresentable
|
||||||
|
var compositor: CompositorView?
|
||||||
|
var lastSyncedText: String = ""
|
||||||
|
|
||||||
|
init(_ parent: CompositorRepresentable) {
|
||||||
|
self.parent = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDocument(_ markdown: String) {
|
||||||
|
guard let compositor = compositor else { return }
|
||||||
|
lastSyncedText = markdown
|
||||||
|
|
||||||
|
let descriptors = parseDocument(markdown)
|
||||||
|
var newBlocks: [CompositorBlock] = []
|
||||||
|
|
||||||
|
for desc in descriptors {
|
||||||
|
switch desc.type {
|
||||||
|
case .text:
|
||||||
|
let block = TextBlock(text: desc.content)
|
||||||
|
block.compositor = compositor
|
||||||
|
newBlocks.append(block)
|
||||||
|
case .horizontalRule:
|
||||||
|
newBlocks.append(HRBlock(sourceText: desc.content))
|
||||||
|
case .table:
|
||||||
|
// Stage 3 will add TableBlock; for now treat as text
|
||||||
|
let block = TextBlock(text: desc.content)
|
||||||
|
block.compositor = compositor
|
||||||
|
newBlocks.append(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compositor.setBlocks(newBlocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncToBinding() {
|
||||||
|
guard let compositor = compositor else { return }
|
||||||
|
let serialized = serializeDocument(compositor.blocks)
|
||||||
|
lastSyncedText = serialized
|
||||||
|
parent.text = serialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue