mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-23 09:17:06 +08:00
feat(ios): intelligent Switch Markdown View & Ephemeral Action View (#9823)
Co-authored-by: EYHN <cneyhn@gmail.com>
This commit is contained in:
8
packages/frontend/apps/ios/App/Packages/MarkdownView/.gitignore
vendored
Normal file
8
packages/frontend/apps/ios/App/Packages/MarkdownView/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
||||
@@ -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>
|
||||
@@ -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 */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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>
|
||||
@@ -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)的示例。
|
||||
|
||||

|
||||
|
||||
## 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 元素,适合用于测试渲染引擎的性能和兼容性。希望它能帮助你完成测试!
|
||||
"""###
|
||||
@@ -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"),
|
||||
]),
|
||||
]
|
||||
)
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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:))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
Reference in New Issue
Block a user