feat(ios): intelligent Switch Markdown View & Ephemeral Action View (#9823)

Co-authored-by: EYHN <cneyhn@gmail.com>
This commit is contained in:
Lakr
2025-03-24 17:47:42 +08:00
committed by GitHub
parent 4d3eee3bad
commit 447b23f25f
192 changed files with 43960 additions and 980 deletions

View File

@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FlowMarkdownView"
BuildableName = "FlowMarkdownView"
BlueprintName = "FlowMarkdownView"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FlowMarkdownView"
BuildableName = "FlowMarkdownView"
BlueprintName = "FlowMarkdownView"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,411 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
50219C662D3E2304006CB93C /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50219C652D3E2304006CB93C /* App.swift */; };
507C16722D2719F100B478D2 /* MarkdownView in Frameworks */ = {isa = PBXBuildFile; productRef = 507C16712D2719F100B478D2 /* MarkdownView */; };
5084C6742D281A41007310F0 /* LookinServer in Frameworks */ = {isa = PBXBuildFile; productRef = 5084C6732D281A41007310F0 /* LookinServer */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
50219C652D3E2304006CB93C /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
505E99EA2D26D8380014A6D3 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
505E99E72D26D8380014A6D3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
507C16722D2719F100B478D2 /* MarkdownView in Frameworks */,
5084C6742D281A41007310F0 /* LookinServer in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
5015F6D32D26DCFB005FA7D2 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
50219C642D3E22FB006CB93C /* Example */ = {
isa = PBXGroup;
children = (
50219C652D3E2304006CB93C /* App.swift */,
);
path = Example;
sourceTree = "<group>";
};
505E99E12D26D8380014A6D3 = {
isa = PBXGroup;
children = (
50219C642D3E22FB006CB93C /* Example */,
5015F6D32D26DCFB005FA7D2 /* Frameworks */,
505E99EB2D26D8380014A6D3 /* Products */,
);
sourceTree = "<group>";
};
505E99EB2D26D8380014A6D3 /* Products */ = {
isa = PBXGroup;
children = (
505E99EA2D26D8380014A6D3 /* Example.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
505E99E92D26D8380014A6D3 /* Example */ = {
isa = PBXNativeTarget;
buildConfigurationList = 505E99F82D26D8390014A6D3 /* Build configuration list for PBXNativeTarget "Example" */;
buildPhases = (
5015F6CD2D26DB1B005FA7D2 /* Format Source */,
505E99E62D26D8380014A6D3 /* Sources */,
505E99E72D26D8380014A6D3 /* Frameworks */,
505E99E82D26D8380014A6D3 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = Example;
packageProductDependencies = (
507C16712D2719F100B478D2 /* MarkdownView */,
5084C6732D281A41007310F0 /* LookinServer */,
);
productName = Example;
productReference = 505E99EA2D26D8380014A6D3 /* Example.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
505E99E22D26D8380014A6D3 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 1620;
TargetAttributes = {
505E99E92D26D8380014A6D3 = {
CreatedOnToolsVersion = 16.2;
LastSwiftMigration = 1620;
};
};
};
buildConfigurationList = 505E99E52D26D8380014A6D3 /* Build configuration list for PBXProject "Example" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 505E99E12D26D8380014A6D3;
minimizedProjectReferenceProxies = 1;
packageReferences = (
5084C6722D281A38007310F0 /* XCRemoteSwiftPackageReference "LookinServer" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 505E99EB2D26D8380014A6D3 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
505E99E92D26D8380014A6D3 /* Example */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
505E99E82D26D8380014A6D3 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
5015F6CD2D26DB1B005FA7D2 /* Format Source */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Format Source";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/opt/homebrew/bin/swiftformat . --swiftversion 6.0\n\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
505E99E62D26D8380014A6D3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
50219C662D3E2304006CB93C /* App.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
505E99F62D26D8390014A6D3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
505E99F72D26D8390014A6D3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
505E99F92D26D8390014A6D3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 964G86XT2P;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = wiki.qaq.Example;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
505E99FA2D26D8390014A6D3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 964G86XT2P;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = wiki.qaq.Example;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
505E99E52D26D8380014A6D3 /* Build configuration list for PBXProject "Example" */ = {
isa = XCConfigurationList;
buildConfigurations = (
505E99F62D26D8390014A6D3 /* Debug */,
505E99F72D26D8390014A6D3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
505E99F82D26D8390014A6D3 /* Build configuration list for PBXNativeTarget "Example" */ = {
isa = XCConfigurationList;
buildConfigurations = (
505E99F92D26D8390014A6D3 /* Debug */,
505E99FA2D26D8390014A6D3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
5084C6722D281A38007310F0 /* XCRemoteSwiftPackageReference "LookinServer" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/QMUI/LookinServer/";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.2.8;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
507C16712D2719F100B478D2 /* MarkdownView */ = {
isa = XCSwiftPackageProductDependency;
productName = MarkdownView;
};
5084C6732D281A41007310F0 /* LookinServer */ = {
isa = XCSwiftPackageProductDependency;
package = 5084C6722D281A38007310F0 /* XCRemoteSwiftPackageReference "LookinServer" */;
productName = LookinServer;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 505E99E22D26D8380014A6D3 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "container:Example.xcodeproj">
</FileRef>
<FileRef
location = "group:../">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,180 @@
//
// App.swift
// Example
//
// Created by on 1/20/25.
//
import SwiftUI
@main
struct TheApp: App {
var body: some Scene {
WindowGroup {
NavigationView {
Content()
.navigationTitle("MarkdownView")
.navigationBarTitleDisplayMode(.inline)
}
.navigationViewStyle(.stack)
}
}
}
import MarkdownParser
import MarkdownView
class ContentController: UIViewController {
let document = MarkdownParser().feed(testDocument)
let scrollView = UIScrollView()
let markdownView = MarkdownView(theme: .default)
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(scrollView)
scrollView.addSubview(markdownView)
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
scrollView.frame = view.bounds
let width = view.bounds.width - 32
let manifest = document.map {
let manifest = $0.manifest(theme: markdownView.theme)
manifest.setLayoutWidth(width)
manifest.layoutIfNeeded()
return manifest
}
markdownView.updateContentViews(manifest)
markdownView.frame = .init(
x: 16,
y: 16,
width: width,
height: markdownView.height
)
scrollView.contentSize = .init(
width: width,
height: markdownView.height + 100
)
}
}
struct Content: UIViewControllerRepresentable {
func makeUIViewController(context _: Context) -> ContentController {
ContentController()
}
func updateUIViewController(_: ContentController, context _: Context) {}
}
let testDocument = ###"""
# Markdown 测试文稿
这是一篇用于测试渲染引擎性能的 Markdown 文档,包含多种格式和元素。以下是不同 Markdown 语法的示例:
## 标题
### 三级标题
#### 四级标题
##### 五级标题
###### 六级标题
## 段落与文本格式
这是一个普通段落。**这是加粗的文字***这是斜体的文字****这是加粗且斜体的文字***。~~这是删除线~~。
这是`行内代码`的示例。
## 列表
### 无序列表
- 项目 1
- 项目 2
- 子项目 2.1
- 子项目 2.2
- 项目 3
### 有序列表
1. 第一项
2. 第二项
1. 子项 2.1
2. 子项 2.2
3. 第三项
## 引用
> 这是一个引用块。引用块可以包含多行文字,甚至可以包含其他 Markdown 元素,比如**加粗**或`代码`。
## 代码块
```python
def hello_world():
print("Hello, World!")
```
```javascript
function helloWorld() {
console.log("Hello, World!");
}
```
## 表格
| 序号 | 名称 | 描述 |
| ---- | ---------- | ------------- |
| 1 | 项目 A | 这是项目 A |
| 2 | 项目 B | 这是项目 B |
| 3 | 项目 C | 这是项目 C |
## 链接与图片
这是一个[短链接](https://example.com)的示例。
![图片描述](https://via.placeholder.com/150)
## HTML 嵌入
<p style="color: red;">这是一个红色的段落,使用 HTML 标签实现。</p>
<a href="https://example.com"></a>
## 线
---
##
[^1]
[^1]:
## HTML
<div style="border: 1px solid black; padding: 10px;">
HTML
</div>
##
$E = mc^2$
$$
\int_{a}^{b} x^2 dx
$$
##
- [x] 1
- [ ] 2
- [ ] 3
##
Markdown HTML
"""###

View File

@@ -0,0 +1,29 @@
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "MarkdownView",
platforms: [
.iOS(.v14),
.macCatalyst(.v14),
],
products: [
.library(name: "MarkdownView", targets: ["MarkdownView"]),
],
dependencies: [
.package(path: "../MarkdownParserCore"),
.package(url: "https://github.com/JohnSundell/Splash", from: "0.16.0"),
],
targets: [
.target(name: "MarkdownView", dependencies: [
"MarkdownParser",
"Splash",
]),
.target(name: "MarkdownParser", dependencies: [
.product(name: "MarkdownParserCore", package: "MarkdownParserCore"),
.product(name: "MarkdownParserCoreExtension", package: "MarkdownParserCore"),
]),
]
)

View File

@@ -0,0 +1,104 @@
import Foundation
extension Sequence<BlockNode> {
func rewrite(_ r: (BlockNode) throws -> [BlockNode]) rethrows -> [BlockNode] {
try flatMap { try $0.rewrite(r) }
}
func rewrite(_ r: (InlineNode) throws -> [InlineNode]) rethrows -> [BlockNode] {
try flatMap { try $0.rewrite(r) }
}
}
extension BlockNode {
func rewrite(_ r: (BlockNode) throws -> [BlockNode]) rethrows -> [BlockNode] {
switch self {
case let .blockquote(children):
try r(.blockquote(children: children.rewrite(r)))
case let .bulletedList(isTight, items):
try r(
.bulletedList(
isTight: isTight,
items: items.map {
try RawListItem(children: $0.children.rewrite(r))
}
)
)
case let .numberedList(isTight, start, items):
try r(
.numberedList(
isTight: isTight,
start: start,
items: items.map {
try RawListItem(children: $0.children.rewrite(r))
}
)
)
case let .taskList(isTight, items):
try r(
.taskList(
isTight: isTight,
items: items.map {
try RawTaskListItem(isCompleted: $0.isCompleted, children: $0.children.rewrite(r))
}
)
)
default:
try r(self)
}
}
func rewrite(_ r: (InlineNode) throws -> [InlineNode]) rethrows -> [BlockNode] {
switch self {
case let .blockquote(children):
try [.blockquote(children: children.rewrite(r))]
case let .bulletedList(isTight, items):
try [
.bulletedList(
isTight: isTight,
items: items.map {
try RawListItem(children: $0.children.rewrite(r))
}
),
]
case let .numberedList(isTight, start, items):
try [
.numberedList(
isTight: isTight,
start: start,
items: items.map {
try RawListItem(children: $0.children.rewrite(r))
}
),
]
case let .taskList(isTight, items):
try [
.taskList(
isTight: isTight,
items: items.map {
try RawTaskListItem(isCompleted: $0.isCompleted, children: $0.children.rewrite(r))
}
),
]
case let .paragraph(content):
try [.paragraph(content: content.rewrite(r))]
case let .heading(level, content):
try [.heading(level: level, content: content.rewrite(r))]
case let .table(columnAlignments, rows):
try [
.table(
columnAlignments: columnAlignments,
rows: rows.map {
try RawTableRow(
cells: $0.cells.map {
try RawTableCell(content: $0.content.rewrite(r))
}
)
}
),
]
default:
[self]
}
}
}

View File

@@ -0,0 +1,85 @@
import Foundation
public enum BlockNode: Hashable, Equatable, Codable {
case blockquote(children: [BlockNode])
case bulletedList(isTight: Bool, items: [RawListItem])
case numberedList(isTight: Bool, start: Int, items: [RawListItem])
case taskList(isTight: Bool, items: [RawTaskListItem])
case codeBlock(fenceInfo: String?, content: String)
// case htmlBlock(content: String)
case paragraph(content: [InlineNode])
case heading(level: Int, content: [InlineNode])
case table(columnAlignments: [RawTableColumnAlignment], rows: [RawTableRow])
case thematicBreak
//
// public var typeIdentifier: String {
// switch self {
// case .blockquote:
// "blockquote"
// case .bulletedList:
// "bulletedList"
// case .numberedList:
// "numberedList"
// case .taskList:
// "taskList"
// case .codeBlock:
// "codeBlock"
// case .htmlBlock:
// "htmlBlock"
// case .paragraph:
// "paragraph"
// case .heading:
// "heading"
// case .table:
// "table"
// case .thematicBreak:
// "thematicBreak"
// }
// }
}
extension BlockNode {
var children: [BlockNode] {
switch self {
case let .blockquote(children):
children
case let .bulletedList(_, items):
items.map(\.children).flatMap(\.self)
case let .numberedList(_, _, items):
items.map(\.children).flatMap(\.self)
case let .taskList(_, items):
items.map(\.children).flatMap(\.self)
default:
[]
}
}
var isParagraph: Bool {
guard case .paragraph = self else { return false }
return true
}
}
public struct RawListItem: Hashable, Equatable, Codable {
public let children: [BlockNode]
}
public struct RawTaskListItem: Hashable, Equatable, Codable {
public let isCompleted: Bool
public let children: [BlockNode]
}
public enum RawTableColumnAlignment: Character, Equatable, Codable {
case none = "\0"
case left = "l"
case center = "c"
case right = "r"
}
public struct RawTableRow: Hashable, Equatable, Codable {
public let cells: [RawTableCell]
}
public struct RawTableCell: Hashable, Equatable, Codable {
public let content: [InlineNode]
}

View File

@@ -0,0 +1,13 @@
import Foundation
extension Sequence<InlineNode> {
func collect<Result>(_ c: (InlineNode) throws -> [Result]) rethrows -> [Result] {
try flatMap { try $0.collect(c) }
}
}
extension InlineNode {
func collect<Result>(_ c: (InlineNode) throws -> [Result]) rethrows -> [Result] {
try children.collect(c) + c(self)
}
}

View File

@@ -0,0 +1,15 @@
import Foundation
extension Sequence<InlineNode> {
func rewrite(_ r: (InlineNode) throws -> [InlineNode]) rethrows -> [InlineNode] {
try flatMap { try $0.rewrite(r) }
}
}
extension InlineNode {
func rewrite(_ r: (InlineNode) throws -> [InlineNode]) rethrows -> [InlineNode] {
var inline = self
inline.children = try children.rewrite(r)
return try r(inline)
}
}

View File

@@ -0,0 +1,52 @@
import Foundation
public enum InlineNode: Hashable, Sendable, Equatable, Codable {
case text(String)
case softBreak
case lineBreak
case code(String)
case html(String)
case emphasis(children: [InlineNode])
case strong(children: [InlineNode])
case strikethrough(children: [InlineNode])
case link(destination: String, children: [InlineNode])
case image(source: String, children: [InlineNode])
}
extension InlineNode {
var children: [InlineNode] {
get {
switch self {
case let .emphasis(children):
children
case let .strong(children):
children
case let .strikethrough(children):
children
case let .link(_, children):
children
case let .image(_, children):
children
default:
[]
}
}
set {
switch self {
case .emphasis:
self = .emphasis(children: newValue)
case .strong:
self = .strong(children: newValue)
case .strikethrough:
self = .strikethrough(children: newValue)
case let .link(destination, _):
self = .link(destination: destination, children: newValue)
case let .image(source, _):
self = .image(source: source, children: newValue)
default:
break
}
}
}
}

View File

@@ -0,0 +1,14 @@
//
// MarkdownParser+Delegate.swift
// FlowMarkdownView
//
// Created by on 2025/1/3.
//
import Foundation
public extension MarkdownParser {
protocol Delegate: AnyObject {
func updateBlockNodes(_ blockNodes: [BlockNode])
}
}

View File

@@ -0,0 +1,22 @@
//
// MarkdownParser+Node.swift
// FlowMarkdownView
//
// Created by on 2025/1/3.
//
import cmark_gfm
import cmark_gfm_extensions
import Foundation
extension MarkdownParser {
func dumpBlocks(root: UnsafeNode?) {
guard let root else {
assertionFailure()
return
}
assert(root.pointee.type == CMARK_NODE_DOCUMENT.rawValue)
blocks = root.children.compactMap(BlockNode.init(unsafeNode:))
}
}

View File

@@ -0,0 +1,25 @@
//
// MarkdownParser+Setup.swift
// FlowMarkdownView
//
// Created by on 2025/1/3.
//
import cmark_gfm
import cmark_gfm_extensions
import Foundation
extension MarkdownParser {
func setupExtensions(parser: UnsafeMutablePointer<cmark_parser>) {
cmark_gfm_core_extensions_ensure_registered()
let extensionNames = ["autolink", "strikethrough", "tagfilter", "tasklist", "table"]
for extensionName in extensionNames {
guard let syntaxExtension = cmark_find_syntax_extension(extensionName) else {
assertionFailure()
continue
}
cmark_parser_attach_syntax_extension(parser, syntaxExtension)
}
}
}

View File

@@ -0,0 +1,34 @@
//
// MarkdownParser.swift
// FlowMarkdownView
//
// Created by on 2025/1/2.
//
import cmark_gfm
import cmark_gfm_extensions
import Foundation
public class MarkdownParser {
public internal(set) var blocks: [BlockNode] = [] {
didSet { delegate?.updateBlockNodes(blocks) }
}
public weak var delegate: Delegate?
var currentDoc = String()
public init() {}
@discardableResult
public func feed(_ text: String) -> [BlockNode] {
currentDoc += text
let parser = cmark_parser_new(CMARK_OPT_DEFAULT)!
defer { cmark_parser_free(parser) }
setupExtensions(parser: parser)
cmark_parser_feed(parser, currentDoc, currentDoc.utf8.count)
let node = cmark_parser_finish(parser)
defer { cmark_node_free(node) }
dumpBlocks(root: node)
return blocks
}
}

View File

@@ -0,0 +1,289 @@
import cmark_gfm
import cmark_gfm_extensions
import Foundation
typealias UnsafeNode = UnsafeMutablePointer<cmark_node>
extension BlockNode {
init?(unsafeNode: UnsafeNode) {
switch unsafeNode.nodeType {
case .blockquote:
self = .blockquote(children: unsafeNode.children.compactMap(BlockNode.init(unsafeNode:)))
case .list:
if unsafeNode.children.contains(where: \.isTaskListItem) {
self = .taskList(
isTight: unsafeNode.isTightList,
items: unsafeNode.children.map(RawTaskListItem.init(unsafeNode:))
)
} else {
switch unsafeNode.listType {
case CMARK_BULLET_LIST:
self = .bulletedList(
isTight: unsafeNode.isTightList,
items: unsafeNode.children.map(RawListItem.init(unsafeNode:))
)
case CMARK_ORDERED_LIST:
self = .numberedList(
isTight: unsafeNode.isTightList,
start: unsafeNode.listStart,
items: unsafeNode.children.map(RawListItem.init(unsafeNode:))
)
default:
fatalError("cmark reported a list node without a list type.")
}
}
case .codeBlock:
self = .codeBlock(fenceInfo: unsafeNode.fenceInfo, content: unsafeNode.literal ?? "")
case .htmlBlock:
// self = .htmlBlock(content: unsafeNode.literal ?? "")
self = .codeBlock(fenceInfo: "html", content: unsafeNode.literal ?? "")
case .paragraph:
self = .paragraph(content: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:)))
case .heading:
self = .heading(
level: unsafeNode.headingLevel,
content: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:))
)
case .table:
self = .table(
columnAlignments: unsafeNode.tableAlignments,
rows: unsafeNode.children.map(RawTableRow.init(unsafeNode:))
)
case .thematicBreak:
self = .thematicBreak
default:
assertionFailure("Unhandled node type '\(unsafeNode.nodeType)' in BlockNode.")
return nil
}
}
}
extension RawListItem {
init(unsafeNode: UnsafeNode) {
guard unsafeNode.nodeType == .item else {
fatalError("Expected a list item but got a '\(unsafeNode.nodeType)' instead.")
}
self.init(children: unsafeNode.children.compactMap(BlockNode.init(unsafeNode:)))
}
}
extension RawTaskListItem {
init(unsafeNode: UnsafeNode) {
guard unsafeNode.nodeType == .taskListItem || unsafeNode.nodeType == .item else {
fatalError("Expected a list item but got a '\(unsafeNode.nodeType)' instead.")
}
self.init(
isCompleted: unsafeNode.isTaskListItemChecked,
children: unsafeNode.children.compactMap(BlockNode.init(unsafeNode:))
)
}
}
extension RawTableRow {
init(unsafeNode: UnsafeNode) {
guard unsafeNode.nodeType == .tableRow || unsafeNode.nodeType == .tableHead else {
fatalError("Expected a table row but got a '\(unsafeNode.nodeType)' instead.")
}
self.init(cells: unsafeNode.children.map(RawTableCell.init(unsafeNode:)))
}
}
extension RawTableCell {
init(unsafeNode: UnsafeNode) {
guard unsafeNode.nodeType == .tableCell else {
fatalError("Expected a table cell but got a '\(unsafeNode.nodeType)' instead.")
}
self.init(content: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:)))
}
}
extension InlineNode {
init?(unsafeNode: UnsafeNode) {
switch unsafeNode.nodeType {
case .text:
self = .text(unsafeNode.literal ?? "")
case .softBreak:
self = .softBreak
case .lineBreak:
self = .lineBreak
case .code:
self = .code(unsafeNode.literal ?? "")
case .html:
self = .html(unsafeNode.literal ?? "")
case .emphasis:
self = .emphasis(children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:)))
case .strong:
self = .strong(children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:)))
case .strikethrough:
self = .strikethrough(children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:)))
case .link:
self = .link(
destination: unsafeNode.url ?? "",
children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:))
)
case .image:
self = .image(
source: unsafeNode.url ?? "",
children: unsafeNode.children.compactMap(InlineNode.init(unsafeNode:))
)
default:
assertionFailure("Unhandled node type '\(unsafeNode.nodeType)' in InlineNode.")
return nil
}
}
}
extension UnsafeNode {
var nodeType: NodeType {
let typeString = String(cString: cmark_node_get_type_string(self))
guard let nodeType = NodeType(rawValue: typeString) else {
fatalError("Unknown node type '\(typeString)' found.")
}
return nodeType
}
var children: UnsafeNodeSequence {
.init(cmark_node_first_child(self))
}
var literal: String? {
cmark_node_get_literal(self).map(String.init(cString:))
}
var url: String? {
cmark_node_get_url(self).map(String.init(cString:))
}
var isTaskListItem: Bool {
nodeType == .taskListItem
}
var listType: cmark_list_type {
cmark_node_get_list_type(self)
}
var listStart: Int {
Int(cmark_node_get_list_start(self))
}
var isTaskListItemChecked: Bool {
cmark_gfm_extensions_get_tasklist_item_checked(self)
}
var isTightList: Bool {
cmark_node_get_list_tight(self) != 0
}
var fenceInfo: String? {
cmark_node_get_fence_info(self).map(String.init(cString:))
}
var headingLevel: Int {
Int(cmark_node_get_heading_level(self))
}
var tableColumns: Int {
Int(cmark_gfm_extensions_get_table_columns(self))
}
var tableAlignments: [RawTableColumnAlignment] {
(0 ..< tableColumns).map { column in
let ascii = cmark_gfm_extensions_get_table_alignments(self)[column]
let scalar = UnicodeScalar(ascii)
let character = Character(scalar)
return .init(rawValue: character) ?? .none
}
}
}
enum NodeType: String {
case document
case blockquote = "block_quote"
case list
case item
case codeBlock = "code_block"
case htmlBlock = "html_block"
case customBlock = "custom_block"
case paragraph
case heading
case thematicBreak = "thematic_break"
case text
case softBreak = "softbreak"
case lineBreak = "linebreak"
case code
case html = "html_inline"
case customInline = "custom_inline"
case emphasis = "emph"
case strong
case link
case image
case inlineAttributes = "attribute"
case none = "NONE"
case unknown = "<unknown>"
// Extensions
case strikethrough
case table
case tableHead = "table_header"
case tableRow = "table_row"
case tableCell = "table_cell"
case taskListItem = "tasklist"
}
struct UnsafeNodeSequence: Sequence {
struct Iterator: IteratorProtocol {
var node: UnsafeNode?
init(_ node: UnsafeNode?) {
self.node = node
}
mutating func next() -> UnsafeNode? {
guard let node else { return nil }
defer { self.node = cmark_node_next(node) }
return node
}
}
let node: UnsafeNode?
init(_ node: UnsafeNode?) {
self.node = node
}
func makeIterator() -> Iterator {
.init(node)
}
}
// Extension node types are not exported in `cmark_gfm_extensions`,
// so we need to look for them in the symbol table
struct ExtensionNodeTypes {
let CMARK_NODE_TABLE: cmark_node_type
let CMARK_NODE_TABLE_ROW: cmark_node_type
let CMARK_NODE_TABLE_CELL: cmark_node_type
let CMARK_NODE_STRIKETHROUGH: cmark_node_type
static let shared = ExtensionNodeTypes()
init() {
func findNodeType(_ name: String, in handle: UnsafeMutableRawPointer!) -> cmark_node_type? {
guard let symbol = dlsym(handle, name) else {
return nil
}
return symbol.assumingMemoryBound(to: cmark_node_type.self).pointee
}
let handle = dlopen(nil, RTLD_LAZY)
CMARK_NODE_TABLE = findNodeType("CMARK_NODE_TABLE", in: handle) ?? CMARK_NODE_NONE
CMARK_NODE_TABLE_ROW = findNodeType("CMARK_NODE_TABLE_ROW", in: handle) ?? CMARK_NODE_NONE
CMARK_NODE_TABLE_CELL =
findNodeType("CMARK_NODE_TABLE_CELL", in: handle) ?? CMARK_NODE_NONE
CMARK_NODE_STRIKETHROUGH =
findNodeType("CMARK_NODE_STRIKETHROUGH", in: handle) ?? CMARK_NODE_NONE
dlclose(handle)
}
}

View File

@@ -0,0 +1,15 @@
//
// Ext+Array.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import Foundation
extension Array {
subscript(safe index: Int) -> Element? {
guard index >= 0, index < count else { return nil }
return self[index]
}
}

View File

@@ -0,0 +1,111 @@
//
// BlockquoteView.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import MarkdownParser
import UIKit
class BlockquoteView: BlockView {
var manifest: Manifest { _manifest as! Manifest }
let backgroundView = UIView()
let childrenContainer = UIView()
var childrenViews: [BlockView] = []
override func viewDidLoad() {
super.viewDidLoad()
addSubview(backgroundView)
backgroundView.backgroundColor = .gray.withAlphaComponent(0.05)
backgroundView.layer.cornerRadius = 8
backgroundView.clipsToBounds = true
backgroundView.layer.masksToBounds = true
addSubview(childrenContainer)
}
override func viewDidLayout() {
super.viewDidLayout()
backgroundView.frame = bounds
childrenViews.first?.frame = manifest.childrenGroupRect
childrenContainer.frame = bounds
}
override func viewDidUpdate() {
super.viewDidUpdate()
childrenContainer.diffableUpdate(
reusingViews: &childrenViews,
manifests: [manifest.childrenGroup]
)
}
override func accept(_ manifest: AnyBlockManifest) -> Bool {
manifest is Manifest
}
}
extension BlockquoteView {
class Manifest: BlockManifest {
var size: CGSize = .zero
var theme: Theme = .default
var dirty: Bool = true
var childrenGroupWidth: CGFloat {
max(0, size.width - spacings.general * 2)
}
var intrinsicWidth: CGFloat {
childrenGroup.intrinsicWidth + spacings.general * 2
}
let childrenGroup: GroupBlockView.Manifest = .init()
var childrenGroupRect: CGRect = .zero
required init() {}
var block: BlockNode? = nil
func load(block: BlockNode) {
guard self.block != block else { return }
dirty = true
self.block = block
guard case let .blockquote(children) = block else {
assertionFailure()
return
}
childrenGroup.setChildren(nodes: children)
}
func setLayoutWidth(_ width: CGFloat) {
guard size.width != width else { return }
assert(width >= 0)
dirty = true
size.width = width
childrenGroup.setLayoutWidth(childrenGroupWidth)
}
func setLayoutTheme(_ theme: Theme) {
guard self.theme != theme else { return }
dirty = true
self.theme = theme
childrenGroup.setLayoutTheme(theme)
}
func layoutIfNeeded() {
guard dirty, size.width > 0 else { return }
defer { dirty = false }
childrenGroup.layoutIfNeeded()
size.height = childrenGroup.size.height + spacings.general * 2
childrenGroupRect = CGRect(
x: spacings.general,
y: spacings.general,
width: childrenGroup.size.width,
height: childrenGroup.size.height
)
}
func determineViewType() -> BlockView.Type {
BlockquoteView.self
}
}
}

View File

@@ -0,0 +1,118 @@
//
// BulletedItemView.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import MarkdownParser
import UIKit
class BulletedItemView: BlockView {
let bulletedIcon = CircleView()
var manifest: Manifest { _manifest as! Manifest }
let childrenContainer = UIView()
var childrenViews: [BlockView] = []
override func viewDidLoad() {
super.viewDidLoad()
addSubview(bulletedIcon)
addSubview(childrenContainer)
}
override func viewDidLayout() {
super.viewDidLayout()
bulletedIcon.frame = manifest.iconRect
childrenViews.first?.frame = manifest.childrenRect
childrenContainer.frame = bounds
}
override func viewDidUpdate() {
super.viewDidUpdate()
childrenContainer.diffableUpdate(
reusingViews: &childrenViews,
manifests: [manifest.childrenGroup]
)
}
override func accept(_ manifest: AnyBlockManifest) -> Bool {
manifest is Manifest
}
}
extension BulletedItemView {
class Manifest: BlockManifest {
var size: CGSize = .zero
var theme: Theme = .default
var dirty: Bool = true
var intrinsicWidth: CGFloat {
childrenGroup.intrinsicWidth + iconLayoutGuideRect.width
}
var iconLayoutGuideRect: CGRect {
.init(x: 0, y: 0, width: sizes.bullet + spacings.list, height: fonts.body.baseLineHeight)
}
var iconRect: CGRect = .zero
let childrenGroup: GroupBlockView.Manifest = .init()
var childrenRect: CGRect = .zero
var childrenGroupWidth: CGFloat {
max(0, size.width - iconLayoutGuideRect.width)
}
required init() {
childrenGroup.overrideGroupSpacing = 0
}
func load(block _: BlockNode) {
assertionFailure("should not be called")
}
func setItems(_ items: RawListItem) {
dirty = true
childrenGroup.setChildren(nodes: items.children)
}
func setLayoutWidth(_ width: CGFloat) {
guard size.width != width else { return }
assert(width >= 0)
dirty = true
size.width = width
childrenGroup.setLayoutWidth(childrenGroupWidth)
}
func setLayoutTheme(_ theme: Theme) {
guard self.theme != theme else { return }
dirty = true
self.theme = theme
childrenGroup.setLayoutTheme(theme)
}
func layoutIfNeeded() {
guard dirty, size.width > 0 else { return }
defer { dirty = false }
let iconLayoutGuideRect = iconLayoutGuideRect
childrenGroup.layoutIfNeeded()
size.height = max(iconLayoutGuideRect.height, childrenGroup.size.height)
iconRect = CGRect(
x: 0,
y: (iconLayoutGuideRect.height - sizes.bullet) / 2,
width: sizes.bullet,
height: sizes.bullet
)
childrenRect = CGRect(
x: iconLayoutGuideRect.maxX,
y: 0,
width: childrenGroup.size.width,
height: childrenGroup.size.height
)
}
func determineViewType() -> BlockView.Type {
BulletedItemView.self
}
}
}

View File

@@ -0,0 +1,106 @@
//
// BulletedListView.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import MarkdownParser
import UIKit
class BulletedListView: BlockView {
var manifest: Manifest { _manifest as! Manifest }
let childrenContainer = UIView()
var childrenViews: [BlockView] = []
override func viewDidLoad() {
super.viewDidLoad()
addSubview(childrenContainer)
}
override func viewDidLayout() {
super.viewDidLayout()
childrenViews.first?.frame = manifest.childrenGroupRect
childrenContainer.frame = bounds
}
override func viewDidUpdate() {
super.viewDidUpdate()
childrenContainer.diffableUpdate(
reusingViews: &childrenViews,
manifests: [manifest.childrenGroup]
)
}
override func accept(_ manifest: AnyBlockManifest) -> Bool {
manifest is Manifest
}
}
extension BulletedListView {
class Manifest: BlockManifest {
var size: CGSize = .zero
var theme: Theme = .default
var dirty: Bool = true
var intrinsicWidth: CGFloat {
childrenGroup.intrinsicWidth
}
required init() {
childrenGroup.overrideGroupSpacing = 0
}
let childrenGroup: GroupBlockView.Manifest = .init()
var childrenGroupRect: CGRect = .zero
var block: BlockNode? = nil
func load(block: BlockNode) {
guard self.block != block else { return }
dirty = true
self.block = block
guard case let .bulletedList(_, items) = block else {
assertionFailure()
return
}
childrenGroup.setChildren(manifests: items.map { listItem in
let manifest = BulletedItemView.Manifest()
manifest.setItems(listItem)
return manifest
})
childrenGroup.setLayoutWidth(size.width)
}
func setLayoutWidth(_ width: CGFloat) {
guard size.width != width else { return }
assert(width >= 0)
dirty = true
size.width = width
childrenGroup.setLayoutWidth(width)
}
func setLayoutTheme(_ theme: Theme) {
guard self.theme != theme else { return }
dirty = true
self.theme = theme
childrenGroup.setLayoutTheme(theme)
}
func layoutIfNeeded() {
guard dirty, size.width > 0 else { return }
defer { dirty = false }
childrenGroup.layoutIfNeeded()
size.height = childrenGroup.size.height
childrenGroupRect = CGRect(
x: 0,
y: 0,
width: size.width,
height: size.height
)
}
func determineViewType() -> BlockView.Type {
BulletedListView.self
}
}
}

View File

@@ -0,0 +1,197 @@
//
// CodeBlockView.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import MarkdownParser
import Splash
import UIKit
class CodeBlockView: BlockView {
let backgroundView = UIView()
let fenceView = UIView()
let fenceLabel = TextLabel()
let fenceCopyButton = UIButton()
let scrollView = UIScrollView()
let codeTextView = TextLabel()
var manifest: Manifest { _manifest as! Manifest }
override func viewDidLoad() {
super.viewDidLoad()
clipsToBounds = true
layer.cornerRadius = 8
layer.masksToBounds = true
addSubview(backgroundView)
backgroundView.backgroundColor = .gray.withAlphaComponent(0.05)
addSubview(fenceView)
fenceView.backgroundColor = .gray.withAlphaComponent(0.05)
addSubview(fenceCopyButton)
fenceCopyButton.setImage(UIImage(systemName: "doc.on.doc"), for: .normal)
fenceCopyButton.tintColor = .label
fenceCopyButton.addTarget(self, action: #selector(copyButtonTapped), for: .touchUpInside)
fenceCopyButton.imageView?.contentMode = .scaleAspectFit
addSubview(fenceLabel)
fenceLabel.isSelectable = false
fenceLabel.textColor = .label
fenceLabel.font = .preferredFont(forTextStyle: .caption1)
fenceLabel.textAlignment = .left
addSubview(scrollView)
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.addSubview(codeTextView)
scrollView.clipsToBounds = false
scrollView.layer.masksToBounds = false
scrollView.bringSubviewToFront(codeTextView)
}
override func viewDidLayout() {
super.viewDidLayout()
backgroundView.frame = bounds
fenceView.frame = manifest.fenceRect
fenceLabel.frame = manifest.fenceLabelRect
fenceCopyButton.frame = manifest.fenceCopyButtonRect
scrollView.frame = manifest.scrollRect
codeTextView.frame = manifest.codeContentRect
scrollView.contentSize = manifest.scrollableContentSize
}
override func viewDidUpdate() {
super.viewDidUpdate()
fenceLabel.attributedText = manifest.fenceInfo
codeTextView.attributedText = manifest.content
}
@objc func copyButtonTapped() {
UIPasteboard.general.string = manifest.content.string
fenceCopyButton.setImage(UIImage(systemName: "checkmark"), for: .normal)
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(setButtonImageBack), object: nil)
perform(#selector(setButtonImageBack), with: nil, afterDelay: 1)
}
@objc func setButtonImageBack() {
fenceCopyButton.setImage(UIImage(systemName: "doc.on.doc"), for: .normal)
}
override func accept(_ manifest: AnyBlockManifest) -> Bool {
manifest is Manifest
}
}
extension CodeBlockView {
class Manifest: BlockManifest {
var size: CGSize = .zero
var theme: Theme = .default
var dirty: Bool = true
var intrinsicWidth: CGFloat {
content.measureWidth() + spacings.general * 4
}
var fenceInfo: NSAttributedString = .init()
var content: NSAttributedString = .init()
var fenceRect: CGRect = .zero
var fenceCopyButtonRect: CGRect = .zero
var fenceLabelRect: CGRect = .zero
var scrollRect: CGRect = .zero
var codeContentRect: CGRect = .zero
var scrollableContentSize: CGSize = .zero
required init() {}
var block: BlockNode? = nil
func load(block: BlockNode) {
guard self.block != block else { return }
dirty = true
self.block = block
guard case let .codeBlock(info, content) = block else {
assertionFailure()
return
}
var infoText: String = info ?? ""
infoText = infoText.trimmingCharacters(in: .whitespacesAndNewlines)
if infoText.isEmpty { infoText = "#" }
fenceInfo = NSAttributedString(string: infoText, attributes: [
.font: theme.fonts.code,
.foregroundColor: theme.colors.body,
.originalFont: theme.fonts.code,
])
let code = content.trimmingCharacters(in: .whitespacesAndNewlines)
let codeTheme = theme.codeTheme(withFont: theme.fonts.code)
let output = AttributedStringOutputFormat(theme: codeTheme)
let result: NSMutableAttributedString?
switch info?.lowercased() {
case "swift":
let splash = SyntaxHighlighter(format: output, grammar: SwiftGrammar())
result = splash.highlight(code).mutableCopy() as? NSMutableAttributedString
default:
let splash = SyntaxHighlighter(format: output)
result = splash.highlight(code).mutableCopy() as? NSMutableAttributedString
}
let defaultAttrs: [NSAttributedString.Key: Any] = [
.font: theme.fonts.code,
.originalFont: theme.fonts.code,
]
result?.addAttributes(defaultAttrs, range: NSRange(location: 0, length: result?.length ?? 0))
self.content = result ?? .init(string: code, attributes: defaultAttrs)
}
func layoutIfNeeded() {
guard dirty, size.width > 0 else { return }
defer { dirty = false }
let fenceLabelHeight = fenceInfo.measureHeight(usingWidth: .greatestFiniteMagnitude)
let fenceHeight = fenceLabelHeight + spacings.general * 2
let fenceCopyButtonSize = fenceLabelHeight
fenceRect = CGRect(
x: 0,
y: 0,
width: size.width,
height: fenceHeight
)
fenceLabelRect = .init(
x: spacings.general,
y: spacings.general,
width: size.width - fenceCopyButtonSize - spacings.general * 2,
height: fenceLabelHeight
)
fenceCopyButtonRect = .init(
x: size.width - fenceCopyButtonSize - spacings.general,
y: fenceLabelRect.minY,
width: fenceCopyButtonSize,
height: fenceCopyButtonSize
)
let contentWidth = content.measureWidth()
let contentHeight = content.measureHeight(usingWidth: .greatestFiniteMagnitude)
scrollRect = .init(
x: 0,
y: fenceRect.maxY,
width: size.width,
height: contentHeight + spacings.general * 2
)
size.height = scrollRect.maxY
codeContentRect = .init(
x: spacings.general,
y: spacings.general,
width: contentWidth + spacings.general * 2,
height: contentHeight
)
scrollableContentSize = .init(
width: codeContentRect.maxX + spacings.general,
height: codeContentRect.maxY + spacings.general
)
}
func determineViewType() -> BlockView.Type {
CodeBlockView.self
}
}
}

View File

@@ -0,0 +1,142 @@
//
// GroupBlockView.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import MarkdownParser
import UIKit
class GroupBlockView: BlockView {
var manifest: Manifest { _manifest as! Manifest }
let childrenContainer = UIView()
var childrenViews: [BlockView] = []
override func viewDidLoad() {
super.viewDidLoad()
addSubview(childrenContainer)
}
override func viewDidLayout() {
super.viewDidLayout()
childrenContainer.frame = bounds
for (index, view) in childrenViews.enumerated() {
view.frame = manifest.children[index].rect
}
}
override func viewDidUpdate() {
childrenContainer.diffableUpdate(
reusingViews: &childrenViews,
manifests: manifest.children.map(\.manifest)
)
}
override func accept(_ manifest: AnyBlockManifest) -> Bool {
manifest is Manifest
}
}
extension GroupBlockView {
class Manifest: BlockManifest {
var size: CGSize = .zero
var theme: Theme = .default
var dirty: Bool = true
var intrinsicWidth: CGFloat {
children.map(\.manifest.intrinsicWidth).max() ?? 0
}
var overrideGroupSpacing: CGFloat? = nil {
didSet { dirty = true }
}
var spacing: CGFloat {
if let overrideGroupSpacing { return overrideGroupSpacing }
return theme.spacings.general
}
required init() {}
func load(block _: BlockNode) {
assertionFailure("should not be called")
}
var children: [Child] = [] {
didSet { dirty = true }
}
func setLayoutWidth(_ width: CGFloat) {
guard size.width != width else { return }
assert(width >= 0)
dirty = true
size.width = width
children.forEach { $0.manifest.setLayoutWidth(width) }
}
func setChildren(manifests: [AnyBlockManifest]) {
dirty = true
children = manifests.map { Child(manifest: $0) }
}
func setChildren(nodes: [BlockNode]) {
setChildren(manifests: nodes.map { $0.manifest(theme: theme) })
}
func setLayoutTheme(_ theme: Theme) {
guard self.theme != theme else { return }
dirty = true
self.theme = theme
children.forEach { $0.manifest.setLayoutTheme(theme) }
}
func layoutIfNeeded() {
guard dirty, size.width > 0 else { return }
defer { dirty = false }
var anchor: CGFloat = 0
for child in children {
child.manifest.setLayoutWidth(size.width)
child.manifest.layoutIfNeeded()
child.rect = CGRect(
x: 0,
y: anchor + spacing,
width: child.manifest.size.width,
height: child.manifest.size.height
)
anchor = child.rect.maxY
}
size.height = anchor
}
func determineViewType() -> BlockView.Type {
GroupBlockView.self
}
}
}
extension GroupBlockView.Manifest {
class Child {
let manifest: AnyBlockManifest
var rect: CGRect
init(manifest: AnyBlockManifest, rect: CGRect = .zero) {
self.manifest = manifest
self.rect = rect
}
}
func build(reusingChildren: [Child], forManifests manifests: [AnyBlockManifest]) -> [Child] {
var ans = [Child]()
for idx in manifests.indices {
let manifest = manifests[idx]
if let child = reusingChildren[safe: idx], type(of: child.manifest) == type(of: manifest) {
ans.append(child)
} else {
ans.append(Child(manifest: manifest))
}
}
return ans
}
}

View File

@@ -0,0 +1,90 @@
////
//// HTMLBlockView.swift
//// MarkdownView
////
//// Created by on 2025/1/3.
////
//
// import Foundation
// import MarkdownParser
// import UIKit
//
// class HTMLBlockView: BlockView {
// let text = TextView()
// var manifest: Manifest { _manifest as! Manifest }
//
// override func viewDidLoad() {
// super.viewDidLoad()
// addSubview(text)
// text.isEditable = false
// text.isSelectable = true
// text.isScrollEnabled = false
// }
//
// override func viewDidLayout() {
// super.viewDidLayout()
// text.frame = manifest.contentRect
// }
//
// override func viewDidUpdate() {
// super.viewDidUpdate()
// text.attributedText = manifest.content
// }
//
// override func accept(_ manifest: AnyBlockManifest) -> Bool {
// manifest is Manifest
// }
// }
//
// extension HTMLBlockView {
// class Manifest: BlockManifest {
// var size: CGSize = .zero
// var theme: Theme = .default
// var dirty: Bool = true
//
// var intrinsicWidth: CGFloat {
// size.width
// }
//
// var content: NSMutableAttributedString = .init()
// var contentRect: CGRect = .zero
//
// required init() {}
//
// var block: BlockNode? = nil
// func load(block: BlockNode) {
// guard self.block != block else { return }
// dirty = true
// self.block = block
// guard case let .htmlBlock(contents) = block else {
// assertionFailure()
// return
// }
// let htmlData = NSString(string: contents).data(using: String.Encoding.unicode.rawValue)
// let options = [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html]
// let ans = try? NSMutableAttributedString(
// data: htmlData ?? Data(),
// options: options,
// documentAttributes: nil
// )
// let content = ans ?? .init()
// content.addAttributes(
// [.originalFont: theme.fonts.body],
// range: .init(location: 0, length: content.length)
// )
// self.content = content
// }
//
// func layoutIfNeeded() {
// guard dirty, size.width > 0 else { return }
// defer { dirty = false }
// let textHeight = content.measureHeight(usingWidth: size.width)
// contentRect = .init(x: 0, y: 0, width: size.width, height: textHeight)
// size.height = textHeight
// }
//
// func determineViewType() -> BlockView.Type {
// HTMLBlockView.self
// }
// }
// }

View File

@@ -0,0 +1,87 @@
//
// HeadingView.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import MarkdownParser
import UIKit
class HeadingView: BlockView {
let text = TextLabel()
var manifest: Manifest { _manifest as! Manifest }
override func viewDidLoad() {
super.viewDidLoad()
addSubview(text)
}
override func viewDidLayout() {
super.viewDidLayout()
text.frame = manifest.contentRect
}
override func viewDidUpdate() {
super.viewDidUpdate()
text.attributedText = manifest.content
}
override func accept(_ manifest: AnyBlockManifest) -> Bool {
manifest is Manifest
}
}
extension HeadingView {
class Manifest: BlockManifest {
var size: CGSize = .zero
var theme: Theme = .default
var dirty: Bool = true
var intrinsicWidth: CGFloat {
size.width
}
var content: NSMutableAttributedString = .init()
var contentRect: CGRect = .zero
required init() {}
var block: BlockNode? = nil
func load(block: BlockNode) {
guard self.block != block else { return }
dirty = true
self.block = block
guard case let .heading(level, inlines) = block else {
assertionFailure()
return
}
let attrText = inlines.render(theme: theme)
var supposeFont: UIFont = theme.fonts.title
if level <= 1 {
supposeFont = theme.fonts.largeTitle
}
attrText.addAttributes(
[
.font: supposeFont,
.originalFont: supposeFont,
.foregroundColor: theme.colors.body,
],
range: .init(location: 0, length: attrText.length)
)
content = attrText
}
func layoutIfNeeded() {
guard dirty, size.width > 0 else { return }
defer { dirty = false }
let textHeight = content.measureHeight(usingWidth: size.width)
contentRect = .init(x: 0, y: 0, width: size.width, height: textHeight)
size.height = textHeight
}
func determineViewType() -> BlockView.Type {
HeadingView.self
}
}
}

View File

@@ -0,0 +1,138 @@
//
// BulletedItemView 2.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import MarkdownParser
import UIKit
class NumberedItemView: BlockView {
let numberView = TextLabel()
var manifest: Manifest { _manifest as! Manifest }
let childrenContainer = UIView()
var childrenViews: [BlockView] = []
override func viewDidLoad() {
super.viewDidLoad()
numberView.textAlignment = .left
numberView.font = .preferredFont(forTextStyle: .body)
numberView.textColor = .label
addSubview(numberView)
addSubview(childrenContainer)
}
override func viewDidLayout() {
super.viewDidLayout()
childrenContainer.frame = bounds
numberView.frame = manifest.iconRect
childrenViews.first?.frame = manifest.childrenRect
}
override func viewDidUpdate() {
super.viewDidUpdate()
numberView.attributedText = manifest.number
childrenContainer.diffableUpdate(
reusingViews: &childrenViews,
manifests: [manifest.childrenGroup]
)
}
override func accept(_ manifest: AnyBlockManifest) -> Bool {
manifest is Manifest
}
}
extension NumberedItemView {
class Manifest: BlockManifest {
var size: CGSize = .zero
var theme: Theme = .default
var dirty: Bool = true
var intrinsicWidth: CGFloat {
childrenGroup.intrinsicWidth + iconLayoutGuideRect.width
}
var number: NSAttributedString = .init()
var iconRect: CGRect = .zero
let childrenGroup: GroupBlockView.Manifest = .init()
var childrenRect: CGRect = .zero
var iconWidth: CGFloat {
"99.".size(withAttributes: [.font: theme.fonts.body]).width
}
var iconLayoutGuideRect: CGRect {
.init(x: 0, y: 0, width: iconWidth + spacings.list, height: fonts.body.baseLineHeight)
}
var childrenGroupWidth: CGFloat {
max(0, size.width - iconLayoutGuideRect.width)
}
required init() {
childrenGroup.overrideGroupSpacing = 0
}
func load(block _: BlockNode) {
assertionFailure("should not be called")
}
func setNumber(_ number: Int) {
dirty = true
self.number = NSMutableAttributedString(string: "\(number).", attributes: [
.font: theme.fonts.body,
.originalFont: theme.fonts.body,
.foregroundColor: UIColor.label,
])
}
func setItems(_ items: RawListItem) {
dirty = true
childrenGroup.setChildren(nodes: items.children)
}
func setLayoutWidth(_ width: CGFloat) {
guard size.width != width else { return }
assert(width >= 0)
dirty = true
size.width = width
childrenGroup.setLayoutWidth(childrenGroupWidth)
}
func setLayoutTheme(_ theme: Theme) {
guard self.theme != theme else { return }
dirty = true
self.theme = theme
childrenGroup.setLayoutTheme(theme)
}
func layoutIfNeeded() {
guard dirty, size.width > 0 else { return }
defer { dirty = false }
let iconLayoutGuideRect = iconLayoutGuideRect
childrenGroup.setLayoutWidth(childrenGroupWidth)
childrenGroup.layoutIfNeeded()
size.height = max(iconLayoutGuideRect.height, childrenGroup.size.height)
iconRect = CGRect(
x: 0,
y: (iconLayoutGuideRect.height - iconLayoutGuideRect.height) / 2,
width: iconWidth,
height: iconLayoutGuideRect.height
)
childrenRect = CGRect(
x: iconLayoutGuideRect.maxX,
y: 0,
width: childrenGroup.size.width,
height: childrenGroup.size.height
)
}
func determineViewType() -> BlockView.Type {
NumberedItemView.self
}
}
}

View File

@@ -0,0 +1,110 @@
//
// NumberedListView.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import MarkdownParser
import UIKit
class NumberedListView: BlockView {
var manifest: Manifest { _manifest as! Manifest }
let childrenContainer = UIView()
var childrenViews: [BlockView] = []
override func viewDidLoad() {
super.viewDidLoad()
addSubview(childrenContainer)
}
override func viewDidLayout() {
super.viewDidLayout()
childrenContainer.frame = bounds
childrenViews.first?.frame = manifest.childrenGroupRect
}
override func viewDidUpdate() {
super.viewDidUpdate()
childrenContainer.diffableUpdate(
reusingViews: &childrenViews,
manifests: [manifest.childrenGroup]
)
}
override func accept(_ manifest: AnyBlockManifest) -> Bool {
manifest is Manifest
}
}
extension NumberedListView {
class Manifest: BlockManifest {
var size: CGSize = .zero
var theme: Theme = .default
var dirty: Bool = true
var intrinsicWidth: CGFloat {
childrenGroup.intrinsicWidth
}
required init() {
childrenGroup.overrideGroupSpacing = 0
}
let childrenGroup: GroupBlockView.Manifest = .init()
var childrenGroupRect: CGRect = .zero
var block: BlockNode? = nil
func load(block: BlockNode) {
guard self.block != block else { return }
dirty = true
self.block = block
guard case let .numberedList(_, start, items) = block else {
assertionFailure()
return
}
var number = start
childrenGroup.setChildren(manifests: items.map { listItem in
defer { number += 1 }
let manifest = NumberedItemView.Manifest()
manifest.setNumber(number)
manifest.setItems(listItem)
return manifest
})
childrenGroup.setLayoutWidth(size.width)
}
func setLayoutWidth(_ width: CGFloat) {
guard size.width != width else { return }
assert(width >= 0)
dirty = true
size.width = width
childrenGroup.setLayoutWidth(width)
}
func setLayoutTheme(_ theme: Theme) {
guard self.theme != theme else { return }
dirty = true
self.theme = theme
childrenGroup.setLayoutTheme(theme)
}
func layoutIfNeeded() {
guard dirty, size.width > 0 else { return }
defer { dirty = false }
childrenGroup.layoutIfNeeded()
size.height = childrenGroup.size.height
childrenGroupRect = CGRect(
x: 0,
y: 0,
width: size.width,
height: size.height
)
}
func determineViewType() -> BlockView.Type {
NumberedListView.self
}
}
}

View File

@@ -0,0 +1,74 @@
//
// ParagraphView.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import MarkdownParser
import UIKit
class ParagraphView: BlockView {
let text = TextLabel()
var manifest: Manifest { _manifest as! Manifest }
override func viewDidLoad() {
super.viewDidLoad()
addSubview(text)
}
override func viewDidLayout() {
super.viewDidLayout()
text.frame = manifest.contentRect
}
override func viewDidUpdate() {
super.viewDidUpdate()
text.attributedText = manifest.content
}
override func accept(_ manifest: AnyBlockManifest) -> Bool {
manifest is Manifest
}
}
extension ParagraphView {
class Manifest: BlockManifest {
var size: CGSize = .zero
var theme: Theme = .default
var dirty: Bool = true
var intrinsicWidth: CGFloat {
content.measureWidth()
}
var content: NSMutableAttributedString = .init()
var contentRect: CGRect = .zero
required init() {}
var block: BlockNode? = nil
func load(block: BlockNode) {
guard self.block != block else { return }
dirty = true
self.block = block
guard case let .paragraph(contents) = block else {
assertionFailure()
return
}
content = contents.render(theme: theme)
}
func layoutIfNeeded() {
guard dirty, size.width > 0 else { return }
defer { dirty = false }
let textHeight = content.measureHeight(usingWidth: size.width)
contentRect = .init(x: 0, y: 0, width: size.width, height: textHeight)
size.height = textHeight
}
func determineViewType() -> BlockView.Type {
ParagraphView.self
}
}
}

View File

@@ -0,0 +1,55 @@
//
// BlockManifest.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import MarkdownParser
import UIKit
public typealias AnyBlockManifest = any BlockManifest
public protocol BlockManifest: AnyObject {
var size: CGSize { get set }
var theme: Theme { get set }
var dirty: Bool { get set }
var intrinsicWidth: CGFloat { get }
init()
func setLayoutWidth(_ width: CGFloat)
func setLayoutTheme(_ theme: Theme)
func load(block: BlockNode)
func layoutIfNeeded()
@inline(__always) func determineViewType() -> BlockView.Type
}
public extension BlockManifest {
func setLayoutWidth(_ width: CGFloat) {
guard size.width != width else { return }
assert(width >= 0)
size.width = width
size.height = .zero
dirty = true
}
func setLayoutTheme(_ theme: Theme) {
guard self.theme != theme else { return }
self.theme = theme
dirty = true
}
func layoutIfNeeded() {
dirty = false
}
}
extension BlockManifest {
var fonts: Theme.Fonts { theme.fonts }
var colors: Theme.Colors { theme.colors }
var spacings: Theme.Spacings { theme.spacings }
var sizes: Theme.Sizes { theme.sizes }
}

View File

@@ -0,0 +1,40 @@
//
// BlockView+DiffableUpdate.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import Foundation
import UIKit
extension UIView {
func diffableUpdate(reusingViews blockViews: inout [BlockView], manifests: [AnyBlockManifest]) {
for idx in 0 ..< max(blockViews.count, manifests.count) {
guard let manifest = manifests[safe: idx] else {
if let view = blockViews[safe: idx] {
view.removeFromSuperview()
blockViews.remove(at: idx)
}
continue
}
lazy var view = {
let view = manifest.determineViewType().init(manifest: manifest)
addSubview(view)
return view
}()
if let currentView = blockViews[safe: idx] {
if currentView.accept(manifest) {
currentView.set(manifest)
continue
} else {
currentView.removeFromSuperview()
blockViews[idx] = view
}
} else {
addSubview(view)
blockViews.insert(view, at: idx)
}
}
}
}

View File

@@ -0,0 +1,84 @@
//
// BlockView.swift
// FlowMarkdownView
//
// Created by on 2025/1/3.
//
import Foundation
import MarkdownParser
import UIKit
public class BlockView: UIView {
private(set) var _manifest: AnyBlockManifest
required init(manifest: AnyBlockManifest) {
_manifest = manifest
super.init(frame: .zero)
backgroundColor = .clear
// NSLog("[*] \(type(of: self)) was initialized at \(Date()) \(debugDescription)")
viewDidLoad()
viewDidUpdate()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError()
}
override public func action(for _: CALayer, forKey _: String) -> (any CAAction)? {
nil
}
override public func layoutSubviews() {
super.layoutSubviews()
assert(_manifest.size.width >= 0, "\(type(of: self))'s manifest has invalid size")
assert(_manifest.size.height >= 0, "\(type(of: self))'s manifest has invalid size")
viewDidLayout()
}
func viewDidLoad() {
assert(Thread.isMainThread)
}
func viewDidUpdate() {
assert(Thread.isMainThread)
}
func viewDidLayout() {
assert(Thread.isMainThread)
}
func accept(_ manifest: AnyBlockManifest) -> Bool {
manifest is Manifest
}
func set(_ manifest: AnyBlockManifest) {
_manifest = manifest
viewDidUpdate()
setNeedsLayout()
}
}
public extension BlockView {
class Manifest: BlockManifest {
public var size: CGSize = .zero
public var theme: Theme = .default
public var dirty: Bool = true
public var intrinsicWidth: CGFloat { 0 }
public required init() {}
public func load(block _: BlockNode) {}
public func layoutIfNeeded() {
dirty = false
}
public func determineViewType() -> BlockView.Type {
BlockView.self
}
}
}

View File

@@ -0,0 +1,205 @@
//
// TableView.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import MarkdownParser
import UIKit
class TableView: BlockView {
var manifest: Manifest { _manifest as! Manifest }
var childrenViews: [TextLabel] = []
let scrollView = UIScrollView()
let gridView = GridView()
override func viewDidLoad() {
super.viewDidLoad()
scrollView.isScrollEnabled = true
scrollView.contentInset = .zero
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.alwaysBounceVertical = false
scrollView.alwaysBounceHorizontal = false
scrollView.clipsToBounds = true
scrollView.backgroundColor = .clear
addSubview(scrollView)
scrollView.addSubview(gridView)
}
override func viewDidLayout() {
super.viewDidLayout()
scrollView.frame = bounds
let flatCells = manifest.cells.flatMap(\.self)
for (index, view) in childrenViews.enumerated() {
view.frame = flatCells[index].contentRect
}
gridView.frame = .init(
x: 0,
y: 0,
width: scrollView.contentSize.width,
height: scrollView.contentSize.height
)
scrollView.contentSize = manifest.contentSize
}
override func viewDidUpdate() {
super.viewDidUpdate()
let currentCells = childrenViews
let targetCells = manifest.cells.flatMap(\.self)
for idx in 0 ..< max(currentCells.count, targetCells.count) {
if let target = targetCells[safe: idx] {
if let current = currentCells[safe: idx] {
current.attributedText = target.content
current.frame = target.rect
} else {
let view = TextLabel(frame: target.rect)
view.attributedText = target.content
scrollView.addSubview(view)
childrenViews.append(view)
}
} else {
currentCells[safe: idx]?.removeFromSuperview()
}
}
gridView.lines = manifest.drawLine.map { start, end in
.init(start: start, end: end)
}
scrollView.contentSize = manifest.contentSize
}
override func accept(_ manifest: AnyBlockManifest) -> Bool {
manifest is Manifest
}
}
extension TableView {
class Manifest: BlockManifest {
var size: CGSize = .zero
var theme: Theme = .default
var dirty: Bool = true
var intrinsicWidth: CGFloat {
contentSize.width
}
var cells: [[Cell]] = []
var drawLine: [(CGPoint, CGPoint)] = []
var contentSize: CGSize {
let rect = cells.last?.last?.rect ?? .zero
return .init(width: rect.maxX, height: rect.maxY)
}
required init() {}
var block: BlockNode? = nil
func load(block: BlockNode) {
guard self.block != block else { return }
dirty = true
self.block = block
guard case let .table(_, rawCells) = block else {
assertionFailure()
return
}
cells = rawCells.map { row in
row.cells.map { cell in
let content = cell.content.render(theme: theme)
return Cell(content: content, rect: .zero)
}
}
}
func layoutIfNeeded() {
guard dirty, size.width > 0 else { return }
defer { dirty = false }
let cols = cells.first?.count ?? 0
let rows = cells.count
drawLine.removeAll()
guard rows > 0, cols > 0 else {
size.height = 0
return
}
// first pass calculate intrinsic width of each column to get the width
var colWidths: [Int: CGFloat] = [:]
var rowHeights: [Int: CGFloat] = [:]
for col in 0 ..< cols {
for row in 0 ..< rows {
let cell = cells[row][col]
colWidths[col] = max(colWidths[col] ?? 0, cell.intrinsicSize.width + 32)
rowHeights[row] = max(rowHeights[row] ?? 0, cell.intrinsicSize.height + 16)
}
}
// now calculate the rects and points
var anchorY: CGFloat = 0
var linePoints: [CGFloat] = [0]
var colPoints: [CGFloat] = [0]
for rowIdx in 0 ..< rows {
var anchorX: CGFloat = 0
let rowHeight = rowHeights[rowIdx] ?? 0
linePoints.append(anchorY)
for colIdx in 0 ..< cols { // column
let colWidth = colWidths[colIdx] ?? 0
if rowIdx == 0 { colPoints.append(anchorX) }
let rect = CGRect(x: anchorX, y: anchorY, width: colWidth, height: rowHeight)
cells[rowIdx][colIdx].rect = rect
anchorX = rect.maxX
}
colPoints.append(anchorX + spacings.general)
anchorY += rowHeight
}
linePoints.append(anchorY)
for x in colPoints {
drawLine.append((CGPoint(x: x, y: 0), CGPoint(x: x, y: linePoints.last ?? 0)))
}
for y in linePoints {
drawLine.append((CGPoint(x: 0, y: y), CGPoint(x: colPoints.last ?? 0, y: y)))
}
size.height = contentSize.height
}
func determineViewType() -> BlockView.Type {
TableView.self
}
}
}
extension TableView.Manifest {
class Cell {
let content: NSMutableAttributedString
var rect: CGRect { didSet { updateContentRect() } }
let intrinsicSize: CGSize
var contentRect: CGRect
init(
content: NSMutableAttributedString,
rect: CGRect = .zero
) {
self.content = content
self.rect = rect
intrinsicSize = .init(
width: content.measureWidth(),
height: content.measureHeight(usingWidth: .greatestFiniteMagnitude)
)
contentRect = .zero
}
func updateContentRect() {
contentRect = .init(
x: rect.minX + (rect.width - intrinsicSize.width) / 2,
y: rect.minY + (rect.height - intrinsicSize.height) / 2,
width: intrinsicSize.width,
height: intrinsicSize.height
)
}
}
}

View File

@@ -0,0 +1,119 @@
//
// TaskItemView.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import MarkdownParser
import UIKit
class TaskItemView: BlockView {
let bulletedIcon = CircleView()
var manifest: Manifest { _manifest as! Manifest }
let childrenContainer = UIView()
var childrenViews: [BlockView] = []
override func viewDidLoad() {
super.viewDidLoad()
addSubview(bulletedIcon)
addSubview(childrenContainer)
}
override func viewDidLayout() {
super.viewDidLayout()
childrenContainer.frame = bounds
bulletedIcon.frame = manifest.iconRect
childrenViews.first?.frame = manifest.childrenRect
}
override func viewDidUpdate() {
super.viewDidUpdate()
childrenContainer.diffableUpdate(
reusingViews: &childrenViews,
manifests: [manifest.childrenGroup]
)
}
override func accept(_ manifest: AnyBlockManifest) -> Bool {
manifest is Manifest
}
}
extension TaskItemView {
class Manifest: BlockManifest {
var size: CGSize = .zero
var theme: Theme = .default
var dirty: Bool = true
var intrinsicWidth: CGFloat {
childrenGroup.intrinsicWidth + iconLayoutGuideRect.width
}
var iconLayoutGuideRect: CGRect {
.init(x: 0, y: 0, width: sizes.bullet + spacings.list, height: fonts.body.baseLineHeight)
}
var iconRect: CGRect = .zero
let childrenGroup: GroupBlockView.Manifest = .init()
var childrenRect: CGRect = .zero
var childrenGroupWidth: CGFloat {
max(0, size.width - iconLayoutGuideRect.width)
}
required init() {
childrenGroup.overrideGroupSpacing = 0
}
func load(block _: BlockNode) {
assertionFailure("should not be called")
}
func setItems(_ items: RawTaskListItem) {
dirty = true
childrenGroup.setChildren(nodes: items.children)
}
func setLayoutWidth(_ width: CGFloat) {
guard size.width != width else { return }
assert(width >= 0)
dirty = true
size.width = width
childrenGroup.setLayoutWidth(childrenGroupWidth)
}
func setLayoutTheme(_ theme: Theme) {
guard self.theme != theme else { return }
dirty = true
self.theme = theme
childrenGroup.setLayoutTheme(theme)
}
func layoutIfNeeded() {
guard dirty, size.width > 0 else { return }
defer { dirty = false }
let iconLayoutGuideRect = iconLayoutGuideRect
childrenGroup.layoutIfNeeded()
size.height = max(iconLayoutGuideRect.height, childrenGroup.size.height)
iconRect = CGRect(
x: 0,
y: (iconLayoutGuideRect.height - sizes.bullet) / 2,
width: sizes.bullet,
height: sizes.bullet
)
childrenRect = CGRect(
x: iconLayoutGuideRect.maxX,
y: 0,
width: childrenGroup.size.width,
height: childrenGroup.size.height
)
}
func determineViewType() -> BlockView.Type {
TaskItemView.self
}
}
}

View File

@@ -0,0 +1,107 @@
//
// TaskListView.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import MarkdownParser
import UIKit
class TaskListView: BlockView {
var manifest: Manifest { _manifest as! Manifest }
let childrenContainer = UIView()
var childrenViews: [BlockView] = []
override func viewDidLoad() {
super.viewDidLoad()
addSubview(childrenContainer)
}
override func viewDidLayout() {
super.viewDidLayout()
childrenContainer.frame = bounds
childrenViews.first?.frame = manifest.childrenGroupRect
}
override func viewDidUpdate() {
super.viewDidUpdate()
childrenContainer.diffableUpdate(
reusingViews: &childrenViews,
manifests: [manifest.childrenGroup]
)
}
override func accept(_ manifest: AnyBlockManifest) -> Bool {
manifest is Manifest
}
}
extension TaskListView {
class Manifest: BlockManifest {
var size: CGSize = .zero
var theme: Theme = .default
var dirty: Bool = true
var intrinsicWidth: CGFloat {
childrenGroup.intrinsicWidth
}
required init() {
childrenGroup.overrideGroupSpacing = 0
}
let childrenGroup: GroupBlockView.Manifest = .init()
var childrenGroupRect: CGRect = .zero
var block: BlockNode? = nil
func load(block: BlockNode) {
guard self.block != block else { return }
dirty = true
self.block = block
guard case let .taskList(_, items) = block else {
assertionFailure()
return
}
childrenGroup.setChildren(manifests: items.map { listItem in
let manifest = TaskItemView.Manifest()
manifest.setItems(listItem)
return manifest
})
childrenGroup.setLayoutWidth(size.width)
}
func setLayoutWidth(_ width: CGFloat) {
guard size.width != width else { return }
assert(width >= 0)
dirty = true
size.width = width
childrenGroup.setLayoutWidth(width)
}
func setLayoutTheme(_ theme: Theme) {
guard self.theme != theme else { return }
dirty = true
self.theme = theme
childrenGroup.setLayoutTheme(theme)
}
func layoutIfNeeded() {
guard dirty, size.width > 0 else { return }
defer { dirty = false }
childrenGroup.layoutIfNeeded()
size.height = childrenGroup.size.height
childrenGroupRect = CGRect(
x: 0,
y: 0,
width: size.width,
height: size.height
)
}
func determineViewType() -> BlockView.Type {
TaskListView.self
}
}
}

View File

@@ -0,0 +1,63 @@
//
// ThematicBreakView.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import MarkdownParser
import UIKit
class ThematicBreakView: BlockView {
let separateView = UIView()
override func viewDidLoad() {
super.viewDidLoad()
addSubview(separateView)
separateView.backgroundColor = .label.withAlphaComponent(0.1)
}
override func viewDidLayout() {
super.viewDidLayout()
separateView.frame = bounds
}
override func accept(_ manifest: AnyBlockManifest) -> Bool {
manifest is Manifest
}
}
extension ThematicBreakView {
class Manifest: BlockManifest {
var size: CGSize = .zero
var theme: Theme = .default
var dirty: Bool = true
var intrinsicWidth: CGFloat {
32
}
required init() {}
var block: BlockNode? = nil
func load(block: BlockNode) {
guard self.block != block else { return }
dirty = true
self.block = block
guard case .thematicBreak = block else {
assertionFailure()
return
}
}
func layoutIfNeeded() {
guard dirty, size.width > 0 else { return }
defer { dirty = false }
size.height = 1
}
func determineViewType() -> BlockView.Type {
ThematicBreakView.self
}
}
}

View File

@@ -0,0 +1,44 @@
// The Swift Programming Language
// https://docs.swift.org/swift-book
import MarkdownParser
import UIKit
public class MarkdownView: UIView {
public var height: CGFloat = 0
var blockViews: [BlockView] = []
public var theme: Theme
public init(theme: Theme = .default) {
self.theme = theme
super.init(frame: .zero)
clipsToBounds = true
}
@available(*, unavailable)
public required init?(coder _: NSCoder) {
fatalError()
}
public func prepareForReuse() {
blockViews.forEach { $0.removeFromSuperview() }
blockViews.removeAll()
}
public func updateContentViews(_ manifest: [AnyBlockManifest]) {
assert(Thread.isMainThread)
diffableUpdate(reusingViews: &blockViews, manifests: manifest)
var anchorY: CGFloat = 0
for view in blockViews {
view.frame = CGRect(
x: 0,
y: anchorY,
width: view._manifest.size.width,
height: view._manifest.size.height
)
anchorY = view.frame.maxY + theme.spacings.final
}
assert(subviews.count == blockViews.count)
}
}

View File

@@ -0,0 +1,25 @@
//
// CircleView.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import UIKit
class CircleView: UIView {
init() {
super.init(frame: .zero)
backgroundColor = .label
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError()
}
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = (frame.height + frame.width) / 4
}
}

View File

@@ -0,0 +1,51 @@
//
// Ext+Array.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import Foundation
extension Array {
subscript(safe index: Int) -> Element? {
guard index >= 0, index < count else { return nil }
return self[index]
}
}
private let maxConcurrentDefaultValue: Int = max(1, ProcessInfo.processInfo.processorCount)
extension Array {
@inline(__always)
func splitInSubArrays(into size: Int) -> [[Element]] {
(0 ..< size).map {
stride(from: $0, to: count, by: size).map { self[$0] }
}
}
func forParallelEach(
maxConcurrent: Int = maxConcurrentDefaultValue,
block: @escaping (Element) -> Void
) {
assert(maxConcurrent > 0)
if count < maxConcurrent || maxConcurrent <= 1 {
for element in self {
block(element)
}
return
}
let cuts = splitInSubArrays(into: maxConcurrent)
let group = DispatchGroup()
for cut in cuts {
group.enter()
DispatchQueue.global().async {
cut.forEach(block)
group.leave()
}
}
group.wait()
}
}

View File

@@ -0,0 +1,43 @@
//
// Ext+BlockNode.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import Foundation
import MarkdownParser
public extension BlockNode {
var manifestType: BlockManifest.Type {
switch self {
case .blockquote:
BlockquoteView.Manifest.self
case .bulletedList:
BulletedListView.Manifest.self
case .numberedList:
NumberedListView.Manifest.self
case .taskList:
TaskListView.Manifest.self
case .codeBlock:
CodeBlockView.Manifest.self
// case .htmlBlock:
// HTMLBlockView.Manifest.self
case .paragraph:
ParagraphView.Manifest.self
case .heading:
HeadingView.Manifest.self
case .table:
TableView.Manifest.self
case .thematicBreak:
ThematicBreakView.Manifest.self
}
}
func manifest(theme: Theme) -> AnyBlockManifest {
let object = manifestType.init()
object.setLayoutTheme(theme)
object.load(block: self)
return object
}
}

View File

@@ -0,0 +1,17 @@
//
// Ext+DispatchQueue.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import Foundation
public extension DispatchQueue {
static func isCurrent(_ queue: DispatchQueue) -> Bool {
let key = DispatchSpecificKey<Void>()
queue.setSpecific(key: key, value: ())
defer { queue.setSpecific(key: key, value: nil) }
return DispatchQueue.getSpecific(key: key) != nil
}
}

View File

@@ -0,0 +1,121 @@
//
// Ext+InlineNode.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import Foundation
import MarkdownParser
import UIKit
extension [InlineNode] {
func render(theme: Theme) -> NSMutableAttributedString {
let result = NSMutableAttributedString()
for node in self {
result.append(node.render(theme: theme))
}
return result
}
}
extension InlineNode {
func render(theme: Theme) -> NSAttributedString {
switch self {
case let .text(string):
return NSAttributedString(
string: string,
attributes: [
.font: theme.fonts.body,
.foregroundColor: theme.colors.body,
.originalFont: theme.fonts.body,
]
)
case .softBreak:
return NSAttributedString(string: " ", attributes: [
.font: theme.fonts.body,
.foregroundColor: theme.colors.body,
.originalFont: theme.fonts.body,
])
case .lineBreak:
return NSAttributedString(string: "\n", attributes: [
.font: theme.fonts.body,
.foregroundColor: theme.colors.body,
.originalFont: theme.fonts.body,
])
case let .code(string):
return NSAttributedString(
string: "\(string)",
attributes: [
.font: theme.fonts.codeInline,
.originalFont: theme.fonts.codeInline,
.foregroundColor: theme.colors.code,
.backgroundColor: theme.colors.codeBackground.withAlphaComponent(0.05),
]
)
case let .html(content):
return NSAttributedString(
string: "\(content)",
attributes: [
.font: theme.fonts.codeInline,
.originalFont: theme.fonts.codeInline,
.foregroundColor: theme.colors.code,
.backgroundColor: theme.colors.codeBackground.withAlphaComponent(0.05),
]
)
// let htmlData = NSString(string: content).data(using: String.Encoding.unicode.rawValue)
// let options = [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html]
// let ans = try? NSMutableAttributedString(
// data: htmlData ?? Data(),
// options: options,
// documentAttributes: nil
// )
// return ans ?? .init()
case let .emphasis(children):
let ans = NSMutableAttributedString()
children.map { $0.render(theme: theme) }.forEach { ans.append($0) }
ans.addAttributes(
[
.underlineStyle: NSUnderlineStyle.thick.rawValue,
.underlineColor: theme.colors.emphasis,
],
range: NSRange(location: 0, length: ans.length)
)
return ans
case let .strong(children):
let ans = NSMutableAttributedString()
children.map { $0.render(theme: theme) }.forEach { ans.append($0) }
ans.addAttributes(
[.font: theme.fonts.bold],
range: NSRange(location: 0, length: ans.length)
)
return ans
case let .strikethrough(children):
let ans = NSMutableAttributedString()
children.map { $0.render(theme: theme) }.forEach { ans.append($0) }
ans.addAttributes(
[.strikethroughStyle: NSUnderlineStyle.thick.rawValue],
range: NSRange(location: 0, length: ans.length)
)
return ans
case let .link(destination, children):
let ans = NSMutableAttributedString()
children.map { $0.render(theme: theme) }.forEach { ans.append($0) }
ans.addAttributes(
[.link: destination],
range: NSRange(location: 0, length: ans.length)
)
return ans
case let .image(source, _): // children => alternative text can be ignored?
return NSAttributedString(
string: source,
attributes: [
.link: source,
.font: theme.fonts.body,
.originalFont: theme.fonts.body,
.foregroundColor: theme.colors.body,
]
)
}
}
}

View File

@@ -0,0 +1,83 @@
//
// Ext+NSAttributedString.swift
// FlowDown
//
// Created by on 2025/1/2.
//
import UIKit
class TextMeasurementHelper {
static let shared = TextMeasurementHelper()
private var textStorage: NSTextStorage
private var textContainer: NSTextContainer
private var layoutManager: NSLayoutManager
private let lock = NSLock()
init() {
textStorage = NSTextStorage()
textContainer = NSTextContainer(size: CGSize(width: CGFloat.infinity, height: CGFloat.infinity))
layoutManager = NSLayoutManager()
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
textContainer.lineFragmentPadding = 0
}
func measureSize(
of attributedString: NSAttributedString,
usingWidth width: CGFloat,
lineLimit: Int = 0,
lineBreakMode: NSLineBreakMode = .byTruncatingTail
) -> CGSize {
lock.lock()
defer { lock.unlock() }
textContainer.size = CGSize(width: width, height: .infinity)
textContainer.maximumNumberOfLines = lineLimit
textContainer.lineBreakMode = lineBreakMode
textStorage.beginEditing()
textStorage.setAttributedString(attributedString)
textStorage.endEditing()
let size = layoutManager.usedRect(for: textContainer).size
return .init(width: ceil(size.width), height: ceil(size.height))
}
}
extension NSAttributedString: @unchecked @retroactive Sendable {}
public extension NSAttributedString {
func measureWidth() -> CGFloat {
if string.trimmingCharacters(in: .whitespacesAndNewlines).count <= 0 {
return 0
}
return TextMeasurementHelper.shared.measureSize(
of: self,
usingWidth: .infinity
).width
}
func measureHeight(
usingWidth width: CGFloat,
lineLimit: Int = 0,
lineBreakMode: NSLineBreakMode = .byTruncatingTail
) -> CGFloat {
if string.trimmingCharacters(in: .whitespacesAndNewlines).count <= 0 {
return 0
}
return TextMeasurementHelper.shared.measureSize(
of: self,
usingWidth: width,
lineLimit: lineLimit,
lineBreakMode: lineBreakMode
).height
}
}
public extension NSAttributedString.Key {
@inline(__always) static let coreTextRunDelegate = NSAttributedString.Key(rawValue: kCTRunDelegateAttributeName as String)
@inline(__always) static let originalFont = NSAttributedString.Key(rawValue: "NSOriginalFont")
}

View File

@@ -0,0 +1,18 @@
//
// Ext+UIColor.swift
// MarkdownView
//
// Created by on 2025/1/7.
//
import UIKit
extension UIColor {
convenience init(light: UIColor, dark: UIColor) {
if #available(iOS 13.0, tvOS 13.0, *) {
self.init(dynamicProvider: { $0.userInterfaceStyle == .dark ? dark : light })
} else {
self.init(cgColor: light.cgColor)
}
}
}

View File

@@ -0,0 +1,29 @@
//
// Ext+UIFont.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import UIKit
public extension UIFont {
var bold: UIFont {
UIFont(descriptor: fontDescriptor.withSymbolicTraits(.traitBold)!, size: 0)
}
var italic: UIFont {
UIFont(descriptor: fontDescriptor.withSymbolicTraits(.traitItalic)!, size: 0)
}
var monospaced: UIFont {
let settings = [[
UIFontDescriptor.FeatureKey.featureIdentifier: kNumberSpacingType,
UIFontDescriptor.FeatureKey.typeIdentifier: kMonospacedNumbersSelector,
]]
let attributes = [UIFontDescriptor.AttributeName.featureSettings: settings]
let newDescriptor = fontDescriptor.addingAttributes(attributes)
return UIFont(descriptor: newDescriptor, size: 0)
}
}

View File

@@ -0,0 +1,64 @@
//
// GridView.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import UIKit
class GridView: UIView {
var stokeColor: UIColor = .label.withAlphaComponent(0.25) {
didSet {
layer.borderWidth = 1
layer.borderColor = stokeColor.cgColor
setNeedsDisplay()
}
}
var lines: [CGPointPair] = [] {
didSet { setNeedsDisplay() }
}
init() {
super.init(frame: .zero)
backgroundColor = .clear
layer.borderWidth = 1
layer.borderColor = stokeColor.cgColor
layer.contentsGravity = .top
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError()
}
override var frame: CGRect {
set { UIView.performWithoutAnimation { super.frame = newValue } }
get { super.frame }
}
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let context = UIGraphicsGetCurrentContext() else { return }
context.setStrokeColor(stokeColor.cgColor)
context.setLineWidth(1)
for pathPair in lines {
let adjustedStart = CGPoint(x: pathPair.start.x, y: pathPair.start.y)
let adjustedEnd = CGPoint(x: pathPair.end.x, y: pathPair.end.y)
context.move(to: adjustedStart)
context.addLine(to: adjustedEnd)
}
context.strokePath()
}
}
extension GridView {
struct CGPointPair: Equatable {
let start: CGPoint
let end: CGPoint
}
}

View File

@@ -0,0 +1,53 @@
//
// TextLabel.swift
// MarkdownView
//
// Created by on 2025/1/12.
//
import UIKit
class TextLabel: UITextView {
#if DEBUG
private var setupCompleted: Bool = false
#endif
override required init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
commitInit()
}
convenience init(frame: CGRect = .zero) {
if #available(iOS 16.0, macCatalyst 16.0, *) {
self.init(usingTextLayoutManager: false)
} else {
self.init(frame: frame, textContainer: nil)
_ = layoutManager.textContainers
}
commitInit()
}
func commitInit() {
#if DEBUG
assert(!setupCompleted)
setupCompleted = true
#endif
showsVerticalScrollIndicator = false
showsHorizontalScrollIndicator = false
textColor = .label
textContainer.lineFragmentPadding = .zero
textAlignment = .natural
backgroundColor = .clear
textContainerInset = .zero
textContainer.lineBreakMode = .byTruncatingTail
clipsToBounds = false
isSelectable = true
isScrollEnabled = false
isEditable = false
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@@ -0,0 +1,121 @@
//
// Theme.swift
// MarkdownView
//
// Created by on 2025/1/3.
//
import Foundation
import Splash
import UIKit
public extension Theme {
static var `default`: Theme = .init()
}
public struct Theme: Equatable {
public struct Fonts: Equatable {
public var body = UIFont.preferredFont(forTextStyle: .body)
public var codeInline = UIFont.monospacedSystemFont(
ofSize: UIFont.preferredFont(forTextStyle: .body).pointSize,
weight: .regular
)
public var bold = UIFont.preferredFont(forTextStyle: .body).bold
public var italic = UIFont.preferredFont(forTextStyle: .body).italic
public var code = UIFont.monospacedSystemFont(
ofSize: UIFont.preferredFont(forTextStyle: .body).pointSize * 0.85,
weight: .regular
)
public var largeTitle = UIFont.preferredFont(forTextStyle: .body).bold
public var title = UIFont.preferredFont(forTextStyle: .body).bold
}
public var fonts: Fonts = .init()
public struct Colors: Equatable {
public var body = UIColor.label
public var emphasis = UIColor.systemOrange
public var code = UIColor.label
public var codeBackground = UIColor.gray.withAlphaComponent(0.25)
}
public var colors: Colors = .init()
public struct Spacings: Equatable {
public var final: CGFloat = 16
public var general: CGFloat = 8
public var list: CGFloat = 12
public var cell: CGFloat = 32
}
public var spacings: Spacings = .init()
public struct Sizes: Equatable {
public var bullet: CGFloat = 4
}
public var sizes: Sizes = .init()
public init() {}
}
extension UIFont {
var baseLineHeight: CGFloat {
NSAttributedString(string: "88", attributes: [
.font: self,
.originalFont: self,
]).measureHeight(usingWidth: .greatestFiniteMagnitude)
}
}
private let codeThemeTemplate: Splash.Theme = .init(
font: .init(size: Double(0)),
plainTextColor: .label,
tokenColors: [
.keyword: Color(
light: Color(red: 0.948, green: 0.140, blue: 0.547, alpha: 1),
dark: Color(red: 0.948, green: 0.140, blue: 0.547, alpha: 1)
),
.string: Color(
light: Color(red: 0.988, green: 0.273, blue: 0.317, alpha: 1),
dark: Color(red: 0.988, green: 0.273, blue: 0.317, alpha: 1)
),
.type: Color(
light: Color(red: 0.384, green: 0.698, blue: 0.161, alpha: 1),
dark: Color(red: 0.584, green: 0.898, blue: 0.361, alpha: 1)
),
.call: Color(
light: Color(red: 0.384, green: 0.698, blue: 0.161, alpha: 1),
dark: Color(red: 0.584, green: 0.898, blue: 0.361, alpha: 1)
),
.number: Color(
light: Color(red: 0.387, green: 0.317, blue: 0.774, alpha: 1),
dark: Color(red: 0.587, green: 0.517, blue: 0.974, alpha: 1)
),
.comment: Color(
light: Color(red: 0.424, green: 0.475, blue: 0.529, alpha: 1),
dark: Color(red: 0.424, green: 0.475, blue: 0.529, alpha: 1)
),
.property: Color(
light: Color(red: 0.384, green: 0.698, blue: 0.161, alpha: 1),
dark: Color(red: 0.584, green: 0.898, blue: 0.361, alpha: 1)
),
.dotAccess: Color(
light: Color(red: 0.384, green: 0.698, blue: 0.161, alpha: 1),
dark: Color(red: 0.584, green: 0.898, blue: 0.361, alpha: 1)
),
.preprocessing: Color(
light: Color(red: 0.752, green: 0.326, blue: 0.12, alpha: 19),
dark: Color(red: 0.952, green: 0.526, blue: 0.22, alpha: 19)
),
],
backgroundColor: .clear
)
public extension Theme {
func codeTheme(withFont font: UIFont) -> Splash.Theme {
var ret = codeThemeTemplate
ret.font = .init(size: Double(font.pointSize))
return ret
}
}

View File

@@ -0,0 +1,6 @@
@testable import FlowMarkdownView
import Testing
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
}