longform: fix stretched/cut-off images in longform notes
Pre-process markdown with ensureBlockLevelImages() to add paragraph breaks around standalone images, forcing proper block-level parsing. Creates KingfisherImageProvider for MarkdownUI to handle proper aspect ratio and image caching. Changelog-Fixed: Fixed stretched/cut-off images in longform notes Closes: https://github.com/damus-io/damus/pull/3489 Closes: https://github.com/damus-io/damus/pull/3496 Signed-off-by: alltheseas <alltheseas@users.noreply.github.com> Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
committed by
Daniel D’Aquino
parent
4f401c6ce9
commit
767b318763
@@ -628,6 +628,7 @@
|
|||||||
7C60CAEF298471A1009C80D6 /* CoreSVG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C60CAEE298471A1009C80D6 /* CoreSVG.swift */; };
|
7C60CAEF298471A1009C80D6 /* CoreSVG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C60CAEE298471A1009C80D6 /* CoreSVG.swift */; };
|
||||||
7C902AE32981D55B002AB16E /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */; };
|
7C902AE32981D55B002AB16E /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */; };
|
||||||
7C95CAEE299DCEF1009DCB67 /* KFOptionSetter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */; };
|
7C95CAEE299DCEF1009DCB67 /* KFOptionSetter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */; };
|
||||||
|
K9G5YXAZ4Y19GSLH8TWS8CO1 /* KingfisherImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */; };
|
||||||
7CFF6317299FEFE5005D382A /* SelectableText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFF6316299FEFE5005D382A /* SelectableText.swift */; };
|
7CFF6317299FEFE5005D382A /* SelectableText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CFF6316299FEFE5005D382A /* SelectableText.swift */; };
|
||||||
82D6FA9A2CD9820500C925F4 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D6FA992CD9820500C925F4 /* ShareViewController.swift */; };
|
82D6FA9A2CD9820500C925F4 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D6FA992CD9820500C925F4 /* ShareViewController.swift */; };
|
||||||
82D6FAA12CD9820500C925F4 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 82D6FA972CD9820500C925F4 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
82D6FAA12CD9820500C925F4 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 82D6FA972CD9820500C925F4 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
@@ -746,6 +747,7 @@
|
|||||||
82D6FB2F2CD99F7900C925F4 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DEE29F88C6B004C165C /* BlurHashDecode.swift */; };
|
82D6FB2F2CD99F7900C925F4 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DEE29F88C6B004C165C /* BlurHashDecode.swift */; };
|
||||||
82D6FB302CD99F7900C925F4 /* PostBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F0F329D779B5005914DB /* PostBox.swift */; };
|
82D6FB302CD99F7900C925F4 /* PostBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F0F329D779B5005914DB /* PostBox.swift */; };
|
||||||
82D6FB312CD99F7900C925F4 /* KFOptionSetter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */; };
|
82D6FB312CD99F7900C925F4 /* KFOptionSetter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */; };
|
||||||
|
FQ9UEENWE218BBQDVQXU3RA9 /* KingfisherImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */; };
|
||||||
82D6FB322CD99F7900C925F4 /* FillAndStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */; };
|
82D6FB322CD99F7900C925F4 /* FillAndStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */; };
|
||||||
82D6FB332CD99F7900C925F4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12772BEED22400F4F781 /* Array.swift */; };
|
82D6FB332CD99F7900C925F4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12772BEED22400F4F781 /* Array.swift */; };
|
||||||
82D6FB342CD99F7900C925F4 /* VectorMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */; };
|
82D6FB342CD99F7900C925F4 /* VectorMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */; };
|
||||||
@@ -1361,6 +1363,7 @@
|
|||||||
D73E5E632C6A97F4007EB227 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DEE29F88C6B004C165C /* BlurHashDecode.swift */; };
|
D73E5E632C6A97F4007EB227 /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DEE29F88C6B004C165C /* BlurHashDecode.swift */; };
|
||||||
D73E5E642C6A97F4007EB227 /* PostBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F0F329D779B5005914DB /* PostBox.swift */; };
|
D73E5E642C6A97F4007EB227 /* PostBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F0F329D779B5005914DB /* PostBox.swift */; };
|
||||||
D73E5E652C6A97F4007EB227 /* KFOptionSetter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */; };
|
D73E5E652C6A97F4007EB227 /* KFOptionSetter+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */; };
|
||||||
|
3RRUR7Z6M0UHAOFZTGU9GRU0 /* KingfisherImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */; };
|
||||||
D73E5E662C6A97F4007EB227 /* FillAndStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */; };
|
D73E5E662C6A97F4007EB227 /* FillAndStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */; };
|
||||||
D73E5E672C6A97F4007EB227 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12772BEED22400F4F781 /* Array.swift */; };
|
D73E5E672C6A97F4007EB227 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12772BEED22400F4F781 /* Array.swift */; };
|
||||||
D73E5E682C6A97F4007EB227 /* VectorMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */; };
|
D73E5E682C6A97F4007EB227 /* VectorMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */; };
|
||||||
@@ -2686,6 +2689,7 @@
|
|||||||
7C60CAEE298471A1009C80D6 /* CoreSVG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreSVG.swift; sourceTree = "<group>"; };
|
7C60CAEE298471A1009C80D6 /* CoreSVG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreSVG.swift; sourceTree = "<group>"; };
|
||||||
7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableScrollView.swift; sourceTree = "<group>"; };
|
7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableScrollView.swift; sourceTree = "<group>"; };
|
||||||
7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KFOptionSetter+.swift"; sourceTree = "<group>"; };
|
7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KFOptionSetter+.swift"; sourceTree = "<group>"; };
|
||||||
|
BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherImageProvider.swift; sourceTree = "<group>"; };
|
||||||
7CFF6316299FEFE5005D382A /* SelectableText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableText.swift; sourceTree = "<group>"; };
|
7CFF6316299FEFE5005D382A /* SelectableText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableText.swift; sourceTree = "<group>"; };
|
||||||
82D6FA972CD9820500C925F4 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
82D6FA972CD9820500C925F4 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
82D6FA992CD9820500C925F4 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
82D6FA992CD9820500C925F4 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||||
@@ -5103,6 +5107,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */,
|
7C95CAED299DCEF1009DCB67 /* KFOptionSetter+.swift */,
|
||||||
|
BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */,
|
||||||
4C7D09752A0AF19E00943473 /* FillAndStroke.swift */,
|
4C7D09752A0AF19E00943473 /* FillAndStroke.swift */,
|
||||||
D72E12772BEED22400F4F781 /* Array.swift */,
|
D72E12772BEED22400F4F781 /* Array.swift */,
|
||||||
D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */,
|
D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */,
|
||||||
@@ -5951,6 +5956,7 @@
|
|||||||
4C0A3F93280F66F5000448DE /* ReplyMap.swift in Sources */,
|
4C0A3F93280F66F5000448DE /* ReplyMap.swift in Sources */,
|
||||||
4C2B7BF22A71B6540049DEE7 /* Id.swift in Sources */,
|
4C2B7BF22A71B6540049DEE7 /* Id.swift in Sources */,
|
||||||
7C95CAEE299DCEF1009DCB67 /* KFOptionSetter+.swift in Sources */,
|
7C95CAEE299DCEF1009DCB67 /* KFOptionSetter+.swift in Sources */,
|
||||||
|
K9G5YXAZ4Y19GSLH8TWS8CO1 /* KingfisherImageProvider.swift in Sources */,
|
||||||
4C7D09722A0AEF5E00943473 /* DamusGradient.swift in Sources */,
|
4C7D09722A0AEF5E00943473 /* DamusGradient.swift in Sources */,
|
||||||
4C463CBF2B960B96008A8C36 /* PurpleBackdrop.swift in Sources */,
|
4C463CBF2B960B96008A8C36 /* PurpleBackdrop.swift in Sources */,
|
||||||
5C8F97502EBD704A009399B1 /* LabsToggleView.swift in Sources */,
|
5C8F97502EBD704A009399B1 /* LabsToggleView.swift in Sources */,
|
||||||
@@ -6510,6 +6516,7 @@
|
|||||||
82D6FB302CD99F7900C925F4 /* PostBox.swift in Sources */,
|
82D6FB302CD99F7900C925F4 /* PostBox.swift in Sources */,
|
||||||
5C8F970B2EB45E8C009399B1 /* LiveChatModel.swift in Sources */,
|
5C8F970B2EB45E8C009399B1 /* LiveChatModel.swift in Sources */,
|
||||||
82D6FB312CD99F7900C925F4 /* KFOptionSetter+.swift in Sources */,
|
82D6FB312CD99F7900C925F4 /* KFOptionSetter+.swift in Sources */,
|
||||||
|
FQ9UEENWE218BBQDVQXU3RA9 /* KingfisherImageProvider.swift in Sources */,
|
||||||
5C8F972F2EB46116009399B1 /* LiveStreamStatus.swift in Sources */,
|
5C8F972F2EB46116009399B1 /* LiveStreamStatus.swift in Sources */,
|
||||||
D73BDB162D71216500D69970 /* UserRelayListManager.swift in Sources */,
|
D73BDB162D71216500D69970 /* UserRelayListManager.swift in Sources */,
|
||||||
82D6FB322CD99F7900C925F4 /* FillAndStroke.swift in Sources */,
|
82D6FB322CD99F7900C925F4 /* FillAndStroke.swift in Sources */,
|
||||||
@@ -6990,6 +6997,7 @@
|
|||||||
D76BE18E2E0CF3DA004AD0C6 /* Interests.swift in Sources */,
|
D76BE18E2E0CF3DA004AD0C6 /* Interests.swift in Sources */,
|
||||||
D73E5E642C6A97F4007EB227 /* PostBox.swift in Sources */,
|
D73E5E642C6A97F4007EB227 /* PostBox.swift in Sources */,
|
||||||
D73E5E652C6A97F4007EB227 /* KFOptionSetter+.swift in Sources */,
|
D73E5E652C6A97F4007EB227 /* KFOptionSetter+.swift in Sources */,
|
||||||
|
3RRUR7Z6M0UHAOFZTGU9GRU0 /* KingfisherImageProvider.swift in Sources */,
|
||||||
D73E5E662C6A97F4007EB227 /* FillAndStroke.swift in Sources */,
|
D73E5E662C6A97F4007EB227 /* FillAndStroke.swift in Sources */,
|
||||||
D73E5E672C6A97F4007EB227 /* Array.swift in Sources */,
|
D73E5E672C6A97F4007EB227 /* Array.swift in Sources */,
|
||||||
D73E5E682C6A97F4007EB227 /* VectorMath.swift in Sources */,
|
D73E5E682C6A97F4007EB227 /* VectorMath.swift in Sources */,
|
||||||
|
|||||||
@@ -382,10 +382,176 @@ struct LongformContent {
|
|||||||
let words: Int
|
let words: Int
|
||||||
|
|
||||||
init(_ markdown: String) {
|
init(_ markdown: String) {
|
||||||
let blocks = [BlockNode].init(markdown: markdown)
|
// Pre-process markdown to ensure images are block-level (have blank lines around them)
|
||||||
|
// This prevents images from being parsed as inline within text paragraphs
|
||||||
|
let processedMarkdown = LongformContent.ensureBlockLevelImages(markdown)
|
||||||
|
let blocks = [BlockNode].init(markdown: processedMarkdown)
|
||||||
self.markdown = MarkdownContent(blocks: blocks)
|
self.markdown = MarkdownContent(blocks: blocks)
|
||||||
self.words = count_markdown_words(blocks: blocks)
|
self.words = count_markdown_words(blocks: blocks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ensures markdown images have blank lines around them so they parse as block-level elements.
|
||||||
|
/// Without blank lines, images followed by text are parsed as inline within a paragraph,
|
||||||
|
/// causing them to be clipped by SwiftUI's Text view.
|
||||||
|
///
|
||||||
|
/// Safety: This function excludes:
|
||||||
|
/// - Fenced code blocks (``` or ~~~), indented code blocks (4 spaces/tab), and inline code
|
||||||
|
/// - Images inside lists (lines starting with -, *, + followed by space)
|
||||||
|
/// - Images inside blockquotes (lines starting with >)
|
||||||
|
/// - Images inside tables (lines containing | with table structure)
|
||||||
|
///
|
||||||
|
/// Known limitations (images remain inline, may clip if mixed with text):
|
||||||
|
/// - Multi-line list items with images on continuation lines
|
||||||
|
/// - Reference-style images: ![alt][id]
|
||||||
|
/// - HTML <img> tags
|
||||||
|
/// - Inline code with nested backticks (e.g., `` `code` `` using longer delimiters)
|
||||||
|
private static func ensureBlockLevelImages(_ markdown: String) -> String {
|
||||||
|
// First, identify regions to exclude (fenced code blocks and inline code)
|
||||||
|
let excludedRanges = findExcludedRanges(in: markdown)
|
||||||
|
|
||||||
|
// Pattern matches markdown images:  or 
|
||||||
|
// Handles URLs with parentheses by matching balanced parens or escaped parens
|
||||||
|
let imagePattern = #"!\[[^\]]*\]\((?:[^()]+|\([^)]*\))+(?:\s+"[^"]*")?\)"#
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: imagePattern, options: []) else {
|
||||||
|
return markdown
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = markdown
|
||||||
|
let matches = regex.matches(in: result, options: [], range: NSRange(result.startIndex..., in: result))
|
||||||
|
|
||||||
|
// Process matches in reverse order to preserve indices
|
||||||
|
for match in matches.reversed() {
|
||||||
|
guard let range = Range(match.range, in: result) else { continue }
|
||||||
|
|
||||||
|
// Skip if this match is inside an excluded region (code block/inline code)
|
||||||
|
// Use String.Index comparison for correctness with non-ASCII content
|
||||||
|
if excludedRanges.contains(where: { $0.overlaps(range) }) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if inside a list, blockquote, or table context
|
||||||
|
if isInStructuredContext(result, at: range.lowerBound) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageMarkdown = String(result[range])
|
||||||
|
|
||||||
|
// Check what's before the image
|
||||||
|
let beforeIndex = range.lowerBound
|
||||||
|
let hasParagraphBreakBefore = beforeIndex == result.startIndex ||
|
||||||
|
result[result.index(before: beforeIndex)] == "\n" && (
|
||||||
|
beforeIndex == result.index(after: result.startIndex) ||
|
||||||
|
result[result.index(beforeIndex, offsetBy: -2)] == "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check what's after the image
|
||||||
|
let afterIndex = range.upperBound
|
||||||
|
let hasParagraphBreakAfter = afterIndex == result.endIndex ||
|
||||||
|
result[afterIndex] == "\n" && (
|
||||||
|
result.index(after: afterIndex) == result.endIndex ||
|
||||||
|
result[result.index(after: afterIndex)] == "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build replacement with proper paragraph breaks
|
||||||
|
var replacement = imageMarkdown
|
||||||
|
if !hasParagraphBreakBefore && beforeIndex != result.startIndex {
|
||||||
|
replacement = "\n\n" + replacement
|
||||||
|
}
|
||||||
|
if !hasParagraphBreakAfter && afterIndex != result.endIndex {
|
||||||
|
replacement = replacement + "\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
if replacement != imageMarkdown {
|
||||||
|
result.replaceSubrange(range, with: replacement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if the position is inside a list, blockquote, or table context.
|
||||||
|
/// Returns true if modifying this position would break markdown structure.
|
||||||
|
private static func isInStructuredContext(_ markdown: String, at position: String.Index) -> Bool {
|
||||||
|
// Find the start of the current line
|
||||||
|
var lineStart = position
|
||||||
|
while lineStart > markdown.startIndex {
|
||||||
|
let prevIndex = markdown.index(before: lineStart)
|
||||||
|
if markdown[prevIndex] == "\n" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lineStart = prevIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the line prefix (content before the image on this line)
|
||||||
|
let linePrefix = String(markdown[lineStart..<position])
|
||||||
|
let trimmedPrefix = linePrefix.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
// Check for list markers: -, *, + followed by space (to avoid false positives)
|
||||||
|
if (trimmedPrefix.hasPrefix("- ") || trimmedPrefix.hasPrefix("* ") ||
|
||||||
|
trimmedPrefix.hasPrefix("+ ")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for numbered list: digit(s) followed by . or ) and space
|
||||||
|
let numberedListPattern = #"^\d+[.)]\s"#
|
||||||
|
if let numberedRegex = try? NSRegularExpression(pattern: numberedListPattern, options: []),
|
||||||
|
numberedRegex.firstMatch(in: trimmedPrefix, options: [], range: NSRange(trimmedPrefix.startIndex..., in: trimmedPrefix)) != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for blockquote marker: >
|
||||||
|
if trimmedPrefix.hasPrefix(">") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for table context: line has | at both start area and elsewhere (actual table row)
|
||||||
|
var lineEnd = position
|
||||||
|
while lineEnd < markdown.endIndex && markdown[lineEnd] != "\n" {
|
||||||
|
lineEnd = markdown.index(after: lineEnd)
|
||||||
|
}
|
||||||
|
let fullLine = String(markdown[lineStart..<lineEnd])
|
||||||
|
// Only treat as table if line has multiple | characters (actual table structure)
|
||||||
|
if fullLine.filter({ $0 == "|" }).count >= 2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds ranges in the markdown that should be excluded from image processing.
|
||||||
|
/// Returns ranges as String.Index for correct handling of non-ASCII content.
|
||||||
|
private static func findExcludedRanges(in markdown: String) -> [Range<String.Index>] {
|
||||||
|
var ranges: [Range<String.Index>] = []
|
||||||
|
let fullRange = NSRange(markdown.startIndex..., in: markdown)
|
||||||
|
|
||||||
|
// Find fenced code blocks (3+ backticks or tildes)
|
||||||
|
let fencedPattern = #"(?:^|\n)(`{3,}|~{3,}).*?(?:\n\1|\z)"#
|
||||||
|
guard let fencedRegex = try? NSRegularExpression(pattern: fencedPattern, options: [.dotMatchesLineSeparators]) else {
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
ranges.append(contentsOf: fencedRegex.matches(in: markdown, options: [], range: fullRange)
|
||||||
|
.compactMap { Range($0.range, in: markdown) })
|
||||||
|
|
||||||
|
// Find indented code blocks (lines starting with 4 spaces or tab, preceded by blank line)
|
||||||
|
// Blank line may contain whitespace: \n followed by optional spaces/tabs then \n
|
||||||
|
let indentedPattern = #"(?:^|\n[ \t]*\n)((?:(?: |\t).+\n?)+)"#
|
||||||
|
guard let indentedRegex = try? NSRegularExpression(pattern: indentedPattern, options: []) else {
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
ranges.append(contentsOf: indentedRegex.matches(in: markdown, options: [], range: fullRange)
|
||||||
|
.filter { $0.numberOfRanges > 1 }
|
||||||
|
.compactMap { Range($0.range(at: 1), in: markdown) })
|
||||||
|
|
||||||
|
// Find inline code (1+ backticks, matching pairs)
|
||||||
|
let inlinePattern = #"(`+)(?!`)[^`]*?\1"#
|
||||||
|
guard let inlineRegex = try? NSRegularExpression(pattern: inlinePattern, options: []) else {
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
ranges.append(contentsOf: inlineRegex.matches(in: markdown, options: [], range: fullRange)
|
||||||
|
.compactMap { Range($0.range, in: markdown) })
|
||||||
|
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func count_markdown_words(blocks: [BlockNode]) -> Int {
|
func count_markdown_words(blocks: [BlockNode]) -> Int {
|
||||||
|
|||||||
@@ -361,7 +361,10 @@ struct NoteContentView: View {
|
|||||||
Group {
|
Group {
|
||||||
switch self.note_artifacts {
|
switch self.note_artifacts {
|
||||||
case .longform(let md):
|
case .longform(let md):
|
||||||
|
// Note: Do NOT apply .fixedSize to longform content - it prevents async images from expanding
|
||||||
Markdown(md.markdown)
|
Markdown(md.markdown)
|
||||||
|
.markdownImageProvider(.kingfisher(disable_animation: damus_state.settings.disable_animation))
|
||||||
|
.markdownInlineImageProvider(.kingfisher)
|
||||||
.padding([.leading, .trailing, .top])
|
.padding([.leading, .trailing, .top])
|
||||||
case .separated(let separated):
|
case .separated(let separated):
|
||||||
if #available(iOS 17.4, macOS 14.4, *) {
|
if #available(iOS 17.4, macOS 14.4, *) {
|
||||||
@@ -369,12 +372,13 @@ struct NoteContentView: View {
|
|||||||
#if !targetEnvironment(macCatalyst)
|
#if !targetEnvironment(macCatalyst)
|
||||||
.translationPresentation(isPresented: $isAppleTranslationPopoverPresented, text: event.get_content(damus_state.keypair))
|
.translationPresentation(isPresented: $isAppleTranslationPopoverPresented, text: event.get_content(damus_state.keypair))
|
||||||
#endif
|
#endif
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
} else {
|
} else {
|
||||||
MainContent(artifacts: separated)
|
MainContent(artifacts: separated)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var normalizedHighlightTerms: [String] {
|
var normalizedHighlightTerms: [String] {
|
||||||
|
|||||||
98
damus/Shared/Extensions/KingfisherImageProvider.swift
Normal file
98
damus/Shared/Extensions/KingfisherImageProvider.swift
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
//
|
||||||
|
// KingfisherImageProvider.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by alltheseas on 2026-01-03.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Kingfisher
|
||||||
|
import MarkdownUI
|
||||||
|
|
||||||
|
/// A custom image provider for MarkdownUI that uses Kingfisher for image loading.
|
||||||
|
/// This provides proper aspect ratio handling and caching for images in longform markdown content.
|
||||||
|
struct KingfisherImageProvider: ImageProvider {
|
||||||
|
let disable_animation: Bool
|
||||||
|
|
||||||
|
init(disable_animation: Bool = false) {
|
||||||
|
self.disable_animation = disable_animation
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeImage(url: URL?) -> some View {
|
||||||
|
KingfisherMarkdownImage(url: url, disable_animation: disable_animation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ImageProvider where Self == KingfisherImageProvider {
|
||||||
|
/// A Kingfisher-based image provider for loading images with proper caching and aspect ratio handling.
|
||||||
|
static var kingfisher: Self { .init() }
|
||||||
|
|
||||||
|
/// A Kingfisher-based image provider with animation disabled.
|
||||||
|
static func kingfisher(disable_animation: Bool) -> Self {
|
||||||
|
.init(disable_animation: disable_animation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - InlineImageProvider (for images mixed with text)
|
||||||
|
|
||||||
|
/// A custom inline image provider for MarkdownUI that uses Kingfisher for loading inline images.
|
||||||
|
/// This handles images that appear within text content (not standalone image paragraphs).
|
||||||
|
struct KingfisherInlineImageProvider: InlineImageProvider {
|
||||||
|
func image(with url: URL, label: String) async throws -> Image {
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
KingfisherManager.shared.retrieveImage(with: url) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let imageResult):
|
||||||
|
continuation.resume(returning: Image(uiImage: imageResult.image))
|
||||||
|
case .failure(let error):
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension InlineImageProvider where Self == KingfisherInlineImageProvider {
|
||||||
|
/// A Kingfisher-based inline image provider for loading images within text.
|
||||||
|
static var kingfisher: Self { .init() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ImageProvider View (for standalone image paragraphs)
|
||||||
|
|
||||||
|
/// Internal view that handles the actual Kingfisher image loading for markdown content.
|
||||||
|
/// Uses state to track loaded image dimensions for proper aspect ratio sizing.
|
||||||
|
private struct KingfisherMarkdownImage: View {
|
||||||
|
let url: URL?
|
||||||
|
let disable_animation: Bool
|
||||||
|
@State private var imageSize: CGSize?
|
||||||
|
|
||||||
|
/// Returns a valid aspect ratio, guarding against zero/invalid dimensions.
|
||||||
|
private var safeAspectRatio: CGSize {
|
||||||
|
guard let size = imageSize, size.width > 0, size.height > 0 else {
|
||||||
|
return CGSize(width: 1, height: 1)
|
||||||
|
}
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let url {
|
||||||
|
KFAnimatedImage(url)
|
||||||
|
.callbackQueue(.dispatch(.global(qos: .background)))
|
||||||
|
.backgroundDecode(true)
|
||||||
|
.imageContext(.note, disable_animation: disable_animation)
|
||||||
|
.image_fade(duration: 0.25)
|
||||||
|
.cancelOnDisappear(true)
|
||||||
|
.configure { view in
|
||||||
|
view.framePreloadCount = 3
|
||||||
|
}
|
||||||
|
.observe_image_size { size in
|
||||||
|
imageSize = size
|
||||||
|
}
|
||||||
|
.aspectRatio(safeAspectRatio, contentMode: .fit)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.kfClickable()
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user