chore: import upstream snapshot with attribution
CI / test (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:34:54 +08:00
commit 5b99bf6bca
226 changed files with 42157 additions and 0 deletions
@@ -0,0 +1,666 @@
import AppKit
// MARK: - TextKit 2 Support
//
// The editor runs on TextKit 2 (NSTextLayoutManager): layout is viewport-based
// the system only lays out what's on screen, which is what makes large
// documents tractable. The hard rule that follows: never touch
// `NSTextView.layoutManager` or store NSTextBlock/NSTextTable attributes
// either silently switches the view back to TextKit 1 for good.
//
// Two custom attributes drive a custom layout fragment:
//
// - `.blockDecoration` (paragraph-level): callout boxes, quote bars, table
// borders, thematic-break rules. Fragment frames tile vertically, so
// per-paragraph drawing renders a multi-line quote run as one continuous
// box/bar.
// - `.fragmentOverlay` (character-level): images drawn at a character's
// position callout header (icon + title), rendered math, list bullets and
// checkboxes. TextKit 1 rendered `.attachment` over any character; TextKit 2
// only honors attachments on U+FFFC, which the storage==rawSource invariant
// forbids. Instead the anchor character is hidden, `.kern` reserves the
// image's advance width (the same trick the table renderer uses for column
// alignment), and the fragment draws the image at the anchor's position.
// - `.tableCellWraps` (paragraph-level): a table cell too wide for its column
// can't wrap in place TextKit 2 only wraps a whole paragraph at the
// container's edge, it has no notion of an independent per-cell flow region
// (that's what NSTextTable/NSTextBlock exist for, and they're banned). So an
// overflowing cell's real characters are hidden, and its styled text is laid
// out separately in a small detached text stack sized to the column's
// width; the fragment draws the resulting lines stacked at the cell's x.
public extension NSAttributedString.Key {
/// Paragraph-level decoration drawn behind the text by
/// `DecoratedTextLayoutFragment`. Value: `BlockDecoration`.
static let blockDecoration = NSAttributedString.Key("MarkdownEditor.blockDecoration")
/// Character-level image drawn at the character's position by
/// `DecoratedTextLayoutFragment`. Value: `FragmentOverlay`. The styling
/// code pairs it with a hidden anchor glyph plus `.kern` for layout space.
static let fragmentOverlay = NSAttributedString.Key("MarkdownEditor.fragmentOverlay")
/// A table row's overflowing cells, wrapped and drawn by
/// `DecoratedTextLayoutFragment`. Value: `TableCellWrapList`.
static let tableCellWraps = NSAttributedString.Key("MarkdownEditor.tableCellWraps")
}
/// Value object describing what to draw behind a decorated paragraph.
/// Reference type (NSObject) so it lives in attributed strings; value
/// equality so attribute-run merging and the test oracle behave.
public final class BlockDecoration: NSObject, @unchecked Sendable {
public enum Kind: Equatable {
/// Filled box across the text column (callouts), with optional borders.
/// `bottomPad` extends the fill/border below the fragment's text frame
/// TextKit 2 does not include trailing `paragraphSpacing` in the
/// fragment height, so a callout's last line carries the bottom padding
/// here (and a matching paragraphSpacing pushes the next block clear).
case box(background: NSColor, borderColor: NSColor?,
borderEdges: CalloutStyle.Edges, borderWidth: CGFloat,
bottomPad: CGFloat)
/// Vertical bar just left of the paragraph's text (plain block quotes).
case leftBar(color: NSColor, width: CGFloat)
/// Table-row chrome: vertical column borders at text-relative x
/// offsets, and a horizontal rule through the separator row. `width`
/// is the table's full width; `leftInset` the text's inset from the
/// table's left edge. `bottomBorder` draws a full-width line at this
/// row's bottom edge the grid line between data rows (the header/
/// separator boundary already gets its line from `separator`).
case tableRow(columnXOffsets: [CGFloat], width: CGFloat,
leftInset: CGFloat, separator: Bool, bottomBorder: Bool)
/// Horizontal hairline across the text column, drawn `centerOffset`
/// points below the fragment's vertical center. The offset compensates
/// for adjacent text sitting at its baseline (low in its line box), so
/// the rule looks equidistant from the text above and below rather
/// than hugging the line above it.
case horizontalRule(color: NSColor, centerOffset: CGFloat)
}
public let kind: Kind
/// For `.box`: horizontal inset (points) from the text column's left and
/// right edges, non-zero for a box nested inside another box (e.g. a
/// callout inside a callout), so the inner box sits within the outer one.
/// For `.leftBar`: rightward shift (points) from the outermost bar
/// position one `quoteMarkerWidth` per nesting level, mirroring the
/// hidden `> ` marker that indents the text, so each nested quote's bar
/// (e.g. `> > text`) sits just left of its own level's text. Absolute per
/// level: the same level's bar lands at the same x on every line, which
/// keeps stacked bars tiling into continuous columns. Ignored by other
/// kinds.
public let inset: CGFloat
/// For `.leftBar`: start the bar at the first line's glyph top (baseline
/// minus ascender) instead of the fragment top. The line box carries its
/// extra spacing (lineSpacing) *above* the glyphs, so a bar over the full
/// fragment pokes past the text. Set only on a quote run's first line
/// interior lines must fill the whole fragment so consecutive lines' bars
/// tile without gaps. Ignored by other kinds.
public let hugsTextTop: Bool
public init(_ kind: Kind, inset: CGFloat = 0, hugsTextTop: Bool = false) {
self.kind = kind
self.inset = inset
self.hugsTextTop = hugsTextTop
}
public override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? BlockDecoration else { return false }
return kind == other.kind && inset == other.inset
&& hugsTextTop == other.hugsTextTop
}
public override var hash: Int {
switch kind {
case .box: return 1
case .leftBar: return 2
case .tableRow: return 3
case .horizontalRule: return 4
}
}
}
/// An ordered stack of decorations drawn behind one paragraph, outermost
/// first. Used when nesting puts more than one box/bar on the same line e.g.
/// a callout's outer box plus an inner nested callout's box. A single
/// decoration still uses a bare `BlockDecoration`; the fragment reads either.
public final class BlockDecorationList: NSObject, @unchecked Sendable {
public let decorations: [BlockDecoration]
public init(_ decorations: [BlockDecoration]) {
self.decorations = decorations
}
public override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? BlockDecorationList else { return false }
return decorations == other.decorations
}
public override var hash: Int { decorations.count }
}
/// An image or stroked vector path drawn at a character's laid-out position,
/// with attachment-style bounds: `bounds.origin.y` is the drawing's bottom
/// relative to the text baseline (negative descends below it).
///
/// The path form exists because of a TextKit 2 wedge: drawing an *image* on a
/// wrapping, multi-line layout fragment collapses that fragment's layout to a
/// single line, while drawing a *shape* does not (see
/// docs/investigations/archives/callout-title-wrap-investigation.md). Overlays that can share a line
/// with wrapping text (the custom-callout-title icon) must use the path form.
public final class FragmentOverlay: NSObject, @unchecked Sendable {
public let image: NSImage?
/// Stroked path in bounds-local coordinates (y-down, origin at the
/// bounds' top-left), pre-scaled to the bounds size.
public let path: CGPath?
public let pathColor: NSColor?
public let pathLineWidth: CGFloat
public let bounds: CGRect
public init(image: NSImage, bounds: CGRect) {
self.image = image
self.path = nil
self.pathColor = nil
self.pathLineWidth = 0
self.bounds = bounds
super.init()
}
public init(path: CGPath, color: NSColor, lineWidth: CGFloat, bounds: CGRect) {
self.image = nil
self.path = path
self.pathColor = color
self.pathLineWidth = lineWidth
self.bounds = bounds
super.init()
}
public override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? FragmentOverlay else { return false }
return other.image === image && other.path == path
&& other.pathColor == pathColor && other.pathLineWidth == pathLineWidth
&& other.bounds == bounds
}
public override var hash: Int { Int(bounds.width) ^ Int(bounds.height) }
}
/// A table cell too wide for its column: its real characters are hidden, and
/// this holds what to draw instead. `x` is text-relative (same coordinate
/// space as `BlockDecoration.tableRow`'s `columnXOffsets`) the cell's
/// content start. `contentWidth` is the column's clamped content width (the
/// width the cell's text must wrap within).
public final class TableCellWrap: NSObject, @unchecked Sendable {
public let styled: NSAttributedString
public let x: CGFloat
public let contentWidth: CGFloat
public init(styled: NSAttributedString, x: CGFloat, contentWidth: CGFloat) {
self.styled = styled
self.x = x
self.contentWidth = contentWidth
}
public override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? TableCellWrap else { return false }
return other.styled.string == styled.string
&& abs(other.x - x) < 0.5 && abs(other.contentWidth - contentWidth) < 0.5
}
public override var hash: Int { styled.string.hashValue }
}
/// A table row's overflowing cells, one `TableCellWrap` per overflowing cell.
public final class TableCellWrapList: NSObject, @unchecked Sendable {
public let wraps: [TableCellWrap]
public init(_ wraps: [TableCellWrap]) {
self.wraps = wraps
}
public override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? TableCellWrapList else { return false }
return wraps == other.wraps
}
public override var hash: Int { wraps.count }
}
/// Layout fragment that draws its paragraph's `BlockDecoration` behind the
/// text and any `FragmentOverlay` images at their characters' positions.
final class DecoratedTextLayoutFragment: NSTextLayoutFragment {
/// Decorations drawn behind the paragraph, outermost first.
let decorations: [BlockDecoration]
/// Paragraph-relative anchor offsets and their overlays.
let overlays: [(offset: Int, overlay: FragmentOverlay)]
/// Whether the text is antialiased (editor-wide setting).
let antialias: Bool
/// Each overflowing cell's x and pre-laid-out lines, from a detached
/// scratch text stack sized to the column's content width. The stack
/// itself is retained (`scratchStacks`) so the line fragments stay valid.
private let resolvedCellWraps: [(x: CGFloat, lines: [NSTextLineFragment])]
private let scratchStacks: [(NSTextContentStorage, NSTextLayoutManager, NSTextContainer)]
init(textElement: NSTextElement, range: NSTextRange?,
decorations: [BlockDecoration],
overlays: [(offset: Int, overlay: FragmentOverlay)],
cellWraps: [TableCellWrap],
antialias: Bool) {
self.decorations = decorations
self.overlays = overlays
self.antialias = antialias
var resolved: [(x: CGFloat, lines: [NSTextLineFragment])] = []
var stacks: [(NSTextContentStorage, NSTextLayoutManager, NSTextContainer)] = []
for wrap in cellWraps {
let contentStorage = NSTextContentStorage()
contentStorage.textStorage = NSTextStorage(attributedString: wrap.styled)
let layoutManager = NSTextLayoutManager()
contentStorage.addTextLayoutManager(layoutManager)
let container = NSTextContainer(
size: NSSize(width: max(1, wrap.contentWidth), height: .greatestFiniteMagnitude))
container.lineFragmentPadding = 0
layoutManager.textContainer = container
var lines: [NSTextLineFragment] = []
layoutManager.enumerateTextLayoutFragments(
from: layoutManager.documentRange.location, options: [.ensuresLayout]
) { frag in
lines.append(contentsOf: frag.textLineFragments)
return true
}
resolved.append((wrap.x, lines))
stacks.append((contentStorage, layoutManager, container))
}
self.resolvedCellWraps = resolved
self.scratchStacks = stacks
super.init(textElement: textElement, range: range)
}
/// Extra row height needed to fit the tallest wrapped cell, beyond the
/// row's natural (single-line) height.
private var tableRowExtraHeight: CGFloat {
guard !resolvedCellWraps.isEmpty else { return 0 }
let tallest = resolvedCellWraps
.map { $0.lines.reduce(0) { $0 + $1.typographicBounds.height } }
.max() ?? 0
return max(0, tallest - super.layoutFragmentFrame.height)
}
required init?(coder: NSCoder) {
fatalError("DecoratedTextLayoutFragment does not support coding")
}
/// Fragment-local x of the text container's left edge. The fragment's
/// frame hugs the laid-out text, so container x = 0 sits at -frame.minX.
private var containerLeft: CGFloat { -layoutFragmentFrame.minX }
private var containerWidth: CGFloat {
textLayoutManager?.textContainer?.size.width ?? layoutFragmentFrame.width
}
/// A box decoration's `bottomPad` grows the fragment's own frame (not just
/// its drawing): TextKit 2 leaves trailing `paragraphSpacing` out of the
/// fragment, so padding added that way is dead space clicks there miss the
/// text. Making the fragment frame taller means the line fragments stay
/// anchored at the top, the extra height is genuine clickable space below
/// the last line, the next block tiles clear of it, and the box (drawn over
/// the full frame height) covers it. Mirrors how the header's raised
/// minimumLineHeight makes the top padding clickable text space.
///
/// Padding is *summed* across stacked boxes: when a nested callout is the
/// last line of its parent, the line needs the nested box's bottom padding
/// *and* the parent's below it (see `draw`), so both fit.
private var boxBottomPad: CGFloat {
decorations.reduce(0) { acc, deco in
if case .box(_, _, _, _, let bottomPad) = deco.kind { return acc + bottomPad }
return acc
}
}
/// Height to actually paint a filled decoration (box / left bar) over,
/// which is *not* always the full frame height. When a callout or quote is
/// the last block AND the document ends with a newline, TextKit 2 folds the
/// document's final empty line into this (the preceding) layout fragment
/// instead of giving it its own fragment it shows up as a trailing
/// zero-length line fragment. Painting the decoration over the full frame
/// then floods the callout color onto that trailing empty line (the
/// "extra colored line at the bottom" bug). Detect the absorbed empty line
/// and stop the fill at the last real content line plus the box's bottom
/// padding.
var decorationDrawHeight: CGFloat {
let full = layoutFragmentFrame.height
let lines = textLineFragments
guard lines.count > 1, let last = lines.last,
last.characterRange.length == 0 else { return full }
// Bottom of the last line that actually holds text (fragment-local).
let contentBottom = lines.dropLast().map { $0.typographicBounds.maxY }.max() ?? 0
// `super` frame excludes our bottomPad; its extent past the content is
// exactly the absorbed empty line. Remove that, keep the bottomPad.
let emptyLineHeight = max(0, super.layoutFragmentFrame.height - contentBottom)
return max(0, full - emptyLineHeight)
}
override var layoutFragmentFrame: CGRect {
var frame = super.layoutFragmentFrame
// A row is never both a box and a table row, so at most one of these
// two is ever nonzero.
frame.size.height += boxBottomPad + tableRowExtraHeight
return frame
}
override var renderingSurfaceBounds: CGRect {
var bounds = super.renderingSurfaceBounds
let frame = layoutFragmentFrame
if !decorations.isEmpty {
bounds = bounds.union(CGRect(x: containerLeft - 4, y: 0,
width: containerWidth + 8, height: frame.height))
}
for (offset, overlay) in overlays {
if let rect = overlayRect(anchorOffset: offset, overlay: overlay) {
bounds = bounds.union(rect.insetBy(dx: -2, dy: -2))
}
}
return bounds
}
override func draw(at point: CGPoint, in context: CGContext) {
context.saveGState()
// Decorations are stacked outermost-first. Each box stops short of the
// fragment bottom by the padding of the boxes drawn before it, so an
// outer box's bottom padding stays visible *below* an inner nested box
// (e.g. the parent callout's padding under a nested callout) instead of
// being covered by it.
var precedingBottomPad: CGFloat = 0
for decoration in decorations {
drawDecoration(decoration, at: point, in: context, bottomInset: precedingBottomPad)
if case .box(_, _, _, _, let bottomPad) = decoration.kind {
precedingBottomPad += bottomPad
}
}
context.restoreGState()
context.saveGState()
context.setShouldAntialias(antialias)
super.draw(at: point, in: context)
context.restoreGState()
for (offset, overlay) in overlays {
guard let rect = overlayRect(anchorOffset: offset, overlay: overlay) else { continue }
let drawRect = rect.offsetBy(dx: point.x, dy: point.y)
if let image = overlay.image {
// Draw the (resolution-independent) NSImage into the flipped context,
// so it rasterizes at the screen's backing scale crisp on Retina,
// and positioned precisely. (Converting to a CGImage first would bake
// it at 1×, then upscale: soft, and quantized a pixel low.) The math
// image carries a small transparent inset, so the flipped draw can't
// clip a descender at the image edge.
let nsContext = NSGraphicsContext(cgContext: context, flipped: true)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = nsContext
image.draw(in: drawRect, from: .zero, operation: .sourceOver,
fraction: 1, respectFlipped: true, hints: nil)
NSGraphicsContext.restoreGraphicsState()
} else if let path = overlay.path, let color = overlay.pathColor {
// Stroke the vector path directly in CG never rasterize it to
// an image first: an image drawn on a multi-line fragment wedges
// its layout to one line (see the FragmentOverlay note). Path
// coords are bounds-local and y-down, matching this flipped
// context, so a translate places them.
context.saveGState()
context.translateBy(x: drawRect.minX, y: drawRect.minY)
context.addPath(path)
context.setStrokeColor(color.cgColor)
context.setLineWidth(overlay.pathLineWidth)
context.setLineCap(.round)
context.setLineJoin(.round)
context.strokePath()
context.restoreGState()
}
}
// Overflowing table cells: the real characters are hidden, so draw
// each cell's pre-wrapped lines here instead, stacked top-down at the
// cell's column x. Left-aligned regardless of the column's declared
// alignment (ponytail: not requested; upgrade path is the same
// per-line x-shift math the kern-based alignment above already uses).
for cellWrap in resolvedCellWraps {
var y = point.y
for line in cellWrap.lines {
line.draw(at: CGPoint(x: point.x + cellWrap.x, y: y), in: context)
y += line.typographicBounds.height
}
}
}
/// Fragment-local rect for an overlay image, anchored to the character at
/// the given paragraph-relative offset.
private func overlayRect(anchorOffset: Int, overlay: FragmentOverlay) -> CGRect? {
guard let line = textLineFragments.first(where: {
NSLocationInRange(anchorOffset, $0.characterRange)
}) ?? textLineFragments.last else { return nil }
let anchorX = line.typographicBounds.minX
+ line.locationForCharacter(at: anchorOffset).x
// Baseline (flipped coords): the line's glyph origin sits at its
// typographic origin plus the ascent-derived glyph origin.
let baselineY = line.typographicBounds.minY + line.glyphOrigin.y
return CGRect(x: anchorX + overlay.bounds.minX,
y: baselineY - overlay.bounds.height - overlay.bounds.minY,
width: overlay.bounds.width,
height: overlay.bounds.height)
}
/// Fragment-local y of the first line's glyph top (baseline minus the
/// line's font ascender). The line box can hold extra space above the
/// glyphs (lineSpacing lands there), which a text-hugging bar skips.
private var firstLineGlyphTop: CGFloat? {
guard let line = textLineFragments.first,
line.characterRange.length > 0,
let font = line.attributedString.attribute(
.font, at: line.characterRange.location, effectiveRange: nil) as? NSFont
else { return nil }
return line.typographicBounds.minY + line.glyphOrigin.y - font.ascender
}
private func drawDecoration(_ decoration: BlockDecoration, at point: CGPoint,
in context: CGContext, bottomInset: CGFloat = 0) {
let frame = layoutFragmentFrame
// Filled decorations (box, bar) stop above an absorbed trailing empty
// line; center-line decorations (rule, table) still use the full frame.
let fillHeight = decorationDrawHeight
// Fragment-local rect spanning the full text column for this fragment.
let columnRect = CGRect(x: point.x + containerLeft, y: point.y,
width: containerWidth, height: fillHeight)
switch decoration.kind {
case .box(let background, let borderColor, let edges, let borderWidth, _):
// The fragment frame already includes any box bottomPad (see
// layoutFragmentFrame), so columnRect covers the padded area. A
// nested box insets symmetrically so it sits within its parent box,
// and stops `bottomInset` short of the frame bottom so the enclosing
// box's padding shows below it.
var columnRect = decoration.inset > 0
? columnRect.insetBy(dx: decoration.inset, dy: 0)
: columnRect
columnRect.size.height -= bottomInset
context.setFillColor(background.cgColor)
context.fill(columnRect)
if let borderColor, !edges.isEmpty {
context.setFillColor(borderColor.cgColor)
if edges.contains(.left) {
context.fill(CGRect(x: columnRect.minX, y: columnRect.minY,
width: borderWidth, height: columnRect.height))
}
if edges.contains(.right) {
context.fill(CGRect(x: columnRect.maxX - borderWidth, y: columnRect.minY,
width: borderWidth, height: columnRect.height))
}
if edges.contains(.top) {
context.fill(CGRect(x: columnRect.minX, y: columnRect.minY,
width: columnRect.width, height: borderWidth))
}
if edges.contains(.bottom) {
context.fill(CGRect(x: columnRect.minX, y: columnRect.maxY - borderWidth,
width: columnRect.width, height: borderWidth))
}
}
case .leftBar(let color, let width):
// The bar sits immediately left of the text (the paragraph style
// insets the text by the bar's width) or `inset` further right,
// for a nested quote's bar next to its own level's text.
var barTop = point.y
var barHeight = fillHeight
if decoration.hugsTextTop, let glyphTop = firstLineGlyphTop {
barTop += glyphTop
barHeight -= glyphTop
}
context.setFillColor(color.cgColor)
context.fill(CGRect(x: point.x - width + decoration.inset, y: barTop,
width: width, height: barHeight))
case .tableRow(let xOffsets, let width, let leftInset, let separator, let bottomBorder):
// Offsets are text-relative; the fragment's origin is the text start.
context.setStrokeColor(NSColor.separatorColor.cgColor)
context.setLineWidth(1)
for x in xOffsets {
let lineX = round(point.x + x) + 0.5
context.move(to: CGPoint(x: lineX, y: point.y))
context.addLine(to: CGPoint(x: lineX, y: point.y + frame.height))
}
if separator {
let y = round(point.y + frame.height / 2) + 0.5
context.move(to: CGPoint(x: point.x - leftInset, y: y))
context.addLine(to: CGPoint(x: point.x - leftInset + width, y: y))
}
if bottomBorder {
let y = round(point.y + frame.height) + 0.5
context.move(to: CGPoint(x: point.x - leftInset, y: y))
context.addLine(to: CGPoint(x: point.x - leftInset + width, y: y))
}
context.strokePath()
case .horizontalRule(let color, let centerOffset):
context.setStrokeColor(color.cgColor)
context.setLineWidth(1)
let y = round(point.y + frame.height / 2 + centerOffset) + 0.5
context.move(to: CGPoint(x: columnRect.minX, y: y))
context.addLine(to: CGPoint(x: columnRect.maxX, y: y))
context.strokePath()
}
}
}
// MARK: - Fragment Vending
extension EditorTextView: NSTextLayoutManagerDelegate {
public nonisolated func textLayoutManager(
_ textLayoutManager: NSTextLayoutManager,
textLayoutFragmentFor location: NSTextLocation,
in textElement: NSTextElement
) -> NSTextLayoutFragment {
guard let paragraph = textElement as? NSTextParagraph,
paragraph.attributedString.length > 0 else {
return NSTextLayoutFragment(textElement: textElement,
range: textElement.elementRange)
}
let str = paragraph.attributedString
let decoValue = str.attribute(.blockDecoration, at: 0, effectiveRange: nil)
let decorations: [BlockDecoration]
if let list = decoValue as? BlockDecorationList {
decorations = list.decorations
} else if let single = decoValue as? BlockDecoration {
decorations = [single]
} else {
decorations = []
}
var overlays: [(offset: Int, overlay: FragmentOverlay)] = []
str.enumerateAttribute(.fragmentOverlay,
in: NSRange(location: 0, length: str.length),
options: []) { value, range, _ in
if let overlay = value as? FragmentOverlay {
overlays.append((range.location, overlay))
}
}
let cellWrapsValue = str.attribute(.tableCellWraps, at: 0, effectiveRange: nil)
let cellWraps = (cellWrapsValue as? TableCellWrapList)?.wraps ?? []
// A plain fragment suffices only when there's nothing to draw over the
// text and antialiasing is on (the default); otherwise vend the custom
// fragment so its draw can disable antialiasing.
guard !decorations.isEmpty || !overlays.isEmpty || !cellWraps.isEmpty || !textAntialias
else {
return NSTextLayoutFragment(textElement: textElement,
range: textElement.elementRange)
}
return DecoratedTextLayoutFragment(textElement: textElement,
range: textElement.elementRange,
decorations: decorations,
overlays: overlays,
cellWraps: cellWraps,
antialias: textAntialias)
}
}
// MARK: - Overlay Application
extension EditorTextView {
/// Renders `overlay` at `anchor` (a single character): hides the anchor
/// glyph, reserves the image's advance width with kern so following text
/// flows around it, and stores the overlay for the layout fragment to draw.
///
/// The kern is capped just short of the full line width: a full-width
/// image/equation (the common case anything wider than the column gets
/// scaled to exactly fill it) would otherwise reserve 100% of the line,
/// leaving zero room for the hidden markdown text that follows the anchor
/// on the same line. TextKit then force-wraps that hidden run onto a new
/// line fragment and since `minimumLineHeight` (reserveLineHeight) is a
/// paragraph-wide property applying to every line fragment, that phantom
/// wrapped line also inflates to the overlay's full height, doubling the
/// reserved space below the image. The slack is comfortably larger than
/// any realistic hidden-text width (near-zero at `hiddenFont`'s size).
func applyOverlay(_ overlay: FragmentOverlay, anchor: NSRange,
in result: NSMutableAttributedString) {
guard anchor.upperBound <= result.length else { return }
let kernSlack: CGFloat = 8
let kernWidth = min(overlay.bounds.width, max(0, availableContentWidth - kernSlack))
result.addAttribute(.font, value: hiddenFont, range: anchor)
result.addAttribute(.foregroundColor, value: NSColor.clear, range: anchor)
result.addAttribute(.kern, value: kernWidth, range: anchor)
result.addAttribute(.fragmentOverlay, value: overlay, range: anchor)
}
/// Reserves vertical room for an overlay taller than the text line that
/// carries it. A `FragmentOverlay` only reserves horizontal advance (kern),
/// so unlike the old `NSTextAttachment`, which grew its line fragment
/// a tall image (e.g. inline math scaled to a heading's size) would
/// otherwise overlap the line below. Raises the enclosing paragraph's
/// `minimumLineHeight` to fit, preserving any other paragraph attributes.
func reserveLineHeight(_ height: CGFloat, forOverlayAt location: Int,
in result: NSMutableAttributedString) {
guard location < result.length else { return }
let ns = result.string as NSString
// The enclosing paragraph (between newlines): minimumLineHeight is a
// paragraph attribute, and for the heading/inline cases the math sits
// on a single line, so this grows exactly the line that needs it.
let para = ns.paragraphRange(for: NSRange(location: location, length: 0))
let base = (result.attribute(.paragraphStyle, at: location, effectiveRange: nil)
as? NSParagraphStyle) ?? bodyParagraphStyle
guard height > base.minimumLineHeight else { return }
let ps = (base.mutableCopy() as! NSMutableParagraphStyle)
ps.minimumLineHeight = height
result.addAttribute(.paragraphStyle, value: ps, range: para)
}
}
// MARK: - Stack Construction
public extension EditorTextView {
/// Builds the TextKit 2 text system chain and returns the wired editor:
/// EditorTextStorage NSTextContentStorage NSTextLayoutManager
/// NSTextContainer EditorTextView
static func makeTextKit2(frame: NSRect, containerSize: NSSize) -> EditorTextView {
let contentStorage = NSTextContentStorage()
contentStorage.textStorage = EditorTextStorage()
let layoutManager = NSTextLayoutManager()
contentStorage.addTextLayoutManager(layoutManager)
let container = NSTextContainer(size: containerSize)
container.widthTracksTextView = true
layoutManager.textContainer = container
return EditorTextView(frame: frame, textContainer: container)
}
}