mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +00:00
feat(ios): intelligent Switch Markdown View & Ephemeral Action View (#9823)
Co-authored-by: EYHN <cneyhn@gmail.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -84,3 +84,4 @@ packages/frontend/core/public/static/templates
|
||||
# script
|
||||
af
|
||||
af.cmd
|
||||
*.resolved
|
||||
|
||||
@@ -33,6 +33,7 @@ packages/common/native/fixtures/**
|
||||
packages/common/graphql/src/graphql/index.ts
|
||||
packages/frontend/native/index.d.ts
|
||||
packages/frontend/native/index.js
|
||||
packages/frontend/apps/android/App/app/build/**
|
||||
packages/frontend/apps/android/App/**
|
||||
packages/frontend/apps/ios/App/**
|
||||
tests/blocksuite/snapshots
|
||||
blocksuite/docs/api/**
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"packages/common/graphql/src/graphql/index.ts",
|
||||
"packages/frontend/native/index.d.ts",
|
||||
"packages/frontend/native/index.js",
|
||||
"packages/frontend/apps/android/App/app/build/**",
|
||||
"packages/frontend/apps/android/App/**",
|
||||
"packages/frontend/apps/ios/App/**",
|
||||
"tests/blocksuite/snapshots",
|
||||
"blocksuite/docs/api/**"
|
||||
],
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
50A285D72D112A5E000D5A6D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */; };
|
||||
50A285D82D112A5E000D5A6D /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */; };
|
||||
50A285DC2D112B24000D5A6D /* Intelligents in Frameworks */ = {isa = PBXBuildFile; productRef = 50A285DB2D112B24000D5A6D /* Intelligents */; };
|
||||
50FF428A2D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */; };
|
||||
50FF428C2D2E77CC0050AA83 /* AffineViewController+AIButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */; };
|
||||
9D52FC432D26CDBF00105D0A /* JSValueContainerExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */; };
|
||||
9D5622962D64A6A5009F1BE4 /* AuthPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5622952D64A6A4009F1BE4 /* AuthPlugin.swift */; };
|
||||
9D6A85332CCF6DA700DAB35F /* HashcashPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D6A85322CCF6DA700DAB35F /* HashcashPlugin.swift */; };
|
||||
@@ -44,6 +46,9 @@
|
||||
50802D5E2D112F7D00694021 /* Intelligents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Intelligents; sourceTree = "<group>"; };
|
||||
50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
|
||||
50A285D62D112A5E000D5A6D /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
50FEBD7B2D3F7719002847B5 /* ChidoriMenu */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = ChidoriMenu; sourceTree = "<group>"; };
|
||||
50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBridgedWindowScript.swift; sourceTree = "<group>"; };
|
||||
50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AffineViewController+AIButton.swift"; sourceTree = "<group>"; };
|
||||
9D52FC422D26CDB600105D0A /* JSValueContainerExt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSValueContainerExt.swift; sourceTree = "<group>"; };
|
||||
9D5622952D64A6A4009F1BE4 /* AuthPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthPlugin.swift; sourceTree = "<group>"; };
|
||||
9D6A85322CCF6DA700DAB35F /* HashcashPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashcashPlugin.swift; sourceTree = "<group>"; };
|
||||
@@ -130,6 +135,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5039CC962D1D42C700874F32 /* AffineGraphQL */,
|
||||
50FEBD7B2D3F7719002847B5 /* ChidoriMenu */,
|
||||
50802D5E2D112F7D00694021 /* Intelligents */,
|
||||
);
|
||||
path = Packages;
|
||||
@@ -186,6 +192,8 @@
|
||||
9D90BE1C2CCB9876006677DB /* AppDelegate.swift */,
|
||||
507513692D1924C600AD60C0 /* RootViewController.swift */,
|
||||
9D90BE1B2CCB9876006677DB /* AffineViewController.swift */,
|
||||
50FF428B2D2E77CC0050AA83 /* AffineViewController+AIButton.swift */,
|
||||
50FF42892D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift */,
|
||||
9D90BE1D2CCB9876006677DB /* Assets.xcassets */,
|
||||
9D90BE1E2CCB9876006677DB /* capacitor.config.json */,
|
||||
9D90BE1F2CCB9876006677DB /* config.xml */,
|
||||
@@ -358,12 +366,14 @@
|
||||
5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */,
|
||||
C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */,
|
||||
9DAE9BD92D8D1AB0000C1D5A /* AppConfigManager.swift in Sources */,
|
||||
50FF428A2D2E757E0050AA83 /* ApplicationBridgedWindowScript.swift in Sources */,
|
||||
C4C97C7D2D030BE000BC2AD1 /* affine_mobile_nativeFFI.h in Sources */,
|
||||
9D5622962D64A6A5009F1BE4 /* AuthPlugin.swift in Sources */,
|
||||
C4C97C7E2D030BE000BC2AD1 /* affine_mobile_nativeFFI.modulemap in Sources */,
|
||||
E93B276C2CED92B1001409B8 /* NavigationGesturePlugin.swift in Sources */,
|
||||
9DEC59432D323EE40027CEBD /* Mutex.swift in Sources */,
|
||||
9D90BE252CCB9876006677DB /* CookieManager.swift in Sources */,
|
||||
50FF428C2D2E77CC0050AA83 /* AffineViewController+AIButton.swift in Sources */,
|
||||
9D90BE262CCB9876006677DB /* CookiePlugin.swift in Sources */,
|
||||
9D6A85332CCF6DA700DAB35F /* HashcashPlugin.swift in Sources */,
|
||||
9D90BE272CCB9876006677DB /* AffineViewController.swift in Sources */,
|
||||
|
||||
@@ -3,37 +3,19 @@
|
||||
{
|
||||
"identity" : "apollo-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apollographql/apollo-ios.git",
|
||||
"location" : "https://github.com/apollographql/apollo-ios",
|
||||
"state" : {
|
||||
"revision" : "9aa748d6f0526a744d49d59a2383dc7fdf9d645b",
|
||||
"version" : "1.18.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "msdisplaylink",
|
||||
"identity" : "splash",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Lakr233/MSDisplayLink",
|
||||
"location" : "https://github.com/JohnSundell/Splash",
|
||||
"state" : {
|
||||
"revision" : "c2fcd28cb99300d83acc30860ce252ef97c20b61",
|
||||
"version" : "1.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "networkimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
||||
"state" : {
|
||||
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
||||
"version" : "6.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "springinterpolation",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Lakr233/SpringInterpolation",
|
||||
"state" : {
|
||||
"revision" : "f9d1ee3d2466bdb00fd0ade7f256ed20229c8413",
|
||||
"version" : "1.3.0"
|
||||
"revision" : "7f4df436eb78fe64fe2c32c58006e9949fa28ad8",
|
||||
"version" : "0.16.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -46,12 +28,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-cmark",
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-cmark",
|
||||
"location" : "https://github.com/apple/swift-collections",
|
||||
"state" : {
|
||||
"revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53",
|
||||
"version" : "0.5.0"
|
||||
"revision" : "671108c96644956dddcd89dd59c203dcdb36cec7",
|
||||
"version" : "1.1.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -62,15 +44,6 @@
|
||||
"revision" : "57051701c58a93603ffa2051f8e9cf0c8cff7814",
|
||||
"version" : "3.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-markdown-ui",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
|
||||
"state" : {
|
||||
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
|
||||
"version" : "2.4.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
|
||||
@@ -10,30 +10,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "msdisplaylink",
|
||||
"identity" : "splash",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Lakr233/MSDisplayLink",
|
||||
"location" : "https://github.com/JohnSundell/Splash",
|
||||
"state" : {
|
||||
"revision" : "c2fcd28cb99300d83acc30860ce252ef97c20b61",
|
||||
"version" : "1.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "networkimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
||||
"state" : {
|
||||
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
||||
"version" : "6.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "springinterpolation",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Lakr233/SpringInterpolation",
|
||||
"state" : {
|
||||
"revision" : "f9d1ee3d2466bdb00fd0ade7f256ed20229c8413",
|
||||
"version" : "1.3.0"
|
||||
"revision" : "7f4df436eb78fe64fe2c32c58006e9949fa28ad8",
|
||||
"version" : "0.16.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -46,12 +28,12 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-cmark",
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-cmark",
|
||||
"location" : "https://github.com/apple/swift-collections",
|
||||
"state" : {
|
||||
"revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53",
|
||||
"version" : "0.5.0"
|
||||
"revision" : "671108c96644956dddcd89dd59c203dcdb36cec7",
|
||||
"version" : "1.1.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -62,15 +44,6 @@
|
||||
"revision" : "57051701c58a93603ffa2051f8e9cf0c8cff7814",
|
||||
"version" : "3.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-markdown-ui",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
|
||||
"state" : {
|
||||
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
|
||||
"version" : "2.4.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//
|
||||
// File.swift
|
||||
// App
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/8.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Intelligents
|
||||
import ChidoriMenu
|
||||
|
||||
extension AFFiNEViewController: IntelligentsButtonDelegate, IntelligentsFocusApertureViewDelegate {
|
||||
func onIntelligentsButtonTapped(_ button: IntelligentsButton) {
|
||||
guard let webView else {
|
||||
assertionFailure() // ? wdym ?
|
||||
return
|
||||
}
|
||||
|
||||
button.beginProgress()
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
group.enter()
|
||||
webView.evaluateScript(.getCurrentServerBaseUrl) { result in
|
||||
self.baseUrl = result as? String
|
||||
print("[*] setting baseUrl: \(self.baseUrl ?? "")")
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.enter()
|
||||
webView.evaluateScript(.getCurrentDocId) { result in
|
||||
self.documentID = result as? String
|
||||
print("[*] setting documentID: \(self.documentID ?? "")")
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.enter()
|
||||
webView.evaluateScript(.getCurrentWorkspaceId) { result in
|
||||
self.workspaceID = result as? String
|
||||
print("[*] setting workspaceID: \(self.workspaceID ?? "")")
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.enter()
|
||||
webView.evaluateScript(.getCurrentDocContentInMarkdown) { input in
|
||||
self.documentContent = input as? String
|
||||
print("[*] setting documentContent: \(self.documentContent?.count ?? 0) chars")
|
||||
group.leave()
|
||||
}
|
||||
|
||||
DispatchQueue.global().asyncAfter(deadline: .now()) {
|
||||
group.wait()
|
||||
DispatchQueue.main.async {
|
||||
button.stopProgress()
|
||||
webView.resignFirstResponder()
|
||||
self.openIntelligentsSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func openIntelligentsSheet() -> IntelligentsFocusApertureView? {
|
||||
dismissIntelligentsButton()
|
||||
view.resignFirstResponder()
|
||||
// stop scroll on webview
|
||||
if let contentOffset = webView?.scrollView.contentOffset {
|
||||
webView?.scrollView.contentOffset = contentOffset
|
||||
}
|
||||
let focus = IntelligentsFocusApertureView()
|
||||
focus.prepareAnimationWith(
|
||||
capturingTargetContentView: webView ?? .init(),
|
||||
coveringRootViewController: self
|
||||
)
|
||||
focus.delegate = self
|
||||
focus.executeAnimationKickIn()
|
||||
dismissIntelligentsButton()
|
||||
return focus
|
||||
}
|
||||
|
||||
func openSimpleChat() {
|
||||
let targetController = IntelligentsChatController()
|
||||
presentIntoCurrentContext(withTargetController: targetController)
|
||||
}
|
||||
|
||||
func focusApertureRequestAction(
|
||||
from view: IntelligentsFocusApertureView,
|
||||
actionType: IntelligentsFocusApertureViewActionType
|
||||
) {
|
||||
switch actionType {
|
||||
case .translateTo:
|
||||
var actions: [UIAction] = []
|
||||
for lang in IntelligentsEphemeralActionController.EphemeralAction.Language.allCases {
|
||||
actions.append(.init(title: lang.rawValue) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
let controller = IntelligentsEphemeralActionController(
|
||||
action: .translate(to: lang)
|
||||
)
|
||||
controller.workspaceID = workspaceID ?? ""
|
||||
controller.documentID = documentID ?? ""
|
||||
controller.documentContent = documentContent ?? ""
|
||||
controller.configure(previewImage: view.capturedImage ?? .init())
|
||||
presentIntoCurrentContext(withTargetController: controller)
|
||||
})
|
||||
}
|
||||
view.present(menu: .init(children: actions)) { menu in
|
||||
menu.overrideUserInterfaceStyle = .dark
|
||||
}
|
||||
case .summary:
|
||||
let controller = IntelligentsEphemeralActionController(
|
||||
action: .summarize
|
||||
)
|
||||
controller.configure(previewImage: view.capturedImage ?? .init())
|
||||
controller.workspaceID = workspaceID ?? ""
|
||||
controller.documentID = documentID ?? ""
|
||||
controller.documentContent = documentContent ?? ""
|
||||
presentIntoCurrentContext(withTargetController: controller)
|
||||
case .chatWithAI:
|
||||
let controller = IntelligentsChatController()
|
||||
controller.metadata[.documentID] = documentID
|
||||
controller.metadata[.workspaceID] = workspaceID
|
||||
controller.metadata[.content] = documentContent
|
||||
presentIntoCurrentContext(withTargetController: controller)
|
||||
case .dismiss:
|
||||
presentIntelligentsButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,13 @@ import Intelligents
|
||||
import UIKit
|
||||
|
||||
class AFFiNEViewController: CAPBridgeViewController {
|
||||
var baseUrl: String? {
|
||||
didSet { Intelligents.setUpstreamEndpoint(baseUrl ?? "") }
|
||||
}
|
||||
var documentID: String?
|
||||
var workspaceID: String?
|
||||
var documentContent: String?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
webView?.allowsBackForwardNavigationGestures = true
|
||||
@@ -39,84 +46,6 @@ class AFFiNEViewController: CAPBridgeViewController {
|
||||
super.viewDidAppear(animated)
|
||||
navigationController?.setNavigationBarHidden(false, animated: animated)
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
}
|
||||
}
|
||||
|
||||
extension AFFiNEViewController: IntelligentsButtonDelegate, IntelligentsFocusApertureViewDelegate {
|
||||
func onIntelligentsButtonTapped(_ button: IntelligentsButton) {
|
||||
guard let webView else {
|
||||
assertionFailure() // ? wdym ?
|
||||
return
|
||||
}
|
||||
|
||||
button.beginProgress()
|
||||
|
||||
let upstreamReaderScript = "window.getCurrentServerBaseUrl();"
|
||||
webView.evaluateJavaScript(upstreamReaderScript) { result, _ in
|
||||
if let baseUrl = result as? String {
|
||||
Intelligents.setUpstreamEndpoint(baseUrl)
|
||||
}
|
||||
|
||||
let script = "return await window.getCurrentDocContentInMarkdown();"
|
||||
webView.callAsyncJavaScript(
|
||||
script,
|
||||
arguments: [:],
|
||||
in: nil,
|
||||
in: .page
|
||||
) { result in
|
||||
button.stopProgress()
|
||||
webView.resignFirstResponder()
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
if case let .success(content) = result,
|
||||
let res = content as? String
|
||||
{
|
||||
print("[*] \(self) received document with \(res.count) characters")
|
||||
self.openIntelligentsSheet(withContext: res)
|
||||
} else {
|
||||
self.openSimpleChat()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openIntelligentsSheet(withContext context: String) {
|
||||
guard let view = webView?.subviews.first else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
assert(view is UIScrollView)
|
||||
_ = context
|
||||
let focus = IntelligentsFocusApertureView()
|
||||
focus.prepareAnimationWith(
|
||||
capturingTargetContentView: view,
|
||||
coveringRootViewController: self
|
||||
)
|
||||
focus.delegate = self
|
||||
focus.executeAnimationKickIn()
|
||||
dismissIntelligentsButton()
|
||||
}
|
||||
|
||||
func openSimpleChat() {
|
||||
let targetController = IntelligentsChatController()
|
||||
presentIntoCurrentContext(withTargetController: targetController)
|
||||
}
|
||||
|
||||
func focusApertureRequestAction(actionType: IntelligentsFocusApertureViewActionType) {
|
||||
switch actionType {
|
||||
case .translateTo:
|
||||
fatalError("not implemented")
|
||||
case .summary:
|
||||
fatalError("not implemented")
|
||||
case .chatWithAI:
|
||||
let controller = IntelligentsChatController()
|
||||
presentIntoCurrentContext(withTargetController: controller)
|
||||
case .dismiss:
|
||||
presentIntelligentsButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// ApplicationBridgedWindowScript.swift
|
||||
// App
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/8.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
enum ApplicationBridgedWindowScript: String {
|
||||
case getCurrentDocContentInMarkdown = "return await window.getCurrentDocContentInMarkdown();"
|
||||
case getCurrentServerBaseUrl = "window.getCurrentServerBaseUrl()"
|
||||
case getCurrentWorkspaceId = "window.getCurrentWorkspaceId();"
|
||||
case getCurrentDocId = "window.getCurrentDocId();"
|
||||
|
||||
var requiresAsyncContext: Bool {
|
||||
switch self {
|
||||
case .getCurrentDocContentInMarkdown: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WKWebView {
|
||||
func evaluateScript(_ script: ApplicationBridgedWindowScript, callback: @escaping (Any?) -> ()) {
|
||||
if script.requiresAsyncContext {
|
||||
callAsyncJavaScript(
|
||||
script.rawValue,
|
||||
arguments: [:],
|
||||
in: nil,
|
||||
in: .page
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let input):
|
||||
callback(input)
|
||||
case .failure:
|
||||
callback(nil)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
evaluateJavaScript(script.rawValue) { output, _ in callback(output) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -497,7 +497,7 @@ fileprivate struct FfiConverterString: FfiConverter {
|
||||
|
||||
|
||||
|
||||
public protocol DocStoragePoolProtocol: AnyObject {
|
||||
public protocol DocStoragePoolProtocol: AnyObject, Sendable {
|
||||
|
||||
func clearClocks(universalId: String) async throws
|
||||
|
||||
@@ -573,6 +573,9 @@ open class DocStoragePool: DocStoragePoolProtocol, @unchecked Sendable {
|
||||
// TODO: We'd like this to be `private` but for Swifty reasons,
|
||||
// we can't implement `FfiConverter` without making this `required` and we can't
|
||||
// make it `required` without making it `public`.
|
||||
#if swift(>=5.8)
|
||||
@_documentation(visibility: private)
|
||||
#endif
|
||||
required public init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) {
|
||||
self.pointer = pointer
|
||||
}
|
||||
@@ -621,7 +624,7 @@ open func clearClocks(universalId: String)async throws {
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_void,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_void,
|
||||
liftFunc: { $0 },
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -641,7 +644,7 @@ open func connect(universalId: String, path: String)async throws {
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_void,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_void,
|
||||
liftFunc: { $0 },
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -658,7 +661,7 @@ open func deleteBlob(universalId: String, key: String, permanently: Bool)async t
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_void,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_void,
|
||||
liftFunc: { $0 },
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -675,7 +678,7 @@ open func deleteDoc(universalId: String, docId: String)async throws {
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_void,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_void,
|
||||
liftFunc: { $0 },
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -692,7 +695,7 @@ open func disconnect(universalId: String)async throws {
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_void,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_void,
|
||||
liftFunc: { $0 },
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -709,7 +712,7 @@ open func getBlob(universalId: String, key: String)async throws -> Blob? {
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_rust_buffer,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_rust_buffer,
|
||||
liftFunc: FfiConverterOptionTypeBlob.lift,
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -726,7 +729,7 @@ open func getBlobUploadedAt(universalId: String, peer: String, blobId: String)as
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_rust_buffer,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_rust_buffer,
|
||||
liftFunc: FfiConverterOptionInt64.lift,
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -743,7 +746,7 @@ open func getDocClock(universalId: String, docId: String)async throws -> DocClo
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_rust_buffer,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_rust_buffer,
|
||||
liftFunc: FfiConverterOptionTypeDocClock.lift,
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -760,7 +763,7 @@ open func getDocClocks(universalId: String, after: Int64?)async throws -> [DocC
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_rust_buffer,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_rust_buffer,
|
||||
liftFunc: FfiConverterSequenceTypeDocClock.lift,
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -777,7 +780,7 @@ open func getDocSnapshot(universalId: String, docId: String)async throws -> Doc
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_rust_buffer,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_rust_buffer,
|
||||
liftFunc: FfiConverterOptionTypeDocRecord.lift,
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -794,7 +797,7 @@ open func getDocUpdates(universalId: String, docId: String)async throws -> [Doc
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_rust_buffer,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_rust_buffer,
|
||||
liftFunc: FfiConverterSequenceTypeDocUpdate.lift,
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -811,7 +814,7 @@ open func getPeerPulledRemoteClock(universalId: String, peer: String, docId: Str
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_rust_buffer,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_rust_buffer,
|
||||
liftFunc: FfiConverterOptionTypeDocClock.lift,
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -828,7 +831,7 @@ open func getPeerPulledRemoteClocks(universalId: String, peer: String)async thro
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_rust_buffer,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_rust_buffer,
|
||||
liftFunc: FfiConverterSequenceTypeDocClock.lift,
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -845,7 +848,7 @@ open func getPeerPushedClock(universalId: String, peer: String, docId: String)as
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_rust_buffer,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_rust_buffer,
|
||||
liftFunc: FfiConverterOptionTypeDocClock.lift,
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -862,7 +865,7 @@ open func getPeerPushedClocks(universalId: String, peer: String)async throws ->
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_rust_buffer,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_rust_buffer,
|
||||
liftFunc: FfiConverterSequenceTypeDocClock.lift,
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -879,7 +882,7 @@ open func getPeerRemoteClock(universalId: String, peer: String, docId: String)as
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_rust_buffer,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_rust_buffer,
|
||||
liftFunc: FfiConverterOptionTypeDocClock.lift,
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -896,7 +899,7 @@ open func getPeerRemoteClocks(universalId: String, peer: String)async throws ->
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_rust_buffer,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_rust_buffer,
|
||||
liftFunc: FfiConverterSequenceTypeDocClock.lift,
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -913,7 +916,7 @@ open func listBlobs(universalId: String)async throws -> [ListedBlob] {
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_rust_buffer,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_rust_buffer,
|
||||
liftFunc: FfiConverterSequenceTypeListedBlob.lift,
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -930,7 +933,7 @@ open func markUpdatesMerged(universalId: String, docId: String, updates: [Int64]
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_u32,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_u32,
|
||||
liftFunc: FfiConverterUInt32.lift,
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -947,7 +950,7 @@ open func pushUpdate(universalId: String, docId: String, update: String)async th
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_i64,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_i64,
|
||||
liftFunc: FfiConverterInt64.lift,
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -964,7 +967,7 @@ open func releaseBlobs(universalId: String)async throws {
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_void,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_void,
|
||||
liftFunc: { $0 },
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -981,7 +984,7 @@ open func setBlob(universalId: String, blob: SetBlob)async throws {
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_void,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_void,
|
||||
liftFunc: { $0 },
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -998,7 +1001,7 @@ open func setBlobUploadedAt(universalId: String, peer: String, blobId: String, u
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_void,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_void,
|
||||
liftFunc: { $0 },
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1015,7 +1018,7 @@ open func setDocSnapshot(universalId: String, snapshot: DocRecord)async throws
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_i8,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_i8,
|
||||
liftFunc: FfiConverterBool.lift,
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1032,7 +1035,7 @@ open func setPeerPulledRemoteClock(universalId: String, peer: String, docId: Str
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_void,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_void,
|
||||
liftFunc: { $0 },
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1049,7 +1052,7 @@ open func setPeerPushedClock(universalId: String, peer: String, docId: String, c
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_void,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_void,
|
||||
liftFunc: { $0 },
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1066,7 +1069,7 @@ open func setPeerRemoteClock(universalId: String, peer: String, docId: String, c
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_void,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_void,
|
||||
liftFunc: { $0 },
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1083,7 +1086,7 @@ open func setSpaceId(universalId: String, spaceId: String)async throws {
|
||||
completeFunc: ffi_affine_mobile_native_rust_future_complete_void,
|
||||
freeFunc: ffi_affine_mobile_native_rust_future_free_void,
|
||||
liftFunc: { $0 },
|
||||
errorHandler: FfiConverterTypeUniffiError.lift
|
||||
errorHandler: FfiConverterTypeUniffiError_lift
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1627,7 +1630,7 @@ public func FfiConverterTypeSetBlob_lower(_ value: SetBlob) -> RustBuffer {
|
||||
}
|
||||
|
||||
|
||||
public enum UniffiError {
|
||||
public enum UniffiError: Swift.Error {
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ let package = Package(
|
||||
.library(name: "AffineGraphQL", targets: ["AffineGraphQL"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apollographql/apollo-ios", exact: "1.18.0"),
|
||||
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.0.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
|
||||
8
packages/frontend/apps/ios/App/Packages/ChidoriMenu/.gitignore
vendored
Normal file
8
packages/frontend/apps/ios/App/Packages/ChidoriMenu/.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,410 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
503E58F02D3C0D6B007BD8C6 /* ChidoriMenu in Frameworks */ = {isa = PBXBuildFile; productRef = 503E58EF2D3C0D6B007BD8C6 /* ChidoriMenu */; };
|
||||
503E58F32D3C10BF007BD8C6 /* LookinServer in Frameworks */ = {isa = PBXBuildFile; productRef = 503E58F22D3C10BF007BD8C6 /* LookinServer */; };
|
||||
503E591E2D3D0F1C007BD8C6 /* SPIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 503E591D2D3D0F1C007BD8C6 /* SPIndicator */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
505109962D3C0D1000F62A71 /* ChidoriMenuExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ChidoriMenuExample.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
505109982D3C0D1000F62A71 /* ChidoriMenuExample */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = ChidoriMenuExample;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
505109932D3C0D1000F62A71 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
503E58F02D3C0D6B007BD8C6 /* ChidoriMenu in Frameworks */,
|
||||
503E58F32D3C10BF007BD8C6 /* LookinServer in Frameworks */,
|
||||
503E591E2D3D0F1C007BD8C6 /* SPIndicator in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
5051098D2D3C0D1000F62A71 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
505109982D3C0D1000F62A71 /* ChidoriMenuExample */,
|
||||
505109972D3C0D1000F62A71 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
505109972D3C0D1000F62A71 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
505109962D3C0D1000F62A71 /* ChidoriMenuExample.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
505109952D3C0D1000F62A71 /* ChidoriMenuExample */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 505109A42D3C0D1100F62A71 /* Build configuration list for PBXNativeTarget "ChidoriMenuExample" */;
|
||||
buildPhases = (
|
||||
503E59202D3D34C8007BD8C6 /* Format Source */,
|
||||
505109922D3C0D1000F62A71 /* Sources */,
|
||||
505109932D3C0D1000F62A71 /* Frameworks */,
|
||||
505109942D3C0D1000F62A71 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
505109982D3C0D1000F62A71 /* ChidoriMenuExample */,
|
||||
);
|
||||
name = ChidoriMenuExample;
|
||||
packageProductDependencies = (
|
||||
503E58EF2D3C0D6B007BD8C6 /* ChidoriMenu */,
|
||||
503E58F22D3C10BF007BD8C6 /* LookinServer */,
|
||||
503E591D2D3D0F1C007BD8C6 /* SPIndicator */,
|
||||
);
|
||||
productName = ChidoriMenuExample;
|
||||
productReference = 505109962D3C0D1000F62A71 /* ChidoriMenuExample.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
5051098E2D3C0D1000F62A71 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1620;
|
||||
LastUpgradeCheck = 1620;
|
||||
TargetAttributes = {
|
||||
505109952D3C0D1000F62A71 = {
|
||||
CreatedOnToolsVersion = 16.2;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 505109912D3C0D1000F62A71 /* Build configuration list for PBXProject "ChidoriMenuExample" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 5051098D2D3C0D1000F62A71;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
503E58F12D3C10BF007BD8C6 /* XCRemoteSwiftPackageReference "LookinServer" */,
|
||||
503E591C2D3D0F1C007BD8C6 /* XCRemoteSwiftPackageReference "SPIndicator" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 505109972D3C0D1000F62A71 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
505109952D3C0D1000F62A71 /* ChidoriMenuExample */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
505109942D3C0D1000F62A71 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
503E59202D3D34C8007BD8C6 /* Format Source */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Format Source";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/zsh;
|
||||
shellScript = "swiftformat . --swiftversion 6.0 --indent 4 || true\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
505109922D3C0D1000F62A71 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
505109A22D3C0D1100F62A71 /* 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;
|
||||
};
|
||||
505109A32D3C0D1100F62A71 /* 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;
|
||||
};
|
||||
505109A52D3C0D1100F62A71 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = ChidoriMenuExample/ChidoriMenuExample.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 964G86XT2P;
|
||||
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.ChidoriMenuExample;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
505109A62D3C0D1100F62A71 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = ChidoriMenuExample/ChidoriMenuExample.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 964G86XT2P;
|
||||
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.ChidoriMenuExample;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
505109912D3C0D1000F62A71 /* Build configuration list for PBXProject "ChidoriMenuExample" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
505109A22D3C0D1100F62A71 /* Debug */,
|
||||
505109A32D3C0D1100F62A71 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
505109A42D3C0D1100F62A71 /* Build configuration list for PBXNativeTarget "ChidoriMenuExample" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
505109A52D3C0D1100F62A71 /* Debug */,
|
||||
505109A62D3C0D1100F62A71 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
503E58F12D3C10BF007BD8C6 /* XCRemoteSwiftPackageReference "LookinServer" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/QMUI/LookinServer/";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.2.8;
|
||||
};
|
||||
};
|
||||
503E591C2D3D0F1C007BD8C6 /* XCRemoteSwiftPackageReference "SPIndicator" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/ivanvorobei/SPIndicator";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.6.5;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
503E58EF2D3C0D6B007BD8C6 /* ChidoriMenu */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = ChidoriMenu;
|
||||
};
|
||||
503E58F22D3C10BF007BD8C6 /* LookinServer */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 503E58F12D3C10BF007BD8C6 /* XCRemoteSwiftPackageReference "LookinServer" */;
|
||||
productName = LookinServer;
|
||||
};
|
||||
503E591D2D3D0F1C007BD8C6 /* SPIndicator */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 503E591C2D3D0F1C007BD8C6 /* XCRemoteSwiftPackageReference "SPIndicator" */;
|
||||
productName = SPIndicator;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 5051098E2D3C0D1000F62A71 /* 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 = "group:ChidoriMenuExample.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:../">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,394 @@
|
||||
//
|
||||
// App.swift
|
||||
// ChidoriMenuExample
|
||||
//
|
||||
// Created by 秋星桥 on 1/19/25.
|
||||
//
|
||||
|
||||
import ChidoriMenu
|
||||
import SPIndicator
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct ChidoriMenuExampleApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Content()
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Chidori Menu")
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
}
|
||||
}
|
||||
|
||||
struct Content: UIViewControllerRepresentable {
|
||||
class ContentController: UIViewController, UITableViewDelegate, UITableViewDataSource {
|
||||
let tableView = UITableView(frame: .zero, style: .insetGrouped)
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
tableView.tableFooterView = FooterButton()
|
||||
view.addSubview(tableView)
|
||||
}
|
||||
|
||||
override func viewWillLayoutSubviews() {
|
||||
super.viewWillLayoutSubviews()
|
||||
tableView.frame = view.bounds
|
||||
}
|
||||
|
||||
struct Menu {
|
||||
let title: String
|
||||
let menu: UIMenu
|
||||
}
|
||||
|
||||
var firstMenu: Menu = .init(title: "Show Menu Set", menu: .init(children: [
|
||||
UIAction(
|
||||
title: "Copy",
|
||||
image: UIImage(systemName: "doc.on.doc"),
|
||||
state: .on
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Copied", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Paste",
|
||||
image: UIImage(systemName: "doc.on.doc"),
|
||||
attributes: .disabled,
|
||||
state: .off
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Pasted", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Delete",
|
||||
image: UIImage(systemName: "trash"),
|
||||
attributes: .destructive,
|
||||
state: .mixed
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Delete", preset: .done).present()
|
||||
},
|
||||
]))
|
||||
|
||||
var secondMenu: Menu = .init(title: "Show Nested Menu Set", menu: .init(title: "Root Menu", children: [
|
||||
UIAction(
|
||||
title: "Copy",
|
||||
image: UIImage(systemName: "doc.on.doc"),
|
||||
state: .on
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Copied", preset: .done).present()
|
||||
},
|
||||
UIMenu(
|
||||
title: "Child Menu",
|
||||
image: .init(systemName: "menucard"),
|
||||
children: [
|
||||
UIAction(
|
||||
title: "Copy",
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Copied", preset: .done).present()
|
||||
},
|
||||
UIMenu(
|
||||
title: "Child Menu",
|
||||
image: .init(systemName: "menucard"),
|
||||
children: [
|
||||
UIAction(
|
||||
title: "Copy",
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Copied", preset: .done).present()
|
||||
},
|
||||
]
|
||||
),
|
||||
]
|
||||
),
|
||||
UIMenu(
|
||||
title: "Inline Menu",
|
||||
image: UIImage(systemName: "arrow.right"),
|
||||
options: [.displayInline],
|
||||
children: [
|
||||
UIAction(
|
||||
title: "Copy",
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Copied", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Paste",
|
||||
image: UIImage(systemName: "doc.on.doc"),
|
||||
attributes: .disabled
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Pasted", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Delete",
|
||||
image: UIImage(systemName: "trash"),
|
||||
attributes: .destructive
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Delete", preset: .done).present()
|
||||
},
|
||||
]
|
||||
),
|
||||
]))
|
||||
|
||||
var veryLongMenu: Menu = .init(title: "Show Looong Menu Set", menu: .init(title: "Root Menu", children: [
|
||||
UIAction(
|
||||
title: "Copy 1",
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Copied", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Paste 1",
|
||||
image: UIImage(systemName: "doc.on.doc"),
|
||||
attributes: .disabled
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Pasted", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Delete 1",
|
||||
image: UIImage(systemName: "trash"),
|
||||
attributes: .destructive
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Delete", preset: .done).present()
|
||||
},
|
||||
UIMenu(
|
||||
options: [.displayInline],
|
||||
children: [
|
||||
UIAction(
|
||||
title: "Copy 2",
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Copied", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Paste 2",
|
||||
image: UIImage(systemName: "doc.on.doc"),
|
||||
attributes: .disabled
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Pasted", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Delete 2",
|
||||
image: UIImage(systemName: "trash"),
|
||||
attributes: .destructive
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Delete", preset: .done).present()
|
||||
},
|
||||
]
|
||||
),
|
||||
UIMenu(
|
||||
options: [.displayInline],
|
||||
children: [
|
||||
UIAction(
|
||||
title: "Copy 3",
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Copied", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Paste 3",
|
||||
image: UIImage(systemName: "doc.on.doc"),
|
||||
attributes: .disabled
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Pasted", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Delete 3",
|
||||
image: UIImage(systemName: "trash"),
|
||||
attributes: .destructive
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Delete", preset: .done).present()
|
||||
},
|
||||
]
|
||||
),
|
||||
UIMenu(
|
||||
title: "Submenu Here",
|
||||
children: [
|
||||
UIAction(
|
||||
title: "Copy 4",
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Copied", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Paste 4",
|
||||
image: UIImage(systemName: "doc.on.doc"),
|
||||
attributes: .disabled
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Pasted", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Delete 4",
|
||||
image: UIImage(systemName: "trash"),
|
||||
attributes: .destructive
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Delete", preset: .done).present()
|
||||
},
|
||||
]
|
||||
),
|
||||
UIMenu(
|
||||
title: "Hello World",
|
||||
options: [.displayInline],
|
||||
children: [
|
||||
UIAction(
|
||||
title: "Copy 5",
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Copied", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Paste 5",
|
||||
image: UIImage(systemName: "doc.on.doc"),
|
||||
attributes: .disabled
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Pasted", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Delete 5",
|
||||
image: UIImage(systemName: "trash"),
|
||||
attributes: .destructive
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Delete", preset: .done).present()
|
||||
},
|
||||
]
|
||||
),
|
||||
UIMenu(
|
||||
options: [.displayInline],
|
||||
children: [
|
||||
UIAction(
|
||||
title: "Copy 6",
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Copied", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Paste 6",
|
||||
image: UIImage(systemName: "doc.on.doc"),
|
||||
attributes: .disabled
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Pasted", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Delete 6",
|
||||
image: UIImage(systemName: "trash"),
|
||||
attributes: .destructive
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Delete", preset: .done).present()
|
||||
},
|
||||
]
|
||||
),
|
||||
UIMenu(
|
||||
options: [.displayInline],
|
||||
children: [
|
||||
UIAction(
|
||||
title: "Copy 7",
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Copied", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Paste 7",
|
||||
image: UIImage(systemName: "doc.on.doc"),
|
||||
attributes: .disabled
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Pasted", preset: .done).present()
|
||||
},
|
||||
UIAction(
|
||||
title: "Delete 7",
|
||||
image: UIImage(systemName: "trash"),
|
||||
attributes: .destructive
|
||||
) { _ in
|
||||
SPIndicatorView(title: "Delete", preset: .done).present()
|
||||
},
|
||||
]
|
||||
),
|
||||
]))
|
||||
|
||||
var menuList: [Menu] { [
|
||||
firstMenu,
|
||||
secondMenu,
|
||||
veryLongMenu,
|
||||
] }
|
||||
|
||||
func numberOfSections(in _: UITableView) -> Int {
|
||||
1
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
|
||||
menuList.count
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
|
||||
let menu = menuList[indexPath.row]
|
||||
cell.textLabel?.text = menu.title
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let cell = tableView.cellForRow(at: indexPath) else { return }
|
||||
let anchorView = UIView()
|
||||
cell.addSubview(anchorView)
|
||||
anchorView.frame = .init(
|
||||
x: cell.bounds.midX,
|
||||
y: cell.bounds.midY,
|
||||
width: 0,
|
||||
height: 0
|
||||
)
|
||||
let menu = menuList[indexPath.row].menu
|
||||
anchorView.present(menu: menu)
|
||||
anchorView.removeFromSuperview()
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, viewForHeaderInSection _: Int) -> UIView? {
|
||||
let view = UIView()
|
||||
view.frame = .init(x: 0, y: 0, width: tableView.bounds.width, height: 20)
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
func makeUIViewController(context _: Context) -> ContentController {
|
||||
ContentController()
|
||||
}
|
||||
|
||||
func updateUIViewController(_: ContentController, context _: Context) {}
|
||||
}
|
||||
|
||||
class FooterButton: UIButton {
|
||||
init() {
|
||||
super.init(frame: .init(x: 0, y: 0, width: 200, height: 44))
|
||||
setTitle("Test Menu", for: .normal)
|
||||
setTitleColor(.systemBlue, for: .normal)
|
||||
titleLabel?.font = .preferredFont(forTextStyle: .footnote)
|
||||
|
||||
interactions = [UIContextMenuInteraction(delegate: self)]
|
||||
|
||||
addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
@objc func buttonTapped() {
|
||||
presentMenu()
|
||||
}
|
||||
|
||||
override func contextMenuInteraction(_: UIContextMenuInteraction, configurationForMenuAtLocation _: CGPoint) -> UIContextMenuConfiguration? {
|
||||
.init(identifier: nil, previewProvider: nil) { items in
|
||||
.init(title: "Hello World", children: items + [UIAction(title: "Action A") { _ in
|
||||
SPIndicator.present(title: "Action A", haptic: .success)
|
||||
}])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
21
packages/frontend/apps/ios/App/Packages/ChidoriMenu/LICENSE
Normal file
21
packages/frontend/apps/ios/App/Packages/ChidoriMenu/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Christian Selig & Lakr Aream
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,18 @@
|
||||
// swift-tools-version: 5.8
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ChidoriMenu",
|
||||
platforms: [.iOS(.v15), .macCatalyst(.v15)],
|
||||
products: [
|
||||
.library(
|
||||
name: "ChidoriMenu",
|
||||
targets: ["ChidoriMenu"]
|
||||
),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "ChidoriMenu"),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
# ChidoriMenu 🐦⚡️
|
||||
|
||||
A seamless drop-in replacement for UIMenu & UIAction, featuring nested menu support and dark mode compatibility.
|
||||
|
||||
The inspiration behind this project stems from Apple's lack of a unified interface across iOS and Mac Catalyst apps, as well as the absence of a built-in menu presentation method. To address these gaps—and to enable greater customization—we developed ChidoriMenu.
|
||||
|
||||
This project draws heavily on code from [ChidoriMenu](https://github.com/christianselig/ChidoriMenu), and as such, we adhere to the same license.
|
||||
|
||||
## Preview
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- [x] Added support for UIMenu & UIAction
|
||||
- [x] Added drop-in replacement for `_presentMenuAtLocation:` (not recommended for general use)
|
||||
- [x] Added support for nested menus in child elements
|
||||
- [x] Added support for the `.displayInline` menu option
|
||||
- [x] Added compatibility with dark mode
|
||||
- [x] Fixed scrolling issues during selection
|
||||
- [x] Fixed multiple actions triggering simultaneously
|
||||
- [ ] Added support for UIDeferredMenuElement (Contributions Welcome!)
|
||||
|
||||
## Requirements
|
||||
|
||||
- iOS 15.0 or later
|
||||
- macCatalyst 15.0 or later
|
||||
|
||||
## Usage
|
||||
|
||||
Getting started is straightforward. While the interface differs slightly from Apple's menu implementation, the core principles remain the same. Mac Catalyst is fully supported, though it does not bridge to AppKit menus—it functions identically to iOS.
|
||||
|
||||
```swift
|
||||
UIButton.presentMenu()
|
||||
UIView.present(menu: menu)
|
||||
```
|
||||
|
||||
For detailed examples, check out the example project included in the repository.
|
||||
|
||||
## License
|
||||
|
||||
ChidoriMenu is available under the MIT license. See the LICENSE file for more info.
|
||||
|
||||
---
|
||||
|
||||
2025.1.20 - Made with ❤️ by Lakr233
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,114 @@
|
||||
//
|
||||
// ChidoriMenu+Cell.swift
|
||||
// Chidori
|
||||
//
|
||||
// Created by Christian Selig on 2021-02-16.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension ChidoriMenu {
|
||||
class Cell: UITableViewCell {
|
||||
var menuTitle: String = "" {
|
||||
didSet { textLabel?.text = menuTitle }
|
||||
}
|
||||
|
||||
var isDestructive: Bool = false {
|
||||
didSet {
|
||||
let color: UIColor = isDestructive ? .systemRed : .label
|
||||
textLabel?.textColor = color
|
||||
imageView?.tintColor = color
|
||||
}
|
||||
}
|
||||
|
||||
var iconImage: UIImage? {
|
||||
didSet { imageView?.image = iconImage }
|
||||
}
|
||||
|
||||
override var accessibilityHint: String? {
|
||||
get { super.accessibilityHint ?? menuTitle }
|
||||
set { super.accessibilityHint = newValue }
|
||||
}
|
||||
|
||||
let sep = UIView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
accessibilityTraits = [.button]
|
||||
textLabel?.textColor = .label
|
||||
imageView?.contentMode = .scaleAspectFit
|
||||
imageView?.tintColor = .label
|
||||
accessoryView?.isUserInteractionEnabled = false
|
||||
preservesSuperviewLayoutMargins = false
|
||||
separatorInset = UIEdgeInsets.zero
|
||||
layoutMargins = UIEdgeInsets.zero
|
||||
sep.backgroundColor = ChidoriMenu.dimmingSectionSepratorColor
|
||||
contentView.addSubview(sep)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) { fatalError() }
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
sep.frame = .init(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: bounds.width,
|
||||
height: 1
|
||||
)
|
||||
}
|
||||
|
||||
override func setSelected(_ selected: Bool, animated: Bool) {
|
||||
super.setSelected(selected, animated: animated)
|
||||
backgroundColor = selected ? Self.highlightCoverColor : .clear
|
||||
}
|
||||
|
||||
override func setHighlighted(_ highlighted: Bool, animated: Bool) {
|
||||
super.setSelected(highlighted, animated: animated)
|
||||
backgroundColor = highlighted ? Self.highlightCoverColor : .clear
|
||||
}
|
||||
}
|
||||
|
||||
class HeaderCell: UIView {
|
||||
let titleLabel: UILabel = .init()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
titleLabel.font = UIFont.preferredFont(forTextStyle: .footnote)
|
||||
titleLabel.numberOfLines = 1
|
||||
titleLabel.textColor = .secondaryLabel
|
||||
titleLabel.textAlignment = .left
|
||||
addSubview(titleLabel)
|
||||
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
titleLabel.leadingAnchor.constraint(
|
||||
equalTo: leadingAnchor,
|
||||
constant: 8
|
||||
),
|
||||
titleLabel.trailingAnchor.constraint(
|
||||
equalTo: trailingAnchor,
|
||||
constant: -8
|
||||
),
|
||||
titleLabel.centerYAnchor.constraint(
|
||||
equalTo: centerYAnchor,
|
||||
constant: -ChidoriMenu.sectionTopPadding / 2
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// ChidoriMenu+Constant.swift
|
||||
// ChidoriMenu
|
||||
//
|
||||
// Created by 秋星桥 on 1/19/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension ChidoriMenu {
|
||||
static let width: CGFloat = 256
|
||||
static let cornerRadius: CGFloat = 14
|
||||
static let shadowRadius: CGFloat = cornerRadius
|
||||
static let sectionTopPadding: CGFloat = 6
|
||||
static let offsetY: CGFloat = 10
|
||||
|
||||
static let dimmingBackgroundColor = UIColor.black.withAlphaComponent(0.2)
|
||||
static let dimmingSectionSepratorColor = UIColor.black.withAlphaComponent(0.2)
|
||||
static let dimmingSectionSepratorHeight: CGFloat = 4
|
||||
|
||||
static let stackScaleFactor: CGFloat = 0.05
|
||||
}
|
||||
|
||||
extension ChidoriMenu.Cell {
|
||||
static let highlightCoverColor = UIColor.black.withAlphaComponent(0.1)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//
|
||||
// ChidoriMenu+DataSource.swift
|
||||
// ChidoriMenu
|
||||
//
|
||||
// Created by 秋星桥 on 1/19/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension ChidoriMenu {
|
||||
struct MenuSection: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
let title: String
|
||||
}
|
||||
|
||||
struct MenuContent: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
let content: Content
|
||||
enum Content {
|
||||
case action(UIAction)
|
||||
case submenu(UIMenu)
|
||||
// case deferred(([UIMenuElement]) -> ())
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
static func == (lhs: ChidoriMenu.MenuContent, rhs: ChidoriMenu.MenuContent) -> Bool {
|
||||
lhs.hashValue == rhs.hashValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ChidoriMenu.MenuContent {
|
||||
var description: String {
|
||||
switch content {
|
||||
case let .action(action):
|
||||
"action: \(action.title)"
|
||||
case let .submenu(menu):
|
||||
"submenu: \(menu.title)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ChidoriMenu {
|
||||
typealias DataSource = UITableViewDiffableDataSource<MenuSection, MenuContent>
|
||||
typealias Snapshot = NSDiffableDataSourceSnapshot<MenuSection, MenuContent>
|
||||
|
||||
func updateSnapshot() {
|
||||
var snapshot = Snapshot()
|
||||
let contents = flatMap(menu: menu)
|
||||
for (section, items) in contents {
|
||||
snapshot.appendSections([section])
|
||||
snapshot.appendItems(items, toSection: section)
|
||||
}
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
|
||||
func flatMap(menu: UIMenu) -> [(MenuSection, [MenuContent])] {
|
||||
var result: [(MenuSection, [MenuContent])] = []
|
||||
|
||||
var sectionTitle: String = menu.title
|
||||
var sectionBuilder: [MenuContent] = []
|
||||
|
||||
let sectionBuilderCommit = {
|
||||
defer {
|
||||
sectionTitle = ""
|
||||
sectionBuilder = []
|
||||
}
|
||||
guard !sectionBuilder.isEmpty else { return }
|
||||
let section = MenuSection(title: sectionTitle)
|
||||
result.append((section, sectionBuilder))
|
||||
}
|
||||
|
||||
for element in menu.children {
|
||||
if let action = element as? UIAction {
|
||||
sectionBuilder.append(.init(content: .action(action)))
|
||||
continue
|
||||
}
|
||||
if let childMenu = element as? UIMenu {
|
||||
if childMenu.options.contains(.displayInline) {
|
||||
sectionBuilderCommit()
|
||||
for (section, items) in flatMap(menu: childMenu) {
|
||||
result.append((section, items))
|
||||
}
|
||||
continue
|
||||
}
|
||||
sectionBuilder.append(.init(content: .submenu(childMenu)))
|
||||
continue
|
||||
}
|
||||
if let deferred = element as? UIDeferredMenuElement {
|
||||
// TODO: IMPL
|
||||
assertionFailure("current \(deferred) unsupported")
|
||||
}
|
||||
assertionFailure("unknown menu element")
|
||||
}
|
||||
sectionBuilderCommit()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func executeAction(_ indexPath: IndexPath) {
|
||||
guard let action = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
guard let cell = tableView.cellForRow(at: indexPath) else { return }
|
||||
for indexPath in tableView.indexPathsForSelectedRows ?? [] {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
}
|
||||
let content = action.content
|
||||
switch content {
|
||||
case let .action(action):
|
||||
action.execute()
|
||||
presentingParent?.dismiss(animated: true)
|
||||
case let .submenu(menu):
|
||||
cell.present(menu: menu, anchorPoint: .init(
|
||||
x: cell.convert(cell.bounds, to: cell.window ?? .init()).midX,
|
||||
y: cell.convert(.zero, to: cell.window ?? .init()).minY - ChidoriMenu.offsetY
|
||||
))
|
||||
iterateMenusInStack {
|
||||
$0.menuStackScaleFactor *= 1 - ChidoriMenu.stackScaleFactor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// ChidoriMenu+Gesture.swift
|
||||
// ChidoriMenu
|
||||
//
|
||||
// Created by 秋星桥 on 1/19/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension ChidoriMenu {
|
||||
@objc func panned(panGestureRecognizer: UIPanGestureRecognizer) {
|
||||
let offsetInTableView = panGestureRecognizer.location(in: tableView)
|
||||
guard let indexPath = tableView.indexPathForRow(at: offsetInTableView) else {
|
||||
// If we pan outside the table and there's a cell selected, unselect it
|
||||
for indexPath in tableView.indexPathsForSelectedRows ?? [] {
|
||||
tableView.deselectRow(at: indexPath, animated: false)
|
||||
}
|
||||
return
|
||||
}
|
||||
if panGestureRecognizer.state == .ended {
|
||||
// Treat is as a tap
|
||||
for indexPath in tableView.indexPathsForSelectedRows ?? [] {
|
||||
tableView.deselectRow(at: indexPath, animated: false)
|
||||
}
|
||||
executeAction(indexPath)
|
||||
} else {
|
||||
// This API always confuses me, it does not *select* the cell in a way that would call `didSelectRowAtIndexPath`, this just visually highlights it!
|
||||
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// ChidoriMenu+TableView.swift
|
||||
// ChidoriMenu
|
||||
//
|
||||
// Created by 秋星桥 on 1/19/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension ChidoriMenu: UITableViewDelegate {
|
||||
func cell(forRowAtIndex indexPath: IndexPath, dataSource: DataSource) -> UITableViewCell? {
|
||||
guard let section = dataSource.sectionIdentifier(for: indexPath.section),
|
||||
let item = dataSource.itemIdentifier(for: indexPath)
|
||||
else { return nil }
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: String(describing: Cell.self),
|
||||
for: indexPath
|
||||
) as! Cell
|
||||
switch item.content {
|
||||
case let .action(action):
|
||||
cell.menuTitle = action.title
|
||||
cell.iconImage = action.image
|
||||
cell.isDestructive = action.attributes.contains(.destructive)
|
||||
switch action.state {
|
||||
case .on: cell.accessoryType = .checkmark
|
||||
case .mixed: cell.accessoryType = .detailButton
|
||||
default: cell.accessoryType = .none
|
||||
}
|
||||
cell.accessoryView?.tintColor = .label
|
||||
case let .submenu(menu):
|
||||
cell.menuTitle = menu.title
|
||||
cell.iconImage = menu.image
|
||||
cell.isDestructive = false
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
cell.accessoryView?.tintColor = .label
|
||||
}
|
||||
if section.title.isEmpty, indexPath.row == 0, indexPath.section == 0 {
|
||||
cell.sep.isHidden = true
|
||||
} else {
|
||||
cell.sep.isHidden = false
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
|
||||
if section == tableView.numberOfSections - 1 { return nil }
|
||||
let footerView = UIView(frame: .zero)
|
||||
footerView.backgroundColor = ChidoriMenu.dimmingSectionSepratorColor
|
||||
return footerView
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
|
||||
if section == tableView.numberOfSections - 1 { return 0 }
|
||||
return ChidoriMenu.dimmingSectionSepratorHeight
|
||||
}
|
||||
|
||||
public func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
executeAction(indexPath)
|
||||
}
|
||||
|
||||
public func tableView(_: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
guard let section = dataSource.sectionIdentifier(for: section) else {
|
||||
return 0
|
||||
}
|
||||
if section.title.isEmpty { return 0 }
|
||||
return UIFont.preferredFont(forTextStyle: .footnote).lineHeight + 8
|
||||
}
|
||||
|
||||
public func tableView(_: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
guard let section = dataSource.sectionIdentifier(for: section) else {
|
||||
return nil
|
||||
}
|
||||
if section.title.isEmpty { return nil }
|
||||
let cell = HeaderCell()
|
||||
cell.titleLabel.text = section.title
|
||||
return cell
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// ChidoriMenu+Transition.swift
|
||||
// ChidoriMenu
|
||||
//
|
||||
// Created by 秋星桥 on 1/19/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension ChidoriMenu: UIViewControllerTransitioningDelegate {
|
||||
public func animationController(
|
||||
forPresented _: UIViewController,
|
||||
presenting _: UIViewController,
|
||||
source _: UIViewController
|
||||
) -> UIViewControllerAnimatedTransitioning? {
|
||||
transitionController = ChidoriAnimationController(type: .presentation)
|
||||
return transitionController
|
||||
}
|
||||
|
||||
public func interactionControllerForPresentation(
|
||||
using _: UIViewControllerAnimatedTransitioning
|
||||
) -> UIViewControllerInteractiveTransitioning? {
|
||||
transitionController
|
||||
}
|
||||
|
||||
public func animationController(
|
||||
forDismissed _: UIViewController
|
||||
) -> UIViewControllerAnimatedTransitioning? {
|
||||
ChidoriAnimationController(type: .dismissal)
|
||||
}
|
||||
|
||||
public func presentationController(
|
||||
forPresented presented: UIViewController,
|
||||
presenting: UIViewController?,
|
||||
source _: UIViewController
|
||||
) -> UIPresentationController? {
|
||||
let controller = ChidoriPresentationController(
|
||||
presentedViewController: presented,
|
||||
presenting: presenting
|
||||
)
|
||||
controller.transitionDelegate = self
|
||||
return controller
|
||||
}
|
||||
}
|
||||
|
||||
extension ChidoriMenu: ChidoriPresentationController.Delegate {
|
||||
func didTapOverlayView(_: ChidoriPresentationController) {
|
||||
transitionController?.cancelTransition()
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//
|
||||
// ChidoriMenu.swift
|
||||
// Chidori
|
||||
//
|
||||
// Created by Christian Selig on 2021-02-15.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public class ChidoriMenu: UIViewController {
|
||||
let tableView: UITableView
|
||||
var dataSource: DataSource!
|
||||
|
||||
let menu: UIMenu
|
||||
let anchorPoint: CGPoint
|
||||
|
||||
let backgroundView = UIView()
|
||||
let shadowView = UIView()
|
||||
let panGestureRecognizer: UIPanGestureRecognizer = .init()
|
||||
|
||||
var transitionController: ChidoriAnimationController?
|
||||
var height: CGFloat {
|
||||
tableView.sizeThatFits(
|
||||
CGSize(
|
||||
width: ChidoriMenu.width,
|
||||
height: CGFloat.greatestFiniteMagnitude
|
||||
)
|
||||
).height.rounded(.up)
|
||||
}
|
||||
|
||||
var presentingParent: UIViewController? {
|
||||
var parent: UIViewController? = presentingViewController
|
||||
while let superMenu = parent as? ChidoriMenu {
|
||||
parent = superMenu.presentingViewController
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
var menuStackScaleFactor: CGFloat = 1.0 {
|
||||
didSet { UIView.animate(
|
||||
withDuration: 0.5,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 1.0,
|
||||
initialSpringVelocity: 0.8
|
||||
) { [self] in
|
||||
let factor = menuStackScaleFactor
|
||||
view.transform = CGAffineTransform(scaleX: factor, y: factor)
|
||||
view.layoutIfNeeded()
|
||||
} }
|
||||
}
|
||||
|
||||
required init(menu: UIMenu, anchorPoint: CGPoint) {
|
||||
self.menu = menu
|
||||
self.anchorPoint = anchorPoint
|
||||
|
||||
tableView = TableView(frame: .zero, style: .plain)
|
||||
|
||||
tableView.register(Cell.self, forCellReuseIdentifier: String(describing: Cell.self))
|
||||
tableView.dataSource = dataSource
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
dataSource = DataSource(
|
||||
tableView: tableView
|
||||
) { [weak self] _, indexPath, _ -> UITableViewCell? in
|
||||
guard let self else { return nil }
|
||||
return cell(forRowAtIndex: indexPath, dataSource: dataSource)
|
||||
}
|
||||
|
||||
modalPresentationStyle = .custom
|
||||
transitioningDelegate = self
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
public required init?(coder _: NSCoder) { fatalError() }
|
||||
|
||||
override public func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = .clear
|
||||
view.layer.masksToBounds = false
|
||||
|
||||
shadowView.backgroundColor = .systemBackground
|
||||
shadowView.layer.shadowColor = UIColor { provider in
|
||||
switch provider.userInterfaceStyle {
|
||||
case .dark: UIColor.black.withAlphaComponent(0.25)
|
||||
default: UIColor.black.withAlphaComponent(0.1)
|
||||
}
|
||||
}.cgColor
|
||||
shadowView.layer.shadowOpacity = 1
|
||||
shadowView.layer.shadowOffset = .zero
|
||||
shadowView.layer.shadowRadius = 8
|
||||
shadowView.layer.cornerRadius = ChidoriMenu.cornerRadius
|
||||
view.addSubview(shadowView)
|
||||
|
||||
backgroundView.backgroundColor = .init(red: 0.2, green: 0.2, blue: 0.2, alpha: 1)
|
||||
backgroundView.layer.masksToBounds = true
|
||||
backgroundView.layer.cornerRadius = ChidoriMenu.cornerRadius
|
||||
backgroundView.layer.cornerCurve = .continuous
|
||||
view.addSubview(backgroundView)
|
||||
|
||||
tableView.separatorInset = .zero
|
||||
tableView.contentInset = .zero
|
||||
tableView.delegate = self
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.allowsMultipleSelection = false
|
||||
tableView.selectionFollowsFocus = true
|
||||
tableView.allowsSelection = true
|
||||
tableView.allowsFocus = false
|
||||
tableView.sectionHeaderTopPadding = Self.sectionTopPadding
|
||||
tableView.verticalScrollIndicatorInsets = UIEdgeInsets(
|
||||
top: ChidoriMenu.cornerRadius,
|
||||
left: 0.0,
|
||||
bottom: ChidoriMenu.cornerRadius,
|
||||
right: 0.0
|
||||
)
|
||||
backgroundView.addSubview(tableView)
|
||||
|
||||
tableView.estimatedSectionHeaderHeight = 0.0
|
||||
tableView.estimatedRowHeight = 0.0
|
||||
tableView.estimatedSectionFooterHeight = 0.0
|
||||
|
||||
panGestureRecognizer.addTarget(
|
||||
self,
|
||||
action: #selector(panned(panGestureRecognizer:))
|
||||
)
|
||||
panGestureRecognizer.cancelsTouchesInView = true
|
||||
tableView.addGestureRecognizer(panGestureRecognizer)
|
||||
|
||||
updateSnapshot()
|
||||
}
|
||||
|
||||
override public func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
transitionController = nil
|
||||
}
|
||||
|
||||
override public func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
backgroundView.frame = view.bounds
|
||||
let contentFrame = backgroundView.bounds
|
||||
tableView.separatorStyle = .none
|
||||
tableView.frame = .init(
|
||||
x: contentFrame.minX,
|
||||
y: contentFrame.minY,
|
||||
width: contentFrame.width,
|
||||
height: contentFrame.height
|
||||
)
|
||||
|
||||
shadowView.layer.shadowPath = UIBezierPath(
|
||||
roundedRect: .init(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: ChidoriMenu.width,
|
||||
height: tableView.frame.height
|
||||
),
|
||||
cornerRadius: ChidoriMenu.cornerRadius
|
||||
).cgPath
|
||||
|
||||
let isTableViewNotFullyVisible = tableView.contentSize.height > view.bounds.height
|
||||
tableView.isScrollEnabled = isTableViewNotFullyVisible
|
||||
panGestureRecognizer.isEnabled = !isTableViewNotFullyVisible
|
||||
}
|
||||
|
||||
override public func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
|
||||
let superController = presentingViewController as? ChidoriMenu
|
||||
superController?.menuStackScaleFactor = 1.0
|
||||
super.dismiss(animated: flag, completion: completion)
|
||||
}
|
||||
|
||||
func dismissIfEmpty() {
|
||||
var count = 0
|
||||
for idx in 0 ..< dataSource.numberOfSections(in: tableView) {
|
||||
count += dataSource.tableView(tableView, numberOfRowsInSection: idx)
|
||||
}
|
||||
guard count <= 0 else { return }
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
func iterateMenusInStack(_ executing: @escaping (ChidoriMenu) -> Void) {
|
||||
var parent: ChidoriMenu? = self
|
||||
while let currentParent = parent {
|
||||
executing(currentParent)
|
||||
parent = currentParent.presentingViewController as? ChidoriMenu
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// Present+UIButton.swift
|
||||
//
|
||||
//
|
||||
// Created by QAQ on 2023/9/16.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public extension UIButton {
|
||||
func presentMenu() {
|
||||
guard let menu = retrieveMenu() else { return }
|
||||
present(menu: menu)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIButton {
|
||||
private func retrieveMenu() -> UIMenu? {
|
||||
for interaction in interactions {
|
||||
if let menuInteraction = interaction as? UIContextMenuInteraction,
|
||||
let menuConfig = menuInteraction.delegate?.contextMenuInteraction(
|
||||
menuInteraction,
|
||||
configurationForMenuAtLocation: .zero
|
||||
),
|
||||
let menu = menuConfig.retrieveMenu()
|
||||
{
|
||||
return menu
|
||||
}
|
||||
}
|
||||
if let menuConfig = contextMenuInteraction(
|
||||
.init(delegate: RetrieveMenuDelegate.shared),
|
||||
configurationForMenuAtLocation: .zero
|
||||
), let menu = menuConfig.retrieveMenu() {
|
||||
return menu
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private class RetrieveMenuDelegate: NSObject, UIContextMenuInteractionDelegate {
|
||||
static let shared = RetrieveMenuDelegate()
|
||||
func contextMenuInteraction(
|
||||
_: UIContextMenuInteraction,
|
||||
configurationForMenuAtLocation _: CGPoint
|
||||
) -> UIContextMenuConfiguration? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIContextMenuConfiguration {
|
||||
func retrieveMenu() -> UIMenu? {
|
||||
guard responds(to: NSSelectorFromString(["Provider", "action"].reversed().joined())),
|
||||
let actionProvider = value(forKey: ["Provider", "action", "_"].reversed().joined())
|
||||
else { return nil }
|
||||
typealias ActionProviderBlock = @convention(block) ([UIMenuElement]) -> (UIMenu?)
|
||||
let blockPtr = UnsafeRawPointer(Unmanaged<AnyObject>.passUnretained(actionProvider as AnyObject).toOpaque())
|
||||
let handler = unsafeBitCast(blockPtr, to: ActionProviderBlock.self)
|
||||
return handler([])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// Present+UIView.swift
|
||||
// ChidoriMenu
|
||||
//
|
||||
// Created by 秋星桥 on 1/19/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public extension UIView {
|
||||
func present(menu: UIMenu, anchorPoint: CGPoint? = nil, onMenuCreated: (ChidoriMenu) -> Void = { _ in }) {
|
||||
guard let presenter = parentViewController else { return }
|
||||
let chidoriMenu = ChidoriMenu(
|
||||
menu: menu,
|
||||
anchorPoint: anchorPoint ?? convert(.init(
|
||||
x: bounds.midX,
|
||||
y: bounds.midY
|
||||
), to: window)
|
||||
)
|
||||
onMenuCreated(chidoriMenu)
|
||||
presenter.present(chidoriMenu, animated: true) {
|
||||
chidoriMenu.dismissIfEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// UIAction.swift
|
||||
// ChidoriMenu
|
||||
//
|
||||
// Created by 秋星桥 on 1/19/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIAction {
|
||||
func execute() {
|
||||
guard responds(to: NSSelectorFromString("handler")),
|
||||
let handler = value(forKey: "_handler")
|
||||
else { return }
|
||||
typealias ActionBlock = @convention(block) (UIAction) -> Void
|
||||
let blockPtr = UnsafeRawPointer(Unmanaged<AnyObject>.passUnretained(handler as AnyObject).toOpaque())
|
||||
let block = unsafeBitCast(blockPtr, to: ActionBlock.self)
|
||||
return block(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// UIView.swift
|
||||
//
|
||||
//
|
||||
// Created by QAQ on 2023/9/16.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIView {
|
||||
var parentViewController: UIViewController? {
|
||||
weak var parentResponder: UIResponder? = self
|
||||
while parentResponder != nil {
|
||||
parentResponder = parentResponder!.next
|
||||
if let viewController = parentResponder as? UIViewController {
|
||||
return viewController
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//
|
||||
// ChidoriAnimationController.swift
|
||||
// Chidori
|
||||
//
|
||||
// Created by Christian Selig on 2021-02-15.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
private typealias ChidoriDelegateProtocol = AnyObject
|
||||
& UIViewControllerAnimatedTransitioning
|
||||
& UIViewControllerInteractiveTransitioning
|
||||
|
||||
class ChidoriAnimationController: NSObject, ChidoriDelegateProtocol {
|
||||
enum AnimationControllerType { case presentation, dismissal }
|
||||
|
||||
let type: AnimationControllerType
|
||||
var animator: UIViewPropertyAnimator?
|
||||
|
||||
weak var context: UIViewControllerContextTransitioning?
|
||||
|
||||
init(type: AnimationControllerType) {
|
||||
self.type = type
|
||||
}
|
||||
|
||||
func transitionDuration(
|
||||
using _: UIViewControllerContextTransitioning?
|
||||
) -> TimeInterval {
|
||||
switch type {
|
||||
case .presentation:
|
||||
0.5
|
||||
case .dismissal:
|
||||
0.35
|
||||
}
|
||||
}
|
||||
|
||||
func startInteractiveTransition(
|
||||
_ transitionContext: UIViewControllerContextTransitioning
|
||||
) {
|
||||
context = transitionContext
|
||||
animateTransition(using: transitionContext)
|
||||
}
|
||||
|
||||
func cancelTransition() {
|
||||
guard let context, let animator else { return }
|
||||
context.cancelInteractiveTransition()
|
||||
animator.isReversed = true
|
||||
animator.startAnimation()
|
||||
|
||||
guard type == .presentation else { return }
|
||||
if let presentingController = context.viewController(forKey: .from) {
|
||||
presentingController.view.tintAdjustmentMode = .automatic
|
||||
}
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
let interruptableAnimator = interruptibleAnimator(using: transitionContext)
|
||||
|
||||
switch type {
|
||||
case .presentation:
|
||||
if let menu = transitionContext.viewController(
|
||||
forKey: .to
|
||||
) as? ChidoriMenu { transitionContext.containerView.addSubview(menu.view) }
|
||||
|
||||
if let presentingController = transitionContext.viewController(
|
||||
forKey: .from
|
||||
) { presentingController.view.tintAdjustmentMode = .dimmed }
|
||||
case .dismissal:
|
||||
if let presentingController = transitionContext.viewController(
|
||||
forKey: .to
|
||||
) { presentingController.view.tintAdjustmentMode = .automatic }
|
||||
}
|
||||
|
||||
interruptableAnimator.startAnimation()
|
||||
}
|
||||
|
||||
func interruptibleAnimator(
|
||||
using context: UIViewControllerContextTransitioning
|
||||
) -> UIViewImplicitlyAnimating {
|
||||
if let animator { return animator }
|
||||
|
||||
let duration = transitionDuration(using: context)
|
||||
let propertyAnimator = UIViewPropertyAnimator(
|
||||
duration: duration,
|
||||
timingParameters: UISpringTimingParameters(
|
||||
dampingRatio: 0.8,
|
||||
initialVelocity: .init(dx: 0.8, dy: 0.8)
|
||||
)
|
||||
)
|
||||
|
||||
propertyAnimator.isInterruptible = true
|
||||
propertyAnimator.isUserInteractionEnabled = true
|
||||
|
||||
let isPresenting = type == .presentation
|
||||
|
||||
guard let menu = (
|
||||
isPresenting
|
||||
? context.viewController(forKey: .to)
|
||||
: context.viewController(forKey: .from)
|
||||
) as? ChidoriMenu else {
|
||||
preconditionFailure()
|
||||
}
|
||||
|
||||
let finalFrame = context.finalFrame(for: menu)
|
||||
menu.view.frame = finalFrame
|
||||
|
||||
let translationRequired: CGVector = .init(
|
||||
dx: 0,
|
||||
dy: menu.view.frame.height * -0.1
|
||||
)
|
||||
|
||||
let initialAlpha: CGFloat = isPresenting ? 0.0 : 1.0
|
||||
let finalAlpha: CGFloat = isPresenting ? 1.0 : 0.0
|
||||
|
||||
let transform = CGAffineTransform(
|
||||
translationX: translationRequired.dx,
|
||||
y: translationRequired.dy
|
||||
).scaledBy(x: 0.9, y: 0.9)
|
||||
let initialTransform = isPresenting ? transform : .identity
|
||||
let finalTransform = isPresenting ? .identity : transform
|
||||
|
||||
menu.view.transform = initialTransform
|
||||
.concatenating(menu.view.transform)
|
||||
menu.view.alpha = initialAlpha
|
||||
|
||||
propertyAnimator.addAnimations {
|
||||
menu.view.transform = finalTransform
|
||||
menu.view.alpha = finalAlpha
|
||||
}
|
||||
|
||||
propertyAnimator.addCompletion { _ in
|
||||
context.completeTransition(!context.transitionWasCancelled)
|
||||
self.animator = nil
|
||||
}
|
||||
|
||||
animator = propertyAnimator
|
||||
return propertyAnimator
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// ChidoriPresentationController.swift
|
||||
// Chidori
|
||||
//
|
||||
// Created by Christian Selig on 2021-02-15.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ChidoriPresentationController: UIPresentationController {
|
||||
let dimmView: UIView = .init()
|
||||
var minimalEdgeInset: CGFloat = 10
|
||||
|
||||
protocol Delegate: AnyObject {
|
||||
func didTapOverlayView(_ chidoriPresentationController: ChidoriPresentationController)
|
||||
}
|
||||
|
||||
weak var transitionDelegate: Delegate?
|
||||
|
||||
override func presentationTransitionWillBegin() {
|
||||
super.presentationTransitionWillBegin()
|
||||
|
||||
guard let containerView else { return }
|
||||
|
||||
dimmView.translatesAutoresizingMaskIntoConstraints = false
|
||||
dimmView.isUserInteractionEnabled = true
|
||||
dimmView.isAccessibilityElement = true
|
||||
dimmView.accessibilityTraits = .button
|
||||
dimmView.accessibilityHint = NSLocalizedString("Close Menu", comment: "")
|
||||
dimmView.backgroundColor = ChidoriMenu.dimmingBackgroundColor
|
||||
dimmView.alpha = 0.0
|
||||
|
||||
presentingViewController.view.tintAdjustmentMode = .dimmed
|
||||
containerView.addSubview(dimmView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
containerView.leadingAnchor.constraint(equalTo: dimmView.leadingAnchor),
|
||||
containerView.trailingAnchor.constraint(equalTo: dimmView.trailingAnchor),
|
||||
containerView.topAnchor.constraint(equalTo: dimmView.topAnchor),
|
||||
containerView.bottomAnchor.constraint(equalTo: dimmView.bottomAnchor),
|
||||
])
|
||||
|
||||
let tapGesture = UITapGestureRecognizer(target: nil, action: nil)
|
||||
tapGesture.addTarget(self, action: #selector(dimmViewTapped))
|
||||
dimmView.addGestureRecognizer(tapGesture)
|
||||
|
||||
if let transitionCoordinator = presentingViewController.transitionCoordinator {
|
||||
transitionCoordinator.animate { _ in self.dimmView.alpha = 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
override func dismissalTransitionWillBegin() {
|
||||
super.dismissalTransitionWillBegin()
|
||||
presentingViewController.view.tintAdjustmentMode = .automatic
|
||||
|
||||
if let transitionCoordinator = presentingViewController.transitionCoordinator {
|
||||
transitionCoordinator.animate { _ in self.dimmView.alpha = 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
override func dismissalTransitionDidEnd(_ completed: Bool) {
|
||||
super.dismissalTransitionDidEnd(completed)
|
||||
|
||||
if completed { dimmView.removeFromSuperview() }
|
||||
}
|
||||
|
||||
override var frameOfPresentedViewInContainerView: CGRect {
|
||||
guard let menu = presentedViewController as? ChidoriMenu else {
|
||||
return .zero
|
||||
}
|
||||
var height = menu.height
|
||||
if let heightLimit = containerView?.bounds.height {
|
||||
height = min(height, heightLimit * 0.75)
|
||||
}
|
||||
let menuSize = CGSize(width: ChidoriMenu.width, height: height)
|
||||
let originatingPoint = calculateOriginatingPoint(
|
||||
anchorPoint: menu.anchorPoint,
|
||||
menuSize: menuSize
|
||||
)
|
||||
return CGRect(origin: originatingPoint, size: menuSize)
|
||||
}
|
||||
|
||||
private func calculateOriginatingPoint(
|
||||
anchorPoint: CGPoint,
|
||||
menuSize: CGSize
|
||||
) -> CGPoint {
|
||||
guard let containerView else { return .zero }
|
||||
|
||||
let x: CGFloat = {
|
||||
let requiredMinX = anchorPoint.x - ChidoriMenu.width / 2
|
||||
let maxPossibleX = minimalEdgeInset
|
||||
+ containerView.safeAreaInsets.left
|
||||
let rightMostPermissableXPosition = containerView.bounds.width
|
||||
- minimalEdgeInset
|
||||
- containerView.safeAreaInsets.right
|
||||
- menuSize.width
|
||||
return min(
|
||||
rightMostPermissableXPosition,
|
||||
max(requiredMinX, maxPossibleX)
|
||||
)
|
||||
}()
|
||||
|
||||
let y: CGFloat = {
|
||||
let maxY = anchorPoint.y + menuSize.height + minimalEdgeInset + ChidoriMenu.offsetY
|
||||
let allowedY = containerView.bounds.height - containerView.safeAreaInsets.bottom
|
||||
if maxY < allowedY { return anchorPoint.y + ChidoriMenu.offsetY /* move below a little bit */ }
|
||||
// if not, iOS tries to keep as much in the bottom half of the screen as possible
|
||||
// to be closer to where the thumb normally is, presumably
|
||||
return containerView.bounds.height
|
||||
- minimalEdgeInset
|
||||
- containerView.safeAreaInsets.bottom
|
||||
- menuSize.height
|
||||
}()
|
||||
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
@objc func dimmViewTapped() {
|
||||
transitionDelegate?.didTapOverlayView(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// TableView.swift
|
||||
// ChidoriMenu
|
||||
//
|
||||
// Created by 秋星桥 on 1/19/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class TableView: UITableView {
|
||||
// - (BOOL)allowsHeaderViewsToFloat;
|
||||
// - (BOOL)allowsFooterViewsToFloat;
|
||||
@objc var allowsHeaderViewsToFloat: Bool { false }
|
||||
@objc var allowsFooterViewsToFloat: Bool { false }
|
||||
}
|
||||
@@ -14,8 +14,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Lakr233/MSDisplayLink",
|
||||
"state" : {
|
||||
"revision" : "c2fcd28cb99300d83acc30860ce252ef97c20b61",
|
||||
"version" : "1.1.1"
|
||||
"revision" : "6e92b5513e3473e064685e64056c4ac46470e7b0",
|
||||
"version" : "2.0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,21 +14,21 @@ let package = Package(
|
||||
.library(name: "Intelligents", targets: ["Intelligents"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"),
|
||||
.package(url: "https://github.com/Lakr233/SpringInterpolation", from: "1.3.0"),
|
||||
.package(url: "https://github.com/Lakr233/MSDisplayLink", from: "1.1.1"),
|
||||
.package(path: "../AffineGraphQL"),
|
||||
.package(path: "../ChidoriMenu"),
|
||||
.package(path: "../MarkdownView"),
|
||||
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.18.0"),
|
||||
.package(url: "https://github.com/LaunchDarkly/swift-eventsource.git", from: "3.3.0"),
|
||||
.package(url: "https://github.com/apple/swift-collections", from: "1.1.4"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "Intelligents", dependencies: [
|
||||
"AffineGraphQL",
|
||||
"SpringInterpolation",
|
||||
"MSDisplayLink",
|
||||
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
|
||||
"ChidoriMenu",
|
||||
"MarkdownView",
|
||||
.product(name: "Apollo", package: "apollo-ios"),
|
||||
.product(name: "LDSwiftEventSource", package: "swift-eventsource"),
|
||||
.product(name: "OrderedCollections", package: "swift-collections"),
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -37,14 +37,16 @@ public extension UIViewController {
|
||||
}
|
||||
|
||||
func presentError(_ error: Error, onDismiss: @escaping () -> Void = {}) {
|
||||
let alert = UIAlertController(
|
||||
title: "Error".localized(),
|
||||
message: error.localizedDescription,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "OK".localized(), style: .default) { _ in
|
||||
onDismiss()
|
||||
})
|
||||
present(alert, animated: true)
|
||||
DispatchQueue.main.async { [self] in
|
||||
let alert = UIAlertController(
|
||||
title: "Error".localized(),
|
||||
message: error.localizedDescription,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "OK".localized(), style: .default) { _ in
|
||||
onDismiss()
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,13 +57,23 @@ public extension UIViewController {
|
||||
button.transform = .identity
|
||||
button.setNeedsLayout()
|
||||
self.view.layoutIfNeeded()
|
||||
} completion: { _ in
|
||||
button.isUserInteractionEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
func dismissIntelligentsButton() {
|
||||
func dismissIntelligentsButton(animated: Bool = true) {
|
||||
guard let button = findIntelligentsButton() else { return }
|
||||
print("[*] \(button) is calling \(#function)")
|
||||
|
||||
button.isUserInteractionEnabled = false
|
||||
|
||||
if !animated {
|
||||
button.stopProgress()
|
||||
button.isHidden = true
|
||||
return
|
||||
}
|
||||
|
||||
button.stopProgress()
|
||||
button.setNeedsLayout()
|
||||
view.layoutIfNeeded()
|
||||
|
||||
@@ -43,6 +43,7 @@ public class IntelligentsButton: UIView {
|
||||
image.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -imageInsetValue),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(activityIndicator)
|
||||
[
|
||||
activityIndicator.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
@@ -74,17 +75,25 @@ public class IntelligentsButton: UIView {
|
||||
layer.cornerRadius = bounds.width / 2
|
||||
}
|
||||
|
||||
private var allowedTap = true
|
||||
|
||||
@objc func tapped() {
|
||||
guard allowedTap else { return }
|
||||
delegate?.onIntelligentsButtonTapped(self)
|
||||
}
|
||||
|
||||
public func beginProgress() {
|
||||
allowedTap = false
|
||||
activityIndicator.startAnimating()
|
||||
activityIndicator.isHidden = false
|
||||
image.isHidden = true
|
||||
bringSubviewToFront(activityIndicator)
|
||||
}
|
||||
|
||||
public func stopProgress() {
|
||||
allowedTap = true
|
||||
activityIndicator.stopAnimating()
|
||||
activityIndicator.isHidden = true
|
||||
image.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
//
|
||||
// ChatTableView+BaseCell.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
private let initialInsetValue: CGFloat = 24
|
||||
|
||||
extension ChatTableView {
|
||||
class BaseCell: UITableViewCell {
|
||||
var inset: UIEdgeInsets { // available for overrides
|
||||
.init(
|
||||
top: initialInsetValue / 2,
|
||||
left: initialInsetValue,
|
||||
bottom: initialInsetValue / 2,
|
||||
right: initialInsetValue
|
||||
)
|
||||
}
|
||||
|
||||
let containerView = UIView()
|
||||
let roundedBackgroundView = UIView()
|
||||
|
||||
var viewModel: AnyObject? {
|
||||
didSet { update(via: viewModel) }
|
||||
}
|
||||
|
||||
enum BackgroundColorType {
|
||||
case clear
|
||||
case highlight
|
||||
case warning
|
||||
case lightGray
|
||||
}
|
||||
|
||||
var backgroundColorType: BackgroundColorType = .clear {
|
||||
didSet {
|
||||
roundedBackgroundView.backgroundColor = backgroundColorType.color
|
||||
}
|
||||
}
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = .clear
|
||||
|
||||
roundedBackgroundView.clipsToBounds = true
|
||||
roundedBackgroundView.layer.cornerRadius = 8
|
||||
roundedBackgroundView.layer.masksToBounds = true
|
||||
|
||||
contentView.addSubview(roundedBackgroundView)
|
||||
roundedBackgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[ // inset half of the container view
|
||||
roundedBackgroundView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: inset.left / 2),
|
||||
roundedBackgroundView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -inset.right / 2),
|
||||
roundedBackgroundView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: inset.top / 2),
|
||||
roundedBackgroundView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -inset.bottom / 2),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
contentView.addSubview(containerView)
|
||||
containerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: inset.left),
|
||||
containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -inset.right),
|
||||
containerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: inset.top),
|
||||
containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -inset.bottom),
|
||||
].forEach { $0.isActive = true }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
viewModel = nil
|
||||
}
|
||||
|
||||
func update(via object: AnyObject?) {
|
||||
_ = object
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatTableView.BaseCell.BackgroundColorType {
|
||||
var color: UIColor {
|
||||
switch self {
|
||||
case .clear:
|
||||
.clear
|
||||
case .highlight:
|
||||
.accent
|
||||
case .warning:
|
||||
.systemRed.withAlphaComponent(0.1)
|
||||
case .lightGray:
|
||||
.systemGray.withAlphaComponent(0.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
//
|
||||
// ChatTableView+ChatCell.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import MarkdownUI
|
||||
import UIKit
|
||||
|
||||
extension ChatTableView {
|
||||
class ChatCell: BaseCell {
|
||||
let avatarView = CircleImageView()
|
||||
let titleLabel = UILabel()
|
||||
let markdownContainer = UIView()
|
||||
|
||||
var markdownView: UIView?
|
||||
var removableConstraints: [NSLayoutConstraint] = []
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
let spacingElement: CGFloat = 12
|
||||
let avatarSize: CGFloat = 24
|
||||
|
||||
containerView.addSubview(avatarView)
|
||||
avatarView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
avatarView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
|
||||
avatarView.topAnchor.constraint(equalTo: containerView.topAnchor),
|
||||
avatarView.widthAnchor.constraint(equalToConstant: avatarSize),
|
||||
avatarView.heightAnchor.constraint(equalToConstant: avatarSize),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: UIFont.labelFontSize, weight: .bold)
|
||||
containerView.addSubview(titleLabel)
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
titleLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: spacingElement),
|
||||
titleLabel.centerYAnchor.constraint(equalTo: avatarView.centerYAnchor),
|
||||
titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
containerView.addSubview(markdownContainer)
|
||||
markdownContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
markdownContainer.topAnchor.constraint(greaterThanOrEqualTo: avatarView.bottomAnchor, constant: spacingElement),
|
||||
markdownContainer.topAnchor.constraint(greaterThanOrEqualTo: titleLabel.bottomAnchor, constant: spacingElement),
|
||||
markdownContainer.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 0),
|
||||
markdownContainer.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 0),
|
||||
markdownContainer.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 0),
|
||||
].forEach { $0.isActive = true }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override func update(via object: AnyObject?) {
|
||||
super.update(via: object)
|
||||
|
||||
guard let viewModel = object as? ViewModel else {
|
||||
return
|
||||
}
|
||||
|
||||
switch viewModel.participant {
|
||||
case .system:
|
||||
avatarView.image = UIImage(systemName: "gearshape.fill")
|
||||
titleLabel.text = "System".localized()
|
||||
backgroundColorType = .warning
|
||||
case .assistant:
|
||||
avatarView.image = UIImage(named: "spark", in: .module, with: .none)
|
||||
titleLabel.text = "AFFiNE AI".localized()
|
||||
backgroundColorType = .lightGray
|
||||
case .user:
|
||||
avatarView.image = UIImage(systemName: "person.fill")
|
||||
titleLabel.text = "You".localized()
|
||||
backgroundColorType = .clear
|
||||
}
|
||||
|
||||
removableConstraints.forEach { $0.isActive = false }
|
||||
if let markdownView { markdownView.removeFromSuperview() }
|
||||
markdownContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||
|
||||
let hostingView: UIView = UIHostingView(
|
||||
rootView: Markdown(.init(viewModel.markdownDocument))
|
||||
)
|
||||
defer { markdownView = hostingView }
|
||||
markdownContainer.addSubview(hostingView)
|
||||
|
||||
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
hostingView.topAnchor.constraint(equalTo: markdownContainer.topAnchor),
|
||||
hostingView.leadingAnchor.constraint(equalTo: markdownContainer.leadingAnchor),
|
||||
hostingView.trailingAnchor.constraint(lessThanOrEqualTo: markdownContainer.trailingAnchor),
|
||||
hostingView.bottomAnchor.constraint(equalTo: markdownContainer.bottomAnchor),
|
||||
].forEach {
|
||||
$0.isActive = true
|
||||
removableConstraints.append($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatTableView.ChatCell {
|
||||
class ViewModel {
|
||||
let participant: Participant
|
||||
var markdownDocument: String
|
||||
|
||||
init(participant: Participant, markdownDocument: String) {
|
||||
self.participant = participant
|
||||
self.markdownDocument = markdownDocument
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatTableView.ChatCell.ViewModel {
|
||||
enum Participant {
|
||||
case user
|
||||
case assistant
|
||||
case system
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
//
|
||||
// ChatTableView+Data.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension ChatTableView {
|
||||
struct DataElement {
|
||||
enum CellType: String, CaseIterable {
|
||||
case base
|
||||
case chat
|
||||
}
|
||||
|
||||
let type: CellType
|
||||
let object: AnyObject?
|
||||
|
||||
init(type: CellType, object: AnyObject?) {
|
||||
self.type = type
|
||||
self.object = object
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatTableView.DataElement.CellType {
|
||||
var cellClassType: ChatTableView.BaseCell.Type {
|
||||
switch self {
|
||||
case .base:
|
||||
ChatTableView.BaseCell.self
|
||||
case .chat:
|
||||
ChatTableView.ChatCell.self
|
||||
}
|
||||
}
|
||||
|
||||
var cellIdentifier: String {
|
||||
NSStringFromClass(cellClassType)
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatTableView: UITableViewDelegate, UITableViewDataSource {
|
||||
func numberOfSections(in _: UITableView) -> Int {
|
||||
1
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
|
||||
dataSource.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: dataSource[indexPath.row].type.cellIdentifier, for: indexPath) as! BaseCell
|
||||
let object = dataSource[indexPath.row].object
|
||||
cell.update(via: object)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
//
|
||||
// ChatTableView+Scroll.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/12/23.
|
||||
//
|
||||
|
||||
import MSDisplayLink
|
||||
import SpringInterpolation
|
||||
import UIKit
|
||||
|
||||
extension ChatTableView: UIScrollViewDelegate, DisplayLinkDelegate {
|
||||
func synchronization() {
|
||||
let now = Date()
|
||||
defer { scrollAnimationDeltaTimeHolder = now }
|
||||
var deltaTime = now.timeIntervalSince(scrollAnimationDeltaTimeHolder)
|
||||
if deltaTime > 0.5 { deltaTime = 0.5 }
|
||||
guard scrollToBottomEnabled else { return }
|
||||
DispatchQueue.main.async { self.tikVsync(deltaTime: deltaTime * 2) }
|
||||
}
|
||||
|
||||
var bottomLocationY: CGFloat {
|
||||
tableView.contentSize.height - tableView.bounds.height
|
||||
}
|
||||
|
||||
private func tikVsync(deltaTime: TimeInterval) {
|
||||
guard scrollToBottomEnabled else { return }
|
||||
guard scrollToBottomAllowed else { return }
|
||||
// read from contentSize if not needed to scroll
|
||||
guard tableView.contentSize.height > tableView.bounds.height else {
|
||||
resetAnimationContext(to: tableView.contentOffset.y)
|
||||
return
|
||||
}
|
||||
guard abs(bottomLocationY - tableView.contentOffset.y) > 1 else {
|
||||
return
|
||||
}
|
||||
scrollAnimationContext.setTarget(bottomLocationY)
|
||||
scrollAnimationContext.update(withDeltaTime: deltaTime)
|
||||
tableView.contentOffset.y = scrollAnimationContext.value
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
func resetAnimationContext(to offset: CGFloat) {
|
||||
scrollAnimationContext.context = .init(
|
||||
currentPos: offset,
|
||||
currentVel: 0,
|
||||
targetPos: offset
|
||||
)
|
||||
}
|
||||
|
||||
func presentScrollBottomIfNeeded() {
|
||||
let visible = !scrollToBottomEnabled && scrollToBottomAllowed
|
||||
UIView.animate(withDuration: 0.25) { [self] in
|
||||
scrollDownButton.alpha = visible ? 1 : 0
|
||||
}
|
||||
}
|
||||
|
||||
func animationEnabledToggleDidSet(oldValue: Bool) {
|
||||
assert(Thread.isMainThread)
|
||||
guard scrollToBottomEnabled != oldValue else { return }
|
||||
resetAnimationContext(to: tableView.contentOffset.y)
|
||||
}
|
||||
|
||||
func animationAllowedToggleDidSet(oldValue: Bool) {
|
||||
assert(Thread.isMainThread)
|
||||
guard scrollToBottomAllowed != oldValue else { return }
|
||||
resetAnimationContext(to: tableView.contentOffset.y)
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
processScrollView(scrollView)
|
||||
}
|
||||
|
||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate _: Bool) {
|
||||
processScrollView(scrollView)
|
||||
}
|
||||
|
||||
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||
processScrollView(scrollView)
|
||||
}
|
||||
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
processScrollView(scrollView)
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
private func processScrollView(_ scrollView: UIScrollView) {
|
||||
guard let tableView = scrollView as? UITableView else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
processTableViewMovements(tableView)
|
||||
}
|
||||
|
||||
private func processTableViewMovements(_ tableView: UITableView) {
|
||||
defer { presentScrollBottomIfNeeded() }
|
||||
if tableView.isDragging {
|
||||
scrollToBottomEnabled = false
|
||||
scrollToBottomAllowed = false
|
||||
} else {
|
||||
// 如果没在减速 就开启 允许滚动
|
||||
scrollToBottomAllowed = !tableView.isDecelerating
|
||||
// 如果滚到底了 就开启 自动滚动
|
||||
let isBottom = tableView.contentOffset.y >= bottomLocationY
|
||||
if isBottom { scrollToBottomEnabled = true }
|
||||
}
|
||||
}
|
||||
|
||||
func scrollToBottom() {
|
||||
scrollToBottomEnabled = true
|
||||
scrollToBottomAllowed = true
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
//
|
||||
// ChatTableView.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import MSDisplayLink
|
||||
import SpringInterpolation
|
||||
import UIKit
|
||||
|
||||
class ChatTableView: UIView {
|
||||
let tableView = UITableView()
|
||||
let footerView = UIView()
|
||||
let scrollDownButton = ScrollBottom()
|
||||
|
||||
var dataSource: [DataElement] = []
|
||||
|
||||
// 控制自动滚动
|
||||
var scrollToBottomEnabled = false {
|
||||
didSet { animationEnabledToggleDidSet(oldValue: oldValue) }
|
||||
}
|
||||
|
||||
// 要让系统自己的动画完成以后再帮用户去挪
|
||||
var scrollToBottomAllowed = false {
|
||||
didSet { animationAllowedToggleDidSet(oldValue: oldValue) }
|
||||
}
|
||||
|
||||
var scrollAnimationController: DisplayLink = .init()
|
||||
var scrollAnimationContext: SpringInterpolation = .init()
|
||||
var scrollAnimationDeltaTimeHolder: Date = .init()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
for eachCase in DataElement.CellType.allCases {
|
||||
let cellClass = eachCase.cellClassType
|
||||
tableView.register(cellClass, forCellReuseIdentifier: eachCase.cellIdentifier)
|
||||
}
|
||||
|
||||
tableView.backgroundColor = .clear
|
||||
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
addSubview(tableView)
|
||||
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
tableView.topAnchor.constraint(equalTo: topAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
footerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
footerView.heightAnchor.constraint(equalToConstant: 128).isActive = true
|
||||
footerView.widthAnchor.constraint(equalToConstant: 128).isActive = true
|
||||
tableView.tableFooterView = footerView
|
||||
tableView.separatorStyle = .none
|
||||
|
||||
addSubview(scrollDownButton)
|
||||
scrollDownButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
// right bottom inset 16 size 32x32
|
||||
scrollDownButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
|
||||
scrollDownButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -32),
|
||||
scrollDownButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
scrollDownButton.heightAnchor.constraint(equalToConstant: 32),
|
||||
].forEach { $0.isActive = true }
|
||||
scrollDownButton.alpha = 0
|
||||
scrollDownButton.onTap = { [weak self] in
|
||||
self?.scrollToBottom()
|
||||
}
|
||||
|
||||
scrollAnimationController.delegatingObject(self)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
func reloadData() {
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
//
|
||||
// ScrollBottom.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/12/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class ScrollBottom: UIView {
|
||||
let imageView = UIImageView()
|
||||
let backgroundView: UIView = UIVisualEffectView(
|
||||
effect: UIBlurEffect(style: .systemUltraThinMaterialDark)
|
||||
)
|
||||
|
||||
var onTap: (() -> Void)?
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
addSubview(backgroundView)
|
||||
addSubview(imageView)
|
||||
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.image = UIImage(systemName: "arrow.down")
|
||||
imageView.tintColor = .accent
|
||||
|
||||
isUserInteractionEnabled = true
|
||||
let tapGesture = UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(tapAction)
|
||||
)
|
||||
addGestureRecognizer(tapGesture)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
clipsToBounds = true
|
||||
|
||||
layer.cornerRadius = (bounds.width + bounds.height) / 4
|
||||
layer.masksToBounds = true
|
||||
|
||||
backgroundView.frame = bounds
|
||||
let imageInset = (bounds.width + bounds.height) / 8
|
||||
imageView.frame = CGRect(
|
||||
x: imageInset,
|
||||
y: imageInset,
|
||||
width: bounds.width - 2 * imageInset,
|
||||
height: bounds.height - 2 * imageInset
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func tapAction() {
|
||||
onTap?()
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
//
|
||||
// IntelligentsChatController+Cell.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/12/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension IntelligentsChatController {
|
||||
func insertIntoTableView(viewModel: ChatTableView.DataElement) {
|
||||
assert(Thread.isMainThread)
|
||||
tableView.dataSource.append(viewModel)
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
func insertIntoTableView(withChatModel chatModel: ChatTableView.ChatCell.ViewModel) {
|
||||
insertIntoTableView(viewModel: .init(
|
||||
type: .chat,
|
||||
object: chatModel
|
||||
))
|
||||
}
|
||||
|
||||
func insertIntoTableView(withError error: Error) {
|
||||
insertIntoTableView(withChatModel: .init(
|
||||
participant: .system,
|
||||
markdownDocument: error.localizedDescription
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import AffineGraphQL
|
||||
import LDSwiftEventSource
|
||||
import MarkdownParser
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsChatController {
|
||||
@@ -29,6 +30,7 @@ extension IntelligentsChatController {
|
||||
@objc func chat_onSend() {
|
||||
beginProgress()
|
||||
let viewModel = inputBox.editor.viewModel.duplicate()
|
||||
viewModel.text = viewModel.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
inputBox.editor.viewModel.reset()
|
||||
inputBox.editor.updateValues()
|
||||
DispatchQueue.global().async {
|
||||
@@ -75,8 +77,12 @@ private extension IntelligentsChatController {
|
||||
|
||||
private extension IntelligentsChatController {
|
||||
func chat_onError(_ error: Error) {
|
||||
// TODO: IMPL add error cell
|
||||
print("[*] chat error", error)
|
||||
dispatchToMain {
|
||||
let key = UUID()
|
||||
let content = ChatContent.error(text: error.localizedDescription)
|
||||
self.simpleChatContents.updateValue(content, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
func chat_createSession(
|
||||
@@ -85,9 +91,9 @@ private extension IntelligentsChatController {
|
||||
) {
|
||||
Intelligents.qlClient.perform(
|
||||
mutation: CreateCopilotSessionMutation(options: .init(
|
||||
docId: "", // TODO: put the real data
|
||||
docId: metadata[.documentID] ?? "",
|
||||
promptName: Prompt.general_Chat_With_AFFiNE_AI.rawValue,
|
||||
workspaceId: "" // TODO: put the real data
|
||||
workspaceId: metadata[.workspaceID] ?? ""
|
||||
)),
|
||||
queue: .global()
|
||||
) { result in
|
||||
@@ -117,10 +123,10 @@ private extension IntelligentsChatController {
|
||||
// let images = viewModel.attachments
|
||||
|
||||
dispatchToMain {
|
||||
self.insertIntoTableView(withChatModel: .init(
|
||||
participant: .user,
|
||||
markdownDocument: text
|
||||
))
|
||||
let content = ChatContent.user(document: text)
|
||||
let key = UUID()
|
||||
self.simpleChatContents.updateValue(content, forKey: key)
|
||||
self.tableView.scrollLastCellToTop()
|
||||
}
|
||||
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
@@ -173,11 +179,13 @@ private extension IntelligentsChatController {
|
||||
return
|
||||
}
|
||||
|
||||
let chatModel = ChatTableView.ChatCell.ViewModel(
|
||||
participant: .assistant,
|
||||
markdownDocument: ""
|
||||
)
|
||||
dispatchToMain { self.insertIntoTableView(withChatModel: chatModel) }
|
||||
let contentIdentifier = UUID()
|
||||
dispatchToMain {
|
||||
self.simpleChatContents.updateValue(
|
||||
.assistant(document: "..."),
|
||||
forKey: contentIdentifier
|
||||
)
|
||||
}
|
||||
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
|
||||
@@ -193,8 +201,14 @@ private extension IntelligentsChatController {
|
||||
eventHandler.onErrorBlock = { error in
|
||||
self.chat_onError(error)
|
||||
}
|
||||
|
||||
var document = ""
|
||||
eventHandler.onMessageBlock = { _, message in
|
||||
self.chat_onEvent(message.data, chatModel: chatModel)
|
||||
self.dispatchToMain {
|
||||
document += message.data
|
||||
let content = ChatContent.assistant(document: document)
|
||||
self.simpleChatContents.updateValue(content, forKey: contentIdentifier)
|
||||
}
|
||||
}
|
||||
let eventSource = EventSource(config: .init(handler: eventHandler, url: url))
|
||||
chatTask = eventSource
|
||||
@@ -202,11 +216,38 @@ private extension IntelligentsChatController {
|
||||
|
||||
sem.wait()
|
||||
}
|
||||
}
|
||||
|
||||
func chat_onEvent(_ data: String, chatModel: ChatTableView.ChatCell.ViewModel) {
|
||||
dispatchToMain { [self] in
|
||||
chatModel.markdownDocument += data
|
||||
tableView.reloadData()
|
||||
extension IntelligentsChatController {
|
||||
func updateContentToPublisher() {
|
||||
assert(Thread.isMainThread)
|
||||
let copy = simpleChatContents
|
||||
let input: [MessageListView.Element] = copy.map { key, value in
|
||||
switch value {
|
||||
case let .assistant(document):
|
||||
let nodes = MarkdownParser().feed(document)
|
||||
return .init(
|
||||
id: key,
|
||||
cell: .assistant,
|
||||
viewModel: MessageListView.AssistantCell.ViewModel(blocks: nodes),
|
||||
object: nil
|
||||
)
|
||||
case let .user(document):
|
||||
return .init(
|
||||
id: key,
|
||||
cell: .user,
|
||||
viewModel: MessageListView.UserCell.ViewModel(text: document),
|
||||
object: nil
|
||||
)
|
||||
case let .error(text):
|
||||
return .init(
|
||||
id: key,
|
||||
cell: .hint,
|
||||
viewModel: MessageListView.HintCell.ViewModel(hint: text),
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
publisher.send(input)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
//
|
||||
// IntelligentsChatController+Keyboard.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/12/6.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsChatController {
|
||||
@objc func keyboardWillAppear(_ notification: Notification) {
|
||||
let info = notification.userInfo ?? [:]
|
||||
let keyboardHeight = (info[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?
|
||||
.cgRectValue
|
||||
.height ?? 0
|
||||
inputBoxKeyboardAdapterHeightConstraint.constant = keyboardHeight
|
||||
view.setNeedsUpdateConstraints()
|
||||
animateWithKeyboard(userInfo: info)
|
||||
}
|
||||
|
||||
@objc func keyboardWillDisappear(_ notification: Notification) {
|
||||
let info = notification.userInfo ?? [:]
|
||||
inputBoxKeyboardAdapterHeightConstraint.constant = 0
|
||||
view.setNeedsUpdateConstraints()
|
||||
animateWithKeyboard(userInfo: info)
|
||||
}
|
||||
|
||||
private func animateWithKeyboard(userInfo info: [AnyHashable: Any]) {
|
||||
let keyboardAnimationDuration = (info[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?
|
||||
.doubleValue ?? 0
|
||||
let keyboardAnimationCurve = (info[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?
|
||||
.uintValue ?? 0
|
||||
UIView.animate(
|
||||
withDuration: keyboardAnimationDuration,
|
||||
delay: 0,
|
||||
options: UIView.AnimationOptions(rawValue: keyboardAnimationCurve),
|
||||
animations: {
|
||||
self.view.layoutIfNeeded()
|
||||
},
|
||||
completion: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,22 +5,43 @@
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import LDSwiftEventSource
|
||||
import OrderedCollections
|
||||
import UIKit
|
||||
|
||||
public class IntelligentsChatController: UIViewController {
|
||||
let header = Header()
|
||||
let inputBoxKeyboardAdapter = UIView()
|
||||
let inputBox = InputBox()
|
||||
let progressView = UIActivityIndicatorView()
|
||||
let tableView = ChatTableView()
|
||||
|
||||
let publisher = PassthroughSubject<MessageListView.ElementPublisher.Output, Never>()
|
||||
lazy var tableView = MessageListView(dataPublisher: publisher.eraseToAnyPublisher())
|
||||
|
||||
var inputBoxKeyboardAdapterHeightConstraint = NSLayoutConstraint()
|
||||
|
||||
enum ChatContent {
|
||||
case user(document: String)
|
||||
case assistant(document: String)
|
||||
case error(text: String)
|
||||
}
|
||||
|
||||
var simpleChatContents: OrderedDictionary<UUID, ChatContent> = [:] {
|
||||
didSet { updateContentToPublisher() }
|
||||
}
|
||||
|
||||
var sessionID: String = "" {
|
||||
didSet { print("[*] new sessionID: \(sessionID)") }
|
||||
}
|
||||
|
||||
public enum MetadataKey: String {
|
||||
case documentID
|
||||
case workspaceID
|
||||
case content
|
||||
}
|
||||
|
||||
public var metadata: [MetadataKey: String] = [:]
|
||||
|
||||
var chatTask: EventSource?
|
||||
|
||||
override public var title: String? {
|
||||
@@ -38,19 +59,6 @@ public class IntelligentsChatController: UIViewController {
|
||||
title = "Chat with AI".localized()
|
||||
|
||||
overrideUserInterfaceStyle = .dark
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyboardWillDisappear),
|
||||
name: UIResponder.keyboardWillHideNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyboardWillAppear),
|
||||
name: UIResponder.keyboardWillShowNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@@ -59,7 +67,6 @@ public class IntelligentsChatController: UIViewController {
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
chatTask?.stop()
|
||||
chatTask = nil
|
||||
}
|
||||
@@ -73,11 +80,14 @@ public class IntelligentsChatController: UIViewController {
|
||||
|
||||
view.addSubview(header)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(inputBoxKeyboardAdapter)
|
||||
view.addSubview(inputBox)
|
||||
view.addSubview(progressView)
|
||||
setupLayout()
|
||||
|
||||
// TODO: IMPL
|
||||
inputBox.editor.controlBanner.cameraButton.isHidden = true
|
||||
inputBox.editor.controlBanner.photoButton.isHidden = true
|
||||
|
||||
chat_onLoad()
|
||||
}
|
||||
|
||||
@@ -96,21 +106,11 @@ public class IntelligentsChatController: UIViewController {
|
||||
header.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 44),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
inputBoxKeyboardAdapter.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
inputBoxKeyboardAdapter.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
inputBoxKeyboardAdapter.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
inputBoxKeyboardAdapter.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
inputBoxKeyboardAdapterHeightConstraint = inputBoxKeyboardAdapter.heightAnchor.constraint(equalToConstant: 0)
|
||||
inputBoxKeyboardAdapterHeightConstraint.isActive = true
|
||||
inputBoxKeyboardAdapter.backgroundColor = inputBox.backgroundView.backgroundColor
|
||||
|
||||
inputBox.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
inputBox.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
inputBox.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
inputBox.bottomAnchor.constraint(equalTo: inputBoxKeyboardAdapter.topAnchor),
|
||||
inputBox.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -118,7 +118,7 @@ public class IntelligentsChatController: UIViewController {
|
||||
tableView.topAnchor.constraint(equalTo: header.bottomAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: inputBox.topAnchor, constant: 16),
|
||||
tableView.bottomAnchor.constraint(equalTo: inputBox.topAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
inputBox.editor.controlBanner.sendButton.addTarget(
|
||||
@@ -136,10 +136,4 @@ public class IntelligentsChatController: UIViewController {
|
||||
].forEach { $0.isActive = true }
|
||||
progressView.style = .large
|
||||
}
|
||||
|
||||
override public func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
tableView.scrollToBottomEnabled = true
|
||||
tableView.scrollToBottomAllowed = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// MessageListView+AssistantCell.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import MarkdownParser
|
||||
import MarkdownView
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
class AssistantCell: BaseCell {
|
||||
let avatarView = UIImageView()
|
||||
let usernameView = UILabel()
|
||||
let markdownView = MarkdownView()
|
||||
|
||||
override func initializeContent() {
|
||||
super.initializeContent()
|
||||
|
||||
avatarView.contentMode = .scaleAspectFit
|
||||
avatarView.image = UIImage(named: "spark", in: .module, with: nil)
|
||||
usernameView.text = "AFFiNE AI"
|
||||
usernameView.font = .preferredFont(forTextStyle: .body).bold
|
||||
usernameView.textColor = .label
|
||||
|
||||
containerView.addSubview(avatarView)
|
||||
containerView.addSubview(usernameView)
|
||||
containerView.addSubview(markdownView)
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
markdownView.prepareForReuse()
|
||||
}
|
||||
|
||||
override func updateContent(
|
||||
object: any MessageListView.Element.ViewModel,
|
||||
originalObject _: Element.UserObject?
|
||||
) {
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
_ = object
|
||||
}
|
||||
|
||||
override func layoutContent(cache: any MessageListView.TableLayoutEngine.LayoutCache) {
|
||||
super.layoutContent(cache: cache)
|
||||
guard let cache = cache as? LayoutCache else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
avatarView.frame = cache.avatarRect
|
||||
usernameView.frame = cache.usernameRect
|
||||
markdownView.frame = cache.markdownFrame
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
markdownView.updateContentViews(cache.manifests)
|
||||
}
|
||||
}
|
||||
|
||||
override class func layoutInsideContainer(
|
||||
containerWidth: CGFloat,
|
||||
object: any MessageListView.Element.ViewModel
|
||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return LayoutCache()
|
||||
}
|
||||
let cache = LayoutCache()
|
||||
cache.width = containerWidth
|
||||
|
||||
let inset: CGFloat = 8
|
||||
let bubbleInset = UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset)
|
||||
|
||||
let avatarRect = CGRect(x: bubbleInset.left, y: bubbleInset.top, width: 24, height: 24)
|
||||
let usernameRect = CGRect(
|
||||
x: avatarRect.maxX + bubbleInset.right,
|
||||
y: bubbleInset.top,
|
||||
width: containerWidth - avatarRect.maxX - bubbleInset.right,
|
||||
height: 24
|
||||
)
|
||||
|
||||
let textWidth = containerWidth - bubbleInset.left - bubbleInset.right
|
||||
|
||||
var height: CGFloat = 0
|
||||
let manifests = object.blocks.map {
|
||||
let ret = $0.manifest(theme: object.theme)
|
||||
ret.setLayoutTheme(.default)
|
||||
ret.setLayoutWidth(textWidth)
|
||||
ret.layoutIfNeeded()
|
||||
height += ret.size.height + Theme.default.spacings.final
|
||||
return ret
|
||||
}
|
||||
if height > 0 { height -= Theme.default.spacings.final }
|
||||
let textRect = CGRect(
|
||||
x: bubbleInset.left,
|
||||
y: usernameRect.maxY + bubbleInset.bottom,
|
||||
width: textWidth,
|
||||
height: height
|
||||
)
|
||||
cache.markdownFrame = textRect
|
||||
cache.avatarRect = avatarRect
|
||||
cache.usernameRect = usernameRect
|
||||
cache.manifests = manifests
|
||||
cache.height = textRect.maxY + bubbleInset.bottom
|
||||
|
||||
return cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.AssistantCell {
|
||||
class ViewModel: MessageListView.Element.ViewModel {
|
||||
var theme: Theme
|
||||
var blocks: [BlockNode]
|
||||
|
||||
enum GroupLocation {
|
||||
case begin
|
||||
case center
|
||||
case end
|
||||
}
|
||||
|
||||
var groupLocation: GroupLocation = .center
|
||||
|
||||
init(theme: Theme = .default, blocks: [BlockNode]) {
|
||||
self.theme = theme
|
||||
self.blocks = blocks
|
||||
}
|
||||
|
||||
func contentIdentifier(hasher: inout Hasher) {
|
||||
hasher.combine(blocks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.AssistantCell {
|
||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
|
||||
var avatarRect: CGRect = .zero
|
||||
var usernameRect: CGRect = .zero
|
||||
|
||||
var markdownFrame: CGRect = .zero
|
||||
var manifests: [AnyBlockManifest] = []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//
|
||||
// MessageListView+BaseCell.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
class BaseCell: UITableViewCell, MessageListView.TableLayoutEngine.LayoutableCell {
|
||||
var associatedObject: Element? = nil
|
||||
var cancellable: Set<AnyCancellable> = []
|
||||
let containerView: UIView = .init()
|
||||
|
||||
var layoutEngine: MessageListView.TableLayoutEngine? = nil
|
||||
|
||||
func layoutCache() -> MessageListView.TableLayoutEngine.LayoutCache {
|
||||
guard let associatedObject, let engine = layoutEngine else {
|
||||
return MessageListView.TableLayoutEngine.ZeroLayoutCache()
|
||||
}
|
||||
let cache = engine.requestLayoutCacheFromCell(
|
||||
forElement: associatedObject,
|
||||
atWidth: bounds.width
|
||||
)
|
||||
return cache
|
||||
}
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
commitInit()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
commitInit()
|
||||
}
|
||||
|
||||
private func commitInit() {
|
||||
selectionStyle = .none
|
||||
separatorInset = .zero
|
||||
contentView.addSubview(containerView)
|
||||
contentView.clipsToBounds = false
|
||||
clipsToBounds = false
|
||||
initializeContent()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard let cache = layoutCache() as? LayoutCache else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
containerView.frame = cache.containerRect
|
||||
layoutContent(cache: cache.containerLayoutCache)
|
||||
}
|
||||
|
||||
func registerViewModel(element: Element) {
|
||||
removeViewModelObject()
|
||||
associatedObject = element
|
||||
updateContent(object: element.viewModel, originalObject: element.object)
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
func removeViewModelObject() {
|
||||
associatedObject = nil
|
||||
cancellable.forEach { $0.cancel() }
|
||||
cancellable.removeAll()
|
||||
}
|
||||
|
||||
func initializeContent() {}
|
||||
func updateContent(object: any Element.ViewModel, originalObject: Element.UserObject?) {
|
||||
_ = object
|
||||
_ = originalObject
|
||||
}
|
||||
|
||||
func layoutContent(cache: MessageListView.TableLayoutEngine.LayoutCache) {
|
||||
_ = cache
|
||||
}
|
||||
|
||||
class func layoutInsideContainer(
|
||||
containerWidth: CGFloat,
|
||||
object: any Element.ViewModel
|
||||
) -> MessageListView.TableLayoutEngine.LayoutCache {
|
||||
_ = containerWidth
|
||||
_ = object
|
||||
assertionFailure("must override")
|
||||
return MessageListView.TableLayoutEngine.ZeroLayoutCache()
|
||||
}
|
||||
|
||||
class func containerInset() -> UIEdgeInsets {
|
||||
let inset: CGFloat = 16
|
||||
let containerInset = UIEdgeInsets(top: inset / 2, left: inset, bottom: inset / 2, right: inset)
|
||||
return containerInset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.BaseCell {
|
||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
||||
var width: CGFloat
|
||||
var height: CGFloat
|
||||
var containerRect: CGRect
|
||||
var containerLayoutCache: any MessageListView.TableLayoutEngine.LayoutCache
|
||||
|
||||
init(
|
||||
width: CGFloat,
|
||||
height: CGFloat,
|
||||
containerRect: CGRect,
|
||||
containerLayoutCache: any MessageListView.TableLayoutEngine.LayoutCache
|
||||
) {
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.containerRect = containerRect
|
||||
self.containerLayoutCache = containerLayoutCache
|
||||
}
|
||||
}
|
||||
|
||||
class func resolveLayout(
|
||||
dataElement element: MessageListView.Element,
|
||||
contentWidth width: CGFloat
|
||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
||||
let object = element.viewModel
|
||||
let containerInset = MessageListView.BaseCell.containerInset()
|
||||
let containerWidth = width - containerInset.left - containerInset.right
|
||||
let containerCache = Self.layoutInsideContainer(containerWidth: containerWidth, object: object)
|
||||
let cellHeight = containerCache.height + containerInset.top + containerInset.bottom
|
||||
let containerRect = CGRect(
|
||||
x: containerInset.left,
|
||||
y: containerInset.top,
|
||||
width: containerWidth,
|
||||
height: containerCache.height
|
||||
)
|
||||
let cache = LayoutCache(
|
||||
width: width,
|
||||
height: cellHeight,
|
||||
containerRect: containerRect,
|
||||
containerLayoutCache: containerCache
|
||||
)
|
||||
return cache
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// MessageListView+HintCell.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
class HintCell: BaseCell {
|
||||
let label = UILabel()
|
||||
|
||||
override func initializeContent() {
|
||||
super.initializeContent()
|
||||
label.font = .preferredFont(forTextStyle: .footnote)
|
||||
label.alpha = 0.5
|
||||
label.numberOfLines = 0
|
||||
containerView.addSubview(label)
|
||||
}
|
||||
|
||||
override func updateContent(
|
||||
object: any MessageListView.Element.ViewModel,
|
||||
originalObject: Element.UserObject?
|
||||
) {
|
||||
super.updateContent(object: object, originalObject: originalObject)
|
||||
guard let object = object as? ViewModel else { return }
|
||||
label.attributedText = object.hint
|
||||
}
|
||||
|
||||
override func layoutContent(cache: any MessageListView.TableLayoutEngine.LayoutCache) {
|
||||
super.layoutContent(cache: cache)
|
||||
guard let cache = cache as? LayoutCache else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
label.frame = cache.labelFrame
|
||||
}
|
||||
|
||||
override class func layoutInsideContainer(
|
||||
containerWidth: CGFloat,
|
||||
object: any MessageListView.Element.ViewModel
|
||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return LayoutCache()
|
||||
}
|
||||
let cache = LayoutCache()
|
||||
cache.width = containerWidth
|
||||
cache.height = object.hint.measureHeight(usingWidth: containerWidth)
|
||||
cache.labelFrame = .init(x: 0, y: 0, width: containerWidth, height: cache.height)
|
||||
return cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.HintCell {
|
||||
class ViewModel: MessageListView.Element.ViewModel {
|
||||
var hint: NSAttributedString = .init()
|
||||
|
||||
init(hint: NSAttributedString) {
|
||||
self.hint = hint
|
||||
}
|
||||
|
||||
convenience init(hint: String) {
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.alignment = .center
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: UIFont.preferredFont(forTextStyle: .footnote),
|
||||
.originalFont: UIFont.preferredFont(forTextStyle: .footnote),
|
||||
.foregroundColor: UIColor.label,
|
||||
.paragraphStyle: paragraphStyle,
|
||||
]
|
||||
let text = NSMutableAttributedString(string: hint, attributes: attributes)
|
||||
self.init(hint: text)
|
||||
}
|
||||
|
||||
func contentIdentifier(hasher: inout Hasher) {
|
||||
hasher.combine(hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.HintCell {
|
||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
|
||||
var labelFrame: CGRect = .zero
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// MessageListView+SpacerCell.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/12.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
class SpacerCell: BaseCell {
|
||||
override class func layoutInsideContainer(
|
||||
containerWidth: CGFloat,
|
||||
object: any MessageListView.Element.ViewModel
|
||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return LayoutCache()
|
||||
}
|
||||
let cache = LayoutCache()
|
||||
cache.width = containerWidth
|
||||
cache.height = object.height
|
||||
return cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.SpacerCell {
|
||||
class ViewModel: MessageListView.Element.ViewModel {
|
||||
var height: CGFloat
|
||||
init(height: CGFloat) {
|
||||
self.height = height
|
||||
}
|
||||
|
||||
func contentIdentifier(hasher: inout Hasher) {
|
||||
hasher.combine(height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.SpacerCell {
|
||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//
|
||||
// MessageListView+UserCell.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
class UserCell: BaseCell {
|
||||
let avatarView = UIImageView()
|
||||
let usernameView = UILabel()
|
||||
let bubbleView = UIView()
|
||||
let textView = UITextView()
|
||||
|
||||
override func initializeContent() {
|
||||
super.initializeContent()
|
||||
|
||||
textView.isSelectable = true
|
||||
textView.isScrollEnabled = true
|
||||
textView.isEditable = false
|
||||
textView.showsVerticalScrollIndicator = false
|
||||
textView.showsHorizontalScrollIndicator = false
|
||||
textView.textColor = .label
|
||||
textView.textContainer.lineFragmentPadding = .zero
|
||||
textView.textAlignment = .natural
|
||||
textView.backgroundColor = .clear
|
||||
textView.textContainerInset = .zero
|
||||
textView.textContainer.lineBreakMode = .byTruncatingTail
|
||||
|
||||
avatarView.contentMode = .scaleAspectFit
|
||||
avatarView.image = UIImage(systemName: "person.fill")
|
||||
usernameView.text = "You"
|
||||
usernameView.font = .preferredFont(forTextStyle: .body).bold
|
||||
usernameView.textColor = .label
|
||||
|
||||
bubbleView.layer.cornerRadius = 8
|
||||
bubbleView.backgroundColor = .gray.withAlphaComponent(0.1)
|
||||
|
||||
containerView.addSubview(bubbleView)
|
||||
containerView.addSubview(avatarView)
|
||||
containerView.addSubview(usernameView)
|
||||
containerView.addSubview(textView)
|
||||
}
|
||||
|
||||
override func updateContent(
|
||||
object: any MessageListView.Element.ViewModel,
|
||||
originalObject: Element.UserObject?
|
||||
) {
|
||||
super.updateContent(object: object, originalObject: originalObject)
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
textView.attributedText = object.text
|
||||
}
|
||||
|
||||
override func layoutContent(cache: any MessageListView.TableLayoutEngine.LayoutCache) {
|
||||
super.layoutContent(cache: cache)
|
||||
guard let cache = cache as? LayoutCache else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
bubbleView.frame = cache.bubbleFrame
|
||||
avatarView.frame = cache.avatarFrame
|
||||
usernameView.frame = cache.usernameFrame
|
||||
textView.frame = cache.labelFrame
|
||||
}
|
||||
|
||||
override class func layoutInsideContainer(
|
||||
containerWidth: CGFloat,
|
||||
object: any MessageListView.Element.ViewModel
|
||||
) -> any MessageListView.TableLayoutEngine.LayoutCache {
|
||||
guard let object = object as? ViewModel else {
|
||||
assertionFailure()
|
||||
return LayoutCache()
|
||||
}
|
||||
let cache = LayoutCache()
|
||||
cache.width = containerWidth
|
||||
|
||||
let inset: CGFloat = 8
|
||||
let bubbleInset = UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset)
|
||||
|
||||
let avatarRect = CGRect(
|
||||
x: bubbleInset.left,
|
||||
y: bubbleInset.top,
|
||||
width: 24,
|
||||
height: 24
|
||||
)
|
||||
let usernameFrame = CGRect(
|
||||
x: avatarRect.maxX + inset,
|
||||
y: bubbleInset.top,
|
||||
width: containerWidth - avatarRect.maxX - bubbleInset.right,
|
||||
height: 24
|
||||
)
|
||||
|
||||
let textWidth = min(
|
||||
object.text.measureWidth(),
|
||||
containerWidth - inset * 2
|
||||
)
|
||||
let textHeight = object.text.measureHeight(usingWidth: textWidth)
|
||||
let textRect = CGRect(
|
||||
x: bubbleInset.left,
|
||||
y: avatarRect.maxY + bubbleInset.top,
|
||||
width: textWidth,
|
||||
height: textHeight
|
||||
)
|
||||
let bubbleRect = CGRect(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: containerWidth,
|
||||
height: textRect.maxY + bubbleInset.bottom
|
||||
)
|
||||
cache.bubbleFrame = bubbleRect
|
||||
cache.avatarFrame = avatarRect
|
||||
cache.usernameFrame = usernameFrame
|
||||
cache.labelFrame = textRect
|
||||
cache.height = bubbleRect.maxY
|
||||
return cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.UserCell {
|
||||
class ViewModel: MessageListView.Element.ViewModel {
|
||||
var text: NSAttributedString = .init()
|
||||
|
||||
init(text: NSAttributedString) {
|
||||
self.text = text
|
||||
}
|
||||
|
||||
convenience init(text: String) {
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.alignment = .natural
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: UIFont.preferredFont(forTextStyle: .body),
|
||||
.originalFont: UIFont.preferredFont(forTextStyle: .body),
|
||||
.foregroundColor: UIColor.label,
|
||||
.paragraphStyle: paragraphStyle,
|
||||
]
|
||||
var text = text
|
||||
while text.contains("\n\n\n") {
|
||||
text = text.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
||||
}
|
||||
print(text)
|
||||
self.init(text: NSMutableAttributedString(string: text, attributes: attributes))
|
||||
}
|
||||
|
||||
func contentIdentifier(hasher: inout Hasher) {
|
||||
hasher.combine(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.UserCell {
|
||||
class LayoutCache: MessageListView.TableLayoutEngine.LayoutCache {
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
|
||||
var bubbleFrame: CGRect = .zero
|
||||
var labelFrame: CGRect = .zero
|
||||
var avatarFrame: CGRect = .zero
|
||||
var usernameFrame: CGRect = .zero
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// MessageListView+DataElement.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
struct Element: Identifiable {
|
||||
let id: AnyHashable // equals to message id if applicable
|
||||
|
||||
enum Cell: String, CaseIterable {
|
||||
case base
|
||||
case hint
|
||||
case user
|
||||
case assistant
|
||||
case spacer
|
||||
}
|
||||
|
||||
let cell: Cell
|
||||
let viewModel: any ViewModel
|
||||
|
||||
typealias UserObject = any(Identifiable & Hashable)
|
||||
let object: UserObject?
|
||||
|
||||
init(id: AnyHashable, cell: Cell, viewModel: any ViewModel, object: UserObject?) {
|
||||
assert(cell != .base)
|
||||
self.id = id
|
||||
self.cell = cell
|
||||
self.viewModel = viewModel
|
||||
self.object = object
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.Element.Cell {
|
||||
var cellClass: MessageListView.BaseCell.Type {
|
||||
switch self {
|
||||
case .base:
|
||||
MessageListView.BaseCell.self
|
||||
case .hint:
|
||||
MessageListView.HintCell.self
|
||||
case .user:
|
||||
MessageListView.UserCell.self
|
||||
case .assistant:
|
||||
MessageListView.AssistantCell.self
|
||||
case .spacer:
|
||||
MessageListView.SpacerCell.self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.Element {
|
||||
protocol ViewModel {
|
||||
func contentIdentifier(hasher: inout Hasher)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// MessageListView+Delegate.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/6.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension MessageListView: UITableViewDelegate, UITableViewDataSource {
|
||||
func item(forIndexPath indexPath: IndexPath) -> Element? {
|
||||
guard indexPath.row < elements.count else {
|
||||
return nil
|
||||
}
|
||||
guard indexPath.row >= 0 else {
|
||||
return nil
|
||||
}
|
||||
return elements.values[indexPath.row]
|
||||
}
|
||||
|
||||
func numberOfSections(in _: UITableView) -> Int {
|
||||
1
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
|
||||
elements.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
guard let item = item(forIndexPath: indexPath) else {
|
||||
assertionFailure()
|
||||
return UITableViewCell()
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: item.cell.rawValue, for: indexPath)
|
||||
if let cell = cell as? BaseCell {
|
||||
cell.layoutEngine = layoutEngine
|
||||
cell.registerViewModel(element: item)
|
||||
}
|
||||
cell.backgroundColor = .clear
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
guard let item = item(forIndexPath: indexPath) else {
|
||||
return 0
|
||||
}
|
||||
if let height = layoutEngine.height(forElement: item) {
|
||||
heightKeeper[item.id] = height
|
||||
return height
|
||||
}
|
||||
let ret = layoutEngine.resolveLayoutNow(item).height
|
||||
heightKeeper[item.id] = ret
|
||||
return ret
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
guard let item = item(forIndexPath: indexPath) else {
|
||||
return 0
|
||||
}
|
||||
if let height = layoutEngine.height(forElement: item) {
|
||||
return height
|
||||
}
|
||||
if let height = heightKeeper[item.id] {
|
||||
return height
|
||||
}
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//
|
||||
// MessageListView+LayoutEngine.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension MessageListView {
|
||||
class TableLayoutEngine {
|
||||
private let lock = NSLock()
|
||||
struct LayoutCacheBox {
|
||||
var cache: LayoutCache
|
||||
var contentIdentifier: Int
|
||||
}
|
||||
|
||||
private var layoutCache: [Element.ID: LayoutCacheBox] = [:]
|
||||
private(set) var contentWidth: CGFloat = .zero
|
||||
var layoutSession: UUID = .init()
|
||||
|
||||
func setContentWidth(_ width: CGFloat) {
|
||||
accessLayoutCache { _ in
|
||||
contentWidth = width
|
||||
layoutSession = .init()
|
||||
}
|
||||
}
|
||||
|
||||
func createSession() -> UUID {
|
||||
let session = UUID()
|
||||
layoutSession = session
|
||||
return session
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func accessLayoutCache<T>(_ block: (inout [Element.ID: LayoutCacheBox]) -> T) -> T {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return block(&layoutCache)
|
||||
}
|
||||
|
||||
func contentIdentifier(forElement dataElement: Element) -> Int? {
|
||||
accessLayoutCache { pool in
|
||||
guard let box = pool[dataElement.id] else { return nil }
|
||||
return box.contentIdentifier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func viewCallingUpdateLayoutEngineWidth() {
|
||||
guard layoutEngine.contentWidth != tableView.bounds.width else { return }
|
||||
layoutEngine.setContentWidth(tableView.bounds.width)
|
||||
reconfigure(enforceReload: false)
|
||||
NSObject.cancelPreviousPerformRequests(
|
||||
withTarget: self,
|
||||
selector: #selector(resolveAllLayoutInBackground),
|
||||
object: nil
|
||||
)
|
||||
perform(#selector(resolveAllLayoutInBackground), with: nil, afterDelay: 0.1)
|
||||
}
|
||||
|
||||
@objc private func resolveAllLayoutInBackground() {
|
||||
let items = Array(elements.values)
|
||||
let date = Date()
|
||||
DispatchQueue.global().async {
|
||||
let session = self.layoutEngine.createSession()
|
||||
for element in items {
|
||||
guard self.layoutEngine.layoutSession == session else { continue }
|
||||
self.layoutEngine.resolveLayoutNow(element)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.reconfigure(enforceReload: true)
|
||||
print("[*] layout engine updated \(items.count) items in \(Date().timeIntervalSince(date)) seconds")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.TableLayoutEngine {
|
||||
protocol LayoutableCell: AnyObject {
|
||||
static func resolveLayout(
|
||||
dataElement: MessageListView.Element,
|
||||
contentWidth: CGFloat
|
||||
) -> LayoutCache
|
||||
}
|
||||
|
||||
protocol LayoutCache: AnyObject {
|
||||
var width: CGFloat { get }
|
||||
var height: CGFloat { get }
|
||||
}
|
||||
|
||||
class ZeroLayoutCache: LayoutCache {
|
||||
var width: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.TableLayoutEngine {
|
||||
@discardableResult
|
||||
func resolveLayoutNow(_ element: MessageListView.Element) -> LayoutCache {
|
||||
var hasher = Hasher()
|
||||
element.viewModel.contentIdentifier(hasher: &hasher)
|
||||
let contentIdentifier = hasher.finalize()
|
||||
|
||||
if let cacheBox = accessLayoutCache({ $0[element.id] }) {
|
||||
if cacheBox.cache.width == contentWidth,
|
||||
cacheBox.contentIdentifier == contentIdentifier
|
||||
{ return cacheBox.cache }
|
||||
}
|
||||
|
||||
let target = element.cell.cellClass.self
|
||||
let cache = target.resolveLayout(dataElement: element, contentWidth: contentWidth)
|
||||
let cacheBox = LayoutCacheBox(cache: cache, contentIdentifier: contentIdentifier)
|
||||
accessLayoutCache { $0[element.id] = cacheBox }
|
||||
return cache
|
||||
}
|
||||
|
||||
func requestLayoutCacheFromCell(
|
||||
forElement dataElement: MessageListView.Element,
|
||||
atWidth width: CGFloat
|
||||
) -> LayoutCache {
|
||||
let cache = accessLayoutCache { pool -> LayoutCache? in
|
||||
guard let box = pool[dataElement.id] else { return nil }
|
||||
guard box.contentIdentifier == dataElement.object?.hashValue else { return nil }
|
||||
guard box.cache.width == contentWidth else { return nil }
|
||||
guard box.cache.width == width else { return nil }
|
||||
return box.cache
|
||||
}
|
||||
if let cache { return cache }
|
||||
return resolveLayoutNow(dataElement)
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView.TableLayoutEngine {
|
||||
func height(forElement dataElement: MessageListView.Element) -> CGFloat? {
|
||||
accessLayoutCache { pool in
|
||||
guard let box = pool[dataElement.id] else { return nil }
|
||||
guard box.contentIdentifier == dataElement.object?.hashValue else { return nil }
|
||||
guard box.cache.width == contentWidth else { return nil }
|
||||
return box.cache.height
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//
|
||||
// MessageListView+Update.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import OrderedCollections
|
||||
import UIKit
|
||||
|
||||
extension MessageListView {
|
||||
func setupPublishers(dataPublisher: AnyPublisher<[Element], Never>) {
|
||||
// process input from data source where we transform those to view model
|
||||
let publisher = dataPublisher
|
||||
.map { input -> [Element] in input + [Element(
|
||||
id: "spacer",
|
||||
cell: .spacer,
|
||||
viewModel: MessageListView.SpacerCell.ViewModel(height: 32),
|
||||
object: nil
|
||||
)] }
|
||||
.map { output in
|
||||
OrderedDictionary<Element.ID, Element>(
|
||||
uniqueKeysWithValues: output.map { ($0.id, $0) }
|
||||
)
|
||||
}
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
// after so, limit the refresh rate so we can handle them better
|
||||
let updateQueue = DispatchQueue(label: "flowdown.message-list-update-queue", qos: .userInteractive)
|
||||
let inQueuePublisher = publisher
|
||||
.throttle(for: .seconds(1 / 5), scheduler: updateQueue, latest: true)
|
||||
.eraseToAnyPublisher()
|
||||
|
||||
// finally before sending to display, call layout engine to process those items
|
||||
inQueuePublisher
|
||||
.sink { [weak self] output in self?.prepare(forNewElements: output) }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func prepare(forNewElements elements: Elements) {
|
||||
print("[*] received \(elements.count) for update at \(Date())")
|
||||
elementUpdateProcessLock.lock()
|
||||
distributedPendingUpdateElements = elements
|
||||
elementUpdateProcessLock.unlock()
|
||||
performSelector(onMainThread: #selector(elementsUpdateExecute), with: nil, waitUntilDone: false)
|
||||
}
|
||||
|
||||
private func pickupElementsPair() -> (oldValue: Elements, newValue: Elements)? {
|
||||
#if DEBUG // just make sure assert is not called in release mode
|
||||
assert(!elementUpdateProcessLock.try(), "Should not call this method without lock")
|
||||
#endif
|
||||
guard let distributedPendingUpdateElements else { return nil }
|
||||
|
||||
let oldValue = elements
|
||||
elements = distributedPendingUpdateElements
|
||||
self.distributedPendingUpdateElements = nil
|
||||
print("[*] pikup is sending \(elements.count) for update at \(Date())")
|
||||
return (oldValue, distributedPendingUpdateElements)
|
||||
}
|
||||
|
||||
@objc private func elementsUpdateExecute() {
|
||||
assert(Thread.isMainThread)
|
||||
elementUpdateProcessLock.lock()
|
||||
defer { elementUpdateProcessLock.unlock() }
|
||||
let pickup = pickupElementsPair()
|
||||
guard let (oldValue, newValue) = pickup else { return }
|
||||
guard window != nil else { return }
|
||||
for value in heightKeeper.keys where !newValue.keys.contains(value) {
|
||||
heightKeeper.removeValue(forKey: value)
|
||||
}
|
||||
|
||||
let shouldRealodTableView = newValue.count != oldValue.count
|
||||
let contentOffset = tableView.contentOffset
|
||||
UIView.performWithoutAnimation {
|
||||
self.reconfigure(enforceReload: shouldRealodTableView)
|
||||
self.tableView.layoutIfNeeded()
|
||||
}
|
||||
tableView.contentOffset = contentOffset
|
||||
}
|
||||
|
||||
func reconfigure(enforceReload: Bool) {
|
||||
if enforceReload || tableView(tableView, numberOfRowsInSection: 0) != elements.count {
|
||||
tableView.reloadData()
|
||||
return
|
||||
}
|
||||
var requiresReload = [IndexPath]()
|
||||
for indexPath in tableView.indexPathsForVisibleRows ?? [] {
|
||||
guard let item = item(forIndexPath: indexPath) else { continue }
|
||||
guard let cell = tableView.cellForRow(at: indexPath) as? BaseCell else { continue }
|
||||
guard type(of: cell) == item.cell.cellClass else {
|
||||
requiresReload.append(indexPath)
|
||||
continue
|
||||
}
|
||||
layoutEngine.resolveLayoutNow(item)
|
||||
cell.registerViewModel(element: item)
|
||||
}
|
||||
tableView.beginUpdates()
|
||||
tableView.reloadRows(at: requiresReload, with: .none)
|
||||
tableView.endUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
extension MessageListView {
|
||||
func scrollToBottom(useTableViewAnimation: Bool = false) {
|
||||
guard elements.count > 0 else { return }
|
||||
guard tableView.contentSize.height > tableView.frame.height else { return }
|
||||
let targetIndexPath = IndexPath(row: elements.count - 1, section: 0)
|
||||
let cellRect = tableView.rectForRow(at: targetIndexPath)
|
||||
if tableView.contentOffset.y + tableView.frame.height >= cellRect.origin.y + cellRect.height { return }
|
||||
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.8) {
|
||||
self.tableView.scrollToRow(
|
||||
at: targetIndexPath,
|
||||
at: .bottom,
|
||||
animated: useTableViewAnimation
|
||||
)
|
||||
self.tableView.layoutIfNeeded()
|
||||
}
|
||||
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(finishAutomaticScroll), object: nil)
|
||||
isAutomaticScrollAnimating = true
|
||||
perform(#selector(finishAutomaticScroll), with: nil, afterDelay: 0.5)
|
||||
}
|
||||
|
||||
func scrollLastCellToTop(useTableViewAnimation: Bool = false) {
|
||||
guard elements.count > 1 else { return }
|
||||
guard tableView.contentSize.height > tableView.frame.height else { return }
|
||||
UIView.animate(withDuration: 0.35, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.8) {
|
||||
self.tableView.scrollToRow(
|
||||
at: IndexPath(row: self.elements.count - 1, section: 0),
|
||||
at: .top,
|
||||
animated: useTableViewAnimation
|
||||
)
|
||||
}
|
||||
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(finishAutomaticScroll), object: nil)
|
||||
isAutomaticScrollAnimating = true
|
||||
perform(#selector(finishAutomaticScroll), with: nil, afterDelay: 0.5)
|
||||
}
|
||||
|
||||
@objc private func finishAutomaticScroll() {
|
||||
isAutomaticScrollAnimating = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// MessageListView.swift
|
||||
// FlowDown
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/2.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import OrderedCollections
|
||||
import UIKit
|
||||
|
||||
class MessageListView: UIView {
|
||||
typealias ElementPublisher = AnyPublisher<[Element], Never>
|
||||
typealias Elements = OrderedDictionary<Element.ID, Element>
|
||||
var elements: Elements = .init()
|
||||
|
||||
var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
let tableView: UITableView = .init(frame: .zero, style: .plain)
|
||||
|
||||
let layoutEngine = TableLayoutEngine()
|
||||
var heightKeeper: [Element.ID: CGFloat] = [:]
|
||||
let elementUpdateProcessLock = NSLock()
|
||||
var distributedPendingUpdateElements: Elements? = nil
|
||||
var isAutomaticScrollAnimating: Bool = false
|
||||
|
||||
let footerView = UIView(frame: .init(x: 0, y: 0, width: 0, height: 500))
|
||||
|
||||
init(dataPublisher: AnyPublisher<[Element], Never>) {
|
||||
super.init(frame: .zero)
|
||||
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
tableView.allowsSelection = false
|
||||
tableView.allowsMultipleSelection = false
|
||||
tableView.allowsFocus = false
|
||||
tableView.selectionFollowsFocus = true
|
||||
tableView.separatorColor = .clear
|
||||
tableView.backgroundColor = .clear
|
||||
for cellIdentifier in Element.Cell.allCases {
|
||||
tableView.register(cellIdentifier.cellClass, forCellReuseIdentifier: cellIdentifier.rawValue)
|
||||
}
|
||||
addSubview(tableView)
|
||||
|
||||
tableView.tableFooterView = footerView
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
tableView.topAnchor.constraint(equalTo: topAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
tableView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
tableView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
|
||||
setupPublishers(dataPublisher: dataPublisher)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancellables.forEach { $0.cancel() }
|
||||
cancellables.removeAll()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
viewCallingUpdateLayoutEngineWidth()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// EphemeralAction.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/8.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension IntelligentsEphemeralActionController {
|
||||
enum EphemeralAction {
|
||||
public enum Language: String, CaseIterable {
|
||||
case langEnglish = "English"
|
||||
case langSpanish = "Spanish"
|
||||
case langGerman = "German"
|
||||
case langFrench = "French"
|
||||
case langItalian = "Italian"
|
||||
case langSimplifiedChinese = "Simplified Chinese"
|
||||
case langTraditionalChinese = "Traditional Chinese"
|
||||
case langJapanese = "Japanese"
|
||||
case langRussian = "Russian"
|
||||
case langKorean = "Korean"
|
||||
}
|
||||
|
||||
case translate(to: Language)
|
||||
case summarize
|
||||
}
|
||||
}
|
||||
|
||||
extension IntelligentsEphemeralActionController.EphemeralAction {
|
||||
var title: String {
|
||||
switch self {
|
||||
case let .translate(to):
|
||||
String(format: NSLocalizedString("Translate to %@", comment: ""), to.rawValue)
|
||||
case .summarize:
|
||||
NSLocalizedString("Summarize", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
var prompt: Prompt {
|
||||
switch self {
|
||||
case .translate:
|
||||
.general_Translate_to
|
||||
case .summarize:
|
||||
.general_Summary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// ImageRotatedPreview.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/8.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public class RotatedImagePreview: UIView {
|
||||
let imageView = UIImageView()
|
||||
let rotationDegree: CGFloat = 5
|
||||
|
||||
public init() {
|
||||
super.init(frame: .zero)
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.layer.cornerRadius = 16
|
||||
imageView.clipsToBounds = true
|
||||
addSubview(imageView)
|
||||
|
||||
clipsToBounds = false
|
||||
|
||||
heightAnchor.constraint(equalToConstant: 300).isActive = true
|
||||
imageView.transform = CGAffineTransform(rotationAngle: rotationDegree * CGFloat.pi / 180)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
public required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
public func configure(previewImage: UIImage) {
|
||||
imageView.image = previewImage
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override public func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
guard let image = imageView.image else {
|
||||
imageView.frame = .zero
|
||||
return
|
||||
}
|
||||
|
||||
let viewHeight = bounds.height // limiter
|
||||
guard bounds.height > 0 else { return }
|
||||
|
||||
// fit in side
|
||||
let imageAspectRatio = image.size.width / image.size.height
|
||||
let imageHeight = viewHeight
|
||||
let imageWidth = imageHeight * imageAspectRatio
|
||||
|
||||
imageView.frame = CGRect(
|
||||
x: (bounds.width - imageWidth) / 2,
|
||||
y: (bounds.height - imageHeight) / 2,
|
||||
width: imageWidth,
|
||||
height: imageHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//
|
||||
// IntelligentsEphemeralActionController+API.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/15.
|
||||
//
|
||||
|
||||
import AffineGraphQL
|
||||
import Foundation
|
||||
import LDSwiftEventSource
|
||||
|
||||
extension IntelligentsEphemeralActionController {
|
||||
func beginAction() {
|
||||
print("[*] begin ephemeral action for did \(documentID) wid \(workspaceID)")
|
||||
chatTask?.stop()
|
||||
chatTask = nil
|
||||
copilotDocumentStorage = ""
|
||||
chat_createSession(
|
||||
documentIdentifier: documentID,
|
||||
workspaceIdentifier: workspaceID
|
||||
) { session in
|
||||
self.sessionID = session
|
||||
self.beginThisRound()
|
||||
} onFailure: { error in
|
||||
self.presentError(error) {
|
||||
self.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func chat_createSession(
|
||||
documentIdentifier: String,
|
||||
workspaceIdentifier: String,
|
||||
onSuccess: @escaping (String) -> Void,
|
||||
onFailure: @escaping (Error) -> Void
|
||||
) {
|
||||
if documentIdentifier.isEmpty || workspaceIdentifier.isEmpty {
|
||||
onFailure(
|
||||
NSError(
|
||||
domain: "Intelligents",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Unable to identify the document or workspace"]
|
||||
)
|
||||
)
|
||||
}
|
||||
Intelligents.qlClient.perform(
|
||||
mutation: CreateCopilotSessionMutation(options: .init(
|
||||
docId: documentIdentifier,
|
||||
promptName: ation.prompt.rawValue,
|
||||
workspaceId: workspaceIdentifier
|
||||
)),
|
||||
queue: .global()
|
||||
) { result in
|
||||
switch result {
|
||||
case let .success(value):
|
||||
if let session = value.data?.createCopilotSession, !session.isEmpty {
|
||||
DispatchQueue.main.async { onSuccess(session) }
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
onFailure(
|
||||
NSError(
|
||||
domain: "Intelligents",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No session created"]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
DispatchQueue.main.async { onFailure(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func beginThisRound() {
|
||||
Intelligents.qlClient.perform(
|
||||
mutation: CreateCopilotMessageMutation(options: .init(
|
||||
content: .init(stringLiteral: "\(documentContent)"),
|
||||
sessionId: sessionID
|
||||
)),
|
||||
queue: .global()
|
||||
) { result in
|
||||
switch result {
|
||||
case let .success(value):
|
||||
if let messageID = value.data?.createCopilotMessage {
|
||||
print("[*] messageID", messageID)
|
||||
self.chat_processWithMessageID(sessionID: self.sessionID, messageID: messageID)
|
||||
}
|
||||
case let .failure(error):
|
||||
self.presentError(error) {
|
||||
self.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func chat_processWithMessageID(sessionID: String, messageID: String) {
|
||||
let url = Constant.affineUpstreamURL
|
||||
.appendingPathComponent("api")
|
||||
.appendingPathComponent("copilot")
|
||||
.appendingPathComponent("chat")
|
||||
.appendingPathComponent(sessionID)
|
||||
.appendingPathComponent("stream")
|
||||
var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
comps?.queryItems = [URLQueryItem(name: "messageId", value: messageID)]
|
||||
|
||||
guard let url = comps?.url else {
|
||||
assertionFailure()
|
||||
presentError(NSError(
|
||||
domain: "Intelligents",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No message created"]
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
let eventHandler = BlockEventHandler()
|
||||
eventHandler.onOpenedBlock = {
|
||||
print("[*] chat opened")
|
||||
}
|
||||
eventHandler.onErrorBlock = { error in
|
||||
self.presentError(error) { self.close() }
|
||||
}
|
||||
eventHandler.onMessageBlock = { _, message in
|
||||
self.chat_onEvent(message.data)
|
||||
}
|
||||
eventHandler.onClosedBlock = {
|
||||
self.chatTask?.stop()
|
||||
self.chatTask = nil
|
||||
}
|
||||
let eventSource = EventSource(config: .init(handler: eventHandler, url: url))
|
||||
eventSource.start()
|
||||
chatTask = eventSource
|
||||
}
|
||||
|
||||
func chat_onEvent(_ data: String) {
|
||||
if Thread.isMainThread {
|
||||
copilotDocumentStorage += data
|
||||
} else {
|
||||
DispatchQueue.main.asyncAndWait {
|
||||
self.copilotDocumentStorage += data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// IntelligentsEphemeralActionController+ActionBar.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/15.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsEphemeralActionController {
|
||||
class ActionBar: UIView {
|
||||
let retryButton = DarkActionButton()
|
||||
let continueToChat = DarkActionButton()
|
||||
let createNewDoc = DarkActionButton()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
defer { removeEveryAutoResizingMasks() }
|
||||
|
||||
let contentSpacing: CGFloat = 16
|
||||
let buttonGroupHeight: CGFloat = 55
|
||||
|
||||
let firstButtonSectionGroup = UIView()
|
||||
addSubview(firstButtonSectionGroup)
|
||||
[
|
||||
firstButtonSectionGroup.topAnchor.constraint(equalTo: topAnchor, constant: contentSpacing),
|
||||
firstButtonSectionGroup.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
firstButtonSectionGroup.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
firstButtonSectionGroup.heightAnchor.constraint(equalToConstant: buttonGroupHeight),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
retryButton.title = NSLocalizedString("Retry", comment: "")
|
||||
retryButton.iconSystemName = "arrow.clockwise"
|
||||
continueToChat.title = NSLocalizedString("Continue to Chat", comment: "")
|
||||
continueToChat.iconSystemName = "paperplane"
|
||||
firstButtonSectionGroup.addSubview(retryButton)
|
||||
firstButtonSectionGroup.addSubview(continueToChat)
|
||||
[
|
||||
retryButton.topAnchor.constraint(equalTo: firstButtonSectionGroup.topAnchor),
|
||||
retryButton.leadingAnchor.constraint(equalTo: firstButtonSectionGroup.leadingAnchor),
|
||||
retryButton.bottomAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor),
|
||||
|
||||
continueToChat.topAnchor.constraint(equalTo: firstButtonSectionGroup.topAnchor),
|
||||
continueToChat.trailingAnchor.constraint(equalTo: firstButtonSectionGroup.trailingAnchor),
|
||||
continueToChat.bottomAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor),
|
||||
|
||||
retryButton.widthAnchor.constraint(equalTo: continueToChat.widthAnchor),
|
||||
retryButton.trailingAnchor.constraint(equalTo: continueToChat.leadingAnchor, constant: -contentSpacing),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
let secondButtonSectionGroup = UIView()
|
||||
addSubview(secondButtonSectionGroup)
|
||||
[
|
||||
secondButtonSectionGroup.topAnchor.constraint(equalTo: firstButtonSectionGroup.bottomAnchor, constant: contentSpacing),
|
||||
secondButtonSectionGroup.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
secondButtonSectionGroup.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
secondButtonSectionGroup.heightAnchor.constraint(equalToConstant: buttonGroupHeight),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
secondButtonSectionGroup.addSubview(createNewDoc)
|
||||
createNewDoc.title = NSLocalizedString("Create New Doc", comment: "")
|
||||
createNewDoc.iconSystemName = "doc.badge.plus"
|
||||
[
|
||||
createNewDoc.topAnchor.constraint(equalTo: secondButtonSectionGroup.topAnchor),
|
||||
createNewDoc.leadingAnchor.constraint(equalTo: secondButtonSectionGroup.leadingAnchor),
|
||||
createNewDoc.bottomAnchor.constraint(equalTo: secondButtonSectionGroup.bottomAnchor),
|
||||
createNewDoc.trailingAnchor.constraint(equalTo: secondButtonSectionGroup.trailingAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
[
|
||||
secondButtonSectionGroup.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// IntelligentsEphemeralActionController+Header.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/8.
|
||||
//
|
||||
|
||||
//
|
||||
// IntelligentsChatController+Header.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/18.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsEphemeralActionController {
|
||||
class Header: UIView {
|
||||
static let height: CGFloat = 44
|
||||
|
||||
let contentView = UIView()
|
||||
let titleLabel = UILabel()
|
||||
let dropMenu = UIButton()
|
||||
let backButton = UIButton()
|
||||
let rightBarItemsStack = UIStackView()
|
||||
let moreMenu = UIButton()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
setupLayout()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
@objc func navigateActionBack() {
|
||||
parentViewController?.dismissInContext()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension IntelligentsEphemeralActionController.Header {
|
||||
func setupLayout() {
|
||||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(contentView)
|
||||
[
|
||||
contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
contentView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
contentView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
contentView.heightAnchor.constraint(equalToConstant: Self.height),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
titleLabel.textColor = .label
|
||||
titleLabel.font = .systemFont(
|
||||
ofSize: UIFont.labelFontSize,
|
||||
weight: .semibold
|
||||
)
|
||||
|
||||
backButton.setImage(
|
||||
UIImage(systemName: "chevron.left"),
|
||||
for: .normal
|
||||
)
|
||||
backButton.tintColor = .accent
|
||||
backButton.addTarget(self, action: #selector(navigateActionBack), for: .touchUpInside)
|
||||
|
||||
dropMenu.setImage(
|
||||
.init(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate),
|
||||
for: .normal
|
||||
)
|
||||
dropMenu.tintColor = .gray.withAlphaComponent(0.5)
|
||||
|
||||
contentView.addSubview(titleLabel)
|
||||
contentView.addSubview(backButton)
|
||||
contentView.addSubview(dropMenu)
|
||||
contentView.addSubview(rightBarItemsStack)
|
||||
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
backButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
dropMenu.translatesAutoresizingMaskIntoConstraints = false
|
||||
rightBarItemsStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
rightBarItemsStack.axis = .horizontal
|
||||
rightBarItemsStack.spacing = 10
|
||||
rightBarItemsStack.alignment = .center
|
||||
rightBarItemsStack.distribution = .equalSpacing
|
||||
|
||||
[
|
||||
backButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
backButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10),
|
||||
backButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
backButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
|
||||
rightBarItemsStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
rightBarItemsStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
|
||||
rightBarItemsStack.heightAnchor.constraint(equalToConstant: 44),
|
||||
|
||||
titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
|
||||
titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
titleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: backButton.trailingAnchor, constant: 10),
|
||||
|
||||
dropMenu.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
dropMenu.widthAnchor.constraint(equalToConstant: 44),
|
||||
dropMenu.heightAnchor.constraint(equalToConstant: 44),
|
||||
titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: dropMenu.leadingAnchor, constant: -10),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
rightBarItemsStack.addArrangedSubview(moreMenu)
|
||||
moreMenu.setImage(
|
||||
.init(systemName: "ellipsis.circle"),
|
||||
for: .normal
|
||||
)
|
||||
moreMenu.tintColor = .accent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//
|
||||
// IntelligentsEphemeralActionController.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2025/1/8.
|
||||
//
|
||||
|
||||
import LDSwiftEventSource
|
||||
import MarkdownParser
|
||||
import MarkdownView
|
||||
import UIKit
|
||||
|
||||
public class IntelligentsEphemeralActionController: UIViewController {
|
||||
let ation: EphemeralAction
|
||||
let scrollView = UIScrollView()
|
||||
let stackView = UIStackView()
|
||||
|
||||
let header = Header()
|
||||
let preview = RotatedImagePreview()
|
||||
|
||||
let markdownView = MarkdownView()
|
||||
let indicator = UIActivityIndicatorView(style: .large)
|
||||
var responseContainer: UIView = .init()
|
||||
var responseHeightAnchor: NSLayoutConstraint?
|
||||
|
||||
let actionBar = ActionBar()
|
||||
|
||||
public var documentID: String = ""
|
||||
public var workspaceID: String = ""
|
||||
public var documentContent: String = ""
|
||||
var sessionID: String = ""
|
||||
|
||||
var chatTask: EventSource?
|
||||
var copilotDocumentStorage: String = "" {
|
||||
didSet {
|
||||
guard copilotDocumentStorage != oldValue else { return }
|
||||
updateDocumentPresentationView()
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
public init(action: EphemeralAction) {
|
||||
ation = action
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
title = action.title
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override public func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
overrideUserInterfaceStyle = .dark
|
||||
hideKeyboardWhenTappedAround()
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
header.titleLabel.text = title
|
||||
header.dropMenu.isHidden = true
|
||||
header.moreMenu.isHidden = true
|
||||
view.addSubview(header)
|
||||
header.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
header.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
header.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
header.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
header.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 44),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
view.addSubview(actionBar)
|
||||
actionBar.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
actionBar.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8),
|
||||
actionBar.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
|
||||
actionBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
scrollView.clipsToBounds = true
|
||||
scrollView.alwaysBounceVertical = true
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(scrollView)
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
scrollView.topAnchor.constraint(equalTo: header.bottomAnchor),
|
||||
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
scrollView.bottomAnchor.constraint(equalTo: actionBar.topAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
let contentView = UIView()
|
||||
scrollView.addSubview(contentView)
|
||||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
contentView.topAnchor.constraint(equalTo: scrollView.topAnchor),
|
||||
contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
|
||||
contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
|
||||
contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
|
||||
contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
|
||||
contentView.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.heightAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
contentView.addSubview(stackView)
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = 16
|
||||
stackView.alignment = .fill
|
||||
stackView.distribution = .fill
|
||||
contentView.addSubview(stackView)
|
||||
|
||||
let stackViewInset: CGFloat = 8
|
||||
[
|
||||
stackView.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: stackViewInset),
|
||||
stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: stackViewInset),
|
||||
stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -stackViewInset),
|
||||
stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -stackViewInset),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
setupContentViews()
|
||||
|
||||
actionBar.retryButton.action = { [weak self] in
|
||||
self?.beginAction()
|
||||
}
|
||||
}
|
||||
|
||||
func setupContentViews() {
|
||||
defer { stackView.addArrangedSubview(UIView()) }
|
||||
|
||||
preview.layer.cornerRadius = 16
|
||||
preview.clipsToBounds = true
|
||||
preview.contentMode = .scaleAspectFill
|
||||
preview.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.addArrangedSubview(preview)
|
||||
|
||||
let headerGroup = UIView()
|
||||
headerGroup.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.addArrangedSubview(headerGroup)
|
||||
|
||||
let headerLabel = UILabel()
|
||||
let headerIcon = UIImageView()
|
||||
|
||||
headerLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
headerLabel.text = NSLocalizedString("AFFiNE AI", comment: "")
|
||||
headerLabel.font = .preferredFont(for: .title3, weight: .bold)
|
||||
headerLabel.textColor = .white
|
||||
headerLabel.textAlignment = .left
|
||||
headerIcon.translatesAutoresizingMaskIntoConstraints = false
|
||||
headerIcon.image = .init(named: "spark", in: .module, with: nil)
|
||||
headerIcon.contentMode = .scaleAspectFit
|
||||
headerIcon.tintColor = .accent
|
||||
headerGroup.addSubview(headerLabel)
|
||||
headerGroup.addSubview(headerIcon)
|
||||
[
|
||||
headerIcon.leadingAnchor.constraint(equalTo: headerGroup.leadingAnchor),
|
||||
headerIcon.centerYAnchor.constraint(equalTo: headerGroup.centerYAnchor),
|
||||
headerIcon.widthAnchor.constraint(equalToConstant: 32),
|
||||
|
||||
headerLabel.leadingAnchor.constraint(equalTo: headerIcon.trailingAnchor, constant: 16),
|
||||
headerLabel.topAnchor.constraint(equalTo: headerGroup.topAnchor),
|
||||
headerLabel.bottomAnchor.constraint(equalTo: headerGroup.bottomAnchor),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
responseContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
responseContainer.setContentHuggingPriority(.required, for: .vertical)
|
||||
responseContainer.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
responseContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: 350).isActive = true
|
||||
stackView.addArrangedSubview(responseContainer)
|
||||
|
||||
responseContainer.addSubview(markdownView)
|
||||
|
||||
markdownView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[
|
||||
markdownView.topAnchor.constraint(equalTo: responseContainer.topAnchor),
|
||||
markdownView.leadingAnchor.constraint(equalTo: responseContainer.leadingAnchor),
|
||||
markdownView.trailingAnchor.constraint(equalTo: responseContainer.trailingAnchor),
|
||||
markdownView.bottomAnchor.constraint(equalTo: responseContainer.bottomAnchor),
|
||||
].forEach {
|
||||
$0.isActive = true
|
||||
}
|
||||
|
||||
indicator.startAnimating()
|
||||
indicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
responseContainer.addSubview(indicator)
|
||||
[
|
||||
indicator.centerXAnchor.constraint(equalTo: responseContainer.centerXAnchor),
|
||||
indicator.centerYAnchor.constraint(equalTo: responseContainer.centerYAnchor),
|
||||
indicator.heightAnchor.constraint(equalToConstant: 200),
|
||||
].forEach {
|
||||
$0.isActive = true
|
||||
}
|
||||
|
||||
updateDocumentPresentationView()
|
||||
}
|
||||
|
||||
public func configure(previewImage: UIImage) {
|
||||
preview.configure(previewImage: previewImage)
|
||||
}
|
||||
|
||||
private var isFirstAppear: Bool = true
|
||||
override public func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
guard isFirstAppear else { return }
|
||||
isFirstAppear = false
|
||||
onFirstAppear()
|
||||
}
|
||||
|
||||
func onFirstAppear() {
|
||||
beginAction()
|
||||
}
|
||||
|
||||
func close() {
|
||||
if let navigationController {
|
||||
navigationController.popViewController(animated: true)
|
||||
} else {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var previousLayoutWidth: CGFloat = 0
|
||||
|
||||
override public func viewWillLayoutSubviews() {
|
||||
super.viewWillLayoutSubviews()
|
||||
|
||||
if previousLayoutWidth != view.bounds.width {
|
||||
previousLayoutWidth = view.bounds.width
|
||||
updateDocumentPresentationView()
|
||||
}
|
||||
}
|
||||
|
||||
func updateDocumentPresentationView() {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
responseHeightAnchor?.isActive = false
|
||||
responseHeightAnchor = nil
|
||||
|
||||
if copilotDocumentStorage.isEmpty {
|
||||
indicator.isHidden = false
|
||||
indicator.startAnimating()
|
||||
responseHeightAnchor = responseContainer.heightAnchor.constraint(equalToConstant: 200)
|
||||
responseHeightAnchor?.isActive = true
|
||||
markdownView.updateContentViews([])
|
||||
return
|
||||
}
|
||||
|
||||
indicator.isHidden = true
|
||||
indicator.stopAnimating()
|
||||
|
||||
let document = MarkdownParser().feed(copilotDocumentStorage)
|
||||
var height: CGFloat = 0
|
||||
let manifests = document.map {
|
||||
let ret = $0.manifest(theme: .default)
|
||||
ret.setLayoutWidth(responseContainer.bounds.width)
|
||||
ret.layoutIfNeeded()
|
||||
height += ret.size.height
|
||||
height += Theme.default.spacings.final
|
||||
return ret
|
||||
}
|
||||
markdownView.updateContentViews(manifests)
|
||||
if height > 0 { height -= Theme.default.spacings.final }
|
||||
responseHeightAnchor = responseContainer.heightAnchor.constraint(equalToConstant: height)
|
||||
responseHeightAnchor?.isActive = true
|
||||
}
|
||||
|
||||
func scrollToBottom() {
|
||||
guard !copilotDocumentStorage.isEmpty else { return }
|
||||
let bottomOffset = CGPoint(
|
||||
x: 0,
|
||||
y: max(0, scrollView.contentSize.height - scrollView.bounds.size.height)
|
||||
)
|
||||
UIView.animate(
|
||||
withDuration: 0.5,
|
||||
delay: 0,
|
||||
usingSpringWithDamping: 1.0,
|
||||
initialSpringVelocity: 0.8
|
||||
) { self.scrollView.setContentOffset(bottomOffset, animated: false) }
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//
|
||||
// IntelligentsFocusApertureView+ActionButton.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/21.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension IntelligentsFocusApertureView.ControlButtonsPanel {
|
||||
class DarkActionButton: UIView {
|
||||
var iconSystemName: String {
|
||||
set { iconView.image = UIImage(systemName: newValue) }
|
||||
get { fatalError() }
|
||||
}
|
||||
|
||||
var title: String {
|
||||
set { titleLabel.text = newValue }
|
||||
get { titleLabel.text ?? "" }
|
||||
}
|
||||
|
||||
let titleLabel = UILabel()
|
||||
let iconView = UIImageView()
|
||||
var action: (() -> Void)? = nil
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white.withAlphaComponent(0.25)
|
||||
layer.cornerRadius = 12
|
||||
|
||||
let layoutGuide = UILayoutGuide()
|
||||
addLayoutGuide(layoutGuide)
|
||||
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.font = .systemFont(ofSize: UIFont.labelFontSize, weight: .semibold)
|
||||
titleLabel.textColor = .white
|
||||
addSubview(titleLabel)
|
||||
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
iconView.tintColor = .white
|
||||
addSubview(iconView)
|
||||
|
||||
[
|
||||
layoutGuide.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
layoutGuide.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
|
||||
iconView.topAnchor.constraint(greaterThanOrEqualTo: layoutGuide.topAnchor),
|
||||
iconView.leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor),
|
||||
iconView.bottomAnchor.constraint(lessThanOrEqualTo: layoutGuide.bottomAnchor),
|
||||
iconView.centerYAnchor.constraint(equalTo: layoutGuide.centerYAnchor),
|
||||
|
||||
titleLabel.topAnchor.constraint(greaterThanOrEqualTo: layoutGuide.topAnchor),
|
||||
titleLabel.trailingAnchor.constraint(equalTo: layoutGuide.trailingAnchor),
|
||||
titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: layoutGuide.bottomAnchor),
|
||||
titleLabel.centerYAnchor.constraint(equalTo: layoutGuide.centerYAnchor),
|
||||
|
||||
titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
isUserInteractionEnabled = true
|
||||
addGestureRecognizer(UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(onTapped)
|
||||
))
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
@objc func onTapped() {
|
||||
action?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,20 @@ import UIKit
|
||||
|
||||
extension IntelligentsFocusApertureView {
|
||||
func captureImageBuffer(_ targetContentView: UIView) {
|
||||
let imageSize = targetContentView.frame.size
|
||||
let renderer = UIGraphicsImageRenderer(size: imageSize)
|
||||
let contentSize = targetContentView.frame.size
|
||||
|
||||
let renderer = UIGraphicsImageRenderer(size: contentSize)
|
||||
let image = renderer.image { _ in
|
||||
let drawRect = CGRect(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: contentSize.width,
|
||||
height: contentSize.height
|
||||
)
|
||||
|
||||
targetContentView.drawHierarchy(
|
||||
in: targetContentView.bounds,
|
||||
afterScreenUpdates: false
|
||||
in: drawRect,
|
||||
afterScreenUpdates: true
|
||||
)
|
||||
}
|
||||
capturedImage = image
|
||||
|
||||
@@ -15,5 +15,8 @@ public enum IntelligentsFocusApertureViewActionType: String {
|
||||
}
|
||||
|
||||
public protocol IntelligentsFocusApertureViewDelegate: AnyObject {
|
||||
func focusApertureRequestAction(actionType: IntelligentsFocusApertureViewActionType)
|
||||
func focusApertureRequestAction(
|
||||
from: IntelligentsFocusApertureView,
|
||||
actionType: IntelligentsFocusApertureViewActionType
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,10 +38,10 @@ extension IntelligentsFocusApertureView {
|
||||
}
|
||||
|
||||
contentBeginConstraints = [
|
||||
snapshotView.leftAnchor.constraint(equalTo: targetView.leftAnchor),
|
||||
snapshotView.rightAnchor.constraint(equalTo: targetView.rightAnchor),
|
||||
snapshotView.topAnchor.constraint(equalTo: targetView.topAnchor),
|
||||
snapshotView.bottomAnchor.constraint(equalTo: targetView.bottomAnchor),
|
||||
snapshotImageView.leftAnchor.constraint(equalTo: targetView.leftAnchor),
|
||||
snapshotImageView.rightAnchor.constraint(equalTo: targetView.rightAnchor),
|
||||
snapshotImageView.topAnchor.constraint(equalTo: targetView.topAnchor),
|
||||
snapshotImageView.bottomAnchor.constraint(equalTo: targetView.bottomAnchor),
|
||||
|
||||
controlButtonsPanel.leftAnchor.constraint(equalTo: leftAnchor),
|
||||
controlButtonsPanel.rightAnchor.constraint(equalTo: rightAnchor),
|
||||
@@ -50,10 +50,10 @@ extension IntelligentsFocusApertureView {
|
||||
|
||||
let sharedInset: CGFloat = 32
|
||||
contentFinalConstraints = [
|
||||
snapshotView.leftAnchor.constraint(equalTo: leftAnchor, constant: sharedInset),
|
||||
snapshotView.rightAnchor.constraint(equalTo: rightAnchor, constant: -sharedInset),
|
||||
snapshotView.topAnchor.constraint(equalTo: topAnchor),
|
||||
snapshotView.bottomAnchor.constraint(equalTo: controlButtonsPanel.topAnchor, constant: -sharedInset / 2),
|
||||
snapshotImageView.leftAnchor.constraint(equalTo: leftAnchor, constant: sharedInset),
|
||||
snapshotImageView.rightAnchor.constraint(equalTo: rightAnchor, constant: -sharedInset),
|
||||
snapshotImageView.topAnchor.constraint(equalTo: topAnchor),
|
||||
snapshotImageView.bottomAnchor.constraint(equalTo: controlButtonsPanel.topAnchor, constant: -sharedInset / 2),
|
||||
|
||||
controlButtonsPanel.leftAnchor.constraint(equalTo: leftAnchor, constant: sharedInset),
|
||||
controlButtonsPanel.rightAnchor.constraint(equalTo: rightAnchor, constant: -sharedInset),
|
||||
@@ -73,12 +73,12 @@ extension IntelligentsFocusApertureView {
|
||||
NSLayoutConstraint.deactivate(contentFinalConstraints)
|
||||
NSLayoutConstraint.activate(contentBeginConstraints)
|
||||
|
||||
snapshotView.layer.cornerRadius = 0
|
||||
snapshotImageView.layer.cornerRadius = 0
|
||||
case .complete:
|
||||
NSLayoutConstraint.deactivate(contentBeginConstraints)
|
||||
NSLayoutConstraint.activate(contentFinalConstraints)
|
||||
|
||||
snapshotView.layer.cornerRadius = 32
|
||||
snapshotImageView.layer.cornerRadius = 32
|
||||
}
|
||||
let effectiveView = superview ?? self
|
||||
effectiveView.setNeedsUpdateConstraints()
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
import UIKit
|
||||
|
||||
public class IntelligentsFocusApertureView: UIView {
|
||||
let backgroundView = UIView()
|
||||
let snapshotView = UIImageView()
|
||||
public let backgroundView = UIView()
|
||||
public let snapshotImageView = UIImageView()
|
||||
let controlButtonsPanel = ControlButtonsPanel()
|
||||
|
||||
public var animationDuration: TimeInterval = 0.75
|
||||
@@ -17,8 +17,8 @@ public class IntelligentsFocusApertureView: UIView {
|
||||
public internal(set) weak var targetView: UIView?
|
||||
public internal(set) weak var targetViewController: UIViewController?
|
||||
public internal(set) weak var capturedImage: UIImage? {
|
||||
get { snapshotView.image }
|
||||
set { snapshotView.image = newValue }
|
||||
get { snapshotImageView.image }
|
||||
set { snapshotImageView.image = newValue }
|
||||
}
|
||||
|
||||
var frameConstraints: [NSLayoutConstraint] = []
|
||||
@@ -32,35 +32,40 @@ public class IntelligentsFocusApertureView: UIView {
|
||||
|
||||
backgroundView.backgroundColor = .black
|
||||
backgroundView.isUserInteractionEnabled = true
|
||||
backgroundView.addGestureRecognizer(UITapGestureRecognizer(
|
||||
let tap = UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(dismissFocus)
|
||||
))
|
||||
)
|
||||
tap.cancelsTouchesInView = true
|
||||
backgroundView.addGestureRecognizer(tap)
|
||||
|
||||
snapshotView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
snapshotView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
snapshotView.layer.contentsGravity = .top
|
||||
snapshotView.layer.masksToBounds = true
|
||||
snapshotView.contentMode = .scaleAspectFill
|
||||
snapshotView.isUserInteractionEnabled = true
|
||||
snapshotView.addGestureRecognizer(UITapGestureRecognizer(
|
||||
snapshotImageView.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
snapshotImageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
||||
snapshotImageView.layer.contentsGravity = .top
|
||||
snapshotImageView.layer.masksToBounds = true
|
||||
snapshotImageView.contentMode = .scaleAspectFill
|
||||
snapshotImageView.isUserInteractionEnabled = true
|
||||
snapshotImageView.addGestureRecognizer(UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(dismissFocus)
|
||||
))
|
||||
|
||||
addSubview(backgroundView)
|
||||
addSubview(controlButtonsPanel)
|
||||
addSubview(snapshotView)
|
||||
bringSubviewToFront(snapshotView)
|
||||
addSubview(snapshotImageView)
|
||||
bringSubviewToFront(snapshotImageView)
|
||||
|
||||
controlButtonsPanel.translateButton.action = { [weak self] in
|
||||
self?.delegate?.focusApertureRequestAction(actionType: .translateTo)
|
||||
guard let self else { return }
|
||||
delegate?.focusApertureRequestAction(from: self, actionType: .translateTo)
|
||||
}
|
||||
controlButtonsPanel.summaryButton.action = { [weak self] in
|
||||
self?.delegate?.focusApertureRequestAction(actionType: .summary)
|
||||
guard let self else { return }
|
||||
delegate?.focusApertureRequestAction(from: self, actionType: .summary)
|
||||
}
|
||||
controlButtonsPanel.chatWithAIButton.action = { [weak self] in
|
||||
self?.delegate?.focusApertureRequestAction(actionType: .chatWithAI)
|
||||
guard let self else { return }
|
||||
delegate?.focusApertureRequestAction(from: self, actionType: .chatWithAI)
|
||||
}
|
||||
removeEveryAutoResizingMasks()
|
||||
}
|
||||
@@ -122,7 +127,7 @@ public class IntelligentsFocusApertureView: UIView {
|
||||
isUserInteractionEnabled = false
|
||||
executeAnimationDismiss {
|
||||
self.removeFromSuperview()
|
||||
self.delegate?.focusApertureRequestAction(actionType: .dismiss)
|
||||
self.delegate?.focusApertureRequestAction(from: self, actionType: .dismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// DarkActionButton.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 2024/11/21.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class DarkActionButton: UIView {
|
||||
var iconSystemName: String {
|
||||
set { iconView.image = UIImage(systemName: newValue) }
|
||||
get { fatalError() }
|
||||
}
|
||||
|
||||
var title: String {
|
||||
set { titleLabel.text = newValue }
|
||||
get { titleLabel.text ?? "" }
|
||||
}
|
||||
|
||||
let titleLabel = UILabel()
|
||||
let iconView = UIImageView()
|
||||
var action: (() -> Void)? = nil
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white.withAlphaComponent(0.25)
|
||||
layer.cornerRadius = 12
|
||||
|
||||
let layoutGuide = UILayoutGuide()
|
||||
addLayoutGuide(layoutGuide)
|
||||
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.font = .systemFont(ofSize: UIFont.labelFontSize, weight: .semibold)
|
||||
titleLabel.textColor = .white
|
||||
addSubview(titleLabel)
|
||||
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
iconView.tintColor = .white
|
||||
addSubview(iconView)
|
||||
|
||||
[
|
||||
layoutGuide.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
layoutGuide.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||||
|
||||
iconView.topAnchor.constraint(greaterThanOrEqualTo: layoutGuide.topAnchor),
|
||||
iconView.leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor),
|
||||
iconView.bottomAnchor.constraint(lessThanOrEqualTo: layoutGuide.bottomAnchor),
|
||||
iconView.centerYAnchor.constraint(equalTo: layoutGuide.centerYAnchor),
|
||||
|
||||
titleLabel.topAnchor.constraint(greaterThanOrEqualTo: layoutGuide.topAnchor),
|
||||
titleLabel.trailingAnchor.constraint(equalTo: layoutGuide.trailingAnchor),
|
||||
titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: layoutGuide.bottomAnchor),
|
||||
titleLabel.centerYAnchor.constraint(equalTo: layoutGuide.centerYAnchor),
|
||||
|
||||
titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
|
||||
].forEach { $0.isActive = true }
|
||||
|
||||
isUserInteractionEnabled = true
|
||||
addGestureRecognizer(UITapGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(onTapped)
|
||||
))
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
@objc func onTapped() {
|
||||
action?()
|
||||
}
|
||||
}
|
||||
46
packages/frontend/apps/ios/App/Packages/MarkdownParserCore/.gitignore
vendored
Normal file
46
packages/frontend/apps/ios/App/Packages/MarkdownParserCore/.gitignore
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
# Object files
|
||||
*.o
|
||||
*.ko
|
||||
*.obj
|
||||
*.elf
|
||||
|
||||
# Libraries
|
||||
*.lib
|
||||
*.a
|
||||
|
||||
# Shared objects (inc. Windows DLLs)
|
||||
*.dll
|
||||
*.so
|
||||
*.so.*
|
||||
*.dylib
|
||||
|
||||
# Executables
|
||||
*.exe
|
||||
*.out
|
||||
*.app
|
||||
*.i*86
|
||||
*.x86_64
|
||||
*.hex
|
||||
*.pyc
|
||||
|
||||
*~
|
||||
*.bak
|
||||
*.diff
|
||||
*#
|
||||
*.zip
|
||||
bstrlib.txt
|
||||
build
|
||||
cmark.dSYM/*
|
||||
cmark
|
||||
.vscode
|
||||
.DS_Store
|
||||
|
||||
# Testing and benchmark
|
||||
alltests.md
|
||||
progit/
|
||||
bench/benchinput.md
|
||||
test/afl_results/
|
||||
|
||||
# Build directories for SwiftPM and Xcode
|
||||
.swiftpm
|
||||
.build
|
||||
@@ -0,0 +1,170 @@
|
||||
Copyright (c) 2014, John MacFarlane
|
||||
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following
|
||||
disclaimer in the documentation and/or other materials provided
|
||||
with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
-----
|
||||
|
||||
houdini.h, houdini_href_e.c, houdini_html_e.c, houdini_html_u.c
|
||||
|
||||
derive from https://github.com/vmg/houdini (with some modifications)
|
||||
|
||||
Copyright (C) 2012 Vicent Martí
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
-----
|
||||
|
||||
buffer.h, buffer.c, chunk.h
|
||||
|
||||
are derived from code (C) 2012 Github, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
-----
|
||||
|
||||
utf8.c and utf8.c
|
||||
|
||||
are derived from utf8proc
|
||||
(<http://www.public-software-group.org/utf8proc>),
|
||||
(C) 2009 Public Software Group e. V., Berlin, Germany.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
-----
|
||||
|
||||
The normalization code in normalize.py was derived from the
|
||||
markdowntest project, Copyright 2013 Karl Dubost:
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013 Karl Dubost
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
-----
|
||||
|
||||
The CommonMark spec (test/spec.txt) is
|
||||
|
||||
Copyright (C) 2014-15 John MacFarlane
|
||||
|
||||
Released under the Creative Commons CC-BY-SA 4.0 license:
|
||||
<http://creativecommons.org/licenses/by-sa/4.0/>.
|
||||
|
||||
-----
|
||||
|
||||
The test software in test/ is
|
||||
|
||||
Copyright (c) 2014, John MacFarlane
|
||||
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following
|
||||
disclaimer in the documentation and/or other materials provided
|
||||
with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,42 @@
|
||||
// swift-tools-version:5.9
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
/*
|
||||
|
||||
The main purpose to fork this library is for adding streamed parser support.
|
||||
|
||||
Suppose we add a new function:
|
||||
- cmark_parser *cmark_parser_fork(cmark_parser *parser);
|
||||
|
||||
In this way we can call `cmark_parser_finish` to get the result without redo the entire document.
|
||||
|
||||
But it is over my ability to implement this function. The parser class is too complex to manage.
|
||||
|
||||
*/
|
||||
|
||||
let cSettings: [CSetting] = [
|
||||
.define("CMARK_THREADING"),
|
||||
]
|
||||
|
||||
let package = Package(
|
||||
name: "MarkdownParserCore",
|
||||
products: [
|
||||
.library(name: "MarkdownParserCore", targets: ["cmark-gfm"]),
|
||||
.library(name: "MarkdownParserCoreExtension", targets: ["cmark-gfm-extensions"]),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "cmark-gfm",
|
||||
path: "src",
|
||||
cSettings: cSettings
|
||||
),
|
||||
.target(
|
||||
name: "cmark-gfm-extensions",
|
||||
dependencies: ["cmark-gfm"],
|
||||
path: "extensions",
|
||||
cSettings: cSettings
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,510 @@
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "autolink.h"
|
||||
#include <parser.h>
|
||||
#include <utf8.h>
|
||||
|
||||
#if defined(_WIN32)
|
||||
#define strncasecmp _strnicmp
|
||||
#else
|
||||
#include <strings.h>
|
||||
#endif
|
||||
|
||||
static int is_valid_hostchar(const uint8_t *link, size_t link_len) {
|
||||
int32_t ch;
|
||||
int r = cmark_utf8proc_iterate(link, (bufsize_t)link_len, &ch);
|
||||
if (r < 0)
|
||||
return 0;
|
||||
return !cmark_utf8proc_is_space(ch) && !cmark_utf8proc_is_punctuation(ch);
|
||||
}
|
||||
|
||||
static int sd_autolink_issafe(const uint8_t *link, size_t link_len) {
|
||||
static const size_t valid_uris_count = 3;
|
||||
static const char *valid_uris[] = {"http://", "https://", "ftp://"};
|
||||
|
||||
size_t i;
|
||||
|
||||
for (i = 0; i < valid_uris_count; ++i) {
|
||||
size_t len = strlen(valid_uris[i]);
|
||||
|
||||
if (link_len > len && strncasecmp((char *)link, valid_uris[i], len) == 0 &&
|
||||
is_valid_hostchar(link + len, link_len - len))
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static size_t autolink_delim(uint8_t *data, size_t link_end) {
|
||||
size_t i;
|
||||
size_t closing = 0;
|
||||
size_t opening = 0;
|
||||
|
||||
for (i = 0; i < link_end; ++i) {
|
||||
const uint8_t c = data[i];
|
||||
if (c == '<') {
|
||||
link_end = i;
|
||||
break;
|
||||
} else if (c == '(') {
|
||||
opening++;
|
||||
} else if (c == ')') {
|
||||
closing++;
|
||||
}
|
||||
}
|
||||
|
||||
while (link_end > 0) {
|
||||
switch (data[link_end - 1]) {
|
||||
case ')':
|
||||
/* Allow any number of matching brackets (as recognised in copen/cclose)
|
||||
* at the end of the URL. If there is a greater number of closing
|
||||
* brackets than opening ones, we remove one character from the end of
|
||||
* the link.
|
||||
*
|
||||
* Examples (input text => output linked portion):
|
||||
*
|
||||
* http://www.pokemon.com/Pikachu_(Electric)
|
||||
* => http://www.pokemon.com/Pikachu_(Electric)
|
||||
*
|
||||
* http://www.pokemon.com/Pikachu_((Electric)
|
||||
* => http://www.pokemon.com/Pikachu_((Electric)
|
||||
*
|
||||
* http://www.pokemon.com/Pikachu_(Electric))
|
||||
* => http://www.pokemon.com/Pikachu_(Electric)
|
||||
*
|
||||
* http://www.pokemon.com/Pikachu_((Electric))
|
||||
* => http://www.pokemon.com/Pikachu_((Electric))
|
||||
*/
|
||||
if (closing <= opening) {
|
||||
return link_end;
|
||||
}
|
||||
closing--;
|
||||
link_end--;
|
||||
break;
|
||||
case '?':
|
||||
case '!':
|
||||
case '.':
|
||||
case ',':
|
||||
case ':':
|
||||
case '*':
|
||||
case '_':
|
||||
case '~':
|
||||
case '\'':
|
||||
case '"':
|
||||
link_end--;
|
||||
break;
|
||||
case ';': {
|
||||
size_t new_end = link_end - 2;
|
||||
|
||||
while (new_end > 0 && cmark_isalpha(data[new_end]))
|
||||
new_end--;
|
||||
|
||||
if (new_end < link_end - 2 && data[new_end] == '&')
|
||||
link_end = new_end;
|
||||
else
|
||||
link_end--;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return link_end;
|
||||
}
|
||||
}
|
||||
|
||||
return link_end;
|
||||
}
|
||||
|
||||
static size_t check_domain(uint8_t *data, size_t size, int allow_short) {
|
||||
size_t i, np = 0, uscore1 = 0, uscore2 = 0;
|
||||
|
||||
/* The purpose of this code is to reject urls that contain an underscore
|
||||
* in one of the last two segments. Examples:
|
||||
*
|
||||
* www.xxx.yyy.zzz autolinked
|
||||
* www.xxx.yyy._zzz not autolinked
|
||||
* www.xxx._yyy.zzz not autolinked
|
||||
* www._xxx.yyy.zzz autolinked
|
||||
*
|
||||
* The reason is that domain names are allowed to include underscores,
|
||||
* but host names are not. See: https://stackoverflow.com/a/2183140
|
||||
*/
|
||||
for (i = 1; i < size - 1; i++) {
|
||||
if (data[i] == '\\' && i < size - 2)
|
||||
i++;
|
||||
if (data[i] == '_')
|
||||
uscore2++;
|
||||
else if (data[i] == '.') {
|
||||
uscore1 = uscore2;
|
||||
uscore2 = 0;
|
||||
np++;
|
||||
} else if (!is_valid_hostchar(data + i, size - i) && data[i] != '-')
|
||||
break;
|
||||
}
|
||||
|
||||
if (uscore1 > 0 || uscore2 > 0) {
|
||||
/* If the url is very long then accept it despite the underscores,
|
||||
* to avoid quadratic behavior causing a denial of service. See:
|
||||
* https://github.com/github/cmark-gfm/security/advisories/GHSA-29g3-96g3-jg6c
|
||||
* Reasonable urls are unlikely to have more than 10 segments, so
|
||||
* this extra condition shouldn't have any impact on normal usage.
|
||||
*/
|
||||
if (np <= 10) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (allow_short) {
|
||||
/* We don't need a valid domain in the strict sense (with
|
||||
* least one dot; so just make sure it's composed of valid
|
||||
* domain characters and return the length of the the valid
|
||||
* sequence. */
|
||||
return i;
|
||||
} else {
|
||||
/* a valid domain needs to have at least a dot.
|
||||
* that's as far as we get */
|
||||
return np ? i : 0;
|
||||
}
|
||||
}
|
||||
|
||||
static cmark_node *www_match(cmark_parser *parser, cmark_node *parent,
|
||||
cmark_inline_parser *inline_parser) {
|
||||
cmark_chunk *chunk = cmark_inline_parser_get_chunk(inline_parser);
|
||||
size_t max_rewind = cmark_inline_parser_get_offset(inline_parser);
|
||||
uint8_t *data = chunk->data + max_rewind;
|
||||
size_t size = chunk->len - max_rewind;
|
||||
int start = cmark_inline_parser_get_column(inline_parser);
|
||||
|
||||
size_t link_end;
|
||||
|
||||
if (max_rewind > 0 && strchr("*_~(", data[-1]) == NULL &&
|
||||
!cmark_isspace(data[-1]))
|
||||
return 0;
|
||||
|
||||
if (size < 4 || memcmp(data, "www.", strlen("www.")) != 0)
|
||||
return 0;
|
||||
|
||||
link_end = check_domain(data, size, 0);
|
||||
|
||||
if (link_end == 0)
|
||||
return NULL;
|
||||
|
||||
while (link_end < size && !cmark_isspace(data[link_end]) && data[link_end] != '<')
|
||||
link_end++;
|
||||
|
||||
link_end = autolink_delim(data, link_end);
|
||||
|
||||
if (link_end == 0)
|
||||
return NULL;
|
||||
|
||||
cmark_inline_parser_set_offset(inline_parser, (int)(max_rewind + link_end));
|
||||
|
||||
cmark_node *node = cmark_node_new_with_mem(CMARK_NODE_LINK, parser->mem);
|
||||
|
||||
cmark_strbuf buf;
|
||||
cmark_strbuf_init(parser->mem, &buf, 10);
|
||||
cmark_strbuf_puts(&buf, "http://");
|
||||
cmark_strbuf_put(&buf, data, (bufsize_t)link_end);
|
||||
node->as.link.url = cmark_chunk_buf_detach(&buf);
|
||||
|
||||
cmark_node *text = cmark_node_new_with_mem(CMARK_NODE_TEXT, parser->mem);
|
||||
text->as.literal =
|
||||
cmark_chunk_dup(chunk, (bufsize_t)max_rewind, (bufsize_t)link_end);
|
||||
cmark_node_append_child(node, text);
|
||||
|
||||
node->start_line = text->start_line =
|
||||
node->end_line = text->end_line =
|
||||
cmark_inline_parser_get_line(inline_parser);
|
||||
|
||||
node->start_column = text->start_column = start - 1;
|
||||
node->end_column = text->end_column = cmark_inline_parser_get_column(inline_parser) - 1;
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
static cmark_node *url_match(cmark_parser *parser, cmark_node *parent,
|
||||
cmark_inline_parser *inline_parser) {
|
||||
size_t link_end, domain_len;
|
||||
int rewind = 0;
|
||||
|
||||
cmark_chunk *chunk = cmark_inline_parser_get_chunk(inline_parser);
|
||||
int max_rewind = cmark_inline_parser_get_offset(inline_parser);
|
||||
uint8_t *data = chunk->data + max_rewind;
|
||||
size_t size = chunk->len - max_rewind;
|
||||
|
||||
if (size < 4 || data[1] != '/' || data[2] != '/')
|
||||
return 0;
|
||||
|
||||
while (rewind < max_rewind && cmark_isalpha(data[-rewind - 1]))
|
||||
rewind++;
|
||||
|
||||
if (!sd_autolink_issafe(data - rewind, size + rewind))
|
||||
return 0;
|
||||
|
||||
link_end = strlen("://");
|
||||
|
||||
domain_len = check_domain(data + link_end, size - link_end, 1);
|
||||
|
||||
if (domain_len == 0)
|
||||
return 0;
|
||||
|
||||
link_end += domain_len;
|
||||
while (link_end < size && !cmark_isspace(data[link_end]) && data[link_end] != '<')
|
||||
link_end++;
|
||||
|
||||
link_end = autolink_delim(data, link_end);
|
||||
|
||||
if (link_end == 0)
|
||||
return NULL;
|
||||
|
||||
cmark_inline_parser_set_offset(inline_parser, (int)(max_rewind + link_end));
|
||||
cmark_node_unput(parent, rewind);
|
||||
|
||||
cmark_node *node = cmark_node_new_with_mem(CMARK_NODE_LINK, parser->mem);
|
||||
|
||||
cmark_chunk url = cmark_chunk_dup(chunk, max_rewind - rewind,
|
||||
(bufsize_t)(link_end + rewind));
|
||||
node->as.link.url = url;
|
||||
|
||||
cmark_node *text = cmark_node_new_with_mem(CMARK_NODE_TEXT, parser->mem);
|
||||
text->as.literal = url;
|
||||
cmark_node_append_child(node, text);
|
||||
|
||||
node->start_line = text->start_line = node->end_line = text->end_line = cmark_inline_parser_get_line(inline_parser);
|
||||
|
||||
node->start_column = text->start_column = max_rewind - rewind;
|
||||
node->end_column = text->end_column = cmark_inline_parser_get_column(inline_parser) - 1;
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
static cmark_node *match(cmark_syntax_extension *ext, cmark_parser *parser,
|
||||
cmark_node *parent, unsigned char c,
|
||||
cmark_inline_parser *inline_parser) {
|
||||
if (cmark_inline_parser_in_bracket(inline_parser, false) ||
|
||||
cmark_inline_parser_in_bracket(inline_parser, true))
|
||||
return NULL;
|
||||
|
||||
if (c == ':')
|
||||
return url_match(parser, parent, inline_parser);
|
||||
|
||||
if (c == 'w')
|
||||
return www_match(parser, parent, inline_parser);
|
||||
|
||||
return NULL;
|
||||
|
||||
// note that we could end up re-consuming something already a
|
||||
// part of an inline, because we don't track when the last
|
||||
// inline was finished in inlines.c.
|
||||
}
|
||||
|
||||
static bool validate_protocol(const char protocol[], uint8_t *data, size_t rewind, size_t max_rewind) {
|
||||
size_t len = strlen(protocol);
|
||||
|
||||
if (len > (max_rewind - rewind)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that the protocol matches
|
||||
if (memcmp(data - rewind - len, protocol, len) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (len == (max_rewind - rewind)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
char prev_char = data[-((ptrdiff_t)rewind) - len - 1];
|
||||
|
||||
// Make sure the character before the protocol is non-alphanumeric
|
||||
return !cmark_isalnum(prev_char);
|
||||
}
|
||||
|
||||
static void postprocess_text(cmark_parser *parser, cmark_node *text) {
|
||||
size_t start = 0;
|
||||
size_t offset = 0;
|
||||
// `text` is going to be split into a list of nodes containing shorter segments
|
||||
// of text, so we detach the memory buffer from text and use `cmark_chunk_dup` to
|
||||
// create references to it. Later, `cmark_chunk_to_cstr` is used to convert
|
||||
// the references into allocated buffers. The detached buffer is freed before we
|
||||
// return.
|
||||
cmark_chunk detached_chunk = text->as.literal;
|
||||
text->as.literal = cmark_chunk_dup(&detached_chunk, 0, detached_chunk.len);
|
||||
|
||||
uint8_t *data = text->as.literal.data;
|
||||
size_t remaining = text->as.literal.len;
|
||||
|
||||
while (true) {
|
||||
size_t link_end;
|
||||
uint8_t *at;
|
||||
bool auto_mailto = true;
|
||||
bool is_xmpp = false;
|
||||
size_t rewind;
|
||||
size_t max_rewind;
|
||||
size_t np = 0;
|
||||
|
||||
if (offset >= remaining)
|
||||
break;
|
||||
|
||||
at = (uint8_t *)memchr(data + start + offset, '@', remaining - offset);
|
||||
if (!at)
|
||||
break;
|
||||
|
||||
max_rewind = at - (data + start + offset);
|
||||
|
||||
found_at:
|
||||
for (rewind = 0; rewind < max_rewind; ++rewind) {
|
||||
uint8_t c = data[start + offset + max_rewind - rewind - 1];
|
||||
|
||||
if (cmark_isalnum(c))
|
||||
continue;
|
||||
|
||||
if (strchr(".+-_", c) != NULL)
|
||||
continue;
|
||||
|
||||
if (strchr(":", c) != NULL) {
|
||||
if (validate_protocol("mailto:", data + start + offset + max_rewind, rewind, max_rewind)) {
|
||||
auto_mailto = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (validate_protocol("xmpp:", data + start + offset + max_rewind, rewind, max_rewind)) {
|
||||
auto_mailto = false;
|
||||
is_xmpp = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (rewind == 0) {
|
||||
offset += max_rewind + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
assert(data[start + offset + max_rewind] == '@');
|
||||
for (link_end = 1; link_end < remaining - offset - max_rewind; ++link_end) {
|
||||
uint8_t c = data[start + offset + max_rewind + link_end];
|
||||
|
||||
if (cmark_isalnum(c))
|
||||
continue;
|
||||
|
||||
if (c == '@') {
|
||||
// Found another '@', so go back and try again with an updated offset and max_rewind.
|
||||
offset += max_rewind + 1;
|
||||
max_rewind = link_end - 1;
|
||||
goto found_at;
|
||||
} else if (c == '.' && link_end < remaining - offset - max_rewind - 1 &&
|
||||
cmark_isalnum(data[start + offset + max_rewind + link_end + 1]))
|
||||
np++;
|
||||
else if (c == '/' && is_xmpp)
|
||||
continue;
|
||||
else if (c != '-' && c != '_')
|
||||
break;
|
||||
}
|
||||
|
||||
if (link_end < 2 || np == 0 ||
|
||||
(!cmark_isalpha(data[start + offset + max_rewind + link_end - 1]) &&
|
||||
data[start + offset + max_rewind + link_end - 1] != '.')) {
|
||||
offset += max_rewind + link_end;
|
||||
continue;
|
||||
}
|
||||
|
||||
link_end = autolink_delim(data + start + offset + max_rewind, link_end);
|
||||
|
||||
if (link_end == 0) {
|
||||
offset += max_rewind + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
cmark_node *link_node = cmark_node_new_with_mem(CMARK_NODE_LINK, parser->mem);
|
||||
cmark_strbuf buf;
|
||||
cmark_strbuf_init(parser->mem, &buf, 10);
|
||||
if (auto_mailto)
|
||||
cmark_strbuf_puts(&buf, "mailto:");
|
||||
cmark_strbuf_put(&buf, data + start + offset + max_rewind - rewind, (bufsize_t)(link_end + rewind));
|
||||
link_node->as.link.url = cmark_chunk_buf_detach(&buf);
|
||||
|
||||
cmark_node *link_text = cmark_node_new_with_mem(CMARK_NODE_TEXT, parser->mem);
|
||||
cmark_chunk email = cmark_chunk_dup(
|
||||
&detached_chunk,
|
||||
(bufsize_t)(start + offset + max_rewind - rewind),
|
||||
(bufsize_t)(link_end + rewind));
|
||||
cmark_chunk_to_cstr(parser->mem, &email);
|
||||
link_text->as.literal = email;
|
||||
cmark_node_append_child(link_node, link_text);
|
||||
|
||||
cmark_node_insert_after(text, link_node);
|
||||
|
||||
cmark_node *post = cmark_node_new_with_mem(CMARK_NODE_TEXT, parser->mem);
|
||||
post->as.literal = cmark_chunk_dup(&detached_chunk,
|
||||
(bufsize_t)(start + offset + max_rewind + link_end),
|
||||
(bufsize_t)(remaining - offset - max_rewind - link_end));
|
||||
|
||||
cmark_node_insert_after(link_node, post);
|
||||
|
||||
text->as.literal = cmark_chunk_dup(&detached_chunk, (bufsize_t)start, (bufsize_t)(offset + max_rewind - rewind));
|
||||
cmark_chunk_to_cstr(parser->mem, &text->as.literal);
|
||||
|
||||
text = post;
|
||||
start += offset + max_rewind + link_end;
|
||||
remaining -= offset + max_rewind + link_end;
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
// Convert the reference to allocated memory.
|
||||
assert(!text->as.literal.alloc);
|
||||
cmark_chunk_to_cstr(parser->mem, &text->as.literal);
|
||||
|
||||
// Free the detached buffer.
|
||||
cmark_chunk_free(parser->mem, &detached_chunk);
|
||||
}
|
||||
|
||||
static cmark_node *postprocess(cmark_syntax_extension *ext, cmark_parser *parser, cmark_node *root) {
|
||||
cmark_iter *iter;
|
||||
cmark_event_type ev;
|
||||
cmark_node *node;
|
||||
bool in_link = false;
|
||||
|
||||
cmark_consolidate_text_nodes(root);
|
||||
iter = cmark_iter_new(root);
|
||||
|
||||
while ((ev = cmark_iter_next(iter)) != CMARK_EVENT_DONE) {
|
||||
node = cmark_iter_get_node(iter);
|
||||
if (in_link) {
|
||||
if (ev == CMARK_EVENT_EXIT && node->type == CMARK_NODE_LINK) {
|
||||
in_link = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ev == CMARK_EVENT_ENTER && node->type == CMARK_NODE_LINK) {
|
||||
in_link = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ev == CMARK_EVENT_ENTER && node->type == CMARK_NODE_TEXT) {
|
||||
postprocess_text(parser, node);
|
||||
}
|
||||
}
|
||||
|
||||
cmark_iter_free(iter);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
cmark_syntax_extension *create_autolink_extension(void) {
|
||||
cmark_syntax_extension *ext = cmark_syntax_extension_new("autolink");
|
||||
cmark_llist *special_chars = NULL;
|
||||
|
||||
cmark_syntax_extension_set_match_inline_func(ext, match);
|
||||
cmark_syntax_extension_set_postprocess_func(ext, postprocess);
|
||||
|
||||
cmark_mem *mem = cmark_get_default_mem_allocator();
|
||||
special_chars = cmark_llist_append(mem, special_chars, (void *)':');
|
||||
special_chars = cmark_llist_append(mem, special_chars, (void *)'w');
|
||||
cmark_syntax_extension_set_special_inline_chars(ext, special_chars);
|
||||
|
||||
return ext;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#ifndef CMARK_GFM_AUTOLINK_H
|
||||
#define CMARK_GFM_AUTOLINK_H
|
||||
|
||||
#include "cmark-gfm-core-extensions.h"
|
||||
|
||||
cmark_syntax_extension *create_autolink_extension(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,31 @@
|
||||
#include "cmark-gfm-core-extensions.h"
|
||||
#include "autolink.h"
|
||||
#include "mutex.h"
|
||||
#include "node.h"
|
||||
#include "strikethrough.h"
|
||||
#include "table.h"
|
||||
#include "tagfilter.h"
|
||||
#include "tasklist.h"
|
||||
#include "registry.h"
|
||||
#include "plugin.h"
|
||||
|
||||
static int core_extensions_registration(cmark_plugin *plugin) {
|
||||
cmark_plugin_register_syntax_extension(plugin, create_table_extension());
|
||||
cmark_plugin_register_syntax_extension(plugin,
|
||||
create_strikethrough_extension());
|
||||
cmark_plugin_register_syntax_extension(plugin, create_autolink_extension());
|
||||
cmark_plugin_register_syntax_extension(plugin, create_tagfilter_extension());
|
||||
cmark_plugin_register_syntax_extension(plugin, create_tasklist_extension());
|
||||
return 1;
|
||||
}
|
||||
|
||||
CMARK_DEFINE_ONCE(registered);
|
||||
|
||||
static void register_plugins(void) {
|
||||
cmark_register_plugin(core_extensions_registration);
|
||||
}
|
||||
|
||||
CMARK_GFM_EXPORT
|
||||
void cmark_gfm_core_extensions_ensure_registered(void) {
|
||||
CMARK_RUN_ONCE(registered, register_plugins);
|
||||
}
|
||||
@@ -0,0 +1,879 @@
|
||||
/* Generated by re2c 1.3 */
|
||||
|
||||
#include "ext_scanners.h"
|
||||
#include <stdlib.h>
|
||||
|
||||
bufsize_t _ext_scan_at(bufsize_t (*scanner)(const unsigned char *),
|
||||
unsigned char *ptr, int len, bufsize_t offset) {
|
||||
bufsize_t res;
|
||||
|
||||
if (ptr == NULL || offset >= len) {
|
||||
return 0;
|
||||
} else {
|
||||
unsigned char lim = ptr[len];
|
||||
|
||||
ptr[len] = '\0';
|
||||
res = scanner(ptr + offset);
|
||||
ptr[len] = lim;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
bufsize_t _scan_table_start(const unsigned char *p) {
|
||||
const unsigned char *marker = NULL;
|
||||
const unsigned char *start = p;
|
||||
|
||||
{
|
||||
unsigned char yych;
|
||||
static const unsigned char yybm[] = {
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 64, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
};
|
||||
yych = *p;
|
||||
if (yych <= ' ') {
|
||||
if (yych <= '\n') {
|
||||
if (yych == '\t')
|
||||
goto yy4;
|
||||
} else {
|
||||
if (yych <= '\f')
|
||||
goto yy4;
|
||||
if (yych >= ' ')
|
||||
goto yy4;
|
||||
}
|
||||
} else {
|
||||
if (yych <= '9') {
|
||||
if (yych == '-')
|
||||
goto yy5;
|
||||
} else {
|
||||
if (yych <= ':')
|
||||
goto yy6;
|
||||
if (yych == '|')
|
||||
goto yy4;
|
||||
}
|
||||
}
|
||||
++p;
|
||||
yy3 : { return 0; }
|
||||
yy4:
|
||||
yych = *(marker = ++p);
|
||||
if (yybm[0 + yych] & 64) {
|
||||
goto yy7;
|
||||
}
|
||||
if (yych == '-')
|
||||
goto yy10;
|
||||
if (yych == ':')
|
||||
goto yy12;
|
||||
goto yy3;
|
||||
yy5:
|
||||
yych = *(marker = ++p);
|
||||
if (yybm[0 + yych] & 128) {
|
||||
goto yy10;
|
||||
}
|
||||
if (yych <= ' ') {
|
||||
if (yych <= 0x08)
|
||||
goto yy3;
|
||||
if (yych <= '\r')
|
||||
goto yy14;
|
||||
if (yych <= 0x1F)
|
||||
goto yy3;
|
||||
goto yy14;
|
||||
} else {
|
||||
if (yych <= ':') {
|
||||
if (yych <= '9')
|
||||
goto yy3;
|
||||
goto yy13;
|
||||
} else {
|
||||
if (yych == '|')
|
||||
goto yy14;
|
||||
goto yy3;
|
||||
}
|
||||
}
|
||||
yy6:
|
||||
yych = *(marker = ++p);
|
||||
if (yybm[0 + yych] & 128) {
|
||||
goto yy10;
|
||||
}
|
||||
goto yy3;
|
||||
yy7:
|
||||
yych = *++p;
|
||||
if (yybm[0 + yych] & 64) {
|
||||
goto yy7;
|
||||
}
|
||||
if (yych == '-')
|
||||
goto yy10;
|
||||
if (yych == ':')
|
||||
goto yy12;
|
||||
yy9:
|
||||
p = marker;
|
||||
goto yy3;
|
||||
yy10:
|
||||
yych = *++p;
|
||||
if (yybm[0 + yych] & 128) {
|
||||
goto yy10;
|
||||
}
|
||||
if (yych <= 0x1F) {
|
||||
if (yych <= '\n') {
|
||||
if (yych <= 0x08)
|
||||
goto yy9;
|
||||
if (yych <= '\t')
|
||||
goto yy13;
|
||||
goto yy15;
|
||||
} else {
|
||||
if (yych <= '\f')
|
||||
goto yy13;
|
||||
if (yych <= '\r')
|
||||
goto yy17;
|
||||
goto yy9;
|
||||
}
|
||||
} else {
|
||||
if (yych <= ':') {
|
||||
if (yych <= ' ')
|
||||
goto yy13;
|
||||
if (yych <= '9')
|
||||
goto yy9;
|
||||
goto yy13;
|
||||
} else {
|
||||
if (yych == '|')
|
||||
goto yy18;
|
||||
goto yy9;
|
||||
}
|
||||
}
|
||||
yy12:
|
||||
yych = *++p;
|
||||
if (yybm[0 + yych] & 128) {
|
||||
goto yy10;
|
||||
}
|
||||
goto yy9;
|
||||
yy13:
|
||||
yych = *++p;
|
||||
yy14:
|
||||
if (yych <= '\r') {
|
||||
if (yych <= '\t') {
|
||||
if (yych <= 0x08)
|
||||
goto yy9;
|
||||
goto yy13;
|
||||
} else {
|
||||
if (yych <= '\n')
|
||||
goto yy15;
|
||||
if (yych <= '\f')
|
||||
goto yy13;
|
||||
goto yy17;
|
||||
}
|
||||
} else {
|
||||
if (yych <= ' ') {
|
||||
if (yych <= 0x1F)
|
||||
goto yy9;
|
||||
goto yy13;
|
||||
} else {
|
||||
if (yych == '|')
|
||||
goto yy18;
|
||||
goto yy9;
|
||||
}
|
||||
}
|
||||
yy15:
|
||||
++p;
|
||||
{ return (bufsize_t)(p - start); }
|
||||
yy17:
|
||||
yych = *++p;
|
||||
if (yych == '\n')
|
||||
goto yy15;
|
||||
goto yy9;
|
||||
yy18:
|
||||
yych = *++p;
|
||||
if (yybm[0 + yych] & 128) {
|
||||
goto yy10;
|
||||
}
|
||||
if (yych <= '\r') {
|
||||
if (yych <= '\t') {
|
||||
if (yych <= 0x08)
|
||||
goto yy9;
|
||||
goto yy18;
|
||||
} else {
|
||||
if (yych <= '\n')
|
||||
goto yy15;
|
||||
if (yych <= '\f')
|
||||
goto yy18;
|
||||
goto yy17;
|
||||
}
|
||||
} else {
|
||||
if (yych <= ' ') {
|
||||
if (yych <= 0x1F)
|
||||
goto yy9;
|
||||
goto yy18;
|
||||
} else {
|
||||
if (yych == ':')
|
||||
goto yy12;
|
||||
goto yy9;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bufsize_t _scan_table_cell(const unsigned char *p) {
|
||||
const unsigned char *marker = NULL;
|
||||
const unsigned char *start = p;
|
||||
|
||||
{
|
||||
unsigned char yych;
|
||||
unsigned int yyaccept = 0;
|
||||
static const unsigned char yybm[] = {
|
||||
64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 0, 64, 64, 0, 64, 64, 64, 64,
|
||||
64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64,
|
||||
64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64,
|
||||
64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64,
|
||||
64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64,
|
||||
64, 64, 128, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64,
|
||||
64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 0, 64,
|
||||
64, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0,
|
||||
};
|
||||
yych = *p;
|
||||
if (yybm[0 + yych] & 64) {
|
||||
goto yy22;
|
||||
}
|
||||
if (yych <= 0xEC) {
|
||||
if (yych <= 0xC1) {
|
||||
if (yych <= '\r')
|
||||
goto yy25;
|
||||
if (yych <= '\\')
|
||||
goto yy27;
|
||||
goto yy25;
|
||||
} else {
|
||||
if (yych <= 0xDF)
|
||||
goto yy29;
|
||||
if (yych <= 0xE0)
|
||||
goto yy30;
|
||||
goto yy31;
|
||||
}
|
||||
} else {
|
||||
if (yych <= 0xF0) {
|
||||
if (yych <= 0xED)
|
||||
goto yy32;
|
||||
if (yych <= 0xEF)
|
||||
goto yy31;
|
||||
goto yy33;
|
||||
} else {
|
||||
if (yych <= 0xF3)
|
||||
goto yy34;
|
||||
if (yych <= 0xF4)
|
||||
goto yy35;
|
||||
goto yy25;
|
||||
}
|
||||
}
|
||||
yy22:
|
||||
yyaccept = 0;
|
||||
yych = *(marker = ++p);
|
||||
if (yybm[0 + yych] & 64) {
|
||||
goto yy22;
|
||||
}
|
||||
if (yych <= 0xEC) {
|
||||
if (yych <= 0xC1) {
|
||||
if (yych <= '\r')
|
||||
goto yy24;
|
||||
if (yych <= '\\')
|
||||
goto yy27;
|
||||
} else {
|
||||
if (yych <= 0xDF)
|
||||
goto yy36;
|
||||
if (yych <= 0xE0)
|
||||
goto yy38;
|
||||
goto yy39;
|
||||
}
|
||||
} else {
|
||||
if (yych <= 0xF0) {
|
||||
if (yych <= 0xED)
|
||||
goto yy40;
|
||||
if (yych <= 0xEF)
|
||||
goto yy39;
|
||||
goto yy41;
|
||||
} else {
|
||||
if (yych <= 0xF3)
|
||||
goto yy42;
|
||||
if (yych <= 0xF4)
|
||||
goto yy43;
|
||||
}
|
||||
}
|
||||
yy24 : { return (bufsize_t)(p - start); }
|
||||
yy25:
|
||||
++p;
|
||||
yy26 : { return 0; }
|
||||
yy27:
|
||||
yyaccept = 0;
|
||||
yych = *(marker = ++p);
|
||||
if (yybm[0 + yych] & 128) {
|
||||
goto yy27;
|
||||
}
|
||||
if (yych <= 0xDF) {
|
||||
if (yych <= '\f') {
|
||||
if (yych == '\n')
|
||||
goto yy24;
|
||||
goto yy22;
|
||||
} else {
|
||||
if (yych <= '\r')
|
||||
goto yy24;
|
||||
if (yych <= 0x7F)
|
||||
goto yy22;
|
||||
if (yych <= 0xC1)
|
||||
goto yy24;
|
||||
goto yy36;
|
||||
}
|
||||
} else {
|
||||
if (yych <= 0xEF) {
|
||||
if (yych <= 0xE0)
|
||||
goto yy38;
|
||||
if (yych == 0xED)
|
||||
goto yy40;
|
||||
goto yy39;
|
||||
} else {
|
||||
if (yych <= 0xF0)
|
||||
goto yy41;
|
||||
if (yych <= 0xF3)
|
||||
goto yy42;
|
||||
if (yych <= 0xF4)
|
||||
goto yy43;
|
||||
goto yy24;
|
||||
}
|
||||
}
|
||||
yy29:
|
||||
yych = *++p;
|
||||
if (yych <= 0x7F)
|
||||
goto yy26;
|
||||
if (yych <= 0xBF)
|
||||
goto yy22;
|
||||
goto yy26;
|
||||
yy30:
|
||||
yyaccept = 1;
|
||||
yych = *(marker = ++p);
|
||||
if (yych <= 0x9F)
|
||||
goto yy26;
|
||||
if (yych <= 0xBF)
|
||||
goto yy36;
|
||||
goto yy26;
|
||||
yy31:
|
||||
yyaccept = 1;
|
||||
yych = *(marker = ++p);
|
||||
if (yych <= 0x7F)
|
||||
goto yy26;
|
||||
if (yych <= 0xBF)
|
||||
goto yy36;
|
||||
goto yy26;
|
||||
yy32:
|
||||
yyaccept = 1;
|
||||
yych = *(marker = ++p);
|
||||
if (yych <= 0x7F)
|
||||
goto yy26;
|
||||
if (yych <= 0x9F)
|
||||
goto yy36;
|
||||
goto yy26;
|
||||
yy33:
|
||||
yyaccept = 1;
|
||||
yych = *(marker = ++p);
|
||||
if (yych <= 0x8F)
|
||||
goto yy26;
|
||||
if (yych <= 0xBF)
|
||||
goto yy39;
|
||||
goto yy26;
|
||||
yy34:
|
||||
yyaccept = 1;
|
||||
yych = *(marker = ++p);
|
||||
if (yych <= 0x7F)
|
||||
goto yy26;
|
||||
if (yych <= 0xBF)
|
||||
goto yy39;
|
||||
goto yy26;
|
||||
yy35:
|
||||
yyaccept = 1;
|
||||
yych = *(marker = ++p);
|
||||
if (yych <= 0x7F)
|
||||
goto yy26;
|
||||
if (yych <= 0x8F)
|
||||
goto yy39;
|
||||
goto yy26;
|
||||
yy36:
|
||||
yych = *++p;
|
||||
if (yych <= 0x7F)
|
||||
goto yy37;
|
||||
if (yych <= 0xBF)
|
||||
goto yy22;
|
||||
yy37:
|
||||
p = marker;
|
||||
if (yyaccept == 0) {
|
||||
goto yy24;
|
||||
} else {
|
||||
goto yy26;
|
||||
}
|
||||
yy38:
|
||||
yych = *++p;
|
||||
if (yych <= 0x9F)
|
||||
goto yy37;
|
||||
if (yych <= 0xBF)
|
||||
goto yy36;
|
||||
goto yy37;
|
||||
yy39:
|
||||
yych = *++p;
|
||||
if (yych <= 0x7F)
|
||||
goto yy37;
|
||||
if (yych <= 0xBF)
|
||||
goto yy36;
|
||||
goto yy37;
|
||||
yy40:
|
||||
yych = *++p;
|
||||
if (yych <= 0x7F)
|
||||
goto yy37;
|
||||
if (yych <= 0x9F)
|
||||
goto yy36;
|
||||
goto yy37;
|
||||
yy41:
|
||||
yych = *++p;
|
||||
if (yych <= 0x8F)
|
||||
goto yy37;
|
||||
if (yych <= 0xBF)
|
||||
goto yy39;
|
||||
goto yy37;
|
||||
yy42:
|
||||
yych = *++p;
|
||||
if (yych <= 0x7F)
|
||||
goto yy37;
|
||||
if (yych <= 0xBF)
|
||||
goto yy39;
|
||||
goto yy37;
|
||||
yy43:
|
||||
yych = *++p;
|
||||
if (yych <= 0x7F)
|
||||
goto yy37;
|
||||
if (yych <= 0x8F)
|
||||
goto yy39;
|
||||
goto yy37;
|
||||
}
|
||||
}
|
||||
|
||||
bufsize_t _scan_table_cell_end(const unsigned char *p) {
|
||||
const unsigned char *start = p;
|
||||
|
||||
{
|
||||
unsigned char yych;
|
||||
static const unsigned char yybm[] = {
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 128, 0, 128, 128, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 128, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
};
|
||||
yych = *p;
|
||||
if (yych == '|')
|
||||
goto yy48;
|
||||
++p;
|
||||
{ return 0; }
|
||||
yy48:
|
||||
yych = *++p;
|
||||
if (yybm[0 + yych] & 128) {
|
||||
goto yy48;
|
||||
}
|
||||
{ return (bufsize_t)(p - start); }
|
||||
}
|
||||
}
|
||||
|
||||
bufsize_t _scan_table_row_end(const unsigned char *p) {
|
||||
const unsigned char *marker = NULL;
|
||||
const unsigned char *start = p;
|
||||
|
||||
{
|
||||
unsigned char yych;
|
||||
static const unsigned char yybm[] = {
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 128, 0, 128, 128, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 128, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
};
|
||||
yych = *p;
|
||||
if (yych <= '\f') {
|
||||
if (yych <= 0x08)
|
||||
goto yy53;
|
||||
if (yych == '\n')
|
||||
goto yy56;
|
||||
goto yy55;
|
||||
} else {
|
||||
if (yych <= '\r')
|
||||
goto yy58;
|
||||
if (yych == ' ')
|
||||
goto yy55;
|
||||
}
|
||||
yy53:
|
||||
++p;
|
||||
yy54 : { return 0; }
|
||||
yy55:
|
||||
yych = *(marker = ++p);
|
||||
if (yych <= 0x08)
|
||||
goto yy54;
|
||||
if (yych <= '\r')
|
||||
goto yy60;
|
||||
if (yych == ' ')
|
||||
goto yy60;
|
||||
goto yy54;
|
||||
yy56:
|
||||
++p;
|
||||
{ return (bufsize_t)(p - start); }
|
||||
yy58:
|
||||
yych = *++p;
|
||||
if (yych == '\n')
|
||||
goto yy56;
|
||||
goto yy54;
|
||||
yy59:
|
||||
yych = *++p;
|
||||
yy60:
|
||||
if (yybm[0 + yych] & 128) {
|
||||
goto yy59;
|
||||
}
|
||||
if (yych <= 0x08)
|
||||
goto yy61;
|
||||
if (yych <= '\n')
|
||||
goto yy56;
|
||||
if (yych <= '\r')
|
||||
goto yy62;
|
||||
yy61:
|
||||
p = marker;
|
||||
goto yy54;
|
||||
yy62:
|
||||
yych = *++p;
|
||||
if (yych == '\n')
|
||||
goto yy56;
|
||||
goto yy61;
|
||||
}
|
||||
}
|
||||
|
||||
bufsize_t _scan_tasklist(const unsigned char *p) {
|
||||
const unsigned char *marker = NULL;
|
||||
const unsigned char *start = p;
|
||||
|
||||
{
|
||||
unsigned char yych;
|
||||
static const unsigned char yybm[] = {
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 64, 64, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
128, 128, 128, 128, 128, 128, 128, 128, 128, 128, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
};
|
||||
yych = *p;
|
||||
if (yych <= ' ') {
|
||||
if (yych <= '\n') {
|
||||
if (yych == '\t')
|
||||
goto yy67;
|
||||
} else {
|
||||
if (yych <= '\f')
|
||||
goto yy67;
|
||||
if (yych >= ' ')
|
||||
goto yy67;
|
||||
}
|
||||
} else {
|
||||
if (yych <= ',') {
|
||||
if (yych <= ')')
|
||||
goto yy65;
|
||||
if (yych <= '+')
|
||||
goto yy68;
|
||||
} else {
|
||||
if (yych <= '-')
|
||||
goto yy68;
|
||||
if (yych <= '/')
|
||||
goto yy65;
|
||||
if (yych <= '9')
|
||||
goto yy69;
|
||||
}
|
||||
}
|
||||
yy65:
|
||||
++p;
|
||||
yy66 : { return 0; }
|
||||
yy67:
|
||||
yych = *(marker = ++p);
|
||||
if (yybm[0 + yych] & 64) {
|
||||
goto yy70;
|
||||
}
|
||||
if (yych <= ',') {
|
||||
if (yych <= ')')
|
||||
goto yy66;
|
||||
if (yych <= '+')
|
||||
goto yy73;
|
||||
goto yy66;
|
||||
} else {
|
||||
if (yych <= '-')
|
||||
goto yy73;
|
||||
if (yych <= '/')
|
||||
goto yy66;
|
||||
if (yych <= '9')
|
||||
goto yy74;
|
||||
goto yy66;
|
||||
}
|
||||
yy68:
|
||||
yych = *(marker = ++p);
|
||||
if (yych <= '\n') {
|
||||
if (yych == '\t')
|
||||
goto yy75;
|
||||
goto yy66;
|
||||
} else {
|
||||
if (yych <= '\f')
|
||||
goto yy75;
|
||||
if (yych == ' ')
|
||||
goto yy75;
|
||||
goto yy66;
|
||||
}
|
||||
yy69:
|
||||
yych = *(marker = ++p);
|
||||
if (yych <= 0x1F) {
|
||||
if (yych <= '\t') {
|
||||
if (yych <= 0x08)
|
||||
goto yy78;
|
||||
goto yy73;
|
||||
} else {
|
||||
if (yych <= '\n')
|
||||
goto yy66;
|
||||
if (yych <= '\f')
|
||||
goto yy73;
|
||||
goto yy78;
|
||||
}
|
||||
} else {
|
||||
if (yych <= 0x7F) {
|
||||
if (yych <= ' ')
|
||||
goto yy73;
|
||||
goto yy78;
|
||||
} else {
|
||||
if (yych <= 0xC1)
|
||||
goto yy66;
|
||||
if (yych <= 0xF4)
|
||||
goto yy78;
|
||||
goto yy66;
|
||||
}
|
||||
}
|
||||
yy70:
|
||||
yych = *++p;
|
||||
if (yybm[0 + yych] & 64) {
|
||||
goto yy70;
|
||||
}
|
||||
if (yych <= ',') {
|
||||
if (yych <= ')')
|
||||
goto yy72;
|
||||
if (yych <= '+')
|
||||
goto yy73;
|
||||
} else {
|
||||
if (yych <= '-')
|
||||
goto yy73;
|
||||
if (yych <= '/')
|
||||
goto yy72;
|
||||
if (yych <= '9')
|
||||
goto yy74;
|
||||
}
|
||||
yy72:
|
||||
p = marker;
|
||||
goto yy66;
|
||||
yy73:
|
||||
yych = *++p;
|
||||
if (yych == '[')
|
||||
goto yy72;
|
||||
goto yy76;
|
||||
yy74:
|
||||
yych = *++p;
|
||||
if (yych <= '\n') {
|
||||
if (yych == '\t')
|
||||
goto yy73;
|
||||
goto yy78;
|
||||
} else {
|
||||
if (yych <= '\f')
|
||||
goto yy73;
|
||||
if (yych == ' ')
|
||||
goto yy73;
|
||||
goto yy78;
|
||||
}
|
||||
yy75:
|
||||
yych = *++p;
|
||||
yy76:
|
||||
if (yych <= '\f') {
|
||||
if (yych == '\t')
|
||||
goto yy75;
|
||||
if (yych <= '\n')
|
||||
goto yy72;
|
||||
goto yy75;
|
||||
} else {
|
||||
if (yych <= ' ') {
|
||||
if (yych <= 0x1F)
|
||||
goto yy72;
|
||||
goto yy75;
|
||||
} else {
|
||||
if (yych == '[')
|
||||
goto yy86;
|
||||
goto yy72;
|
||||
}
|
||||
}
|
||||
yy77:
|
||||
yych = *++p;
|
||||
yy78:
|
||||
if (yybm[0 + yych] & 128) {
|
||||
goto yy77;
|
||||
}
|
||||
if (yych <= 0xC1) {
|
||||
if (yych <= '\f') {
|
||||
if (yych <= 0x08)
|
||||
goto yy73;
|
||||
if (yych == '\n')
|
||||
goto yy72;
|
||||
goto yy75;
|
||||
} else {
|
||||
if (yych == ' ')
|
||||
goto yy75;
|
||||
if (yych <= 0x7F)
|
||||
goto yy73;
|
||||
goto yy72;
|
||||
}
|
||||
} else {
|
||||
if (yych <= 0xED) {
|
||||
if (yych <= 0xDF)
|
||||
goto yy79;
|
||||
if (yych <= 0xE0)
|
||||
goto yy80;
|
||||
if (yych <= 0xEC)
|
||||
goto yy81;
|
||||
goto yy82;
|
||||
} else {
|
||||
if (yych <= 0xF0) {
|
||||
if (yych <= 0xEF)
|
||||
goto yy81;
|
||||
goto yy83;
|
||||
} else {
|
||||
if (yych <= 0xF3)
|
||||
goto yy84;
|
||||
if (yych <= 0xF4)
|
||||
goto yy85;
|
||||
goto yy72;
|
||||
}
|
||||
}
|
||||
}
|
||||
yy79:
|
||||
yych = *++p;
|
||||
if (yych <= 0x7F)
|
||||
goto yy72;
|
||||
if (yych <= 0xBF)
|
||||
goto yy73;
|
||||
goto yy72;
|
||||
yy80:
|
||||
yych = *++p;
|
||||
if (yych <= 0x9F)
|
||||
goto yy72;
|
||||
if (yych <= 0xBF)
|
||||
goto yy79;
|
||||
goto yy72;
|
||||
yy81:
|
||||
yych = *++p;
|
||||
if (yych <= 0x7F)
|
||||
goto yy72;
|
||||
if (yych <= 0xBF)
|
||||
goto yy79;
|
||||
goto yy72;
|
||||
yy82:
|
||||
yych = *++p;
|
||||
if (yych <= 0x7F)
|
||||
goto yy72;
|
||||
if (yych <= 0x9F)
|
||||
goto yy79;
|
||||
goto yy72;
|
||||
yy83:
|
||||
yych = *++p;
|
||||
if (yych <= 0x8F)
|
||||
goto yy72;
|
||||
if (yych <= 0xBF)
|
||||
goto yy81;
|
||||
goto yy72;
|
||||
yy84:
|
||||
yych = *++p;
|
||||
if (yych <= 0x7F)
|
||||
goto yy72;
|
||||
if (yych <= 0xBF)
|
||||
goto yy81;
|
||||
goto yy72;
|
||||
yy85:
|
||||
yych = *++p;
|
||||
if (yych <= 0x7F)
|
||||
goto yy72;
|
||||
if (yych <= 0x8F)
|
||||
goto yy81;
|
||||
goto yy72;
|
||||
yy86:
|
||||
yych = *++p;
|
||||
if (yych <= 'W') {
|
||||
if (yych != ' ')
|
||||
goto yy72;
|
||||
} else {
|
||||
if (yych <= 'X')
|
||||
goto yy87;
|
||||
if (yych != 'x')
|
||||
goto yy72;
|
||||
}
|
||||
yy87:
|
||||
yych = *++p;
|
||||
if (yych != ']')
|
||||
goto yy72;
|
||||
yych = *++p;
|
||||
if (yych <= '\n') {
|
||||
if (yych != '\t')
|
||||
goto yy72;
|
||||
} else {
|
||||
if (yych <= '\f')
|
||||
goto yy89;
|
||||
if (yych != ' ')
|
||||
goto yy72;
|
||||
}
|
||||
yy89:
|
||||
yych = *++p;
|
||||
if (yych <= '\n') {
|
||||
if (yych == '\t')
|
||||
goto yy89;
|
||||
} else {
|
||||
if (yych <= '\f')
|
||||
goto yy89;
|
||||
if (yych == ' ')
|
||||
goto yy89;
|
||||
}
|
||||
{ return (bufsize_t)(p - start); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#include "chunk.h"
|
||||
#include "cmark-gfm.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
bufsize_t _ext_scan_at(bufsize_t (*scanner)(const unsigned char *),
|
||||
unsigned char *ptr, int len, bufsize_t offset);
|
||||
bufsize_t _scan_table_start(const unsigned char *p);
|
||||
bufsize_t _scan_table_cell(const unsigned char *p);
|
||||
bufsize_t _scan_table_cell_end(const unsigned char *p);
|
||||
bufsize_t _scan_table_row_end(const unsigned char *p);
|
||||
bufsize_t _scan_tasklist(const unsigned char *p);
|
||||
|
||||
#define scan_table_start(c, l, n) _ext_scan_at(&_scan_table_start, c, l, n)
|
||||
#define scan_table_cell(c, l, n) _ext_scan_at(&_scan_table_cell, c, l, n)
|
||||
#define scan_table_cell_end(c, l, n) _ext_scan_at(&_scan_table_cell_end, c, l, n)
|
||||
#define scan_table_row_end(c, l, n) _ext_scan_at(&_scan_table_row_end, c, l, n)
|
||||
#define scan_tasklist(c, l, n) _ext_scan_at(&_scan_tasklist, c, l, n)
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,87 @@
|
||||
#ifndef CMARK_GFM_CORE_EXTENSIONS_H
|
||||
#define CMARK_GFM_CORE_EXTENSIONS_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#include "cmark-gfm-extension_api.h"
|
||||
#include "export.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
CMARK_GFM_EXPORT
|
||||
void cmark_gfm_core_extensions_ensure_registered(void);
|
||||
|
||||
CMARK_GFM_EXPORT
|
||||
uint16_t cmark_gfm_extensions_get_table_columns(cmark_node *node);
|
||||
|
||||
/** Sets the number of columns for the table, returning 1 on success and 0 on error.
|
||||
*/
|
||||
CMARK_GFM_EXPORT
|
||||
int cmark_gfm_extensions_set_table_columns(cmark_node *node, uint16_t n_columns);
|
||||
|
||||
CMARK_GFM_EXPORT
|
||||
uint8_t *cmark_gfm_extensions_get_table_alignments(cmark_node *node);
|
||||
|
||||
/** Sets the alignments for the table, returning 1 on success and 0 on error.
|
||||
*/
|
||||
CMARK_GFM_EXPORT
|
||||
int cmark_gfm_extensions_set_table_alignments(cmark_node *node, uint16_t ncols, uint8_t *alignments);
|
||||
|
||||
CMARK_GFM_EXPORT
|
||||
int cmark_gfm_extensions_get_table_row_is_header(cmark_node *node);
|
||||
|
||||
/** Sets the column span for the table cell, returning 1 on success and 0 on error.
|
||||
*/
|
||||
CMARK_GFM_EXPORT
|
||||
int cmark_gfm_extensions_set_table_cell_colspan(cmark_node *node, unsigned colspan);
|
||||
|
||||
/** Sets the row span for the table cell, returning 1 on success and 0 on error.
|
||||
*/
|
||||
CMARK_GFM_EXPORT
|
||||
int cmark_gfm_extensions_set_table_cell_rowspan(cmark_node *node, unsigned rowspan);
|
||||
|
||||
/**
|
||||
Gets the column span for the table cell, returning \c UINT_MAX on error.
|
||||
|
||||
A value of 0 indicates that the cell is a "filler" cell, intended to be overlapped with a previous
|
||||
cell with a span > 1.
|
||||
|
||||
Column span is only parsed when \c CMARK_OPT_TABLE_SPANS is set.
|
||||
*/
|
||||
CMARK_GFM_EXPORT
|
||||
unsigned cmark_gfm_extensions_get_table_cell_colspan(cmark_node *node);
|
||||
|
||||
/**
|
||||
Gets the row span for the table cell, returning \c UINT_MAX on error.
|
||||
|
||||
A value of 0 indicates that the cell is a "filler" cell, intended to be overlapped with a previous
|
||||
cell with a span > 1.
|
||||
|
||||
Row span is only parsed when \c CMARK_OPT_TABLE_SPANS is set.
|
||||
*/
|
||||
CMARK_GFM_EXPORT
|
||||
unsigned cmark_gfm_extensions_get_table_cell_rowspan(cmark_node *node);
|
||||
|
||||
/** Sets whether the node is a table header row, returning 1 on success and 0 on error.
|
||||
*/
|
||||
CMARK_GFM_EXPORT
|
||||
int cmark_gfm_extensions_set_table_row_is_header(cmark_node *node, int is_header);
|
||||
|
||||
CMARK_GFM_EXPORT
|
||||
bool cmark_gfm_extensions_get_tasklist_item_checked(cmark_node *node);
|
||||
/* For backwards compatibility */
|
||||
#define cmark_gfm_extensions_tasklist_is_checked cmark_gfm_extensions_get_tasklist_item_checked
|
||||
|
||||
/** Sets whether a tasklist item is "checked" (completed), returning 1 on success and 0 on error.
|
||||
*/
|
||||
CMARK_GFM_EXPORT
|
||||
int cmark_gfm_extensions_set_tasklist_item_checked(cmark_node *node, bool is_checked);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
module cmark_gfm_extensions {
|
||||
header "cmark-gfm-core-extensions.h"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
#include <stdbool.h>
|
||||
|
||||
#include "strikethrough.h"
|
||||
#include <parser.h>
|
||||
#include <render.h>
|
||||
|
||||
cmark_node_type CMARK_NODE_STRIKETHROUGH;
|
||||
|
||||
static cmark_node *match(cmark_syntax_extension *self, cmark_parser *parser,
|
||||
cmark_node *parent, unsigned char character,
|
||||
cmark_inline_parser *inline_parser) {
|
||||
cmark_node *res = NULL;
|
||||
int left_flanking, right_flanking, punct_before, punct_after, delims;
|
||||
char buffer[101];
|
||||
|
||||
if (character != '~')
|
||||
return NULL;
|
||||
|
||||
delims = cmark_inline_parser_scan_delimiters(
|
||||
inline_parser, sizeof(buffer) - 1, '~',
|
||||
&left_flanking,
|
||||
&right_flanking, &punct_before, &punct_after);
|
||||
|
||||
memset(buffer, '~', delims);
|
||||
buffer[delims] = 0;
|
||||
|
||||
res = cmark_node_new_with_mem(CMARK_NODE_TEXT, parser->mem);
|
||||
cmark_node_set_literal(res, buffer);
|
||||
res->start_line = res->end_line = cmark_inline_parser_get_line(inline_parser);
|
||||
res->start_column = cmark_inline_parser_get_column(inline_parser) - delims;
|
||||
|
||||
if ((left_flanking || right_flanking) &&
|
||||
(delims == 2 || (!(parser->options & CMARK_OPT_STRIKETHROUGH_DOUBLE_TILDE) && delims == 1))) {
|
||||
cmark_inline_parser_push_delimiter(inline_parser, character, left_flanking,
|
||||
right_flanking, res);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
static delimiter *insert(cmark_syntax_extension *self, cmark_parser *parser,
|
||||
cmark_inline_parser *inline_parser, delimiter *opener,
|
||||
delimiter *closer) {
|
||||
cmark_node *strikethrough;
|
||||
cmark_node *tmp, *next;
|
||||
delimiter *delim, *tmp_delim;
|
||||
delimiter *res = closer->next;
|
||||
|
||||
strikethrough = opener->inl_text;
|
||||
|
||||
if (opener->inl_text->as.literal.len != closer->inl_text->as.literal.len)
|
||||
goto done;
|
||||
|
||||
if (!cmark_node_set_type(strikethrough, CMARK_NODE_STRIKETHROUGH))
|
||||
goto done;
|
||||
|
||||
cmark_node_set_syntax_extension(strikethrough, self);
|
||||
|
||||
tmp = cmark_node_next(opener->inl_text);
|
||||
|
||||
while (tmp) {
|
||||
if (tmp == closer->inl_text)
|
||||
break;
|
||||
next = cmark_node_next(tmp);
|
||||
cmark_node_append_child(strikethrough, tmp);
|
||||
tmp = next;
|
||||
}
|
||||
|
||||
strikethrough->end_column = closer->inl_text->start_column + closer->inl_text->as.literal.len - 1;
|
||||
cmark_node_free(closer->inl_text);
|
||||
|
||||
done:
|
||||
delim = closer;
|
||||
while (delim != NULL && delim != opener) {
|
||||
tmp_delim = delim->previous;
|
||||
cmark_inline_parser_remove_delimiter(inline_parser, delim);
|
||||
delim = tmp_delim;
|
||||
}
|
||||
|
||||
cmark_inline_parser_remove_delimiter(inline_parser, opener);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
static const char *get_type_string(cmark_syntax_extension *extension,
|
||||
cmark_node *node) {
|
||||
return node->type == CMARK_NODE_STRIKETHROUGH ? "strikethrough" : "<unknown>";
|
||||
}
|
||||
|
||||
static int can_contain(cmark_syntax_extension *extension, cmark_node *node,
|
||||
cmark_node_type child_type) {
|
||||
if (node->type != CMARK_NODE_STRIKETHROUGH)
|
||||
return false;
|
||||
|
||||
return CMARK_NODE_TYPE_INLINE_P(child_type);
|
||||
}
|
||||
|
||||
static void commonmark_render(cmark_syntax_extension *extension,
|
||||
cmark_renderer *renderer, cmark_node *node,
|
||||
cmark_event_type ev_type, int options) {
|
||||
renderer->out(renderer, node, "~~", false, LITERAL);
|
||||
}
|
||||
|
||||
static void latex_render(cmark_syntax_extension *extension,
|
||||
cmark_renderer *renderer, cmark_node *node,
|
||||
cmark_event_type ev_type, int options) {
|
||||
// requires \usepackage{ulem}
|
||||
bool entering = (ev_type == CMARK_EVENT_ENTER);
|
||||
if (entering) {
|
||||
renderer->out(renderer, node, "\\sout{", false, LITERAL);
|
||||
} else {
|
||||
renderer->out(renderer, node, "}", false, LITERAL);
|
||||
}
|
||||
}
|
||||
|
||||
static void man_render(cmark_syntax_extension *extension,
|
||||
cmark_renderer *renderer, cmark_node *node,
|
||||
cmark_event_type ev_type, int options) {
|
||||
bool entering = (ev_type == CMARK_EVENT_ENTER);
|
||||
if (entering) {
|
||||
renderer->cr(renderer);
|
||||
renderer->out(renderer, node, ".ST \"", false, LITERAL);
|
||||
} else {
|
||||
renderer->out(renderer, node, "\"", false, LITERAL);
|
||||
renderer->cr(renderer);
|
||||
}
|
||||
}
|
||||
|
||||
static void html_render(cmark_syntax_extension *extension,
|
||||
cmark_html_renderer *renderer, cmark_node *node,
|
||||
cmark_event_type ev_type, int options) {
|
||||
bool entering = (ev_type == CMARK_EVENT_ENTER);
|
||||
if (entering) {
|
||||
cmark_strbuf_puts(renderer->html, "<del>");
|
||||
} else {
|
||||
cmark_strbuf_puts(renderer->html, "</del>");
|
||||
}
|
||||
}
|
||||
|
||||
static void plaintext_render(cmark_syntax_extension *extension,
|
||||
cmark_renderer *renderer, cmark_node *node,
|
||||
cmark_event_type ev_type, int options) {
|
||||
renderer->out(renderer, node, "~", false, LITERAL);
|
||||
}
|
||||
|
||||
cmark_syntax_extension *create_strikethrough_extension(void) {
|
||||
cmark_syntax_extension *ext = cmark_syntax_extension_new("strikethrough");
|
||||
cmark_llist *special_chars = NULL;
|
||||
|
||||
cmark_syntax_extension_set_get_type_string_func(ext, get_type_string);
|
||||
cmark_syntax_extension_set_can_contain_func(ext, can_contain);
|
||||
cmark_syntax_extension_set_commonmark_render_func(ext, commonmark_render);
|
||||
cmark_syntax_extension_set_latex_render_func(ext, latex_render);
|
||||
cmark_syntax_extension_set_man_render_func(ext, man_render);
|
||||
cmark_syntax_extension_set_html_render_func(ext, html_render);
|
||||
cmark_syntax_extension_set_plaintext_render_func(ext, plaintext_render);
|
||||
CMARK_NODE_STRIKETHROUGH = cmark_syntax_extension_add_node(1);
|
||||
|
||||
cmark_syntax_extension_set_match_inline_func(ext, match);
|
||||
cmark_syntax_extension_set_inline_from_delim_func(ext, insert);
|
||||
|
||||
cmark_mem *mem = cmark_get_default_mem_allocator();
|
||||
special_chars = cmark_llist_append(mem, special_chars, (void *)'~');
|
||||
cmark_syntax_extension_set_special_inline_chars(ext, special_chars);
|
||||
|
||||
cmark_syntax_extension_set_emphasis(ext, 1);
|
||||
|
||||
return ext;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#ifndef CMARK_GFM_STRIKETHROUGH_H
|
||||
#define CMARK_GFM_STRIKETHROUGH_H
|
||||
|
||||
#include "cmark-gfm-core-extensions.h"
|
||||
|
||||
extern cmark_node_type CMARK_NODE_STRIKETHROUGH;
|
||||
cmark_syntax_extension *create_strikethrough_extension(void);
|
||||
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
#ifndef CMARK_GFM_TABLE_H
|
||||
#define CMARK_GFM_TABLE_H
|
||||
|
||||
#include "cmark-gfm-core-extensions.h"
|
||||
|
||||
|
||||
extern cmark_node_type CMARK_NODE_TABLE, CMARK_NODE_TABLE_ROW,
|
||||
CMARK_NODE_TABLE_CELL;
|
||||
|
||||
cmark_syntax_extension *create_table_extension(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,60 @@
|
||||
#include "tagfilter.h"
|
||||
#include <parser.h>
|
||||
#include <ctype.h>
|
||||
|
||||
static const char *blacklist[] = {
|
||||
"title", "textarea", "style", "xmp", "iframe",
|
||||
"noembed", "noframes", "script", "plaintext", NULL,
|
||||
};
|
||||
|
||||
static int is_tag(const unsigned char *tag_data, size_t tag_size,
|
||||
const char *tagname) {
|
||||
size_t i;
|
||||
|
||||
if (tag_size < 3 || tag_data[0] != '<')
|
||||
return 0;
|
||||
|
||||
i = 1;
|
||||
|
||||
if (tag_data[i] == '/') {
|
||||
i++;
|
||||
}
|
||||
|
||||
for (; i < tag_size; ++i, ++tagname) {
|
||||
if (*tagname == 0)
|
||||
break;
|
||||
|
||||
if (tolower(tag_data[i]) != *tagname)
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (i == tag_size)
|
||||
return 0;
|
||||
|
||||
if (cmark_isspace(tag_data[i]) || tag_data[i] == '>')
|
||||
return 1;
|
||||
|
||||
if (tag_data[i] == '/' && tag_size >= i + 2 && tag_data[i + 1] == '>')
|
||||
return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int filter(cmark_syntax_extension *ext, const unsigned char *tag,
|
||||
size_t tag_len) {
|
||||
const char **it;
|
||||
|
||||
for (it = blacklist; *it; ++it) {
|
||||
if (is_tag(tag, tag_len, *it)) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
cmark_syntax_extension *create_tagfilter_extension(void) {
|
||||
cmark_syntax_extension *ext = cmark_syntax_extension_new("tagfilter");
|
||||
cmark_syntax_extension_set_html_filter_func(ext, filter);
|
||||
return ext;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#ifndef CMARK_GFM_TAGFILTER_H
|
||||
#define CMARK_GFM_TAGFILTER_H
|
||||
|
||||
#include "cmark-gfm-core-extensions.h"
|
||||
|
||||
cmark_syntax_extension *create_tagfilter_extension(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,160 @@
|
||||
#include <stdbool.h>
|
||||
|
||||
#include "tasklist.h"
|
||||
#include <parser.h>
|
||||
#include <render.h>
|
||||
#include <html.h>
|
||||
#include "ext_scanners.h"
|
||||
|
||||
typedef enum {
|
||||
CMARK_TASKLIST_NOCHECKED,
|
||||
CMARK_TASKLIST_CHECKED,
|
||||
} cmark_tasklist_type;
|
||||
|
||||
// Local constants
|
||||
static const char *TYPE_STRING = "tasklist";
|
||||
|
||||
static const char *get_type_string(cmark_syntax_extension *extension, cmark_node *node) {
|
||||
return TYPE_STRING;
|
||||
}
|
||||
|
||||
|
||||
// Return 1 if state was set, 0 otherwise
|
||||
CMARK_GFM_EXPORT
|
||||
int cmark_gfm_extensions_set_tasklist_item_checked(cmark_node *node, bool is_checked) {
|
||||
// The node has to exist, and be an extension, and actually be the right type in order to get the value.
|
||||
if (!node || !node->extension || strcmp(cmark_node_get_type_string(node), TYPE_STRING))
|
||||
return 0;
|
||||
|
||||
node->as.list.checked = is_checked;
|
||||
return 1;
|
||||
}
|
||||
|
||||
CMARK_GFM_EXPORT
|
||||
bool cmark_gfm_extensions_get_tasklist_item_checked(cmark_node *node) {
|
||||
if (!node || !node->extension || strcmp(cmark_node_get_type_string(node), TYPE_STRING))
|
||||
return false;
|
||||
|
||||
if (node->as.list.checked) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool parse_node_item_prefix(cmark_parser *parser, const char *input,
|
||||
cmark_node *container) {
|
||||
bool res = false;
|
||||
|
||||
if (parser->indent >=
|
||||
container->as.list.marker_offset + container->as.list.padding) {
|
||||
cmark_parser_advance_offset(parser, input, container->as.list.marker_offset +
|
||||
container->as.list.padding,
|
||||
true);
|
||||
res = true;
|
||||
} else if (parser->blank && container->first_child != NULL) {
|
||||
// if container->first_child is NULL, then the opening line
|
||||
// of the list item was blank after the list marker; in this
|
||||
// case, we are done with the list item.
|
||||
cmark_parser_advance_offset(parser, input, parser->first_nonspace - parser->offset,
|
||||
false);
|
||||
res = true;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
static int matches(cmark_syntax_extension *self, cmark_parser *parser,
|
||||
unsigned char *input, int len,
|
||||
cmark_node *parent_container) {
|
||||
return parse_node_item_prefix(parser, (const char*)input, parent_container);
|
||||
}
|
||||
|
||||
static int can_contain(cmark_syntax_extension *extension, cmark_node *node,
|
||||
cmark_node_type child_type) {
|
||||
return (node->type == CMARK_NODE_ITEM) ? 1 : 0;
|
||||
}
|
||||
|
||||
static cmark_node *open_tasklist_item(cmark_syntax_extension *self,
|
||||
int indented, cmark_parser *parser,
|
||||
cmark_node *parent_container,
|
||||
unsigned char *input, int len) {
|
||||
cmark_node_type node_type = cmark_node_get_type(parent_container);
|
||||
if (node_type != CMARK_NODE_ITEM) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
bufsize_t matched = scan_tasklist(input, len, 0);
|
||||
if (!matched) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cmark_node_set_syntax_extension(parent_container, self);
|
||||
cmark_parser_advance_offset(parser, (char *)input, 3, false);
|
||||
|
||||
// Either an upper or lower case X means the task is completed.
|
||||
parent_container->as.list.checked = (strstr((char*)input, "[x]") || strstr((char*)input, "[X]"));
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void commonmark_render(cmark_syntax_extension *extension,
|
||||
cmark_renderer *renderer, cmark_node *node,
|
||||
cmark_event_type ev_type, int options) {
|
||||
bool entering = (ev_type == CMARK_EVENT_ENTER);
|
||||
if (entering) {
|
||||
renderer->cr(renderer);
|
||||
if (node->as.list.checked) {
|
||||
renderer->out(renderer, node, "- [x] ", false, LITERAL);
|
||||
} else {
|
||||
renderer->out(renderer, node, "- [ ] ", false, LITERAL);
|
||||
}
|
||||
cmark_strbuf_puts(renderer->prefix, " ");
|
||||
} else {
|
||||
cmark_strbuf_truncate(renderer->prefix, renderer->prefix->size - 2);
|
||||
renderer->cr(renderer);
|
||||
}
|
||||
}
|
||||
|
||||
static void html_render(cmark_syntax_extension *extension,
|
||||
cmark_html_renderer *renderer, cmark_node *node,
|
||||
cmark_event_type ev_type, int options) {
|
||||
bool entering = (ev_type == CMARK_EVENT_ENTER);
|
||||
if (entering) {
|
||||
cmark_html_render_cr(renderer->html);
|
||||
cmark_strbuf_puts(renderer->html, "<li");
|
||||
cmark_html_render_sourcepos(node, renderer->html, options);
|
||||
cmark_strbuf_putc(renderer->html, '>');
|
||||
if (node->as.list.checked) {
|
||||
cmark_strbuf_puts(renderer->html, "<input type=\"checkbox\" checked=\"\" disabled=\"\" /> ");
|
||||
} else {
|
||||
cmark_strbuf_puts(renderer->html, "<input type=\"checkbox\" disabled=\"\" /> ");
|
||||
}
|
||||
} else {
|
||||
cmark_strbuf_puts(renderer->html, "</li>\n");
|
||||
}
|
||||
}
|
||||
|
||||
static const char *xml_attr(cmark_syntax_extension *extension,
|
||||
cmark_node *node) {
|
||||
if (node->as.list.checked) {
|
||||
return " completed=\"true\"";
|
||||
} else {
|
||||
return " completed=\"false\"";
|
||||
}
|
||||
}
|
||||
|
||||
cmark_syntax_extension *create_tasklist_extension(void) {
|
||||
cmark_syntax_extension *ext = cmark_syntax_extension_new("tasklist");
|
||||
|
||||
cmark_syntax_extension_set_match_block_func(ext, matches);
|
||||
cmark_syntax_extension_set_get_type_string_func(ext, get_type_string);
|
||||
cmark_syntax_extension_set_open_block_func(ext, open_tasklist_item);
|
||||
cmark_syntax_extension_set_can_contain_func(ext, can_contain);
|
||||
cmark_syntax_extension_set_commonmark_render_func(ext, commonmark_render);
|
||||
cmark_syntax_extension_set_plaintext_render_func(ext, commonmark_render);
|
||||
cmark_syntax_extension_set_html_render_func(ext, html_render);
|
||||
cmark_syntax_extension_set_xml_attr_func(ext, xml_attr);
|
||||
|
||||
return ext;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#ifndef TASKLIST_H
|
||||
#define TASKLIST_H
|
||||
|
||||
#include "cmark-gfm-core-extensions.h"
|
||||
|
||||
cmark_syntax_extension *create_tasklist_extension(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,123 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include "cmark-gfm.h"
|
||||
#include "cmark-gfm-extension_api.h"
|
||||
#include "mutex.h"
|
||||
|
||||
CMARK_DEFINE_LOCK(arena)
|
||||
|
||||
static struct arena_chunk {
|
||||
size_t sz, used;
|
||||
uint8_t push_point;
|
||||
void *ptr;
|
||||
struct arena_chunk *prev;
|
||||
} *A = NULL;
|
||||
|
||||
static struct arena_chunk *alloc_arena_chunk(size_t sz, struct arena_chunk *prev) {
|
||||
struct arena_chunk *c = (struct arena_chunk *)calloc(1, sizeof(*c));
|
||||
if (!c)
|
||||
abort();
|
||||
c->sz = sz;
|
||||
c->ptr = calloc(1, sz);
|
||||
if (!c->ptr)
|
||||
abort();
|
||||
c->prev = prev;
|
||||
return c;
|
||||
}
|
||||
|
||||
void cmark_arena_push(void) {
|
||||
CMARK_INITIALIZE_AND_LOCK(arena);
|
||||
if (A) {
|
||||
A->push_point = 1;
|
||||
A = alloc_arena_chunk(10240, A);
|
||||
}
|
||||
CMARK_UNLOCK(arena);
|
||||
}
|
||||
|
||||
int cmark_arena_pop(void) {
|
||||
int ret = 1;
|
||||
CMARK_INITIALIZE_AND_LOCK(arena);
|
||||
if (!A)
|
||||
ret = 0;
|
||||
else {
|
||||
while (A && !A->push_point) {
|
||||
free(A->ptr);
|
||||
struct arena_chunk *n = A->prev;
|
||||
free(A);
|
||||
A = n;
|
||||
}
|
||||
if (A)
|
||||
A->push_point = 0;
|
||||
}
|
||||
CMARK_UNLOCK(arena);
|
||||
return ret;
|
||||
}
|
||||
|
||||
static void init_arena(void) {
|
||||
CMARK_INITIALIZE_AND_LOCK(arena);
|
||||
A = alloc_arena_chunk(4 * 1048576, NULL);
|
||||
CMARK_UNLOCK(arena);
|
||||
}
|
||||
|
||||
void cmark_arena_reset(void) {
|
||||
CMARK_INITIALIZE_AND_LOCK(arena);
|
||||
while (A) {
|
||||
free(A->ptr);
|
||||
struct arena_chunk *n = A->prev;
|
||||
free(A);
|
||||
A = n;
|
||||
}
|
||||
CMARK_UNLOCK(arena);
|
||||
}
|
||||
|
||||
static void *arena_calloc(size_t nmem, size_t size) {
|
||||
if (!A)
|
||||
init_arena();
|
||||
|
||||
size_t sz = nmem * size + sizeof(size_t);
|
||||
|
||||
// Round allocation sizes to largest integer size to
|
||||
// ensure returned memory is correctly aligned
|
||||
const size_t align = sizeof(size_t) - 1;
|
||||
sz = (sz + align) & ~align;
|
||||
|
||||
CMARK_INITIALIZE_AND_LOCK(arena);
|
||||
|
||||
struct arena_chunk *chunk;
|
||||
if (sz > A->sz) {
|
||||
A->prev = chunk = alloc_arena_chunk(sz, A->prev);
|
||||
} else if (sz > A->sz - A->used) {
|
||||
A = chunk = alloc_arena_chunk(A->sz + A->sz / 2, A);
|
||||
} else {
|
||||
chunk = A;
|
||||
}
|
||||
void *ptr = (uint8_t *) chunk->ptr + chunk->used;
|
||||
chunk->used += sz;
|
||||
*((size_t *) ptr) = sz - sizeof(size_t);
|
||||
|
||||
CMARK_UNLOCK(arena);
|
||||
|
||||
return (uint8_t *) ptr + sizeof(size_t);
|
||||
}
|
||||
|
||||
static void *arena_realloc(void *ptr, size_t size) {
|
||||
if (!A)
|
||||
init_arena();
|
||||
|
||||
void *new_ptr = arena_calloc(1, size);
|
||||
if (ptr)
|
||||
memcpy(new_ptr, ptr, ((size_t *) ptr)[-1]);
|
||||
return new_ptr;
|
||||
}
|
||||
|
||||
static void arena_free(void *ptr) {
|
||||
(void) ptr;
|
||||
/* no-op */
|
||||
}
|
||||
|
||||
cmark_mem CMARK_ARENA_MEM_ALLOCATOR = {arena_calloc, arena_realloc, arena_free};
|
||||
|
||||
cmark_mem *cmark_get_arena_mem_allocator(void) {
|
||||
return &CMARK_ARENA_MEM_ALLOCATOR;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,277 @@
|
||||
#include <assert.h>
|
||||
#include <limits.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cmark_ctype.h"
|
||||
#include "buffer.h"
|
||||
|
||||
/* Used as default value for cmark_strbuf->ptr so that people can always
|
||||
* assume ptr is non-NULL and zero terminated even for new cmark_strbufs.
|
||||
*/
|
||||
unsigned char cmark_strbuf__initbuf[1];
|
||||
|
||||
#ifndef MIN
|
||||
#define MIN(x, y) ((x < y) ? x : y)
|
||||
#endif
|
||||
|
||||
void cmark_strbuf_init(cmark_mem *mem, cmark_strbuf *buf,
|
||||
bufsize_t initial_size) {
|
||||
buf->mem = mem;
|
||||
buf->asize = 0;
|
||||
buf->size = 0;
|
||||
buf->ptr = cmark_strbuf__initbuf;
|
||||
|
||||
if (initial_size > 0)
|
||||
cmark_strbuf_grow(buf, initial_size);
|
||||
}
|
||||
|
||||
static inline void S_strbuf_grow_by(cmark_strbuf *buf, bufsize_t add) {
|
||||
cmark_strbuf_grow(buf, buf->size + add);
|
||||
}
|
||||
|
||||
void cmark_strbuf_grow(cmark_strbuf *buf, bufsize_t target_size) {
|
||||
assert(target_size > 0);
|
||||
|
||||
if (target_size < buf->asize)
|
||||
return;
|
||||
|
||||
if (target_size > (bufsize_t)(INT32_MAX / 2)) {
|
||||
fprintf(stderr,
|
||||
"[cmark] cmark_strbuf_grow requests buffer with size > %d, aborting\n",
|
||||
(INT32_MAX / 2));
|
||||
abort();
|
||||
}
|
||||
|
||||
/* Oversize the buffer by 50% to guarantee amortized linear time
|
||||
* complexity on append operations. */
|
||||
bufsize_t new_size = target_size + target_size / 2;
|
||||
new_size += 1;
|
||||
new_size = (new_size + 7) & ~7;
|
||||
|
||||
buf->ptr = (unsigned char *)buf->mem->realloc(buf->asize ? buf->ptr : NULL,
|
||||
new_size);
|
||||
buf->asize = new_size;
|
||||
}
|
||||
|
||||
bufsize_t cmark_strbuf_len(const cmark_strbuf *buf) { return buf->size; }
|
||||
|
||||
void cmark_strbuf_free(cmark_strbuf *buf) {
|
||||
if (!buf)
|
||||
return;
|
||||
|
||||
if (buf->ptr != cmark_strbuf__initbuf)
|
||||
buf->mem->free(buf->ptr);
|
||||
|
||||
cmark_strbuf_init(buf->mem, buf, 0);
|
||||
}
|
||||
|
||||
void cmark_strbuf_clear(cmark_strbuf *buf) {
|
||||
buf->size = 0;
|
||||
|
||||
if (buf->asize > 0)
|
||||
buf->ptr[0] = '\0';
|
||||
}
|
||||
|
||||
void cmark_strbuf_set(cmark_strbuf *buf, const unsigned char *data,
|
||||
bufsize_t len) {
|
||||
if (len <= 0 || data == NULL) {
|
||||
cmark_strbuf_clear(buf);
|
||||
} else {
|
||||
if (data != buf->ptr) {
|
||||
if (len >= buf->asize)
|
||||
cmark_strbuf_grow(buf, len);
|
||||
memmove(buf->ptr, data, len);
|
||||
}
|
||||
buf->size = len;
|
||||
buf->ptr[buf->size] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
void cmark_strbuf_sets(cmark_strbuf *buf, const char *string) {
|
||||
cmark_strbuf_set(buf, (const unsigned char *)string,
|
||||
string ? (bufsize_t)strlen(string) : 0);
|
||||
}
|
||||
|
||||
void cmark_strbuf_putc(cmark_strbuf *buf, int c) {
|
||||
S_strbuf_grow_by(buf, 1);
|
||||
buf->ptr[buf->size++] = (unsigned char)(c & 0xFF);
|
||||
buf->ptr[buf->size] = '\0';
|
||||
}
|
||||
|
||||
void cmark_strbuf_put(cmark_strbuf *buf, const unsigned char *data,
|
||||
bufsize_t len) {
|
||||
if (len <= 0)
|
||||
return;
|
||||
|
||||
S_strbuf_grow_by(buf, len);
|
||||
memmove(buf->ptr + buf->size, data, len);
|
||||
buf->size += len;
|
||||
buf->ptr[buf->size] = '\0';
|
||||
}
|
||||
|
||||
void cmark_strbuf_puts(cmark_strbuf *buf, const char *string) {
|
||||
cmark_strbuf_put(buf, (const unsigned char *)string, (bufsize_t)strlen(string));
|
||||
}
|
||||
|
||||
void cmark_strbuf_copy_cstr(char *data, bufsize_t datasize,
|
||||
const cmark_strbuf *buf) {
|
||||
bufsize_t copylen;
|
||||
|
||||
assert(buf);
|
||||
if (!data || datasize <= 0)
|
||||
return;
|
||||
|
||||
data[0] = '\0';
|
||||
|
||||
if (buf->size == 0 || buf->asize <= 0)
|
||||
return;
|
||||
|
||||
copylen = buf->size;
|
||||
if (copylen > datasize - 1)
|
||||
copylen = datasize - 1;
|
||||
memmove(data, buf->ptr, copylen);
|
||||
data[copylen] = '\0';
|
||||
}
|
||||
|
||||
void cmark_strbuf_swap(cmark_strbuf *buf_a, cmark_strbuf *buf_b) {
|
||||
cmark_strbuf t = *buf_a;
|
||||
*buf_a = *buf_b;
|
||||
*buf_b = t;
|
||||
}
|
||||
|
||||
unsigned char *cmark_strbuf_detach(cmark_strbuf *buf) {
|
||||
unsigned char *data = buf->ptr;
|
||||
|
||||
if (buf->asize == 0) {
|
||||
/* return an empty string */
|
||||
return (unsigned char *)buf->mem->calloc(1, 1);
|
||||
}
|
||||
|
||||
cmark_strbuf_init(buf->mem, buf, 0);
|
||||
return data;
|
||||
}
|
||||
|
||||
int cmark_strbuf_cmp(const cmark_strbuf *a, const cmark_strbuf *b) {
|
||||
int result = memcmp(a->ptr, b->ptr, MIN(a->size, b->size));
|
||||
return (result != 0) ? result
|
||||
: (a->size < b->size) ? -1 : (a->size > b->size) ? 1 : 0;
|
||||
}
|
||||
|
||||
bufsize_t cmark_strbuf_strchr(const cmark_strbuf *buf, int c, bufsize_t pos) {
|
||||
if (pos >= buf->size)
|
||||
return -1;
|
||||
if (pos < 0)
|
||||
pos = 0;
|
||||
|
||||
const unsigned char *p =
|
||||
(unsigned char *)memchr(buf->ptr + pos, c, buf->size - pos);
|
||||
if (!p)
|
||||
return -1;
|
||||
|
||||
return (bufsize_t)(p - (const unsigned char *)buf->ptr);
|
||||
}
|
||||
|
||||
bufsize_t cmark_strbuf_strrchr(const cmark_strbuf *buf, int c, bufsize_t pos) {
|
||||
if (pos < 0 || buf->size == 0)
|
||||
return -1;
|
||||
if (pos >= buf->size)
|
||||
pos = buf->size - 1;
|
||||
|
||||
bufsize_t i;
|
||||
for (i = pos; i >= 0; i--) {
|
||||
if (buf->ptr[i] == (unsigned char)c)
|
||||
return i;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
void cmark_strbuf_truncate(cmark_strbuf *buf, bufsize_t len) {
|
||||
if (len < 0)
|
||||
len = 0;
|
||||
|
||||
if (len < buf->size) {
|
||||
buf->size = len;
|
||||
buf->ptr[buf->size] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
void cmark_strbuf_drop(cmark_strbuf *buf, bufsize_t n) {
|
||||
if (n > 0) {
|
||||
if (n > buf->size)
|
||||
n = buf->size;
|
||||
buf->size = buf->size - n;
|
||||
if (buf->size)
|
||||
memmove(buf->ptr, buf->ptr + n, buf->size);
|
||||
|
||||
buf->ptr[buf->size] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
void cmark_strbuf_rtrim(cmark_strbuf *buf) {
|
||||
if (!buf->size)
|
||||
return;
|
||||
|
||||
while (buf->size > 0) {
|
||||
if (!cmark_isspace(buf->ptr[buf->size - 1]))
|
||||
break;
|
||||
|
||||
buf->size--;
|
||||
}
|
||||
|
||||
buf->ptr[buf->size] = '\0';
|
||||
}
|
||||
|
||||
void cmark_strbuf_trim(cmark_strbuf *buf) {
|
||||
bufsize_t i = 0;
|
||||
|
||||
if (!buf->size)
|
||||
return;
|
||||
|
||||
while (i < buf->size && cmark_isspace(buf->ptr[i]))
|
||||
i++;
|
||||
|
||||
cmark_strbuf_drop(buf, i);
|
||||
|
||||
cmark_strbuf_rtrim(buf);
|
||||
}
|
||||
|
||||
// Destructively modify string, collapsing consecutive
|
||||
// space and newline characters into a single space.
|
||||
void cmark_strbuf_normalize_whitespace(cmark_strbuf *s) {
|
||||
bool last_char_was_space = false;
|
||||
bufsize_t r, w;
|
||||
|
||||
for (r = 0, w = 0; r < s->size; ++r) {
|
||||
if (cmark_isspace(s->ptr[r])) {
|
||||
if (!last_char_was_space) {
|
||||
s->ptr[w++] = ' ';
|
||||
last_char_was_space = true;
|
||||
}
|
||||
} else {
|
||||
s->ptr[w++] = s->ptr[r];
|
||||
last_char_was_space = false;
|
||||
}
|
||||
}
|
||||
|
||||
cmark_strbuf_truncate(s, w);
|
||||
}
|
||||
|
||||
// Destructively unescape a string: remove backslashes before punctuation chars.
|
||||
extern void cmark_strbuf_unescape(cmark_strbuf *buf) {
|
||||
bufsize_t r, w;
|
||||
|
||||
for (r = 0, w = 0; r < buf->size; ++r) {
|
||||
if (buf->ptr[r] == '\\' && cmark_ispunct(buf->ptr[r + 1]))
|
||||
r++;
|
||||
|
||||
buf->ptr[w++] = buf->ptr[r];
|
||||
}
|
||||
|
||||
cmark_strbuf_truncate(buf, w);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
#include <stdlib.h>
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include "registry.h"
|
||||
#include "node.h"
|
||||
#include "houdini.h"
|
||||
#include "cmark-gfm.h"
|
||||
#include "buffer.h"
|
||||
|
||||
cmark_node_type CMARK_NODE_LAST_BLOCK = CMARK_NODE_FOOTNOTE_DEFINITION;
|
||||
cmark_node_type CMARK_NODE_LAST_INLINE = CMARK_NODE_ATTRIBUTE;
|
||||
|
||||
int cmark_version(void) { return CMARK_GFM_VERSION; }
|
||||
|
||||
const char *cmark_version_string(void) { return CMARK_GFM_VERSION_STRING; }
|
||||
|
||||
static void *xcalloc(size_t nmem, size_t size) {
|
||||
void *ptr = calloc(nmem, size);
|
||||
if (!ptr) {
|
||||
fprintf(stderr, "[cmark] calloc returned null pointer, aborting\n");
|
||||
abort();
|
||||
}
|
||||
return ptr;
|
||||
}
|
||||
|
||||
static void *xrealloc(void *ptr, size_t size) {
|
||||
void *new_ptr = realloc(ptr, size);
|
||||
if (!new_ptr) {
|
||||
fprintf(stderr, "[cmark] realloc returned null pointer, aborting\n");
|
||||
abort();
|
||||
}
|
||||
return new_ptr;
|
||||
}
|
||||
|
||||
static void xfree(void *ptr) {
|
||||
free(ptr);
|
||||
}
|
||||
|
||||
cmark_mem CMARK_DEFAULT_MEM_ALLOCATOR = {xcalloc, xrealloc, xfree};
|
||||
|
||||
cmark_mem *cmark_get_default_mem_allocator(void) {
|
||||
return &CMARK_DEFAULT_MEM_ALLOCATOR;
|
||||
}
|
||||
|
||||
char *cmark_markdown_to_html(const char *text, size_t len, int options) {
|
||||
cmark_node *doc;
|
||||
char *result;
|
||||
|
||||
doc = cmark_parse_document(text, len, options);
|
||||
|
||||
result = cmark_render_html(doc, options, NULL);
|
||||
cmark_node_free(doc);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#include <stdint.h>
|
||||
|
||||
#include "cmark_ctype.h"
|
||||
|
||||
/** 1 = space, 2 = punct, 3 = digit, 4 = alpha, 0 = other
|
||||
*/
|
||||
static const uint8_t cmark_ctype_class[256] = {
|
||||
/* 0 1 2 3 4 5 6 7 8 9 a b c d e f */
|
||||
/* 0 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0, 0,
|
||||
/* 1 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
/* 2 */ 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
|
||||
/* 3 */ 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2,
|
||||
/* 4 */ 2, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
|
||||
/* 5 */ 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 2, 2, 2, 2, 2,
|
||||
/* 6 */ 2, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4,
|
||||
/* 7 */ 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 2, 2, 2, 2, 0,
|
||||
/* 8 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
/* 9 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
/* a */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
/* b */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
/* c */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
/* d */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
/* e */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
/* f */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
|
||||
|
||||
/**
|
||||
* Returns 1 if c is a "whitespace" character as defined by the spec.
|
||||
*/
|
||||
int cmark_isspace(char c) { return cmark_ctype_class[(uint8_t)c] == 1; }
|
||||
|
||||
/**
|
||||
* Returns 1 if c is an ascii punctuation character.
|
||||
*/
|
||||
int cmark_ispunct(char c) { return cmark_ctype_class[(uint8_t)c] == 2; }
|
||||
|
||||
int cmark_isalnum(char c) {
|
||||
uint8_t result;
|
||||
result = cmark_ctype_class[(uint8_t)c];
|
||||
return (result == 3 || result == 4);
|
||||
}
|
||||
|
||||
int cmark_isdigit(char c) { return cmark_ctype_class[(uint8_t)c] == 3; }
|
||||
|
||||
int cmark_isalpha(char c) { return cmark_ctype_class[(uint8_t)c] == 4; }
|
||||
@@ -0,0 +1,524 @@
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cmark-gfm.h"
|
||||
#include "node.h"
|
||||
#include "buffer.h"
|
||||
#include "utf8.h"
|
||||
#include "scanners.h"
|
||||
#include "render.h"
|
||||
#include "syntax_extension.h"
|
||||
|
||||
#define OUT(s, wrap, escaping) renderer->out(renderer, node, s, wrap, escaping)
|
||||
#define LIT(s) renderer->out(renderer, node, s, false, LITERAL)
|
||||
#define CR() renderer->cr(renderer)
|
||||
#define BLANKLINE() renderer->blankline(renderer)
|
||||
#define ENCODED_SIZE 20
|
||||
#define LISTMARKER_SIZE 20
|
||||
|
||||
// Functions to convert cmark_nodes to commonmark strings.
|
||||
|
||||
static inline void outc(cmark_renderer *renderer, cmark_node *node,
|
||||
cmark_escaping escape, int32_t c, unsigned char nextc) {
|
||||
bool needs_escaping = false;
|
||||
bool follows_digit =
|
||||
renderer->buffer->size > 0 &&
|
||||
cmark_isdigit(renderer->buffer->ptr[renderer->buffer->size - 1]);
|
||||
char encoded[ENCODED_SIZE];
|
||||
|
||||
needs_escaping =
|
||||
c < 0x80 && escape != LITERAL &&
|
||||
((escape == NORMAL &&
|
||||
(c < 0x20 ||
|
||||
c == '*' || c == '_' || c == '[' || c == ']' || c == '#' || c == '<' ||
|
||||
c == '>' || c == '\\' || c == '`' || c == '~' || c == '!' ||
|
||||
(c == '&' && cmark_isalpha(nextc)) || (c == '!' && nextc == '[') ||
|
||||
(c == '^' && nextc == '[') ||
|
||||
(renderer->begin_content && (c == '-' || c == '+' || c == '=') &&
|
||||
// begin_content doesn't get set to false til we've passed digits
|
||||
// at the beginning of line, so...
|
||||
!follows_digit) ||
|
||||
(renderer->begin_content && (c == '.' || c == ')') && follows_digit &&
|
||||
(nextc == 0 || cmark_isspace(nextc))))) ||
|
||||
(escape == URL &&
|
||||
(c == '`' || c == '<' || c == '>' || cmark_isspace((char)c) || c == '\\' ||
|
||||
c == ')' || c == '(')) ||
|
||||
(escape == TITLE &&
|
||||
(c == '`' || c == '<' || c == '>' || c == '"' || c == '\\')));
|
||||
|
||||
if (needs_escaping) {
|
||||
if (escape == URL && cmark_isspace((char)c)) {
|
||||
// use percent encoding for spaces
|
||||
snprintf(encoded, ENCODED_SIZE, "%%%2X", c);
|
||||
cmark_strbuf_puts(renderer->buffer, encoded);
|
||||
renderer->column += 3;
|
||||
} else if (cmark_ispunct((char)c)) {
|
||||
cmark_render_ascii(renderer, "\\");
|
||||
cmark_render_code_point(renderer, c);
|
||||
} else { // render as entity
|
||||
snprintf(encoded, ENCODED_SIZE, "&#%d;", c);
|
||||
cmark_strbuf_puts(renderer->buffer, encoded);
|
||||
renderer->column += (int)strlen(encoded);
|
||||
}
|
||||
} else {
|
||||
cmark_render_code_point(renderer, c);
|
||||
}
|
||||
}
|
||||
|
||||
static int longest_backtick_sequence(const char *code) {
|
||||
int longest = 0;
|
||||
int current = 0;
|
||||
size_t i = 0;
|
||||
size_t code_len = strlen(code);
|
||||
while (i <= code_len) {
|
||||
if (code[i] == '`') {
|
||||
current++;
|
||||
} else {
|
||||
if (current > longest) {
|
||||
longest = current;
|
||||
}
|
||||
current = 0;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return longest;
|
||||
}
|
||||
|
||||
static int shortest_unused_backtick_sequence(const char *code) {
|
||||
// note: if the shortest sequence is >= 32, this returns 32
|
||||
// so as not to overflow the bit array.
|
||||
uint32_t used = 1;
|
||||
int current = 0;
|
||||
size_t i = 0;
|
||||
size_t code_len = strlen(code);
|
||||
while (i <= code_len) {
|
||||
if (code[i] == '`') {
|
||||
current++;
|
||||
} else {
|
||||
if (current > 0 && current < 32) {
|
||||
used |= (1U << current);
|
||||
}
|
||||
current = 0;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
// return number of first bit that is 0:
|
||||
i = 0;
|
||||
while (i < 32 && used & 1) {
|
||||
used = used >> 1;
|
||||
i++;
|
||||
}
|
||||
return (int)i;
|
||||
}
|
||||
|
||||
static bool is_autolink(cmark_node *node) {
|
||||
cmark_chunk *title;
|
||||
cmark_chunk *url;
|
||||
cmark_node *link_text;
|
||||
char *realurl;
|
||||
int realurllen;
|
||||
|
||||
if (node->type != CMARK_NODE_LINK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
url = &node->as.link.url;
|
||||
if (url->len == 0 || scan_scheme(url, 0) == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
title = &node->as.link.title;
|
||||
// if it has a title, we can't treat it as an autolink:
|
||||
if (title->len > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
link_text = node->first_child;
|
||||
if (link_text == NULL) {
|
||||
return false;
|
||||
}
|
||||
cmark_consolidate_text_nodes(link_text);
|
||||
realurl = (char *)url->data;
|
||||
realurllen = url->len;
|
||||
if (strncmp(realurl, "mailto:", 7) == 0) {
|
||||
realurl += 7;
|
||||
realurllen -= 7;
|
||||
}
|
||||
return (realurllen == link_text->as.literal.len &&
|
||||
strncmp(realurl, (char *)link_text->as.literal.data,
|
||||
link_text->as.literal.len) == 0);
|
||||
}
|
||||
|
||||
static int S_render_node(cmark_renderer *renderer, cmark_node *node,
|
||||
cmark_event_type ev_type, int options) {
|
||||
int list_number;
|
||||
cmark_delim_type list_delim;
|
||||
int numticks;
|
||||
bool extra_spaces;
|
||||
int i;
|
||||
bool entering = (ev_type == CMARK_EVENT_ENTER);
|
||||
const char *info, *code, *title;
|
||||
char fencechar[2] = {'\0', '\0'};
|
||||
size_t info_len, code_len;
|
||||
char listmarker[LISTMARKER_SIZE];
|
||||
const char *emph_delim;
|
||||
bool first_in_list_item;
|
||||
bufsize_t marker_width;
|
||||
bool allow_wrap = renderer->width > 0 && !(CMARK_OPT_NOBREAKS & options) &&
|
||||
!(CMARK_OPT_HARDBREAKS & options);
|
||||
|
||||
// Don't adjust tight list status til we've started the list.
|
||||
// Otherwise we loose the blank line between a paragraph and
|
||||
// a following list.
|
||||
if (entering) {
|
||||
if (node->parent && node->parent->type == CMARK_NODE_ITEM) {
|
||||
renderer->in_tight_list_item = node->parent->parent->as.list.tight;
|
||||
}
|
||||
} else {
|
||||
if (node->type == CMARK_NODE_LIST) {
|
||||
renderer->in_tight_list_item =
|
||||
node->parent &&
|
||||
node->parent->type == CMARK_NODE_ITEM &&
|
||||
node->parent->parent->as.list.tight;
|
||||
}
|
||||
}
|
||||
|
||||
if (node->extension && node->extension->commonmark_render_func) {
|
||||
node->extension->commonmark_render_func(node->extension, renderer, node, ev_type, options);
|
||||
return 1;
|
||||
}
|
||||
|
||||
switch (node->type) {
|
||||
case CMARK_NODE_DOCUMENT:
|
||||
break;
|
||||
|
||||
case CMARK_NODE_BLOCK_QUOTE:
|
||||
if (entering) {
|
||||
LIT("> ");
|
||||
renderer->begin_content = true;
|
||||
cmark_strbuf_puts(renderer->prefix, "> ");
|
||||
} else {
|
||||
cmark_strbuf_truncate(renderer->prefix, renderer->prefix->size - 2);
|
||||
BLANKLINE();
|
||||
}
|
||||
break;
|
||||
|
||||
case CMARK_NODE_LIST:
|
||||
if (!entering && node->next && (node->next->type == CMARK_NODE_CODE_BLOCK ||
|
||||
node->next->type == CMARK_NODE_LIST)) {
|
||||
// this ensures that a following indented code block or list will be
|
||||
// inteprereted correctly.
|
||||
CR();
|
||||
LIT("<!-- end list -->");
|
||||
BLANKLINE();
|
||||
}
|
||||
break;
|
||||
|
||||
case CMARK_NODE_ITEM:
|
||||
if (cmark_node_get_list_type(node->parent) == CMARK_BULLET_LIST) {
|
||||
marker_width = 4;
|
||||
} else {
|
||||
list_number = cmark_node_get_item_index(node);
|
||||
list_delim = cmark_node_get_list_delim(node->parent);
|
||||
// we ensure a width of at least 4 so
|
||||
// we get nice transition from single digits
|
||||
// to double
|
||||
snprintf(listmarker, LISTMARKER_SIZE, "%d%s%s", list_number,
|
||||
list_delim == CMARK_PAREN_DELIM ? ")" : ".",
|
||||
list_number < 10 ? " " : " ");
|
||||
marker_width = (bufsize_t)strlen(listmarker);
|
||||
}
|
||||
if (entering) {
|
||||
if (cmark_node_get_list_type(node->parent) == CMARK_BULLET_LIST) {
|
||||
LIT(" - ");
|
||||
renderer->begin_content = true;
|
||||
} else {
|
||||
LIT(listmarker);
|
||||
renderer->begin_content = true;
|
||||
}
|
||||
for (i = marker_width; i--;) {
|
||||
cmark_strbuf_putc(renderer->prefix, ' ');
|
||||
}
|
||||
} else {
|
||||
cmark_strbuf_truncate(renderer->prefix,
|
||||
renderer->prefix->size - marker_width);
|
||||
CR();
|
||||
}
|
||||
break;
|
||||
|
||||
case CMARK_NODE_HEADING:
|
||||
if (entering) {
|
||||
for (i = cmark_node_get_heading_level(node); i > 0; i--) {
|
||||
LIT("#");
|
||||
}
|
||||
LIT(" ");
|
||||
renderer->begin_content = true;
|
||||
renderer->no_linebreaks = true;
|
||||
} else {
|
||||
renderer->no_linebreaks = false;
|
||||
BLANKLINE();
|
||||
}
|
||||
break;
|
||||
|
||||
case CMARK_NODE_CODE_BLOCK:
|
||||
first_in_list_item = node->prev == NULL && node->parent &&
|
||||
node->parent->type == CMARK_NODE_ITEM;
|
||||
|
||||
if (!first_in_list_item) {
|
||||
BLANKLINE();
|
||||
}
|
||||
info = cmark_node_get_fence_info(node);
|
||||
info_len = strlen(info);
|
||||
fencechar[0] = strchr(info, '`') == NULL ? '`' : '~';
|
||||
code = cmark_node_get_literal(node);
|
||||
code_len = strlen(code);
|
||||
// use indented form if no info, and code doesn't
|
||||
// begin or end with a blank line, and code isn't
|
||||
// first thing in a list item
|
||||
if (info_len == 0 && (code_len > 2 && !cmark_isspace(code[0]) &&
|
||||
!(cmark_isspace(code[code_len - 1]) &&
|
||||
cmark_isspace(code[code_len - 2]))) &&
|
||||
!first_in_list_item) {
|
||||
LIT(" ");
|
||||
cmark_strbuf_puts(renderer->prefix, " ");
|
||||
OUT(cmark_node_get_literal(node), false, LITERAL);
|
||||
cmark_strbuf_truncate(renderer->prefix, renderer->prefix->size - 4);
|
||||
} else {
|
||||
numticks = longest_backtick_sequence(code) + 1;
|
||||
if (numticks < 3) {
|
||||
numticks = 3;
|
||||
}
|
||||
for (i = 0; i < numticks; i++) {
|
||||
LIT(fencechar);
|
||||
}
|
||||
LIT(" ");
|
||||
OUT(info, false, LITERAL);
|
||||
CR();
|
||||
OUT(cmark_node_get_literal(node), false, LITERAL);
|
||||
CR();
|
||||
for (i = 0; i < numticks; i++) {
|
||||
LIT(fencechar);
|
||||
}
|
||||
}
|
||||
BLANKLINE();
|
||||
break;
|
||||
|
||||
case CMARK_NODE_HTML_BLOCK:
|
||||
BLANKLINE();
|
||||
OUT(cmark_node_get_literal(node), false, LITERAL);
|
||||
BLANKLINE();
|
||||
break;
|
||||
|
||||
case CMARK_NODE_CUSTOM_BLOCK:
|
||||
BLANKLINE();
|
||||
OUT(entering ? cmark_node_get_on_enter(node) : cmark_node_get_on_exit(node),
|
||||
false, LITERAL);
|
||||
BLANKLINE();
|
||||
break;
|
||||
|
||||
case CMARK_NODE_THEMATIC_BREAK:
|
||||
BLANKLINE();
|
||||
LIT("-----");
|
||||
BLANKLINE();
|
||||
break;
|
||||
|
||||
case CMARK_NODE_PARAGRAPH:
|
||||
if (!entering) {
|
||||
BLANKLINE();
|
||||
}
|
||||
break;
|
||||
|
||||
case CMARK_NODE_TEXT:
|
||||
OUT(cmark_node_get_literal(node), allow_wrap, NORMAL);
|
||||
break;
|
||||
|
||||
case CMARK_NODE_LINEBREAK:
|
||||
if (!(CMARK_OPT_HARDBREAKS & options)) {
|
||||
LIT(" ");
|
||||
}
|
||||
CR();
|
||||
break;
|
||||
|
||||
case CMARK_NODE_SOFTBREAK:
|
||||
if (CMARK_OPT_HARDBREAKS & options) {
|
||||
LIT(" ");
|
||||
CR();
|
||||
} else if (!renderer->no_linebreaks && renderer->width == 0 &&
|
||||
!(CMARK_OPT_HARDBREAKS & options) &&
|
||||
!(CMARK_OPT_NOBREAKS & options)) {
|
||||
CR();
|
||||
} else {
|
||||
OUT(" ", allow_wrap, LITERAL);
|
||||
}
|
||||
break;
|
||||
|
||||
case CMARK_NODE_CODE:
|
||||
code = cmark_node_get_literal(node);
|
||||
code_len = strlen(code);
|
||||
numticks = shortest_unused_backtick_sequence(code);
|
||||
extra_spaces = code_len == 0 ||
|
||||
code[0] == '`' || code[code_len - 1] == '`' ||
|
||||
code[0] == ' ' || code[code_len - 1] == ' ';
|
||||
for (i = 0; i < numticks; i++) {
|
||||
LIT("`");
|
||||
}
|
||||
if (extra_spaces) {
|
||||
LIT(" ");
|
||||
}
|
||||
OUT(cmark_node_get_literal(node), allow_wrap, LITERAL);
|
||||
if (extra_spaces) {
|
||||
LIT(" ");
|
||||
}
|
||||
for (i = 0; i < numticks; i++) {
|
||||
LIT("`");
|
||||
}
|
||||
break;
|
||||
|
||||
case CMARK_NODE_HTML_INLINE:
|
||||
OUT(cmark_node_get_literal(node), false, LITERAL);
|
||||
break;
|
||||
|
||||
case CMARK_NODE_CUSTOM_INLINE:
|
||||
OUT(entering ? cmark_node_get_on_enter(node) : cmark_node_get_on_exit(node),
|
||||
false, LITERAL);
|
||||
break;
|
||||
|
||||
case CMARK_NODE_STRONG:
|
||||
if (node->parent == NULL || node->parent->type != CMARK_NODE_STRONG) {
|
||||
if (entering) {
|
||||
LIT("**");
|
||||
} else {
|
||||
LIT("**");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case CMARK_NODE_EMPH:
|
||||
// If we have EMPH(EMPH(x)), we need to use *_x_*
|
||||
// because **x** is STRONG(x):
|
||||
if (node->parent && node->parent->type == CMARK_NODE_EMPH &&
|
||||
node->next == NULL && node->prev == NULL) {
|
||||
emph_delim = "_";
|
||||
} else {
|
||||
emph_delim = "*";
|
||||
}
|
||||
if (entering) {
|
||||
LIT(emph_delim);
|
||||
} else {
|
||||
LIT(emph_delim);
|
||||
}
|
||||
break;
|
||||
|
||||
case CMARK_NODE_LINK:
|
||||
if (is_autolink(node)) {
|
||||
if (entering) {
|
||||
LIT("<");
|
||||
if (strncmp(cmark_node_get_url(node), "mailto:", 7) == 0) {
|
||||
LIT((const char *)cmark_node_get_url(node) + 7);
|
||||
} else {
|
||||
LIT((const char *)cmark_node_get_url(node));
|
||||
}
|
||||
LIT(">");
|
||||
// return signal to skip contents of node...
|
||||
return 0;
|
||||
}
|
||||
} else {
|
||||
if (entering) {
|
||||
LIT("[");
|
||||
} else {
|
||||
LIT("](");
|
||||
OUT(cmark_node_get_url(node), false, URL);
|
||||
title = cmark_node_get_title(node);
|
||||
if (strlen(title) > 0) {
|
||||
LIT(" \"");
|
||||
OUT(title, false, TITLE);
|
||||
LIT("\"");
|
||||
}
|
||||
LIT(")");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case CMARK_NODE_IMAGE:
|
||||
if (entering) {
|
||||
LIT(";
|
||||
OUT(cmark_node_get_url(node), false, URL);
|
||||
title = cmark_node_get_title(node);
|
||||
if (strlen(title) > 0) {
|
||||
OUT(" \"", allow_wrap, LITERAL);
|
||||
OUT(title, false, TITLE);
|
||||
LIT("\"");
|
||||
}
|
||||
LIT(")");
|
||||
}
|
||||
break;
|
||||
|
||||
case CMARK_NODE_ATTRIBUTE:
|
||||
if (entering) {
|
||||
LIT("^[");
|
||||
} else {
|
||||
LIT("](");
|
||||
OUT(cmark_node_get_attributes(node), false, LITERAL);
|
||||
LIT(")");
|
||||
}
|
||||
break;
|
||||
|
||||
case CMARK_NODE_FOOTNOTE_REFERENCE:
|
||||
if (entering) {
|
||||
LIT("[^");
|
||||
|
||||
char *footnote_label = renderer->mem->calloc(node->parent_footnote_def->as.literal.len + 1, sizeof(char));
|
||||
memmove(footnote_label, node->parent_footnote_def->as.literal.data, node->parent_footnote_def->as.literal.len);
|
||||
|
||||
OUT(footnote_label, false, LITERAL);
|
||||
renderer->mem->free(footnote_label);
|
||||
|
||||
LIT("]");
|
||||
}
|
||||
break;
|
||||
|
||||
case CMARK_NODE_FOOTNOTE_DEFINITION:
|
||||
if (entering) {
|
||||
renderer->footnote_ix += 1;
|
||||
LIT("[^");
|
||||
|
||||
char *footnote_label = renderer->mem->calloc(node->as.literal.len + 1, sizeof(char));
|
||||
memmove(footnote_label, node->as.literal.data, node->as.literal.len);
|
||||
|
||||
OUT(footnote_label, false, LITERAL);
|
||||
renderer->mem->free(footnote_label);
|
||||
|
||||
LIT("]:\n");
|
||||
|
||||
cmark_strbuf_puts(renderer->prefix, " ");
|
||||
} else {
|
||||
cmark_strbuf_truncate(renderer->prefix, renderer->prefix->size - 4);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
assert(false);
|
||||
break;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
char *cmark_render_commonmark(cmark_node *root, int options, int width) {
|
||||
return cmark_render_commonmark_with_mem(root, options, width, cmark_node_mem(root));
|
||||
}
|
||||
|
||||
char *cmark_render_commonmark_with_mem(cmark_node *root, int options, int width, cmark_mem *mem) {
|
||||
if (options & CMARK_OPT_HARDBREAKS) {
|
||||
// disable breaking on width, since it has
|
||||
// a different meaning with OPT_HARDBREAKS
|
||||
width = 0;
|
||||
}
|
||||
return cmark_render(mem, root, options, width, outc, S_render_node);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
#include "cmark-gfm.h"
|
||||
#include "parser.h"
|
||||
#include "footnotes.h"
|
||||
#include "inlines.h"
|
||||
#include "chunk.h"
|
||||
|
||||
static void footnote_free(cmark_map *map, cmark_map_entry *_ref) {
|
||||
cmark_footnote *ref = (cmark_footnote *)_ref;
|
||||
cmark_mem *mem = map->mem;
|
||||
if (ref != NULL) {
|
||||
mem->free(ref->entry.label);
|
||||
if (ref->node)
|
||||
cmark_node_free(ref->node);
|
||||
mem->free(ref);
|
||||
}
|
||||
}
|
||||
|
||||
void cmark_footnote_create(cmark_map *map, cmark_node *node) {
|
||||
cmark_footnote *ref;
|
||||
unsigned char *reflabel = normalize_map_label(map->mem, &node->as.literal);
|
||||
|
||||
/* empty footnote name, or composed from only whitespace */
|
||||
if (reflabel == NULL)
|
||||
return;
|
||||
|
||||
assert(map->sorted == NULL);
|
||||
|
||||
ref = (cmark_footnote *)map->mem->calloc(1, sizeof(*ref));
|
||||
ref->entry.label = reflabel;
|
||||
ref->node = node;
|
||||
ref->entry.age = map->size;
|
||||
ref->entry.next = map->refs;
|
||||
|
||||
map->refs = (cmark_map_entry *)ref;
|
||||
map->size++;
|
||||
}
|
||||
|
||||
cmark_map *cmark_footnote_map_new(cmark_mem *mem) {
|
||||
return cmark_map_new(mem, footnote_free);
|
||||
}
|
||||
|
||||
// Before calling `cmark_map_free` on a map with `cmark_footnotes`, first
|
||||
// unlink all of the footnote nodes before freeing their memory.
|
||||
//
|
||||
// Sometimes, two (unused) footnote nodes can end up referencing each other,
|
||||
// which as they get freed up by calling `cmark_map_free` -> `footnote_free` ->
|
||||
// etc, can lead to a use-after-free error.
|
||||
//
|
||||
// Better to `unlink` every footnote node first, setting their next, prev, and
|
||||
// parent pointers to NULL, and only then walk thru & free them up.
|
||||
void cmark_unlink_footnotes_map(cmark_map *map) {
|
||||
cmark_map_entry *ref;
|
||||
cmark_map_entry *next;
|
||||
|
||||
ref = map->refs;
|
||||
while(ref) {
|
||||
next = ref->next;
|
||||
if (((cmark_footnote *)ref)->node) {
|
||||
cmark_node_unlink(((cmark_footnote *)ref)->node);
|
||||
}
|
||||
ref = next;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "houdini.h"
|
||||
|
||||
#if !defined(__has_builtin)
|
||||
# define __has_builtin(b) 0
|
||||
#endif
|
||||
|
||||
#if !__has_builtin(__builtin_expect)
|
||||
# define __builtin_expect(e, v) (e)
|
||||
#endif
|
||||
|
||||
#define likely(e) __builtin_expect((e), 1)
|
||||
|
||||
/*
|
||||
* The following characters will not be escaped:
|
||||
*
|
||||
* -_.+!*'(),%#@?=;:/,+&$~ alphanum
|
||||
*
|
||||
* Note that this character set is the addition of:
|
||||
*
|
||||
* - The characters which are safe to be in an URL
|
||||
* - The characters which are *not* safe to be in
|
||||
* an URL because they are RESERVED characters.
|
||||
*
|
||||
* We assume (lazily) that any RESERVED char that
|
||||
* appears inside an URL is actually meant to
|
||||
* have its native function (i.e. as an URL
|
||||
* component/separator) and hence needs no escaping.
|
||||
*
|
||||
* There are two exceptions: the chacters & (amp)
|
||||
* and ' (single quote) do not appear in the table.
|
||||
* They are meant to appear in the URL as components,
|
||||
* yet they require special HTML-entity escaping
|
||||
* to generate valid HTML markup.
|
||||
*
|
||||
* All other characters will be escaped to %XX.
|
||||
*
|
||||
*/
|
||||
static const char HREF_SAFE[] = {
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1,
|
||||
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
||||
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1,
|
||||
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
|
||||
1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
};
|
||||
|
||||
int houdini_escape_href(cmark_strbuf *ob, const uint8_t *src, bufsize_t size) {
|
||||
static const uint8_t hex_chars[] = "0123456789ABCDEF";
|
||||
bufsize_t i = 0, org;
|
||||
uint8_t hex_str[3];
|
||||
|
||||
hex_str[0] = '%';
|
||||
|
||||
while (i < size) {
|
||||
org = i;
|
||||
while (i < size && HREF_SAFE[src[i]] != 0)
|
||||
i++;
|
||||
|
||||
if (likely(i > org))
|
||||
cmark_strbuf_put(ob, src + org, i - org);
|
||||
|
||||
/* escaping */
|
||||
if (i >= size)
|
||||
break;
|
||||
|
||||
switch (src[i]) {
|
||||
/* amp appears all the time in URLs, but needs
|
||||
* HTML-entity escaping to be inside an href */
|
||||
case '&':
|
||||
cmark_strbuf_puts(ob, "&");
|
||||
break;
|
||||
|
||||
/* the single quote is a valid URL character
|
||||
* according to the standard; it needs HTML
|
||||
* entity escaping too */
|
||||
case '\'':
|
||||
cmark_strbuf_puts(ob, "'");
|
||||
break;
|
||||
|
||||
/* the space can be escaped to %20 or a plus
|
||||
* sign. we're going with the generic escape
|
||||
* for now. the plus thing is more commonly seen
|
||||
* when building GET strings */
|
||||
#if 0
|
||||
case ' ':
|
||||
cmark_strbuf_putc(ob, '+');
|
||||
break;
|
||||
#endif
|
||||
|
||||
/* every other character goes with a %XX escaping */
|
||||
default:
|
||||
hex_str[1] = hex_chars[(src[i] >> 4) & 0xF];
|
||||
hex_str[2] = hex_chars[src[i] & 0xF];
|
||||
cmark_strbuf_put(ob, hex_str, 3);
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user