Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 59 additions & 11 deletions Sources/PreviewMD/MarkdownSourceEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ struct MarkdownSourceEditor: NSViewRepresentable {
var documentID: UUID? = nil
var splitSynchronizer: SplitEditorSynchronizer? = nil
var isSplitSynchronizationEnabled = false
var isVisible = true

func makeCoordinator() -> Coordinator {
Coordinator(
Expand All @@ -235,6 +236,7 @@ struct MarkdownSourceEditor: NSViewRepresentable {
let textView = MarkdownSourceTextView()
textView.delegate = context.coordinator
textView.isRichText = false
textView.isEditable = isVisible
textView.importsGraphics = false
textView.allowsUndo = false
textView.isAutomaticQuoteSubstitutionEnabled = false
Expand All @@ -252,7 +254,7 @@ struct MarkdownSourceEditor: NSViewRepresentable {
height: CGFloat.greatestFiniteMagnitude
)
textView.backgroundColor = .clear
textView.string = text
textView.string = isVisible ? text : ""
scrollView.documentView = textView

let lineNumberRuler = MarkdownLineNumberRulerView(
Expand All @@ -272,7 +274,7 @@ struct MarkdownSourceEditor: NSViewRepresentable {
splitSynchronizer: splitSynchronizer,
isEnabled: isSplitSynchronizationEnabled
)
context.coordinator.highlight()
if isVisible { context.coordinator.highlight() }
return container
}

Expand All @@ -286,14 +288,31 @@ struct MarkdownSourceEditor: NSViewRepresentable {
splitSynchronizer: splitSynchronizer,
isEnabled: isSplitSynchronizationEnabled
)
// Keep the view identity and selection while Preview is visible, but
// defer text storage, layout and coloring until the source is revealed.
// The binding always supplies the latest document on that update.
guard let textView = context.coordinator.textView else { return }
textView.isEditable = isVisible
guard isVisible else {
if textView.window?.firstResponder === textView {
textView.window?.makeFirstResponder(nil)
}
return
}
let contentChanged = textView.string != text
let appearanceChanged = context.coordinator.lastAppearanceName
!= textView.effectiveAppearance.name
if contentChanged {
let selection = textView.selectedRange()
textView.string = text
context.coordinator.withoutPublishingSelection {
if let storage = textView.textStorage,
let ranges = MarkdownSourceHighlighting.changedTextRanges(from: textView.string, to: text) {
// Preserve unaffected text attributes and layout when a
// rich edit is echoed into the visible source pane.
storage.replaceCharacters(in: ranges.previous, with: (text as NSString).substring(with: ranges.current))
} else {
textView.string = text
}
textView.setSelectedRange(
NSRange(
location: min(selection.location, (text as NSString).length),
Expand All @@ -303,7 +322,7 @@ struct MarkdownSourceEditor: NSViewRepresentable {
}
}
if contentChanged || appearanceChanged {
context.coordinator.highlight(forceFull: true)
context.coordinator.highlight(forceFull: appearanceChanged || textView.textStorage == nil)
context.coordinator.lineNumberRuler?.reload()
}
if contentChanged,
Expand Down Expand Up @@ -357,7 +376,7 @@ struct MarkdownSourceEditor: NSViewRepresentable {
}

func textDidChange(_ notification: Notification) {
guard let textView else { return }
guard let textView, textView.isEditable else { return }
text.wrappedValue = textView.string
lineNumberRuler?.reload()
scheduleHighlight()
Expand Down Expand Up @@ -686,10 +705,9 @@ struct MarkdownSourceEditor: NSViewRepresentable {
}

enum MarkdownSourceHighlighting {
/// AppKit shifts existing attributes as characters are edited. Recolor only
/// changed logical lines; edits to fence boundaries can affect the rest of
/// the document, so those deliberately retain the full pass.
static func changedLineRange(from previous: String, to current: String) -> NSRange? {
/// Matching UTF-16 ranges, widened at surrogate boundaries so the inserted
/// substring cannot lose half of an emoji during NSString-to-String bridging.
static func changedTextRanges(from previous: String, to current: String) -> (previous: NSRange, current: NSRange)? {
let old = previous as NSString
let new = current as NSString
var start = 0
Expand All @@ -705,8 +723,38 @@ enum MarkdownSourceHighlighting {
oldEnd -= 1
newEnd -= 1
}
let oldLines = old.lineRange(for: NSRange(location: start, length: oldEnd - start))
let newLines = new.lineRange(for: NSRange(location: start, length: newEnd - start))
if splitsSurrogate(in: old, at: start) || splitsSurrogate(in: new, at: start) {
start -= 1
}
if splitsSurrogate(in: old, at: oldEnd) || splitsSurrogate(in: new, at: newEnd) {
oldEnd += 1
newEnd += 1
}
return (NSRange(location: start, length: oldEnd - start), NSRange(location: start, length: newEnd - start))
}

private static func splitsSurrogate(in source: NSString, at offset: Int) -> Bool {
guard offset > 0, offset < source.length else { return false }
return (0xD800...0xDBFF).contains(source.character(at: offset - 1))
&& (0xDC00...0xDFFF).contains(source.character(at: offset))
}

/// AppKit shifts existing attributes as characters are edited. Recolor only
/// changed logical lines; edits to fence boundaries can affect the rest of
/// the document, so those deliberately retain the full pass.
static func changedLineRange(from previous: String, to current: String) -> NSRange? {
guard let ranges = changedTextRanges(from: previous, to: current) else { return nil }
let old = previous as NSString
let new = current as NSString
let oldLines = old.lineRange(for: ranges.previous)
// A line split can move an unchanged suffix beyond the inserted range.
// Its old syntax attributes still need resetting in the new context.
let movedLineEnd = NSMaxRange(oldLines) + new.length - old.length
let affectedEnd = max(NSMaxRange(ranges.current), movedLineEnd)
let newLines = new.lineRange(for: NSRange(
location: oldLines.location,
length: affectedEnd - oldLines.location
))
let oldChangedLines = old.substring(with: oldLines)
let newChangedLines = new.substring(with: newLines)
if oldChangedLines.contains("```") || oldChangedLines.contains("~~~")
Expand Down
19 changes: 15 additions & 4 deletions Sources/PreviewMD/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,16 +303,27 @@ enum WorkspaceFileSort: String, CaseIterable, Identifiable {
/// repeatedly without rescanning the full document during an editing update.
@propertyWrapper
struct MarkdownText: Equatable {
private var text: String
var wrappedValue: String {
didSet { updateStatistics() }
get { text }
set {
text = newValue
// AppKit and the WebKit bridge can supply NSString-backed text.
// Materialize UTF-8 once instead of paying foreign-string character
// iteration costs on every statistics pass and subsequent read.
text.makeContiguousUTF8()
updateStatistics()
}
}
private(set) var wordCount: Int
private(set) var characterCount: Int

init(wrappedValue: String) {
self.wrappedValue = wrappedValue
wordCount = wrappedValue.split(whereSeparator: \.isWhitespace).count
characterCount = wrappedValue.count
var text = wrappedValue
text.makeContiguousUTF8()
self.text = text
wordCount = text.split(whereSeparator: \.isWhitespace).count
characterCount = text.count
}

private mutating func updateStatistics() {
Expand Down
7 changes: 7 additions & 0 deletions Sources/PreviewMD/Resources/Renderer/renderer.css
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,7 @@ code {

.table-scroll.is-wide {
max-width: none;
margin-top: max(36px, calc(1.55em + 8px));
}

.table-scroll.is-wide .table-sizer {
Expand All @@ -632,6 +633,9 @@ code {

.table-scroll.is-wide > .table-expand,
.table-scroll.is-expanded > .table-expand {
/* A wide surface has no room beside the table. Keep its action above the
header instead of obscuring the final column or a scrolled header. */
top: -32px;
right: 8px;
}

Expand All @@ -649,6 +653,9 @@ code {
}

.table-sizer {
/* Let intrinsic table content grow the rounded surface instead of being
clipped by a width chosen for shorter headers. The viewport owns scroll. */
display: table;
width: 100%;
overflow: hidden;
border: 1px solid var(--line);
Expand Down
19 changes: 14 additions & 5 deletions Sources/PreviewMD/Resources/Renderer/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -550,16 +550,25 @@
const readableColumnWidth = Math.max(144, computedCellWidth || 0);
const expanded = wrapper.classList.contains("is-expanded");
const contentWidth = articleContentWidth();
const viewport = wrapper.querySelector(".table-viewport");
// Classic scrollbars reserve inline space even without vertical overflow
// because the viewport uses scrollbar-gutter: stable. Overlay bars do not.
const scrollbarGutter = viewport ? Math.max(0, viewport.offsetWidth - viewport.clientWidth) : 0;
const baseTableWidth = columnCount * readableColumnWidth;
const tableWidth = columnCount *
(expanded ? Math.max(220, readableColumnWidth) : readableColumnWidth);
const shouldUseWideSurface = expanded || baseTableWidth > contentWidth + 1;
const shouldUseWideSurface = expanded || baseTableWidth > contentWidth - scrollbarGutter + 1;
const surface = shouldUseWideSurface ? wideTableSurface() : null;
const surfaceWidth = surface ? Math.max(contentWidth, surface.width) : contentWidth;
const availableWidth = surface ? Math.max(0, surfaceWidth - surface.leadingGutter - scrollbarGutter) : contentWidth - scrollbarGutter;
// Use the free surface before introducing scroll, but keep the collapsed
// minimum so narrow windows never squeeze columns below their readable size.
const tableWidth = expanded
? Math.max(baseTableWidth, Math.min(availableWidth, columnCount * Math.max(220, readableColumnWidth)))
: baseTableWidth;

if (sizer) sizer.style.minWidth = tableWidth + "px";
wrapper.classList.toggle("is-wide", shouldUseWideSurface);
if (shouldUseWideSurface) {
const surface = wideTableSurface();
wrapper.style.width = Math.max(contentWidth, surface.width) + "px";
wrapper.style.width = surfaceWidth + "px";
wrapper.style.marginLeft = -surface.leadingGutter + "px";
wrapper.style.setProperty(
"--table-leading-gutter",
Expand Down
7 changes: 5 additions & 2 deletions Sources/PreviewMD/WorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1860,7 +1860,8 @@ private struct DocumentWorkspace: View {
SourceEditor(
documentID: document.id,
splitSynchronizer: splitSynchronizer,
isSplitSynchronizationEnabled: isSplit
isSplitSynchronizationEnabled: isSplit,
isVisible: visibleSourceWidth > 0
)
.frame(width: visibleSourceWidth)
.opacity(visibleSourceWidth > 0 ? 1 : 0)
Expand Down Expand Up @@ -2153,6 +2154,7 @@ private struct SourceEditor: View {
let documentID: UUID
let splitSynchronizer: SplitEditorSynchronizer
let isSplitSynchronizationEnabled: Bool
let isVisible: Bool

var body: some View {
ZStack {
Expand All @@ -2161,7 +2163,8 @@ private struct SourceEditor: View {
text: state.bindingForCurrentContent(),
documentID: documentID,
splitSynchronizer: splitSynchronizer,
isSplitSynchronizationEnabled: isSplitSynchronizationEnabled
isSplitSynchronizationEnabled: isSplitSynchronizationEnabled,
isVisible: isVisible
)
}
}
Expand Down
Loading
Loading