├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ └── build-and-test.yml
├── .gitignore
├── .periphery.yml
├── .swiftlint.yml
├── Build.xcconfig
├── CoreEditor
├── .gitignore
├── .yarnrc.yml
├── eslint.config.mjs
├── index.css
├── index.html
├── index.ts
├── jest.config.js
├── package.json
├── src
│ ├── @codegen
│ │ ├── README.md
│ │ ├── config.json
│ │ ├── swift-config.mustache
│ │ ├── swift-named-type.mustache
│ │ ├── swift-native-module.mustache
│ │ ├── swift-shared-types.mustache
│ │ └── swift-web-module.mustache
│ ├── @light
│ │ ├── README.md
│ │ ├── index.html
│ │ ├── index.ts
│ │ └── vite.config.mts
│ ├── @res
│ │ ├── SF-Mono-Bold.woff2
│ │ └── SF-Mono-Regular.woff2
│ ├── @types
│ │ ├── WebFontFace.ts
│ │ ├── WebMenuItem.ts
│ │ ├── WebPoint.ts
│ │ ├── WebRect.ts
│ │ └── global.d.ts
│ ├── @vendor
│ │ ├── README.md
│ │ ├── commands
│ │ │ └── history.ts
│ │ ├── joplin
│ │ │ └── markdownMathParser.ts
│ │ ├── lang-markdown
│ │ │ ├── README.md
│ │ │ ├── commands.ts
│ │ │ ├── index.ts
│ │ │ └── markdown.ts
│ │ └── language-data
│ │ │ ├── README.md
│ │ │ └── index.ts
│ ├── api
│ │ ├── editor.ts
│ │ ├── methods.ts
│ │ ├── modules.ts
│ │ └── ui.ts
│ ├── bridge
│ │ ├── native
│ │ │ ├── api.ts
│ │ │ ├── completion.ts
│ │ │ ├── core.ts
│ │ │ ├── preview.ts
│ │ │ └── tokenizer.ts
│ │ ├── nativeModule.ts
│ │ ├── web
│ │ │ ├── api.ts
│ │ │ ├── completion.ts
│ │ │ ├── config.ts
│ │ │ ├── core.ts
│ │ │ ├── dummy.ts
│ │ │ ├── format.ts
│ │ │ ├── history.ts
│ │ │ ├── lineEndings.ts
│ │ │ ├── search.ts
│ │ │ ├── selection.ts
│ │ │ ├── textChecker.ts
│ │ │ ├── toc.ts
│ │ │ └── writingTools.ts
│ │ └── webModule.ts
│ ├── common
│ │ ├── store.ts
│ │ └── utils.ts
│ ├── config.ts
│ ├── core.ts
│ ├── extensions.ts
│ ├── modules
│ │ ├── commands
│ │ │ ├── formatContent.ts
│ │ │ ├── index.ts
│ │ │ ├── insertBlockWithMarks.ts
│ │ │ ├── removeListMarkers.ts
│ │ │ ├── replaceSelections.ts
│ │ │ ├── toggleBlockWithMarks.ts
│ │ │ ├── toggleLineLeadingMark.ts
│ │ │ ├── toggleListStyle.ts
│ │ │ └── types.ts
│ │ ├── completion
│ │ │ └── index.ts
│ │ ├── config
│ │ │ └── index.ts
│ │ ├── events
│ │ │ └── index.ts
│ │ ├── frontMatter
│ │ │ └── index.ts
│ │ ├── history
│ │ │ └── index.ts
│ │ ├── indentation
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── input
│ │ │ ├── index.ts
│ │ │ ├── insertCodeBlock.ts
│ │ │ └── wrapBlock.ts
│ │ ├── lezer
│ │ │ └── index.ts
│ │ ├── lineEndings
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── lines
│ │ │ └── index.ts
│ │ ├── localization
│ │ │ └── index.ts
│ │ ├── preview
│ │ │ ├── index.css
│ │ │ └── index.ts
│ │ ├── search
│ │ │ ├── counterInfo.ts
│ │ │ ├── index.ts
│ │ │ ├── matchFromQuery.ts
│ │ │ ├── operations
│ │ │ │ ├── index.ts
│ │ │ │ ├── replaceAll.ts
│ │ │ │ ├── replaceAllInSelection.ts
│ │ │ │ ├── selectAll.ts
│ │ │ │ └── selectAllInSelection.ts
│ │ │ ├── options.ts
│ │ │ ├── queryCursor.ts
│ │ │ ├── rangesFromQuery.ts
│ │ │ └── searchOccurrences.ts
│ │ ├── selection
│ │ │ ├── hasSelection.ts
│ │ │ ├── index.ts
│ │ │ ├── navigate.ts
│ │ │ ├── redrawSelectionLayer.ts
│ │ │ ├── reverseRange.ts
│ │ │ ├── searchMatchElement.ts
│ │ │ ├── selectWholeLineAt.ts
│ │ │ ├── selectWithRanges.ts
│ │ │ ├── selectedLineColumn.ts
│ │ │ ├── selectedRanges.ts
│ │ │ └── types.ts
│ │ ├── snippets
│ │ │ ├── index.ts
│ │ │ └── insertSnippet.ts
│ │ ├── textChecker
│ │ │ ├── index.ts
│ │ │ └── options.ts
│ │ ├── toc
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── tokenizer
│ │ │ ├── anchorAtPos.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ └── writingTools
│ │ │ └── index.ts
│ └── styling
│ │ ├── builder.ts
│ │ ├── config.ts
│ │ ├── helper.ts
│ │ ├── markdown.ts
│ │ ├── matchers
│ │ ├── lezer.ts
│ │ └── regex.ts
│ │ ├── nodes
│ │ ├── code.ts
│ │ ├── frontMatter.ts
│ │ ├── gutter.ts
│ │ ├── heading.ts
│ │ ├── indent.ts
│ │ ├── invisible.ts
│ │ ├── line.ts
│ │ ├── link.ts
│ │ ├── selection.ts
│ │ ├── table.ts
│ │ └── task.ts
│ │ ├── themes
│ │ ├── cobalt.ts
│ │ ├── colors.ts
│ │ ├── dracula.ts
│ │ ├── github-dark.ts
│ │ ├── github-light.ts
│ │ ├── index.ts
│ │ ├── minimal-dark.ts
│ │ ├── minimal-light.ts
│ │ ├── night-owl.ts
│ │ ├── rose-pine-dawn.ts
│ │ ├── rose-pine.ts
│ │ ├── solarized-dark.ts
│ │ ├── solarized-light.ts
│ │ ├── synthwave84.ts
│ │ ├── winter-is-coming-dark.ts
│ │ ├── winter-is-coming-light.ts
│ │ ├── xcode-dark.ts
│ │ └── xcode-light.ts
│ │ ├── types.ts
│ │ └── views
│ │ ├── index.ts
│ │ └── types.ts
├── test
│ ├── basic.test.ts
│ ├── build.test.ts
│ ├── commands.test.ts
│ ├── frontMatter.test.ts
│ ├── history.test.ts
│ ├── input.test.ts
│ ├── lezer.test.ts
│ ├── lineEndings.test.ts
│ ├── search.test.ts
│ ├── styling.test.ts
│ ├── toc.test.ts
│ └── utils
│ │ ├── editor.ts
│ │ ├── helpers.ts
│ │ └── mock.ts
├── tsconfig.json
├── vite.config.mts
└── yarn.lock
├── Icon.png
├── LICENSE
├── MarkEdit.xcodeproj
├── project.pbxproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
└── xcshareddata
│ └── xcschemes
│ ├── MarkEditMac (screenshots).xcscheme
│ ├── MarkEditMac (zh-Hans).xcscheme
│ ├── MarkEditMac (zh-Hant).xcscheme
│ ├── MarkEditMac.xcscheme
│ └── PreviewExtension.xcscheme
├── MarkEditCore
├── .gitignore
├── .swiftpm
│ └── xcode
│ │ └── xcshareddata
│ │ └── xcschemes
│ │ └── MarkEditCore.xcscheme
├── Package.swift
├── README.md
├── Sources
│ ├── EditorConfig.swift
│ ├── EditorLocalizable.swift
│ ├── EditorSharedTypes.swift
│ └── Extensions
│ │ ├── Data+Extension.swift
│ │ ├── EditorConfig+Extension.swift
│ │ ├── Encodable+Extension.swift
│ │ ├── String+Extension.swift
│ │ └── TextTokenizeAnchor+Extension.swift
└── Tests
│ ├── EncodingTests.swift
│ └── Files
│ ├── sample-gb18030.md
│ ├── sample-japanese-euc.md
│ ├── sample-korean-euc.md
│ └── sample-utf8.md
├── MarkEditKit
├── .gitignore
├── .swiftpm
│ └── xcode
│ │ └── xcshareddata
│ │ └── xcschemes
│ │ └── MarkEditKit.xcscheme
├── Package.swift
├── README.md
└── Sources
│ ├── Bridge
│ ├── Native
│ │ ├── Generated
│ │ │ ├── NativeModuleAPI.swift
│ │ │ ├── NativeModuleCompletion.swift
│ │ │ ├── NativeModuleCore.swift
│ │ │ ├── NativeModulePreview.swift
│ │ │ └── NativeModuleTokenizer.swift
│ │ ├── Modules
│ │ │ ├── EditorModuleAPI.swift
│ │ │ ├── EditorModuleCompletion.swift
│ │ │ ├── EditorModuleCore.swift
│ │ │ ├── EditorModulePreview.swift
│ │ │ └── EditorModuleTokenizer.swift
│ │ └── NativeModules.swift
│ └── Web
│ │ ├── Generated
│ │ ├── WebBridgeAPI.swift
│ │ ├── WebBridgeCompletion.swift
│ │ ├── WebBridgeConfig.swift
│ │ ├── WebBridgeCore.swift
│ │ ├── WebBridgeDummy.swift
│ │ ├── WebBridgeFormat.swift
│ │ ├── WebBridgeHistory.swift
│ │ ├── WebBridgeLineEndings.swift
│ │ ├── WebBridgeSearch.swift
│ │ ├── WebBridgeSelection.swift
│ │ ├── WebBridgeTableOfContents.swift
│ │ ├── WebBridgeTextChecker.swift
│ │ └── WebBridgeWritingTools.swift
│ │ └── WebModuleBridge.swift
│ ├── EditorLogger.swift
│ ├── EditorMessageHandler.swift
│ ├── EditorTextEncoding.swift
│ └── Extensions
│ ├── Array+Extension.swift
│ ├── LineEndings+Extension.swift
│ ├── NSObject+Extension.swift
│ ├── URL+Extension.swift
│ ├── UserDefaults+Extension.swift
│ ├── WKWebView+Extension.swift
│ ├── WKWebViewConfiguration+Extension.swift
│ ├── WebPoint+Extension.swift
│ └── WebRect+Extension.swift
├── MarkEditMac
├── AppShortcuts.xcstrings
├── Base.lproj
│ └── Main.storyboard
├── Info.entitlements
├── Info.plist
├── Modules
│ ├── .gitignore
│ ├── .swiftpm
│ │ └── xcode
│ │ │ └── xcshareddata
│ │ │ └── xcschemes
│ │ │ ├── AppKitControls.xcscheme
│ │ │ ├── AppKitExtensions.xcscheme
│ │ │ └── FontPicker.xcscheme
│ ├── Package.swift
│ ├── README.md
│ ├── Sources
│ │ ├── AppKitControls
│ │ │ ├── BackgroundTheming.swift
│ │ │ ├── BezelView.swift
│ │ │ ├── Buttons
│ │ │ │ ├── IconOnlyButton.swift
│ │ │ │ ├── NonBezelButton.swift
│ │ │ │ └── TitleOnlyButton.swift
│ │ │ ├── DividerView.swift
│ │ │ ├── FocusTrackingView.swift
│ │ │ ├── GotoLine
│ │ │ │ ├── GotoLineView.swift
│ │ │ │ └── GotoLineWindow.swift
│ │ │ ├── LabelView.swift
│ │ │ ├── LabeledSearchField.swift
│ │ │ ├── RoundedButtonGroup.swift
│ │ │ └── RoundedNavigateButtons.swift
│ │ ├── AppKitExtensions
│ │ │ ├── Foundation
│ │ │ │ ├── Bundle+Extension.swift
│ │ │ │ ├── Data+Extension.swift
│ │ │ │ ├── NSApplication+Extension.swift
│ │ │ │ ├── NSDataDetector+Extension.swift
│ │ │ │ ├── NSDocument+Extension.swift
│ │ │ │ ├── NSEvent+Extension.swift
│ │ │ │ ├── NSFileVersion+Extension.swift
│ │ │ │ ├── NSObject+Extension.swift
│ │ │ │ ├── NSPasteboard+Extension.swift
│ │ │ │ ├── NSSavePanel+Extension.swift
│ │ │ │ ├── NSWorkspace+Extension.swift
│ │ │ │ ├── ProcessInfo+Extension.swift
│ │ │ │ └── URLComponents+Extension.swift
│ │ │ └── UI
│ │ │ │ ├── NSAlert+Extension.swift
│ │ │ │ ├── NSAnimationContext+Extension.swift
│ │ │ │ ├── NSAppearance+Extension.swift
│ │ │ │ ├── NSColor+Extension.swift
│ │ │ │ ├── NSControl+Extension.swift
│ │ │ │ ├── NSFont+Extension.swift
│ │ │ │ ├── NSImage+Extension.swift
│ │ │ │ ├── NSMenu+Extension.swift
│ │ │ │ ├── NSMenuItem+Extension.swift
│ │ │ │ ├── NSScrollView+Extension.swift
│ │ │ │ ├── NSSearchField+Extension.swift
│ │ │ │ ├── NSTextField+Extension.swift
│ │ │ │ ├── NSView+Extension.swift
│ │ │ │ ├── NSViewController+Extension.swift
│ │ │ │ └── NSWindow+Extension.swift
│ │ ├── DiffKit
│ │ │ ├── Diff.swift
│ │ │ └── Resources
│ │ │ │ └── diff.js
│ │ ├── FileVersion
│ │ │ ├── FileVersionLocalizable.swift
│ │ │ ├── FileVersionPicker.swift
│ │ │ └── Internal
│ │ │ │ ├── NSButton+Extension.swift
│ │ │ │ ├── NSColor+Extension.swift
│ │ │ │ └── Result+Extension.swift
│ │ ├── FontPicker
│ │ │ ├── Extensions
│ │ │ │ └── Notification+Extension.swift
│ │ │ ├── FontPicker.swift
│ │ │ ├── FontPickerConfiguration.swift
│ │ │ ├── FontPickerHandlers.swift
│ │ │ ├── FontStyle.swift
│ │ │ └── Internal
│ │ │ │ └── FontManagerDelegate.swift
│ │ ├── Previewer
│ │ │ ├── Previewer.swift
│ │ │ ├── Resources
│ │ │ │ ├── katex.html
│ │ │ │ ├── mermaid.html
│ │ │ │ └── table.html
│ │ │ └── Unchecked.swift
│ │ ├── SettingsUI
│ │ │ ├── Extensions
│ │ │ │ ├── NSCursor+Extension.swift
│ │ │ │ └── View+Extension.swift
│ │ │ ├── SettingsForm.swift
│ │ │ ├── SettingsRootViewController.swift
│ │ │ └── SettingsTabViewController.swift
│ │ ├── Statistics
│ │ │ ├── StatisticsController.swift
│ │ │ ├── StatisticsLocalizable.swift
│ │ │ ├── StatisticsResult.swift
│ │ │ ├── Utilities
│ │ │ │ ├── FileSize.swift
│ │ │ │ ├── ReadTime.swift
│ │ │ │ └── Tokenizer.swift
│ │ │ └── Views
│ │ │ │ ├── StatisticsCell.swift
│ │ │ │ └── StatisticsView.swift
│ │ ├── TextBundle
│ │ │ ├── Extensions
│ │ │ │ ├── FileWrapper+Extension.swift
│ │ │ │ └── String+Extension.swift
│ │ │ ├── Internal
│ │ │ │ ├── Errors.swift
│ │ │ │ └── FileNames.swift
│ │ │ └── TextBundleWrapper.swift
│ │ └── TextCompletion
│ │ │ ├── Extensions
│ │ │ ├── Color+Extension.swift
│ │ │ └── View+Extension.swift
│ │ │ ├── Internal
│ │ │ ├── TextCompletionPanel.swift
│ │ │ ├── TextCompletionState.swift
│ │ │ └── TextCompletionView.swift
│ │ │ ├── TextCompletionContext.swift
│ │ │ ├── TextCompletionLocalizable.swift
│ │ │ └── Unchecked.swift
│ └── Tests
│ │ ├── DataDetectorTests.swift
│ │ ├── Files
│ │ └── sample.textbundle
│ │ │ ├── assets
│ │ │ └── textbundle.png
│ │ │ ├── info.json
│ │ │ └── text.markdown
│ │ ├── PasteboardTests.swift
│ │ ├── RuntimeTests.swift
│ │ └── TextBundleTests.swift
├── Resources
│ ├── Assets.xcassets
│ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ ├── AppIcon.appiconset
│ │ │ ├── Contents.json
│ │ │ ├── icon_128x128.png
│ │ │ ├── icon_128x128@2x.png
│ │ │ ├── icon_16x16.png
│ │ │ ├── icon_16x16@2x.png
│ │ │ ├── icon_256x256.png
│ │ │ ├── icon_256x256@2x.png
│ │ │ ├── icon_32x32.png
│ │ │ ├── icon_32x32@2x.png
│ │ │ ├── icon_512x512.png
│ │ │ └── icon_512x512@2x.png
│ │ └── Contents.json
│ ├── Localizable.xcstrings
│ └── MarkEdit.sdef
├── Sources
│ ├── Editor
│ │ ├── Controllers
│ │ │ ├── EditorViewController+Completion.swift
│ │ │ ├── EditorViewController+Config.swift
│ │ │ ├── EditorViewController+Delegate.swift
│ │ │ ├── EditorViewController+Encoding.swift
│ │ │ ├── EditorViewController+Events.swift
│ │ │ ├── EditorViewController+FileVersion.swift
│ │ │ ├── EditorViewController+GotoLine.swift
│ │ │ ├── EditorViewController+HyperLink.swift
│ │ │ ├── EditorViewController+LineEndings.swift
│ │ │ ├── EditorViewController+Menu.swift
│ │ │ ├── EditorViewController+Pandoc.swift
│ │ │ ├── EditorViewController+Preview.swift
│ │ │ ├── EditorViewController+Statistics.swift
│ │ │ ├── EditorViewController+TextFinder.swift
│ │ │ ├── EditorViewController+Toolbar.swift
│ │ │ ├── EditorViewController+UI.swift
│ │ │ └── EditorViewController.swift
│ │ ├── EditorChunkLoader.swift
│ │ ├── EditorImageLoader.swift
│ │ ├── EditorWindow.swift
│ │ ├── EditorWindowController.swift
│ │ ├── Models
│ │ │ ├── EditorDocument.swift
│ │ │ ├── EditorMenuItem.swift
│ │ │ ├── EditorReusePool.swift
│ │ │ └── EditorToolbarItems.swift
│ │ └── Views
│ │ │ ├── EditorPanelView.swift
│ │ │ ├── EditorSaveOptionsView.swift
│ │ │ ├── EditorStatusView.swift
│ │ │ ├── EditorTextInput.swift
│ │ │ └── EditorWebView.swift
│ ├── Extensions
│ │ ├── NSApplication+Extension.swift
│ │ ├── NSDocumentController+Extension.swift
│ │ ├── NSMenu+Extension.swift
│ │ ├── NSPopover+Extension.swift
│ │ └── NSSpellChecker+Extension.swift
│ ├── Main
│ │ ├── AppCustomization.swift
│ │ ├── AppDocumentController.swift
│ │ ├── AppHacks.swift
│ │ ├── AppHotKeys.swift
│ │ ├── AppPreferences.swift
│ │ ├── AppResources.swift
│ │ ├── AppRuntimeConfig.swift
│ │ ├── AppTheme.swift
│ │ └── Application
│ │ │ ├── AppDelegate+Document.swift
│ │ │ ├── AppDelegate+FileSystem.swift
│ │ │ ├── AppDelegate+Menu.swift
│ │ │ ├── AppDelegate.swift
│ │ │ └── Application.swift
│ ├── ObjC
│ │ ├── MarkEditMac-Bridging-Header.h
│ │ ├── MarkEditWritingTools.h
│ │ └── MarkEditWritingTools.m
│ ├── Panels
│ │ ├── Find
│ │ │ ├── EditorFindPanel+Delegate.swift
│ │ │ ├── EditorFindPanel+Menu.swift
│ │ │ ├── EditorFindPanel+UI.swift
│ │ │ └── EditorFindPanel.swift
│ │ └── Replace
│ │ │ ├── EditorReplaceButtons.swift
│ │ │ ├── EditorReplacePanel.swift
│ │ │ └── EditorReplaceTextField.swift
│ ├── Scripting
│ │ ├── EditorDocument+Scripting.swift
│ │ ├── Error+Scripting.swift
│ │ └── NSColor+Scripting.swift
│ ├── Settings
│ │ ├── AssistantSettingsView.swift
│ │ ├── EditorSettingsView.swift
│ │ ├── GeneralSettingsView.swift
│ │ ├── SettingTabs.swift
│ │ └── WindowSettingsView.swift
│ ├── Shortcuts
│ │ ├── Extensions
│ │ │ └── AppIntent+Extension.swift
│ │ ├── IntentError.swift
│ │ ├── IntentProvider.swift
│ │ └── Intents
│ │ │ ├── CreateNewDocumentIntent.swift
│ │ │ ├── EvaluateJavaScriptIntent.swift
│ │ │ ├── GetFileContentIntent.swift
│ │ │ └── UpdateFileContentIntent.swift
│ └── Updater
│ │ ├── AppUpdater.swift
│ │ └── AppVersion.swift
└── mul.lproj
│ └── Main.xcstrings
├── MarkEditTools
├── .swiftpm
│ └── xcode
│ │ └── package.xcworkspace
│ │ └── contents.xcworkspacedata
├── Package.swift
├── Plugins
│ └── SwiftLint
│ │ └── main.swift
└── README.md
├── PreviewExtension
├── Base.lproj
│ └── Main.xib
├── Info.entitlements
├── Info.plist
├── PreviewViewController.swift
└── mul.lproj
│ └── Main.xcstrings
├── README.md
└── Screenshots
├── 01.png
├── 02.png
├── 03.png
└── install.png
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve.
4 | title: '[Bug] '
5 | labels: ''
6 | assignees: ''
7 | ---
8 |
9 |
20 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project.
4 | title: '[Feature Request] '
5 | labels: ''
6 | assignees: ''
7 | ---
8 |
9 |
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## System
2 | *.DS_Store
3 |
4 | ## User settings
5 | xcuserdata/
6 | Local.xcconfig
7 |
--------------------------------------------------------------------------------
/.periphery.yml:
--------------------------------------------------------------------------------
1 | project: MarkEdit.xcodeproj
2 | retain_public: true
3 | schemes:
4 | - MarkEditMac
5 | - PreviewExtension
6 | targets:
7 | - MarkEditCore.MarkEditCore
8 | - MarkEditKit.MarkEditKit
9 | - MarkEditMac
10 | - Modules.AppKitControls
11 | - Modules.AppKitExtensions
12 | - Modules.FontPicker
13 | - Modules.Previewer
14 | - Modules.SettingsUI
15 | - Modules.Statistics
16 | - Modules.TextBundle
17 | - Modules.TextCompletion
18 | - PreviewExtension
19 |
--------------------------------------------------------------------------------
/Build.xcconfig:
--------------------------------------------------------------------------------
1 | //
2 | // Build.xcconfig
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 12/26/22.
6 | //
7 | // https://developer.apple.com/documentation/xcode/adding-a-build-configuration-file-to-your-project
8 |
9 | CODE_SIGN_IDENTITY = -
10 | DEVELOPMENT_TEAM =
11 | PRODUCT_BUNDLE_IDENTIFIER = app.cyan.markedit-dev
12 |
13 | MARKETING_VERSION = 1.23.0
14 | CURRENT_PROJECT_VERSION = 73
15 |
16 | #include? "Local.xcconfig"
17 |
--------------------------------------------------------------------------------
/CoreEditor/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
3 | .yarn/install-state.gz
4 |
--------------------------------------------------------------------------------
/CoreEditor/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | nodeLinker: node-modules
2 |
--------------------------------------------------------------------------------
/CoreEditor/index.css:
--------------------------------------------------------------------------------
1 | /* General */
2 |
3 | :root {
4 | color-scheme: light dark;
5 | }
6 |
7 | html, body {
8 | margin: 0;
9 | padding: 0;
10 | overflow: hidden;
11 | }
12 |
13 | /* CodeMirror */
14 |
15 | .cm-editor {
16 | height: 100vh;
17 |
18 | /* selectionMatch doesn't work well with some fonts, such as system-ui, ui-rounded */
19 | font-kerning: none;
20 | }
21 |
22 | .cm-gutters {
23 | cursor: default;
24 | user-select: none;
25 | -webkit-touch-callout: none;
26 | -webkit-user-select: none;
27 |
28 | /* To work around a WebKit bug where :hover is not cleared when mouse is outside the window */
29 | margin: 1px;
30 | }
31 |
32 | .cm-lineNumbers {
33 | /* Disable this to have better experience of "swipe to select multiple lines" */
34 | pointer-events: none;
35 | }
36 |
37 | .cm-focused {
38 | /* Disable the dashed border when the editor is focused */
39 | outline: none !important;
40 | }
41 |
42 | /* Markdown */
43 |
44 | .cm-md-header {
45 | font-weight: bolder;
46 | }
47 |
48 | .cm-md-contentIndent {
49 | display: inline-block;
50 | }
51 |
52 | .cm-md-contentIndent .cm-visibleSpace::before, .cm-md-contentIndent .cm-visibleLineBreak {
53 | text-indent: 0px;
54 | margin-inline-start: 0px;
55 | }
56 |
--------------------------------------------------------------------------------
/CoreEditor/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | MarkEdit
7 |
8 |
9 |
21 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/CoreEditor/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type { import('ts-jest').JestConfigWithTsJest } */
2 |
3 | // eslint-disable-next-line no-undef
4 | module.exports = {
5 | preset: 'ts-jest',
6 | testEnvironment: 'jsdom',
7 | };
8 |
--------------------------------------------------------------------------------
/CoreEditor/src/@codegen/README.md:
--------------------------------------------------------------------------------
1 | # @codegen
2 |
3 | This folder contains the code generation templates for the bridge between the web application and native code.
4 |
5 | It uses [ts-gyb](https://github.com/microsoft/ts-gyb) to analyze TypeScript interfaces and generate Swift code.
--------------------------------------------------------------------------------
/CoreEditor/src/@codegen/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "parsing": {
3 | "targets": {
4 | "config": {
5 | "source": ["../config.ts"]
6 | },
7 | "native": {
8 | "source": ["../bridge/native/*.ts"]
9 | },
10 | "web": {
11 | "source": ["../bridge/web/*.ts"]
12 | }
13 | },
14 | "predefinedTypes": [
15 | "CodeGen_Int",
16 | "CodeGen_Self",
17 | "CodeGen_Dict"
18 | ],
19 | "defaultCustomTags": {},
20 | "dropInterfaceIPrefix": true
21 | },
22 | "rendering": {
23 | "swift": {
24 | "renders": [
25 | {
26 | "target": "config",
27 | "template": "swift-config.mustache",
28 | "outputPath": "../../../MarkEditCore/Sources"
29 | },
30 | {
31 | "target": "native",
32 | "template": "swift-native-module.mustache",
33 | "outputPath": "../../../MarkEditKit/Sources/Bridge/Native/Generated"
34 | },
35 | {
36 | "target": "web",
37 | "template": "swift-web-module.mustache",
38 | "outputPath": "../../../MarkEditKit/Sources/Bridge/Web/Generated"
39 | }
40 | ],
41 | "namedTypesTemplatePath": "swift-shared-types.mustache",
42 | "namedTypesOutputPath": "../../../MarkEditCore/Sources/EditorSharedTypes.swift",
43 | "typeNameMap": {
44 | "CodeGen_Int": "Int",
45 | "CodeGen_Self": "Self",
46 | "CodeGen_Dict": "[String: Any]"
47 | }
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/CoreEditor/src/@codegen/swift-config.mustache:
--------------------------------------------------------------------------------
1 | //
2 | // {{moduleName}}.swift
3 | //
4 | // Generated using https://github.com/microsoft/ts-gyb
5 | //
6 | // Don't modify this file manually, it's auto generated.
7 | //
8 | // To make changes, edit template files under /CoreEditor/src/@codegen
9 |
10 | import Foundation
11 |
12 | public struct {{moduleName}}: Encodable {
13 | {{#members}}
14 | {{#documentationLines}}
15 | ///{{{.}}}
16 | {{/documentationLines}}
17 | let {{name}}: {{type}}
18 | {{/members}}
19 |
20 | public init(
21 | {{#members}}
22 | {{name}}: {{type}}{{^last}},{{/last}}
23 | {{/members}}
24 | ) {
25 | {{#members}}
26 | self.{{name}} = {{name}}
27 | {{/members}}
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/CoreEditor/src/@codegen/swift-named-type.mustache:
--------------------------------------------------------------------------------
1 | {{#custom}}
2 | {{#documentationLines}}
3 | ///{{{.}}}
4 | {{/documentationLines}}
5 | public struct {{typeName}}: Codable {
6 | {{#members}}
7 | {{#documentationLines}}
8 | ///{{{.}}}
9 | {{/documentationLines}}
10 | public var {{name}}: {{type}}
11 | {{/members}}
12 | {{#staticMembers}}
13 | {{#documentationLines}}
14 | ///{{{.}}}
15 | {{/documentationLines}}
16 | private var {{name}}: {{type}} = {{{value}}}
17 | {{/staticMembers}}
18 |
19 | public init({{#members}}{{name}}: {{type}}{{^last}}, {{/last}}{{/members}}) {
20 | {{#members}}
21 | self.{{name}} = {{name}}
22 | {{/members}}
23 | }
24 | }
25 | {{/custom}}
26 | {{#enum}}
27 | {{#documentationLines}}
28 | ///{{{.}}}
29 | {{/documentationLines}}
30 | public enum {{typeName}}: {{valueType}}, Codable {
31 | {{#members}}
32 | {{#documentationLines}}
33 | ///{{{.}}}
34 | {{/documentationLines}}
35 | case {{key}} = {{{value}}}
36 | {{/members}}
37 | }
38 | {{/enum}}
39 |
--------------------------------------------------------------------------------
/CoreEditor/src/@codegen/swift-shared-types.mustache:
--------------------------------------------------------------------------------
1 | //
2 | // SharedTypes.swift
3 | //
4 | // Generated using https://github.com/microsoft/ts-gyb
5 | //
6 | // Don't modify this file manually, it's auto generated.
7 | //
8 | // To make changes, edit template files under /CoreEditor/src/@codegen
9 |
10 | import Foundation
11 | {{#.}}
12 |
13 | {{> swift-named-type}}
14 | {{/.}}
15 |
--------------------------------------------------------------------------------
/CoreEditor/src/@light/README.md:
--------------------------------------------------------------------------------
1 | # @light
2 |
3 | This package is used to build a light version of the CoreEditor for the preview extension, it's read-only, and very limited extensions are enabled.
--------------------------------------------------------------------------------
/CoreEditor/src/@light/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | MarkEdit
7 |
8 |
9 |
10 |
11 |
12 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/CoreEditor/src/@light/vite.config.mts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import { viteSingleFile } from 'vite-plugin-singlefile';
3 |
4 | export default defineConfig({
5 | root: './src/@light',
6 | build: {
7 | outDir: './dist',
8 | },
9 | plugins: [viteSingleFile()],
10 | });
11 |
--------------------------------------------------------------------------------
/CoreEditor/src/@res/SF-Mono-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MarkEdit-app/MarkEdit/99d3027c13ec7de8096197b43b4841394cbc4f85/CoreEditor/src/@res/SF-Mono-Bold.woff2
--------------------------------------------------------------------------------
/CoreEditor/src/@res/SF-Mono-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MarkEdit-app/MarkEdit/99d3027c13ec7de8096197b43b4841394cbc4f85/CoreEditor/src/@res/SF-Mono-Regular.woff2
--------------------------------------------------------------------------------
/CoreEditor/src/@types/WebFontFace.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Font face attributes to control the font styles.
3 | */
4 | export interface WebFontFace {
5 | family: string;
6 | weight?: string;
7 | style?: string;
8 | }
9 |
--------------------------------------------------------------------------------
/CoreEditor/src/@types/WebMenuItem.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Represents a menu item in native menus.
3 | */
4 | export interface WebMenuItem {
5 | separator: boolean;
6 | title?: string;
7 | actionID?: string;
8 | key?: string;
9 | modifiers?: string[];
10 | children?: CodeGen_Self[];
11 | }
12 |
--------------------------------------------------------------------------------
/CoreEditor/src/@types/WebPoint.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * "CGPoint-fashion" point.
3 | */
4 | export interface WebPoint {
5 | x: number;
6 | y: number;
7 | }
8 |
--------------------------------------------------------------------------------
/CoreEditor/src/@types/WebRect.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * "CGRect-fashion" rect.
3 | */
4 | export interface WebRect {
5 | x: number;
6 | y: number;
7 | width: number;
8 | height: number;
9 | }
10 |
--------------------------------------------------------------------------------
/CoreEditor/src/@vendor/README.md:
--------------------------------------------------------------------------------
1 | # @vendor
2 |
3 | Due to different reasons, this folder contains several packages that are copied from other projects instead of using them as npm packages, with necessary modifications made to fit our needs.
4 |
5 | ESLint ignores this entire folder.
--------------------------------------------------------------------------------
/CoreEditor/src/@vendor/lang-markdown/README.md:
--------------------------------------------------------------------------------
1 | # MarkEdit-app/lang-markdown
2 |
3 | For now, lang-markdown is mostly copied from the [@codemirror/lang-markdown](https://github.com/codemirror/lang-markdown) package, with minimal changes to the `insertNewlineContinueMarkup` command and `findSectionEnd` function.
4 |
5 | Check "[MarkEdit]" to see the actual modified behavior.
6 |
7 |
8 |
9 | # @codemirror/lang-markdown [](https://www.npmjs.org/package/@codemirror/lang-markdown)
10 |
11 | [ [**WEBSITE**](https://codemirror.net/) | [**ISSUES**](https://github.com/codemirror/dev/issues) | [**FORUM**](https://discuss.codemirror.net/c/next/) | [**CHANGELOG**](https://github.com/codemirror/lang-markdown/blob/main/CHANGELOG.md) ]
12 |
13 | This package implements Markdown language support for the
14 | [CodeMirror](https://codemirror.net/) code editor.
15 |
16 | The [project page](https://codemirror.net/) has more information, a
17 | number of [examples](https://codemirror.net/examples/) and the
18 | [documentation](https://codemirror.net/docs/).
19 |
20 | This code is released under an
21 | [MIT license](https://github.com/codemirror/lang-markdown/tree/main/LICENSE).
22 |
23 | We aim to be an inclusive, welcoming community. To make that explicit,
24 | we have a [code of
25 | conduct](http://contributor-covenant.org/version/1/1/0/) that applies
26 | to communication around the project.
27 |
28 | ## API Reference
29 |
30 | @markdown
31 |
32 | @markdownLanguage
33 |
34 | @commonmarkLanguage
35 |
36 | @insertNewlineContinueMarkup
37 |
38 | @deleteMarkupBackward
39 |
40 | @markdownKeymap
41 |
--------------------------------------------------------------------------------
/CoreEditor/src/@vendor/language-data/README.md:
--------------------------------------------------------------------------------
1 | # MarkEdit-app/language-data
2 |
3 | For now, language-data is mostly copied from the [@codemirror/language-data](https://github.com/codemirror/language-data) package, only kept commonly used ones to make the bundle smaller.
4 |
5 | # @codemirror/language-data [](https://www.npmjs.org/package/@codemirror/language-data)
6 |
7 | [ [**WEBSITE**](https://codemirror.net/) | [**DOCS**](https://codemirror.net/docs/ref/#language-data) | [**ISSUES**](https://github.com/codemirror/dev/issues) | [**FORUM**](https://discuss.codemirror.net/c/next/) | [**CHANGELOG**](https://github.com/codemirror/language-data/blob/main/CHANGELOG.md) ]
8 |
9 | This package implements language metadata and dynamic loading for the
10 | [CodeMirror](https://codemirror.net/) code editor.
11 |
12 | The [project page](https://codemirror.net/) has more information, a
13 | number of [examples](https://codemirror.net/examples/) and the
14 | [documentation](https://codemirror.net/docs/).
15 |
16 | This code is released under an
17 | [MIT license](https://github.com/codemirror/language-data/tree/main/LICENSE).
18 |
19 | We aim to be an inclusive, welcoming community. To make that explicit,
20 | we have a [code of
21 | conduct](http://contributor-covenant.org/version/1/1/0/) that applies
22 | to communication around the project.
23 |
--------------------------------------------------------------------------------
/CoreEditor/src/bridge/native/api.ts:
--------------------------------------------------------------------------------
1 | import { NativeModule } from '../nativeModule';
2 | import { WebMenuItem } from '../../@types/WebMenuItem';
3 | import { WebPoint } from '../../@types/WebPoint';
4 |
5 | /**
6 | * @shouldExport true
7 | * @invokePath api
8 | * @bridgeName NativeBridgeAPI
9 | */
10 | export interface NativeModuleAPI extends NativeModule {
11 | getFileInfo(): Promise;
12 | getPasteboardItems(): Promise;
13 | getPasteboardString(): Promise;
14 | addMainMenuItems({ items }: { items: WebMenuItem[] }): void;
15 | showContextMenu(args: { items: WebMenuItem[]; location: WebPoint }): void;
16 | showAlert(args: { title?: string; message?: string; buttons?: string[] }): Promise;
17 | showTextBox(args: { title?: string; placeholder?: string; defaultValue?: string }): Promise;
18 | }
19 |
--------------------------------------------------------------------------------
/CoreEditor/src/bridge/native/completion.ts:
--------------------------------------------------------------------------------
1 | import { NativeModule } from '../nativeModule';
2 | import { TextTokenizeAnchor } from '../../modules/tokenizer/types';
3 |
4 | /**
5 | * @shouldExport true
6 | * @invokePath completion
7 | * @bridgeName NativeBridgeCompletion
8 | */
9 | export interface NativeModuleCompletion extends NativeModule {
10 | requestCompletions({ anchor, fullText }: { anchor: TextTokenizeAnchor; fullText?: string }): void;
11 | commitCompletion(): void;
12 | cancelCompletion(): void;
13 | selectPrevious(): void;
14 | selectNext(): void;
15 | selectTop(): void;
16 | selectBottom(): void;
17 | }
18 |
--------------------------------------------------------------------------------
/CoreEditor/src/bridge/native/core.ts:
--------------------------------------------------------------------------------
1 | import { NativeModule } from '../nativeModule';
2 | import { LineColumnInfo } from '../../modules/selection/types';
3 |
4 | /**
5 | * @shouldExport true
6 | * @invokePath core
7 | * @bridgeName NativeBridgeCore
8 | */
9 | export interface NativeModuleCore extends NativeModule {
10 | notifyWindowDidLoad(): void;
11 | notifyEditorDidBecomeIdle(): void;
12 | notifyBackgroundColorDidChange({ color }: { color: CodeGen_Int }): void;
13 | notifyViewportScaleDidChange(): void;
14 | notifyViewDidUpdate(args: { contentEdited: boolean; compositionEnded: boolean; isDirty: boolean; selectedLineColumn: LineColumnInfo }): void;
15 | notifyContentHeightDidChange({ bottomPanelHeight }: { bottomPanelHeight: number }): void;
16 | notifyContentOffsetDidChange(): void;
17 | notifyCompositionEnded({ selectedLineColumn }: { selectedLineColumn: LineColumnInfo }): void;
18 | notifyLinkClicked({ link }: { link: string }): void;
19 | notifyLightWarning(): void;
20 | }
21 |
--------------------------------------------------------------------------------
/CoreEditor/src/bridge/native/preview.ts:
--------------------------------------------------------------------------------
1 | import { NativeModule } from '../nativeModule';
2 | import { PreviewType } from '../../modules/preview';
3 | import { WebRect } from '../../@types/WebRect';
4 |
5 | /**
6 | * @shouldExport true
7 | * @invokePath preview
8 | * @bridgeName NativeBridgePreview
9 | */
10 | export interface NativeModulePreview extends NativeModule {
11 | show({ code, type, rect }: { code: string; type: PreviewType; rect: WebRect }): void;
12 | }
13 |
--------------------------------------------------------------------------------
/CoreEditor/src/bridge/native/tokenizer.ts:
--------------------------------------------------------------------------------
1 | import { NativeModule } from '../nativeModule';
2 | import { TextTokenizeAnchor } from '../../modules/tokenizer/types';
3 |
4 | /**
5 | * @shouldExport true
6 | * @invokePath tokenizer
7 | * @bridgeName NativeBridgeTokenizer
8 | */
9 | export interface NativeModuleTokenizer extends NativeModule {
10 | tokenize({ anchor }: { anchor: TextTokenizeAnchor }): Promise;
11 | moveWordBackward({ anchor }: { anchor: TextTokenizeAnchor }): Promise;
12 | moveWordForward({ anchor }: { anchor: TextTokenizeAnchor }): Promise;
13 | }
14 |
--------------------------------------------------------------------------------
/CoreEditor/src/bridge/nativeModule.ts:
--------------------------------------------------------------------------------
1 | import { isReleaseMode } from '../common/utils';
2 |
3 | /**
4 | * Module used to send message to native.
5 | */
6 | export interface NativeModule {
7 | name: string;
8 | }
9 |
10 | /**
11 | * Create a Proxy for redirecting messages to native by relying on messageHandlers.
12 | *
13 | * @param moduleName name of the native module
14 | * @returns The created proxy
15 | */
16 | export function createNativeModule(moduleName: string): T {
17 | return new Proxy({} as T, {
18 | get(_target, p): ((args?: Map) => Promise) | undefined {
19 | if (typeof p !== 'string') {
20 | return undefined;
21 | }
22 |
23 | // eslint-disable-next-line compat/compat
24 | return args => new Promise((resolve, reject) => {
25 | const message = {
26 | moduleName,
27 | methodName: p,
28 | parameters: JSON.stringify(args ?? {}),
29 | };
30 |
31 | // Message is serialized and sent to native here
32 | if (isReleaseMode) {
33 | // eslint-disable-next-line promise/prefer-await-to-then
34 | window.webkit?.messageHandlers?.bridge?.postMessage(message).then(resolve, reject);
35 | } else {
36 | console.log(message);
37 | }
38 | });
39 | },
40 | });
41 | }
42 |
--------------------------------------------------------------------------------
/CoreEditor/src/bridge/web/api.ts:
--------------------------------------------------------------------------------
1 | import { WebModule } from '../webModule';
2 | import { handleMainMenuAction, handleContextMenuAction } from '../../api/ui';
3 |
4 | /**
5 | * @shouldExport true
6 | * @invokePath api
7 | * @overrideModuleName WebBridgeAPI
8 | */
9 | export interface WebModuleAPI extends WebModule {
10 | handleMainMenuAction({ id }: { id: string }): void;
11 | handleContextMenuAction({ id }: { id: string }): void;
12 | }
13 |
14 | export class WebModuleAPIImpl implements WebModuleAPI {
15 | handleMainMenuAction({ id }: { id: string }): void {
16 | handleMainMenuAction(id);
17 | }
18 |
19 | handleContextMenuAction({ id }: { id: string }): void {
20 | handleContextMenuAction(id);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/CoreEditor/src/bridge/web/completion.ts:
--------------------------------------------------------------------------------
1 | import { WebModule } from '../webModule';
2 | import { startCompletion, setPanelVisible, acceptInlinePrediction } from '../../modules/completion';
3 |
4 | /**
5 | * @shouldExport true
6 | * @invokePath completion
7 | * @overrideModuleName WebBridgeCompletion
8 | */
9 | export interface WebModuleCompletion extends WebModule {
10 | startCompletion({ afterDelay }: { afterDelay: number }): void;
11 | setState({ panelVisible }: { panelVisible: boolean }): void;
12 | acceptInlinePrediction({ prediction }: { prediction: string }): void;
13 | }
14 |
15 | export class WebModuleCompletionImpl implements WebModuleCompletion {
16 | startCompletion({ afterDelay }: { afterDelay: number }): void {
17 | startCompletion({ afterDelay });
18 | }
19 |
20 | setState({ panelVisible }: { panelVisible: boolean }): void {
21 | setPanelVisible(panelVisible);
22 | }
23 |
24 | acceptInlinePrediction({ prediction }: { prediction: string }): void {
25 | acceptInlinePrediction(prediction);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/CoreEditor/src/bridge/web/dummy.ts:
--------------------------------------------------------------------------------
1 | import { WebModule } from '../webModule';
2 | import { IndentBehavior } from '../../config';
3 |
4 | /**
5 | * @shouldExport true
6 | * @invokePath dummy
7 | * @overrideModuleName WebBridgeDummy
8 | */
9 | export interface WebModuleDummy extends WebModule {
10 | /**
11 | * Don't call this directly, it does nothing.
12 | *
13 | * We use this to generate types that are not covered in exposed interfaces, as a workaround.
14 | */
15 | __generateTypes__(_types: { arg0: IndentBehavior }): void;
16 | }
17 |
18 | export class WebModuleDummyImpl implements WebModuleDummy {
19 | __generateTypes__(_types: { arg0: IndentBehavior }): void {
20 | // no-op
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/CoreEditor/src/bridge/web/history.ts:
--------------------------------------------------------------------------------
1 | import { WebModule } from '../webModule';
2 | import { undo, redo, canUndo, canRedo, markContentClean } from '../../modules/history';
3 |
4 | /**
5 | * @shouldExport true
6 | * @invokePath history
7 | * @overrideModuleName WebBridgeHistory
8 | */
9 | export interface WebModuleHistory extends WebModule {
10 | undo(): void;
11 | redo(): void;
12 | canUndo(): boolean;
13 | canRedo(): boolean;
14 | markContentClean(): void;
15 | }
16 |
17 | export class WebModuleHistoryImpl implements WebModuleHistory {
18 | undo(): void {
19 | undo();
20 | }
21 |
22 | redo(): void {
23 | redo();
24 | }
25 |
26 | canUndo(): boolean {
27 | return canUndo();
28 | }
29 |
30 | canRedo(): boolean {
31 | return canRedo();
32 | }
33 |
34 | markContentClean(): void {
35 | markContentClean();
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/CoreEditor/src/bridge/web/lineEndings.ts:
--------------------------------------------------------------------------------
1 | import { WebModule } from '../webModule';
2 | import { LineEndings, getLineEndings, setLineEndings } from '../../modules/lineEndings';
3 |
4 | /**
5 | * @shouldExport true
6 | * @invokePath lineEndings
7 | * @overrideModuleName WebBridgeLineEndings
8 | */
9 | export interface WebModuleLineEndings extends WebModule {
10 | getLineEndings(): LineEndings;
11 | setLineEndings({ lineEndings }: { lineEndings: LineEndings }): void;
12 | }
13 |
14 | export class WebModuleLineEndingsImpl implements WebModuleLineEndings {
15 | getLineEndings(): LineEndings {
16 | return getLineEndings();
17 | }
18 |
19 | setLineEndings({ lineEndings }: { lineEndings: LineEndings }): void {
20 | setLineEndings(lineEndings);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/CoreEditor/src/bridge/web/selection.ts:
--------------------------------------------------------------------------------
1 | import { WebModule } from '../webModule';
2 | import { WebRect } from '../../@types/WebRect';
3 | import { selectWholeDocument, selectedMainText, scrollToSelection, getRect, gotoLine, refreshEditFocus, scrollToBottomSmoothly } from '../../modules/selection';
4 | import { navigateGoBack } from '../../modules/selection/navigate';
5 |
6 | /**
7 | * @shouldExport true
8 | * @invokePath selection
9 | * @overrideModuleName WebBridgeSelection
10 | */
11 | export interface WebModuleSelection extends WebModule {
12 | selectWholeDocument(): void;
13 | getText(): string;
14 | getRect({ pos }: { pos: CodeGen_Int }): WebRect | undefined;
15 | scrollToSelection(): void;
16 | gotoLine({ lineNumber }: { lineNumber: CodeGen_Int }): void;
17 | refreshEditFocus(): void;
18 | scrollToBottomSmoothly(): void;
19 | navigateGoBack(): void;
20 | }
21 |
22 | export class WebModuleSelectionImpl implements WebModuleSelection {
23 | selectWholeDocument(): void {
24 | selectWholeDocument();
25 | }
26 |
27 | getText(): string {
28 | return selectedMainText();
29 | }
30 |
31 | getRect({ pos }: { pos: CodeGen_Int }): WebRect | undefined {
32 | return getRect(pos);
33 | }
34 |
35 | scrollToSelection(): void {
36 | scrollToSelection();
37 | }
38 |
39 | gotoLine({ lineNumber }: { lineNumber: CodeGen_Int }): void {
40 | gotoLine(lineNumber);
41 | }
42 |
43 | refreshEditFocus(): void {
44 | refreshEditFocus();
45 | }
46 |
47 | scrollToBottomSmoothly(): void {
48 | scrollToBottomSmoothly();
49 | }
50 |
51 | navigateGoBack(): void {
52 | navigateGoBack();
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/CoreEditor/src/bridge/web/textChecker.ts:
--------------------------------------------------------------------------------
1 | import { WebModule } from '../webModule';
2 | import { TextCheckerOptions, update } from '../../modules/textChecker';
3 |
4 | /**
5 | * @shouldExport true
6 | * @invokePath textChecker
7 | * @overrideModuleName WebBridgeTextChecker
8 | */
9 | export interface WebModuleTextChecker extends WebModule {
10 | update({ options }: { options: TextCheckerOptions }): void;
11 | }
12 |
13 | export class WebModuleTextCheckerImpl implements WebModuleTextChecker {
14 | update({ options }: { options: TextCheckerOptions }): void {
15 | update(options);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/CoreEditor/src/bridge/web/toc.ts:
--------------------------------------------------------------------------------
1 | import { WebModule } from '../webModule';
2 | import { HeadingInfo, getTableOfContents, selectPreviousSection, selectNextSection, gotoHeader } from '../../modules/toc';
3 |
4 | /**
5 | * @shouldExport true
6 | * @invokePath toc
7 | * @overrideModuleName WebBridgeTableOfContents
8 | */
9 | export interface WebModuleTableOfContents extends WebModule {
10 | getTableOfContents(): HeadingInfo[];
11 | selectPreviousSection(): void;
12 | selectNextSection(): void;
13 | gotoHeader({ headingInfo }: { headingInfo: HeadingInfo }): void;
14 | }
15 |
16 | export class WebModuleTableOfContentsImpl implements WebModuleTableOfContents {
17 | getTableOfContents(): HeadingInfo[] {
18 | return getTableOfContents();
19 | }
20 |
21 | selectPreviousSection(): void {
22 | selectPreviousSection();
23 | }
24 |
25 | selectNextSection(): void {
26 | selectNextSection();
27 | }
28 |
29 | gotoHeader({ headingInfo }: { headingInfo: HeadingInfo }): void {
30 | gotoHeader(headingInfo);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/CoreEditor/src/bridge/web/writingTools.ts:
--------------------------------------------------------------------------------
1 | import { WebModule } from '../webModule';
2 | import { WebRect } from '../../@types/WebRect';
3 | import { setActive, getSelectionRect, ensureSelectionRect } from '../../modules/writingTools';
4 |
5 | /**
6 | * @shouldExport true
7 | * @invokePath writingTools
8 | * @overrideModuleName WebBridgeWritingTools
9 | */
10 | export interface WebModuleWritingTools extends WebModule {
11 | setActive({ isActive, reselect }: { isActive: boolean; reselect: boolean }): void;
12 | getSelectionRect({ reselect }: { reselect: boolean }): WebRect | undefined;
13 | ensureSelectionRect(): void;
14 | }
15 |
16 | export class WebModuleWritingToolsImpl implements WebModuleWritingTools {
17 | setActive({ isActive, reselect }: { isActive: boolean; reselect: boolean }): void {
18 | setActive(isActive, reselect);
19 | }
20 |
21 | getSelectionRect({ reselect }: { reselect: boolean }): WebRect | undefined {
22 | return getSelectionRect(reselect);
23 | }
24 |
25 | ensureSelectionRect(): void {
26 | ensureSelectionRect();
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/CoreEditor/src/bridge/webModule.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Module used to invoke web from native.
3 | *
4 | * Implementaions of WebModule should be wrappers to functions in modules.
5 | */
6 |
7 | export interface WebModule {}
8 |
--------------------------------------------------------------------------------
/CoreEditor/src/common/store.ts:
--------------------------------------------------------------------------------
1 | import { EditorColors } from '../styling/types';
2 | import StyleSheets from '../styling/config';
3 |
4 | export const globalState: {
5 | colors?: EditorColors;
6 | contextMenuOpenTime: number;
7 | gutterHovered: boolean;
8 | hasModalSheet: boolean;
9 | } = {
10 | colors: undefined,
11 | contextMenuOpenTime: 0,
12 | gutterHovered: false,
13 | hasModalSheet: false,
14 | };
15 |
16 | export const editingState = {
17 | isIdle: false,
18 | hasSelection: false,
19 | compositionEnded: true,
20 | };
21 |
22 | export const styleSheets: StyleSheets = {};
23 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/commands/formatContent.ts:
--------------------------------------------------------------------------------
1 | import { ChangeSpec } from '@codemirror/state';
2 | import { getEditorText } from '../../core';
3 |
4 | /**
5 | * Format the content, usually gets called when saving files.
6 | *
7 | * @param insertFinalNewline Whether to insert newline at end of file
8 | * @param trimTrailingWhitespace Whether to remove trailing whitespaces
9 | */
10 | export default function formatContent(insertFinalNewline: boolean, trimTrailingWhitespace: boolean) {
11 | const editor = window.editor;
12 | const state = editor.state;
13 |
14 | const apply = (changes: ChangeSpec) => {
15 | editor.dispatch({
16 | changes,
17 | userEvent: '@none', // Ignore automatic scrolling
18 | });
19 | };
20 |
21 | if (insertFinalNewline) {
22 | // We don't need to ensure final newline when it's empty
23 | const text = getEditorText();
24 | if (text.length > 0 && !text.endsWith(state.lineBreak)) {
25 | apply({
26 | insert: state.lineBreak,
27 | from: state.doc.length,
28 | });
29 | }
30 | }
31 |
32 | if (trimTrailingWhitespace) {
33 | // We need to update reversely to avoid index shift
34 | for (let index = state.doc.lines; index >= 1; --index) {
35 | const line = state.doc.line(index);
36 | const match = /\s+$/g.exec(line.text);
37 | if (match !== null) {
38 | apply({
39 | insert: '',
40 | from: match.index + line.from,
41 | to: line.to,
42 | });
43 | }
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/commands/insertBlockWithMarks.ts:
--------------------------------------------------------------------------------
1 | import { EditorSelection } from '@codemirror/state';
2 |
3 | /**
4 | * Generally used to insert blocks like fenced code.
5 | */
6 | export default function insertBlockWithMarks(marks: string) {
7 | const editor = window.editor;
8 | const lineBreak = editor.state.lineBreak;
9 |
10 | const updates = editor.state.changeByRange(({ from, to }) => {
11 | const line = editor.state.doc.lineAt(from);
12 | const selected = editor.state.sliceDoc(from, to);
13 | const prefix = line.from === from ? '' : lineBreak;
14 | const suffix = line.to === to ? '' : lineBreak;
15 |
16 | // Replace with the updated content and keep the original selection
17 | const insert = `${prefix}${marks}${lineBreak}${selected}${lineBreak}${marks}${suffix}`;
18 | const anchor = from + prefix.length + marks.length + lineBreak.length;
19 | const head = anchor + selected.length;
20 |
21 | return {
22 | range: EditorSelection.range(anchor, head),
23 | changes: { from, to, insert },
24 | };
25 | });
26 |
27 | editor.dispatch(updates);
28 | }
29 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/commands/removeListMarkers.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Remove existing list marker from text, e.g., changing "- Item" to "Item".
3 | */
4 | export default function removeListMarkers(text: string): string {
5 | return text.replace(/^([-*+] +\[[ xX]\] )|^([-*+] )|^(\d+\. )/, '');
6 | }
7 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/commands/replaceSelections.ts:
--------------------------------------------------------------------------------
1 | import { EditorSelection } from '@codemirror/state';
2 |
3 | export default function replaceSelections(replacement: string) {
4 | const editor = window.editor;
5 | const updates = editor.state.changeByRange(({ from, to }) => ({
6 | range: EditorSelection.cursor(from + replacement.length),
7 | changes: {
8 | from, to, insert: replacement,
9 | },
10 | }));
11 |
12 | editor.dispatch(updates);
13 | }
14 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/commands/types.ts:
--------------------------------------------------------------------------------
1 | export enum EditCommand {
2 | indentLess = 'indentLess',
3 | indentMore = 'indentMore',
4 | expandSelection = 'expandSelection',
5 | shrinkSelection = 'shrinkSelection',
6 | selectLine = 'selectLine',
7 | moveLineUp = 'moveLineUp',
8 | moveLineDown = 'moveLineDown',
9 | copyLineUp = 'copyLineUp',
10 | copyLineDown = 'copyLineDown',
11 | toggleLineComment = 'toggleLineComment',
12 | toggleBlockComment = 'toggleBlockComment',
13 | }
14 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/frontMatter/index.ts:
--------------------------------------------------------------------------------
1 | import { load as loadYaml } from 'js-yaml';
2 | import { replaceRange } from '../../common/utils';
3 | import { takePossibleNewline } from '../lineEndings';
4 |
5 | /**
6 | * Get the range of possible front matter section.
7 | */
8 | export function frontMatterRange(source?: string) {
9 | const editor = window.editor;
10 | const state = editor.state;
11 | const leadingMarks = source === undefined ? state.sliceDoc(0, 3) : source.substring(0, 3);
12 |
13 | // Fail fast, it's not possible to be front matter
14 | if (leadingMarks !== '---') {
15 | return undefined;
16 | }
17 |
18 | // Definition: https://jekyllrb.com/docs/front-matter/
19 | const text = source === undefined ? state.doc.toString() : source;
20 | const match = /^---\n(.+?)\n---/s.exec(text);
21 | if (match && isYaml(match[1])) {
22 | return { from: 0, to: match[0].length };
23 | }
24 |
25 | return undefined;
26 | }
27 |
28 | export function removeFrontMatter(source: string) {
29 | const range = frontMatterRange(source);
30 | if (range === undefined) {
31 | return source;
32 | }
33 |
34 | return replaceRange(source, range.from, takePossibleNewline(source, range.to), '');
35 | }
36 |
37 | function isYaml(source: string) {
38 | try {
39 | return typeof loadYaml(source) === 'object';
40 | } catch (error) {
41 | return false;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/indentation/index.ts:
--------------------------------------------------------------------------------
1 | import { KeyBinding } from '@codemirror/view';
2 | import { indentLess, indentMore, insertTab } from '@codemirror/commands';
3 | import { TabKeyBehavior } from './types';
4 | import replaceSelections from '../commands/replaceSelections';
5 |
6 | /**
7 | * Customized tab key behavior.
8 | */
9 | export const indentationKeymap: KeyBinding[] = [
10 | {
11 | key: 'Tab',
12 | preventDefault: true,
13 | run: ({ state, dispatch }) => {
14 | switch (window.config.tabKeyBehavior) {
15 | case TabKeyBehavior.insertTwoSpaces:
16 | replaceSelections(' ');
17 | return true;
18 | case TabKeyBehavior.insertFourSpaces:
19 | replaceSelections(' ');
20 | return true;
21 | case TabKeyBehavior.indentMore:
22 | return indentMore({ state, dispatch });
23 | default:
24 | return insertTab({ state, dispatch });
25 | }
26 | },
27 | },
28 | {
29 | key: 'Shift-Tab',
30 | preventDefault: true,
31 | run: indentLess,
32 | },
33 | ];
34 |
35 | export type { TabKeyBehavior };
36 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/indentation/types.ts:
--------------------------------------------------------------------------------
1 | export enum TabKeyBehavior {
2 | insertTab = 0,
3 | insertTwoSpaces = 1,
4 | insertFourSpaces = 2,
5 | indentMore = 3,
6 | }
7 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/input/insertCodeBlock.ts:
--------------------------------------------------------------------------------
1 | import { EditorSelection } from '@codemirror/state';
2 | import { EditorView } from '@codemirror/view';
3 |
4 | /**
5 | * When backtick key is detected, try inserting a code block if we already have two backticks.
6 | */
7 | export default function insertCodeBlock(editor: EditorView) {
8 | const state = editor.state;
9 | const doc = state.doc;
10 | const mark = '`';
11 | const prefix = mark + state.lineBreak;
12 |
13 | editor.dispatch(state.changeByRange(({ from, to }) => {
14 | if (doc.sliceString(from - 2, from) !== `${mark}${mark}`) {
15 | // Fallback to inserting only one backtick
16 | return {
17 | range: EditorSelection.cursor(from + mark.length),
18 | changes: { from, to, insert: mark },
19 | };
20 | }
21 |
22 | // Insert an empty code block and move the cursor to the empty line
23 | return {
24 | range: EditorSelection.cursor(from + mark.length + 1), // Don't use prefix.length, it doesn't work for CRLF
25 | changes: {
26 | from, to, insert: prefix + `${state.lineBreak}${mark}${mark}${mark}`,
27 | },
28 | };
29 | }));
30 |
31 | // Intercepted, default behavior is ignored
32 | return true;
33 | }
34 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/input/wrapBlock.ts:
--------------------------------------------------------------------------------
1 | import { EditorSelection } from '@codemirror/state';
2 | import { EditorView } from '@codemirror/view';
3 | import hasSelection from '../selection/hasSelection';
4 |
5 | /**
6 | * Wrap selected blocks with a pair of mark.
7 | *
8 | * @param mark The mark, e.g., "*"
9 | */
10 | export default function wrapBlock(mark: string, editor: EditorView) {
11 | // Fallback to the default behavior if all selections are empty
12 | if (!hasSelection()) {
13 | return false;
14 | }
15 |
16 | const state = editor.state;
17 | editor.dispatch(state.changeByRange(({ from, to }) => {
18 | const selection = state.sliceDoc(from, to);
19 | const replacement = from === to ? mark : `${mark}${selection}${mark}`;
20 | const newPos = from + mark.length;
21 | return {
22 | range: EditorSelection.range(newPos, newPos + selection.length),
23 | changes: {
24 | from, to, insert: replacement,
25 | },
26 | };
27 | }));
28 |
29 | // Intercepted, default behavior is ignored
30 | return true;
31 | }
32 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/lineEndings/types.ts:
--------------------------------------------------------------------------------
1 | export enum LineEndings {
2 | /**
3 | * Unspecified, let CodeMirror do the normalization magic.
4 | */
5 | Unspecified = 0,
6 | /**
7 | * Line Feed, used on macOS and Unix systems.
8 | */
9 | LF = 1,
10 | /**
11 | * Carriage Return and Line Feed, used on Windows.
12 | */
13 | CRLF = 2,
14 | /**
15 | * Carriage Return, previously used on Classic Mac OS.
16 | */
17 | CR = 3,
18 | }
19 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/localization/index.ts:
--------------------------------------------------------------------------------
1 | import { EditorState } from '@codemirror/state';
2 |
3 | // https://codemirror.net/examples/translate/
4 | export function localizePhrases() {
5 | const strings = window.config.localizable;
6 | return EditorState.phrases.of({
7 | // "key": "value" ?? "fallback"
8 | 'Control character': strings?.controlCharacter ?? 'Control Character',
9 | 'Folded lines': strings?.foldedLines ?? 'Folded Lines',
10 | 'Unfolded lines': strings?.unfoldedLines ?? 'Unfolded Lines',
11 | 'folded code': strings?.foldedCode ?? 'Folded Code',
12 | 'unfold': strings?.unfold ?? 'Unfold',
13 | 'Fold line': strings?.foldLine ?? 'Fold Line',
14 | 'Unfold line': strings?.unfoldLine ?? 'Unfold Line',
15 | });
16 | }
17 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/preview/index.css:
--------------------------------------------------------------------------------
1 | .cm-md-previewWrapper {
2 | margin: 0 2px;
3 | padding: 0 4px;
4 | border-radius: 4px;
5 | transition: background 0.2s ease-in-out;
6 | }
7 |
8 | .cm-md-previewButton {
9 | cursor: pointer;
10 | background-position: center;
11 | background-repeat: no-repeat;
12 | background-size: 18px;
13 | padding-inline-end: 18px;
14 | }
15 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/preview/index.ts:
--------------------------------------------------------------------------------
1 | import { getClientRect } from '../../common/utils';
2 |
3 | export enum PreviewType {
4 | mermaid = 'mermaid',
5 | katex = 'katex',
6 | table = 'table',
7 | }
8 |
9 | /**
10 | * Invokes native methods to show code preview.
11 | */
12 | export function showPreview(event: MouseEvent) {
13 | const target = event.target as HTMLSpanElement;
14 | if (!(target instanceof HTMLSpanElement)) {
15 | return;
16 | }
17 |
18 | const code = target.getAttribute('data-code');
19 | if (code === null) {
20 | return;
21 | }
22 |
23 | const pos = target.getAttribute('data-pos');
24 | if (pos === null) {
25 | return;
26 | }
27 |
28 | const rect = window.editor.coordsAtPos(parseInt(pos));
29 | if (rect === null) {
30 | return;
31 | }
32 |
33 | const type = target.getAttribute('data-type') as PreviewType;
34 | window.nativeModules.preview.show({ code, type, rect: getClientRect(rect) });
35 |
36 | cancelDefaultEvent(event);
37 | }
38 |
39 | export function cancelDefaultEvent(event: MouseEvent) {
40 | const target = event.target as HTMLElement;
41 | if (target.className.includes('cm-md-previewButton')) {
42 | event.preventDefault();
43 | event.stopPropagation();
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/search/counterInfo.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Info to show text like "1 of 3".
3 | */
4 | export default interface SearchCounterInfo {
5 | /** Total number of matched items */
6 | numberOfItems: CodeGen_Int;
7 |
8 | /** Index for the selected item, zero-based */
9 | currentIndex: CodeGen_Int;
10 | }
11 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/search/matchFromQuery.ts:
--------------------------------------------------------------------------------
1 | import { SearchQuery } from '@codemirror/search';
2 | import { QueryResult, cursorFromQuery } from './queryCursor';
3 |
4 | export default function matchFromQuery(query: SearchQuery): QueryResult | null {
5 | const cursor = cursorFromQuery(query);
6 | if (cursor === null) {
7 | return null;
8 | }
9 |
10 | const state = window.editor.state;
11 | const { from, to } = state.selection.main;
12 | return cursor.nextMatch(state, to, to) ?? cursor.prevMatch(state, from, from);
13 | }
14 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/search/operations/index.ts:
--------------------------------------------------------------------------------
1 | export enum SearchOperation {
2 | selectAll = 'selectAll',
3 | selectAllInSelection = 'selectAllInSelection',
4 | replaceAll = 'replaceAll',
5 | replaceAllInSelection = 'replaceAllInSelection',
6 | }
7 |
8 | export { default as performReplaceAll } from './replaceAll';
9 | export { default as performReplaceAllInSelection } from './replaceAllInSelection';
10 | export { default as performSelectAll } from './selectAll';
11 | export { default as performSelectAllInSelection } from './selectAllInSelection';
12 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/search/operations/replaceAll.ts:
--------------------------------------------------------------------------------
1 | import { replaceAll as replaceAllCommand } from '@codemirror/search';
2 |
3 | export default function replaceAll() {
4 | replaceAllCommand(window.editor);
5 | }
6 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/search/operations/replaceAllInSelection.ts:
--------------------------------------------------------------------------------
1 | import { SearchQuery } from '@codemirror/search';
2 | import { cursorFromQuery } from '../queryCursor';
3 | import SearchOptions from '../options';
4 | import selectedRanges from '../../selection/selectedRanges';
5 |
6 | export default function replaceAllInSelection(options: SearchOptions) {
7 | const cursor = cursorFromQuery(new SearchQuery(options));
8 | if (cursor === null) {
9 | return;
10 | }
11 |
12 | const editor = window.editor;
13 | const matches = cursor.matchAll(editor.state, 1e9) ?? [];
14 | if (matches.length === 0) {
15 | return;
16 | }
17 |
18 | const changes = matches.map(match => ({
19 | from: match.from,
20 | to: match.to,
21 | insert: cursor.getReplacement(match),
22 | }));
23 |
24 | const selections = selectedRanges();
25 | editor.dispatch({
26 | changes: changes.filter(({ from, to }) => {
27 | for (const selection of selections) {
28 | if (from >= selection.from && to <= selection.to) {
29 | return true;
30 | }
31 | }
32 |
33 | return false;
34 | }),
35 | });
36 | }
37 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/search/operations/selectAll.ts:
--------------------------------------------------------------------------------
1 | import { selectMatches as selectMatchesCommand } from '@codemirror/search';
2 |
3 | export default function selectAll() {
4 | selectMatchesCommand(window.editor);
5 | }
6 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/search/operations/selectAllInSelection.ts:
--------------------------------------------------------------------------------
1 | import { SearchQuery } from '@codemirror/search';
2 | import SearchOptions from '../options';
3 | import rangesFromQuery from '../rangesFromQuery';
4 | import selectWithRanges from '../../selection/selectWithRanges';
5 |
6 | export default function selectAllInSelection(options: SearchOptions) {
7 | const query = new SearchQuery(options);
8 | const state = window.editor.state;
9 | const ranges = state.selection.ranges;
10 | selectWithRanges(ranges.flatMap(range => rangesFromQuery(query, range)));
11 | }
12 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/search/options.ts:
--------------------------------------------------------------------------------
1 | export default interface SearchOptions {
2 | search: string;
3 | caseSensitive: boolean;
4 | diacriticInsensitive: boolean;
5 | wholeWord: boolean;
6 | literal: boolean;
7 | regexp: boolean;
8 | refocus: boolean;
9 | replace?: string;
10 | }
11 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/search/queryCursor.ts:
--------------------------------------------------------------------------------
1 | import { SearchQuery } from '@codemirror/search';
2 | import { EditorState } from '@codemirror/state';
3 |
4 | export interface QueryResult {
5 | from: number;
6 | to: number;
7 | }
8 |
9 | export interface QueryCursor {
10 | matchAll: (state: EditorState, limit: number) => QueryResult[] | null;
11 | nextMatch: (state: EditorState, from: number, to: number) => QueryResult | null;
12 | prevMatch: (state: EditorState, from: number, to: number) => QueryResult | null;
13 | getReplacement: (result: QueryResult) => string;
14 | }
15 |
16 | export function cursorFromQuery(query: SearchQuery) {
17 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
18 | const anyQuery = (query as any);
19 | if (typeof anyQuery.create !== 'function') {
20 | return null;
21 | }
22 |
23 | const cursor = anyQuery.create();
24 | if (typeof cursor !== 'object') {
25 | return null;
26 | }
27 |
28 | if ([cursor.matchAll, cursor.nextMatch, cursor.prevMatch, cursor.getReplacement].some($ => typeof $ !== 'function')) {
29 | return null;
30 | }
31 |
32 | return cursor as QueryCursor;
33 | }
34 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/search/rangesFromQuery.ts:
--------------------------------------------------------------------------------
1 | import { EditorSelection, SelectionRange } from '@codemirror/state';
2 | import { SearchCursor, SearchQuery } from '@codemirror/search';
3 |
4 | export default function rangesFromQuery(query: SearchQuery, range?: SelectionRange): SelectionRange[] {
5 | const cursor = query.getCursor(window.editor.state, range?.from, range?.to) as SearchCursor;
6 | const ranges: SelectionRange[] = [];
7 |
8 | while (!cursor.next().done) {
9 | ranges.push(EditorSelection.range(cursor.value.from, cursor.value.to));
10 | }
11 |
12 | return ranges;
13 | }
14 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/search/searchOccurrences.ts:
--------------------------------------------------------------------------------
1 | import { SelectionRange, EditorSelection } from '@codemirror/state';
2 |
3 | export default function searchOccurrences(text: string, query: string) {
4 | const ranges: SelectionRange[] = [];
5 | let index = -1;
6 |
7 | // Case senstive, naive search
8 | while ((index = text.indexOf(query, index + 1)) >= 0) {
9 | const from = index;
10 | const to = index + query.length;
11 | ranges.push(EditorSelection.range(from, to));
12 | }
13 |
14 | return ranges;
15 | }
16 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/selection/hasSelection.ts:
--------------------------------------------------------------------------------
1 | import selectedRanges from './selectedRanges';
2 |
3 | export default function hasSelection() {
4 | return selectedRanges().some(range => !range.empty);
5 | }
6 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/selection/navigate.ts:
--------------------------------------------------------------------------------
1 | import { EditorSelection } from '@codemirror/state';
2 | import { scrollToSelection } from './index';
3 |
4 | export function navigateGoBack() {
5 | if (storage.selectionToGoBack === undefined) {
6 | return;
7 | }
8 |
9 | const selection = storage.selectionToGoBack;
10 | saveGoBackSelection();
11 |
12 | window.editor.dispatch({ selection });
13 | scrollToSelection();
14 | }
15 |
16 | export function saveGoBackSelection() {
17 | storage.selectionToGoBack = window.editor.state.selection;
18 | }
19 |
20 | const storage: {
21 | selectionToGoBack: EditorSelection | undefined;
22 | } = {
23 | selectionToGoBack: undefined,
24 | };
25 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/selection/redrawSelectionLayer.ts:
--------------------------------------------------------------------------------
1 | import { forceRedrawElement } from '../../common/utils';
2 |
3 | export default function redrawSelectionLayer() {
4 | const layer = document.querySelector('.cm-selectionLayer') as HTMLElement | null;
5 | if (layer === null) {
6 | return;
7 | }
8 |
9 | forceRedrawElement(layer);
10 | }
11 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/selection/reverseRange.ts:
--------------------------------------------------------------------------------
1 | import { EditorSelection, SelectionRange } from '@codemirror/state';
2 |
3 | export default function invertRange(range: SelectionRange, needsInvert: boolean) {
4 | if (needsInvert) {
5 | return EditorSelection.range(range.to, range.from);
6 | }
7 |
8 | return range;
9 | }
10 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/selection/searchMatchElement.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Returns the current selected search match or null if not found.
3 | */
4 | export default function searchMatchElement() {
5 | return document.querySelector('.cm-searchMatch-selected') as HTMLElement | null;
6 | }
7 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/selection/selectWholeLineAt.ts:
--------------------------------------------------------------------------------
1 | import { EditorSelection } from '@codemirror/state';
2 |
3 | /**
4 | * Select the whole line, it's slightly different compared to the CodeMirror built-in one,
5 | * more specifically, it doesn't include the following linebreak.
6 | *
7 | * @param n 1-based line number
8 | */
9 | export default function selectWholeLineAt(n: number) {
10 | try {
11 | const editor = window.editor;
12 | const line = editor.state.doc.line(n);
13 | editor.dispatch({ selection: EditorSelection.range(line.from, line.to) });
14 | } catch (error) {
15 | // The state.doc.line can *sometimes* throw exceptions, haven't looked into it,
16 | // but we don't want to make other features non-functional.
17 | console.error(error);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/selection/selectWithRanges.ts:
--------------------------------------------------------------------------------
1 | import { EditorSelection, SelectionRange } from '@codemirror/state';
2 |
3 | export default function selectWithRanges(ranges: SelectionRange[]) {
4 | const editor = window.editor;
5 | editor.dispatch({
6 | selection: EditorSelection.create(ranges),
7 | });
8 | }
9 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/selection/selectedLineColumn.ts:
--------------------------------------------------------------------------------
1 | import { LineColumnInfo } from './types';
2 |
3 | /**
4 | * Get the information of the current selected line and column.
5 | */
6 | export function selectedLineColumn(): LineColumnInfo {
7 | const editor = window.editor;
8 | const state = editor.state;
9 | const selection = state.selection.main;
10 | const line = state.doc.lineAt(selection.head);
11 |
12 | return {
13 | lineNumber: line.number as CodeGen_Int,
14 | columnText: state.sliceDoc(line.from, selection.head),
15 | selectionText: state.sliceDoc(selection.from, selection.to),
16 | };
17 | }
18 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/selection/selectedRanges.ts:
--------------------------------------------------------------------------------
1 | export default function selectedRanges() {
2 | return [...window.editor.state.selection.ranges];
3 | }
4 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/selection/types.ts:
--------------------------------------------------------------------------------
1 | export interface LineColumnInfo {
2 | lineNumber: CodeGen_Int;
3 | columnText: string;
4 | selectionText: string;
5 | }
6 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/snippets/index.ts:
--------------------------------------------------------------------------------
1 | import insertSnippet from './insertSnippet';
2 |
3 | export function insertHyperLink(title: string, url: string, prefix = '') {
4 | insertSnippet(`${prefix}[#{${title}}](#{${url}})`);
5 | }
6 |
7 | export function insertTable(columnName: string, itemName: string) {
8 | // It's merely a trivial approach,
9 | // we don't want to spend time improving it,
10 | // using tables in Markdown can hardly be great.
11 | //
12 | // Let's just use this as a hint for those who are not familiar with the syntax.
13 | insertSnippet([
14 | `| #{${columnName} 1} | #{${columnName} 2} | #{${columnName} 3} |`,
15 | '|:----|:---:|:---:|',
16 | `| #{${itemName} 1} | #{${itemName} 2} | #{${itemName} 3} |`,
17 | `| #{${itemName} 4} | #{${itemName} 5} | #{${itemName} 6} |`,
18 | ].join(window.editor.state.lineBreak));
19 | }
20 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/snippets/insertSnippet.ts:
--------------------------------------------------------------------------------
1 | import { snippet } from '@codemirror/autocomplete';
2 |
3 | /**
4 | * Insert snippet with placeholder tokens, it only handles the main selection.
5 | *
6 | * @param template Template string as described in https://codemirror.net/docs/ref/#autocomplete.snippet
7 | */
8 | export default function insertSnippet(template: string, label = '') {
9 | const editor = window.editor;
10 | const { from, to } = editor.state.selection.main;
11 |
12 | // Make #{} the last one to be the border
13 | snippet(template + '#{}')(editor, { label }, from, to);
14 | }
15 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/textChecker/index.ts:
--------------------------------------------------------------------------------
1 | import TextCheckerOptions from './options';
2 |
3 | /**
4 | * Div level text checker settings.
5 | */
6 | export function update(options: TextCheckerOptions) {
7 | const contentDOM = window.editor.contentDOM;
8 | contentDOM.setAttribute('spellcheck', options.spellcheck ? 'true' : 'false');
9 | contentDOM.setAttribute('autocorrect', options.autocorrect ? 'on' : 'off');
10 |
11 | // Remove attributes to respect system preferences,
12 | // we don't use EditorView.contentAttributes because it doesn't support removing.
13 | contentDOM.removeAttribute('autocomplete');
14 | contentDOM.removeAttribute('autocapitalize');
15 | }
16 |
17 | export type { TextCheckerOptions };
18 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/textChecker/options.ts:
--------------------------------------------------------------------------------
1 | export default interface TextCheckerOptions {
2 | spellcheck: boolean;
3 | autocorrect: boolean;
4 | }
5 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/toc/types.ts:
--------------------------------------------------------------------------------
1 | export interface HeadingInfo {
2 | title: string;
3 | level: CodeGen_Int;
4 | from: CodeGen_Int;
5 | to: CodeGen_Int;
6 | selected: boolean;
7 | }
8 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/tokenizer/anchorAtPos.ts:
--------------------------------------------------------------------------------
1 | import { TextTokenizeAnchor } from './types';
2 |
3 | /**
4 | * Returns the line anchor used for text tokenization, based on a position.
5 | */
6 | export function anchorAtPos(pos: number): TextTokenizeAnchor {
7 | const editor = window.editor;
8 | const line = editor.state.doc.lineAt(pos);
9 | const offset = line.from;
10 |
11 | return {
12 | text: line.text,
13 | pos: (pos - offset) as CodeGen_Int,
14 | offset: offset as CodeGen_Int,
15 | };
16 | }
17 |
--------------------------------------------------------------------------------
/CoreEditor/src/modules/tokenizer/types.ts:
--------------------------------------------------------------------------------
1 | export interface TextTokenizeAnchor {
2 | text: string;
3 | pos: CodeGen_Int;
4 | offset: CodeGen_Int;
5 | }
6 |
--------------------------------------------------------------------------------
/CoreEditor/src/styling/matchers/regex.ts:
--------------------------------------------------------------------------------
1 | import { Decoration, MatchDecorator } from '@codemirror/view';
2 |
3 | /**
4 | * Create mark decorations.
5 | *
6 | * @param regexp Regular expression
7 | * @param builder Closure to build the decoration, or class name as a shortcut
8 | */
9 | export function createMarkDeco(regexp: RegExp, builder: ((match: RegExpExecArray, pos: number) => Decoration | null) | string) {
10 | return createDecos(regexp, (match, pos) => {
11 | if (typeof builder === 'function') {
12 | return builder(match, pos);
13 | } else {
14 | return Decoration.mark({ class: builder });
15 | }
16 | });
17 | }
18 |
19 | /**
20 | * Build decorations by leveraging MatchDecorator.
21 | *
22 | * @param regexp Regular expression
23 | * @param builder Closure to create the decoration
24 | */
25 | function createDecos(regexp: RegExp, builder: (match: RegExpExecArray, pos: number) => Decoration | null) {
26 | const matcher = new MatchDecorator({
27 | regexp,
28 | boundary: /\S/,
29 | decoration: (match, _, pos) => builder(match, pos),
30 | });
31 |
32 | return matcher.createDeco(window.editor);
33 | }
34 |
--------------------------------------------------------------------------------
/CoreEditor/src/styling/nodes/frontMatter.ts:
--------------------------------------------------------------------------------
1 | import { Decoration } from '@codemirror/view';
2 | import { createDecoPlugin, lineDecoRanges } from '../helper';
3 | import { frontMatterRange } from '../../modules/frontMatter';
4 |
5 | /**
6 | * Front Matter: https://jekyllrb.com/docs/front-matter/.
7 | */
8 | export const frontMatterStyle = createDecoPlugin(() => {
9 | const range = frontMatterRange();
10 | if (range === undefined) {
11 | return Decoration.none;
12 | }
13 |
14 | // We don't have a cm6 parser for yaml just yet,
15 | // let's simply decorate the front matter section with a class.
16 | return Decoration.set(lineDecoRanges(range.from, range.to, 'cm-md-frontMatter'));
17 | });
18 |
--------------------------------------------------------------------------------
/CoreEditor/src/styling/nodes/gutter.ts:
--------------------------------------------------------------------------------
1 | import { lineNumbers } from '@codemirror/view';
2 | import { codeFolding, foldGutter } from '@codemirror/language';
3 |
4 | export const gutterExtensions = [
5 | lineNumbers(),
6 | codeFolding({ placeholderText: '•••' }),
7 | foldGutter({ openText: '▼', closedText: '▶︎' }),
8 | ];
9 |
--------------------------------------------------------------------------------
/CoreEditor/src/styling/nodes/heading.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Calculate the font size, take headers into account.
3 | *
4 | * For example, if the regular font size is 15, "# Heading 1" goes with 20 (15 + 5) by default.
5 | *
6 | * @param level Heading level
7 | * @returns Font size for a *possible* header
8 | */
9 | export function calculateFontSize(fontSize: number, level: number) {
10 | const diffs = window.config.headerFontSizeDiffs ?? [5, 3, 1];
11 | return fontSize + ([0, ...diffs][level] || 0);
12 | }
13 |
--------------------------------------------------------------------------------
/CoreEditor/src/styling/nodes/selection.ts:
--------------------------------------------------------------------------------
1 | import { Decoration, DecorationSet, ViewPlugin, ViewUpdate } from '@codemirror/view';
2 | import { lineDecoRanges as createDeco } from '../helper';
3 |
4 | /**
5 | * We only decorate active lines with a cm-md-activeIndicator layer,
6 | * use this extension to decorate all selected ranges.
7 | *
8 | * This is useful for implementing features like showing whitespaces for selections.
9 | */
10 | export const selectedVisiblesDecoration = createViewPlugin('cm-selectedVisible');
11 |
12 | /**
13 | * We only decorate active lines with a cm-md-activeIndicator layer,
14 | * use this extension to decorate all selected ranges.
15 | *
16 | * This is useful for implementing features like focus mode.
17 | */
18 | export const selectedLinesDecoration = createViewPlugin('cm-selectedLineRange');
19 |
20 | function createViewPlugin(className: string) {
21 | return ViewPlugin.fromClass(class {
22 | decorations: DecorationSet;
23 | constructor() {
24 | this.decorations = Decoration.none;
25 | }
26 |
27 | update(update: ViewUpdate) {
28 | // selectionSet is false when the selected text is cut
29 | if (!update.selectionSet && !update.docChanged) {
30 | return;
31 | }
32 |
33 | const ranges = update.state.selection.ranges;
34 | const lineDecos = ranges.flatMap(range => createDeco(range.from, range.to, className));
35 | this.decorations = Decoration.set(lineDecos.sort((lhs, rhs) => lhs.from - rhs.from));
36 | }
37 | }, { decorations: value => value.decorations });
38 | }
39 |
--------------------------------------------------------------------------------
/CoreEditor/src/styling/nodes/table.ts:
--------------------------------------------------------------------------------
1 | import { createLineDeco, createWidgetDeco } from '../matchers/lezer';
2 | import { createDecoPlugin } from '../helper';
3 | import { PreviewWidget } from '../views';
4 | import { cancelDefaultEvent, PreviewType, showPreview } from '../../modules/preview';
5 |
6 | /**
7 | * Always use monospace font for Table.
8 | */
9 | export const tableStyle = createDecoPlugin(() => {
10 | return createLineDeco('Table', 'cm-md-monospace cm-md-table');
11 | });
12 |
13 | /**
14 | * Enable [preview] button for GFM tables.
15 | */
16 | export const previewTable = createDecoPlugin(() => {
17 | return createWidgetDeco('Table', node => {
18 | const header = node.node.getChild('TableHeader');
19 | if (header === null) {
20 | return null;
21 | }
22 |
23 | const state = window.editor.state;
24 | const code = state.sliceDoc(node.from, node.to);
25 | return new PreviewWidget(code, PreviewType.table, header.to);
26 | });
27 | }, {
28 | click: showPreview,
29 | mousedown: cancelDefaultEvent,
30 | });
31 |
--------------------------------------------------------------------------------
/CoreEditor/src/styling/themes/colors.ts:
--------------------------------------------------------------------------------
1 | const lightBase = {
2 | red: '#82071e',
3 | green: '#116329',
4 | gray1: '#8e8e93',
5 | gray2: '#aeaeb2',
6 | gray3: '#c7c7cc',
7 | gray4: '#d1d1d6',
8 | gray5: '#e5e5ea',
9 | gray6: '#f2f2f7',
10 | };
11 |
12 | const darkBase = {
13 | red: '#ffa198',
14 | green: '#7ee787',
15 | gray1: '#8e8e93',
16 | gray2: '#636366',
17 | gray3: '#48484a',
18 | gray4: '#3a3a3c',
19 | gray5: '#2c2c2e',
20 | gray6: '#1c1c1e',
21 | };
22 |
23 | export { lightBase, darkBase };
24 |
--------------------------------------------------------------------------------
/CoreEditor/src/styling/themes/index.ts:
--------------------------------------------------------------------------------
1 | import { EditorTheme } from '../types';
2 |
3 | import GitHubLight from './github-light';
4 | import GitHubDark from './github-dark';
5 | import XcodeLight from './xcode-light';
6 | import XcodeDark from './xcode-dark';
7 | import Dracula from './dracula';
8 | import Cobalt from './cobalt';
9 | import WinterIsComingLight from './winter-is-coming-light';
10 | import WinterIsComingDark from './winter-is-coming-dark';
11 | import MinimalLight from './minimal-light';
12 | import MinimalDark from './minimal-dark';
13 | import SynthWave84 from './synthwave84';
14 | import NightOwl from './night-owl';
15 | import RosePineDawn from './rose-pine-dawn';
16 | import RosePine from './rose-pine';
17 | import SolarizedLight from './solarized-light';
18 | import SolarizedDark from './solarized-dark';
19 |
20 | const themes: { [key: string]: (() => EditorTheme) | undefined } = {
21 | 'github-light': GitHubLight,
22 | 'github-dark': GitHubDark,
23 | 'xcode-light': XcodeLight,
24 | 'xcode-dark': XcodeDark,
25 | 'dracula': Dracula,
26 | 'cobalt': Cobalt,
27 | 'winter-is-coming-light': WinterIsComingLight,
28 | 'winter-is-coming-dark': WinterIsComingDark,
29 | 'minimal-light': MinimalLight,
30 | 'minimal-dark': MinimalDark,
31 | 'synthwave84': SynthWave84,
32 | 'night-owl': NightOwl,
33 | 'rose-pine-dawn': RosePineDawn,
34 | 'rose-pine': RosePine,
35 | 'solarized-light': SolarizedLight,
36 | 'solarized-dark': SolarizedDark,
37 | };
38 |
39 | export function loadTheme(name: string): EditorTheme {
40 | return (themes[name] ?? GitHubLight)();
41 | }
42 |
43 | export type { EditorTheme };
44 |
--------------------------------------------------------------------------------
/CoreEditor/src/styling/themes/solarized-dark.ts:
--------------------------------------------------------------------------------
1 | import { EditorColors, EditorTheme } from '../types';
2 | import { buildTheme } from '../builder';
3 | import { highlight } from './solarized-light';
4 |
5 | const palette = {
6 | gray: '#073642',
7 | grass: '#859900',
8 | };
9 |
10 | const colors: EditorColors = {
11 | accent: '#268bd2',
12 | text: '#93a1a1',
13 | comment: '#657b83',
14 | background: '#002b36',
15 | caret: '#93a1a1',
16 | selection: palette.gray,
17 | activeLine: palette.gray,
18 | matchingBracket: '#083d3d',
19 | lineNumber: '#566c74',
20 | searchMatch: '#584032',
21 | selectionHighlight: '#005a6faa',
22 | visibleSpace: '#93a1a180',
23 | lighterBackground: '#93a1a11a',
24 | bracketBorder: '#888888',
25 | };
26 |
27 | function theme() {
28 | return buildTheme(colors, 'dark');
29 | }
30 |
31 | export default function SolarizedDark(): EditorTheme {
32 | return {
33 | colors,
34 | extension: [theme(), highlight(palette, colors)],
35 | };
36 | }
37 |
38 | export { colors };
39 |
--------------------------------------------------------------------------------
/CoreEditor/src/styling/types.ts:
--------------------------------------------------------------------------------
1 | import { Extension } from '@codemirror/state';
2 |
3 | export type ColorScheme = 'light' | 'dark';
4 |
5 | export interface EditorColors {
6 | accent: string;
7 | text: string;
8 | comment: string;
9 | background: string;
10 | caret: string;
11 | selection: string;
12 | activeLine: string;
13 | matchingBracket: string;
14 | lineNumber: string;
15 | searchMatch: string;
16 | selectionHighlight: string;
17 | visibleSpace: string;
18 | lighterBackground: string;
19 | lineBorder?: string;
20 | bracketBorder?: string;
21 | }
22 |
23 | export interface EditorTheme {
24 | colors: EditorColors;
25 | extension: Extension;
26 | }
27 |
--------------------------------------------------------------------------------
/CoreEditor/src/styling/views/types.ts:
--------------------------------------------------------------------------------
1 | import { WidgetType } from '@codemirror/view';
2 |
3 | /**
4 | * Extend WidgetType with a pos to indicate where to draw.
5 | */
6 | export abstract class WidgetView extends WidgetType {
7 | pos: number;
8 | }
9 |
--------------------------------------------------------------------------------
/CoreEditor/test/basic.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from '@jest/globals';
2 | import { replaceRange } from '../src/common/utils';
3 |
4 | describe('Basic test suite', () => {
5 | test('test deduplicate items using Set', () => {
6 | const deduped = [...new Set([
7 | 'ui-monospace', 'ui-monospace', 'monospace', 'Menlo',
8 | 'system-ui', 'system-ui', 'Helvetica', 'Arial', 'sans-serif',
9 | ])];
10 |
11 | expect(deduped).toStrictEqual([
12 | 'ui-monospace', 'monospace', 'Menlo',
13 | 'system-ui', 'Helvetica', 'Arial', 'sans-serif',
14 | ]);
15 | });
16 |
17 | test('test replacing ranges in string', () => {
18 | expect(replaceRange('Hello, World', 0, 2, '')).toBe('llo, World');
19 | expect(replaceRange('Hello, World', 7, 12, 'MarkEdit')).toBe('Hello, MarkEdit');
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/CoreEditor/test/build.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from '@jest/globals';
2 | import fs from 'fs';
3 | import path from 'path';
4 |
5 | describe('Build system', () => {
6 | test('test existence of magic variables', () => {
7 | const testFileName = (fileName: string, hasChunks: boolean) => {
8 | const html = fs.readFileSync(path.join(__dirname, fileName), 'utf-8');
9 | expect(html).toContain('"{{EDITOR_CONFIG}}"');
10 |
11 | if (hasChunks) {
12 | expect(html).toContain('/chunk-loader/');
13 | }
14 | };
15 |
16 | testFileName('../dist/index.html', true);
17 | testFileName('../src/@light/dist/index.html', false);
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/CoreEditor/test/frontMatter.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from '@jest/globals';
2 | import { frontMatterRange, removeFrontMatter } from '../src/modules/frontMatter';
3 | import * as editor from './utils/editor';
4 |
5 | describe('Front Matter tests', () => {
6 | test('test frontMatter parsing', () => {
7 | editor.setUp('Hello World');
8 | expect(frontMatterRange()).toBe(undefined);
9 |
10 | editor.setUp('---\ntitle: MarkEdit\n---\nHello World');
11 | expect(frontMatterRange()).toStrictEqual({ from: 0, to: 23 });
12 |
13 | const source = '---\ntitle: WhatCopied\n---\nHello World';
14 | expect(frontMatterRange(source)).toStrictEqual({ from: 0, to: 25 });
15 | expect(removeFrontMatter(source)).toBe('Hello World');
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/CoreEditor/test/input.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from '@jest/globals';
2 | import wrapBlock from '../src/modules/input/wrapBlock';
3 | import * as editor from './utils/editor';
4 |
5 | describe('Input module', () => {
6 | test('test wrapBlock', () => {
7 | editor.setUp('Hello World');
8 | editor.selectRange(0, 5);
9 |
10 | wrapBlock('~', window.editor);
11 | expect(editor.getText()).toBe('~Hello~ World');
12 |
13 | wrapBlock('@', window.editor);
14 | expect(editor.getText()).toBe('~@Hello@~ World');
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/CoreEditor/test/styling.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from '@jest/globals';
2 | import { gutterExtensions } from '../src/styling/nodes/gutter';
3 | import { sleep } from './utils/helpers';
4 | import * as editor from './utils/editor';
5 |
6 | describe('Styling module', () => {
7 | test('test CodeMirror class names', async () => {
8 | editor.setUp('Hello World', [
9 | ...gutterExtensions,
10 | ]);
11 |
12 | await sleep(200);
13 | const elements = [...document.querySelectorAll('*')] as HTMLElement[];
14 |
15 | const classNames = elements.reduce((acc, cur) => {
16 | [...cur.classList].forEach(cls => acc.add(cls.toString()));
17 | return acc;
18 | }, new Set());
19 |
20 | expect(classNames.has('cm-editor')).toBeTruthy();
21 | expect(classNames.has('cm-focused')).toBeTruthy();
22 | expect(classNames.has('cm-content')).toBeTruthy();
23 | expect(classNames.has('cm-scroller')).toBeTruthy();
24 | expect(classNames.has('cm-gutters')).toBeTruthy();
25 | expect(classNames.has('cm-gutter')).toBeTruthy();
26 | expect(classNames.has('cm-gutterElement')).toBeTruthy();
27 | expect(classNames.has('cm-foldGutter')).toBeTruthy();
28 | expect(classNames.has('cm-line')).toBeTruthy();
29 | expect(classNames.has('cm-lineNumbers')).toBeTruthy();
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/CoreEditor/test/toc.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from '@jest/globals';
2 | import { sleep } from './utils/helpers';
3 | import * as editor from './utils/editor';
4 | import * as toc from '../src/modules/toc';
5 |
6 | describe('Table of contents module', () => {
7 | test('test getting table of contents', async() => {
8 | editor.setUp('## Hello\n\n- One\n- Two\n- Three\n\n### MarkEdit\n\nHave fun.');
9 | await sleep(200);
10 | const results = toc.getTableOfContents();
11 |
12 | expect(results[0].level).toBe(2);
13 | expect(results[0].title).toBe('Hello');
14 | expect(results[1].level).toBe(3);
15 | expect(results[1].title).toBe(' MarkEdit');
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/CoreEditor/test/utils/editor.ts:
--------------------------------------------------------------------------------
1 | import { EditorView } from '@codemirror/view';
2 | import { Extension, EditorSelection } from '@codemirror/state';
3 | import { markdown, markdownLanguage } from '../../src/@vendor/lang-markdown';
4 | import { Config } from '../../src/config';
5 |
6 | export function setUp(doc: string, extensions: Extension = []) {
7 | const editor = new EditorView({
8 | doc,
9 | parent: document.body,
10 | extensions: [
11 | ...[extensions],
12 | markdown({ base: markdownLanguage }),
13 | ],
14 | });
15 |
16 | editor.focus();
17 | window.config = {} as Config;
18 | window.editor = editor;
19 | }
20 |
21 | export function setText(doc: string) {
22 | window.editor.dispatch({
23 | changes: {
24 | insert: doc,
25 | from: 0, to: window.editor.state.doc.length,
26 | },
27 | selection: EditorSelection.cursor(0),
28 | });
29 | }
30 |
31 | export function insertText(text: string) {
32 | window.editor.dispatch({
33 | changes: {
34 | insert: text,
35 | from: window.editor.state.doc.length,
36 | },
37 | });
38 | }
39 |
40 | export function getText() {
41 | return window.editor.state.doc.toString();
42 | }
43 |
44 | export function selectAll() {
45 | selectRange(0);
46 | }
47 |
48 | export function selectRange(from: number, to?: number) {
49 | window.editor.dispatch({
50 | selection: EditorSelection.range(from, to === undefined ? window.editor.state.doc.length - from : to),
51 | });
52 | }
53 |
--------------------------------------------------------------------------------
/CoreEditor/test/utils/helpers.ts:
--------------------------------------------------------------------------------
1 | export { sleep } from '../../src/common/utils';
2 |
--------------------------------------------------------------------------------
/CoreEditor/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "typeRoots": ["./node_modules/@types", "./src/@types"],
4 | "module": "esnext",
5 | "target": "esnext",
6 | "lib": ["es2019", "dom"],
7 | "composite": true,
8 | "noImplicitAny": true,
9 | "moduleResolution": "node",
10 | "esModuleInterop": true,
11 | "allowSyntheticDefaultImports": true,
12 | "strictNullChecks": true,
13 | "experimentalDecorators": true,
14 | "importHelpers": true,
15 | "skipLibCheck": true
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/CoreEditor/vite.config.mts:
--------------------------------------------------------------------------------
1 | import { createLogger, defineConfig } from 'vite';
2 |
3 | export default defineConfig(({ command }) => (
4 | {
5 | base: command === 'build' ? '/chunk-loader/' : '',
6 | build: {
7 | assetsDir: 'chunks',
8 | chunkSizeWarningLimit: 768,
9 | },
10 | customLogger: (() => {
11 | const logger = createLogger();
12 | const warn = logger.warn;
13 |
14 | logger.warn = (message, options) => {
15 | // Ignore CodeMirror dynamic import warnings since we are not going to do anything (#214)
16 | if (message.includes('@vendor/language-data') && message.includes('dynamic import will not move module into another chunk')) {
17 | return;
18 | }
19 |
20 | warn(message, options);
21 | };
22 |
23 | return logger;
24 | })(),
25 | }
26 | ));
27 |
--------------------------------------------------------------------------------
/Icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MarkEdit-app/MarkEdit/99d3027c13ec7de8096197b43b4841394cbc4f85/Icon.png
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 MarkEdit.app
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MarkEdit.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/MarkEdit.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/MarkEditCore/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/config/registries.json
8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
9 | .netrc
10 |
--------------------------------------------------------------------------------
/MarkEditCore/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "MarkEditCore",
8 | platforms: [
9 | .iOS(.v17),
10 | .macOS(.v14),
11 | ],
12 | products: [
13 | .library(
14 | name: "MarkEditCore",
15 | targets: ["MarkEditCore"]
16 | ),
17 | ],
18 | dependencies: [
19 | .package(path: "../MarkEditTools"),
20 | ],
21 | targets: [
22 | .target(
23 | name: "MarkEditCore",
24 | path: "Sources",
25 | swiftSettings: [
26 | .enableExperimentalFeature("StrictConcurrency")
27 | ],
28 | plugins: [
29 | .plugin(name: "SwiftLint", package: "MarkEditTools"),
30 | ]
31 | ),
32 |
33 | .testTarget(
34 | name: "MarkEditCoreTests",
35 | dependencies: ["MarkEditCore"],
36 | path: "Tests",
37 | resources: [
38 | .process("Files"),
39 | ],
40 | plugins: [
41 | .plugin(name: "SwiftLint", package: "MarkEditTools"),
42 | ]
43 | ),
44 | ]
45 | )
46 |
--------------------------------------------------------------------------------
/MarkEditCore/README.md:
--------------------------------------------------------------------------------
1 | # MarkEditCore
2 |
3 | This package provides core capabilities to bootstrap the CoreEditor, including configurations, encoding, and decoding.
4 |
5 | It can be used in a full-fledged editor like `MarkEditMac`, or a light version like `PreviewExtension`.
6 |
7 | > Note that this package should be platform-independent.
--------------------------------------------------------------------------------
/MarkEditCore/Sources/EditorLocalizable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorLocalizable.swift
3 | //
4 | // Generated using https://github.com/microsoft/ts-gyb
5 | //
6 | // Don't modify this file manually, it's auto generated.
7 | //
8 | // To make changes, edit template files under /CoreEditor/src/@codegen
9 |
10 | import Foundation
11 |
12 | public struct EditorLocalizable: Encodable {
13 | let controlCharacter: String
14 | let foldedLines: String
15 | let unfoldedLines: String
16 | let foldedCode: String
17 | let unfold: String
18 | let foldLine: String
19 | let unfoldLine: String
20 | let previewButtonTitle: String
21 | let cmdClickToFollow: String
22 | let cmdClickToToggleTodo: String
23 |
24 | public init(
25 | controlCharacter: String,
26 | foldedLines: String,
27 | unfoldedLines: String,
28 | foldedCode: String,
29 | unfold: String,
30 | foldLine: String,
31 | unfoldLine: String,
32 | previewButtonTitle: String,
33 | cmdClickToFollow: String,
34 | cmdClickToToggleTodo: String
35 | ) {
36 | self.controlCharacter = controlCharacter
37 | self.foldedLines = foldedLines
38 | self.unfoldedLines = unfoldedLines
39 | self.foldedCode = foldedCode
40 | self.unfold = unfold
41 | self.foldLine = foldLine
42 | self.unfoldLine = unfoldLine
43 | self.previewButtonTitle = previewButtonTitle
44 | self.cmdClickToFollow = cmdClickToFollow
45 | self.cmdClickToToggleTodo = cmdClickToToggleTodo
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/MarkEditCore/Sources/EditorSharedTypes.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SharedTypes.swift
3 | //
4 | // Generated using https://github.com/microsoft/ts-gyb
5 | //
6 | // Don't modify this file manually, it's auto generated.
7 | //
8 | // To make changes, edit template files under /CoreEditor/src/@codegen
9 |
10 | import Foundation
11 |
12 | /// Font face attributes to control the font styles.
13 | public struct WebFontFace: Codable {
14 | public var family: String
15 | public var weight: String?
16 | public var style: String?
17 |
18 | public init(family: String, weight: String?, style: String?) {
19 | self.family = family
20 | self.weight = weight
21 | self.style = style
22 | }
23 | }
24 |
25 | public enum EditorInvisiblesBehavior: String, Codable {
26 | case never = "never"
27 | case selection = "selection"
28 | case trailing = "trailing"
29 | case always = "always"
30 | }
31 |
32 | public enum EditorIndentBehavior: String, Codable {
33 | case never = "never"
34 | case paragraph = "paragraph"
35 | case line = "line"
36 | }
37 |
38 | /// "CGRect-fashion" rect.
39 | public struct WebRect: Codable {
40 | public var x: Double
41 | public var y: Double
42 | public var width: Double
43 | public var height: Double
44 |
45 | public init(x: Double, y: Double, width: Double, height: Double) {
46 | self.x = x
47 | self.y = y
48 | self.width = width
49 | self.height = height
50 | }
51 | }
52 |
53 | public struct TextTokenizeAnchor: Codable {
54 | public var text: String
55 | public var pos: Int
56 | public var offset: Int
57 |
58 | public init(text: String, pos: Int, offset: Int) {
59 | self.text = text
60 | self.pos = pos
61 | self.offset = offset
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/MarkEditCore/Sources/Extensions/Data+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Data+Extension.swift
3 | //
4 | // Created by cyan on 12/28/22.
5 | //
6 |
7 | import Foundation
8 |
9 | public extension Data {
10 | /// Handle text encoding in Cocoa apps: https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Strings/introStrings.html.
11 | ///
12 | /// Ideally, the encoding for Markdown should always be utf-8 as described in: https://daringfireball.net/linked/2011/08/05/markdown-uti.
13 | func toString(encoding: String.Encoding = .utf8) -> String? {
14 | // Perfect, successfully decoded it with the preferred encoding
15 | if let decoded = String(data: self, encoding: encoding) {
16 | return decoded
17 | }
18 |
19 | // Oh no, guess the encoding since we failed to decode it directly
20 | var converted: NSString?
21 | NSString.stringEncoding(
22 | for: self,
23 | encodingOptions: [
24 | // Just a blind guess, it's not possible to know without extra information
25 | .suggestedEncodingsKey: [
26 | String.Encoding(from: .GB_18030_2000).rawValue,
27 | String.Encoding(from: .big5).rawValue,
28 | String.Encoding.japaneseEUC.rawValue,
29 | String.Encoding.shiftJIS.rawValue,
30 | String.Encoding(from: .EUC_KR).rawValue,
31 | encoding.rawValue,
32 | ],
33 | ],
34 | convertedString: &converted,
35 | usedLossyConversion: nil
36 | )
37 |
38 | // It can still be nil, in that case we should allow users to reopen with an encoding
39 | return converted as? String
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/MarkEditCore/Sources/Extensions/EditorConfig+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorConfig+Extension.swift
3 | //
4 | // Created by cyan on 12/23/22.
5 | //
6 |
7 | import Foundation
8 |
9 | public extension EditorConfig {
10 | var toHtml: String {
11 | indexHtml?
12 | .replacingOccurrences(of: "/chunk-loader/", with: "chunk-loader://")
13 | .replacingOccurrences(of: "\"{{EDITOR_CONFIG}}\"", with: jsonEncoded) ?? ""
14 | }
15 | }
16 |
17 | extension EditorConfig {
18 | /// index.html built by CoreEditor.
19 | private var indexHtml: String? {
20 | guard let path = Bundle.main.url(forResource: "index", withExtension: "html") else {
21 | fatalError("Missing dist/index.html to set up the editor. In the wiki, see Building CoreEditor.")
22 | }
23 |
24 | return try? Data(contentsOf: path).toString()
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/MarkEditCore/Sources/Extensions/Encodable+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Encodable+Extension.swift
3 | //
4 | // Created by cyan on 12/22/22.
5 | //
6 |
7 | import Foundation
8 |
9 | public extension Encodable {
10 | var jsonEncoded: String {
11 | (try? JSONEncoder().encode(self).toString()) ?? "{}"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/MarkEditCore/Sources/Extensions/String+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // String+Extension.swift
3 | //
4 | // Created by cyan on 12/28/22.
5 | //
6 |
7 | import Foundation
8 |
9 | public extension String {
10 | /// Overload of the String.Encoding version.
11 | init?(data: Data, encoding: CFStringEncodings) {
12 | self.init(data: data, encoding: String.Encoding(from: encoding))
13 | }
14 |
15 | /// Overload of the String.Encoding version.
16 | func data(using encoding: CFStringEncodings, allowLossyConversion: Bool = false) -> Data? {
17 | data(using: String.Encoding(from: encoding), allowLossyConversion: allowLossyConversion)
18 | }
19 |
20 | func toData(encoding: String.Encoding = .utf8) -> Data? {
21 | data(using: encoding)
22 | }
23 |
24 | func hasPrefixIgnoreCase(_ prefix: String) -> Bool {
25 | range(of: prefix, options: [.anchored, .caseInsensitive]) != nil
26 | }
27 |
28 | func getLineBreak(defaultValue: String) -> String? {
29 | let CRLFs = components(separatedBy: "\r\n").count - 1
30 | let CRs = components(separatedBy: "\r").count - CRLFs - 1
31 | let LFs = components(separatedBy: "\n").count - CRLFs - 1
32 | let usedMost = Swift.max(CRLFs, CRs, LFs)
33 |
34 | switch usedMost {
35 | case 0: return defaultValue
36 | case CRLFs: return "\r\n"
37 | case CRs: return "\r"
38 | case LFs: return "\n"
39 | default: return nil
40 | }
41 | }
42 | }
43 |
44 | extension String.Encoding {
45 | init(from: CFStringEncodings) {
46 | let encoding = CFStringEncoding(from.rawValue)
47 | self.init(rawValue: CFStringConvertEncodingToNSStringEncoding(encoding))
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/MarkEditCore/Sources/Extensions/TextTokenizeAnchor+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TextTokenizeAnchor+Extension.swift
3 | //
4 | // Created by cyan on 11/9/23.
5 | //
6 |
7 | import Foundation
8 |
9 | public extension TextTokenizeAnchor {
10 | var afterSpace: Bool {
11 | guard pos > 0 else {
12 | return false
13 | }
14 |
15 | return text[text.utf16.index(text.startIndex, offsetBy: pos - 1)] == " "
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/MarkEditCore/Tests/EncodingTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EncodingTests.swift
3 | //
4 | // Created by cyan on 2/2/23.
5 | //
6 |
7 | import MarkEditCore
8 | import XCTest
9 |
10 | final class EncodingTests: XCTestCase {
11 | func testDecodeUTF8() {
12 | let data = fileData(of: "sample-utf8.md")
13 | XCTAssertEqual(data.toString(), "Hello, World!\n")
14 | }
15 |
16 | func testDecodeGB18030() {
17 | let data = fileData(of: "sample-gb18030.md")
18 | XCTAssertEqual(data.toString(), "你好,世界!\n")
19 | }
20 |
21 | func testDecodeJapaneseEUC() {
22 | let data = fileData(of: "sample-japanese-euc.md")
23 | XCTAssertEqual(data.toString(), "ゼルダの伝説\n")
24 | }
25 |
26 | func testDecodeKoreanEUC() {
27 | let data = fileData(of: "sample-korean-euc.md")
28 | XCTAssertEqual(data.toString(), "오징어 게임\n")
29 | }
30 | }
31 |
32 | // MARK: - Private
33 |
34 | private extension EncodingTests {
35 | func fileData(of name: String) -> Data {
36 | // swiftlint:disable:next force_unwrapping
37 | let url = Bundle.module.url(forResource: name, withExtension: nil)!
38 | // swiftlint:disable:next force_try
39 | return try! Data(contentsOf: url)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/MarkEditCore/Tests/Files/sample-gb18030.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MarkEdit-app/MarkEdit/99d3027c13ec7de8096197b43b4841394cbc4f85/MarkEditCore/Tests/Files/sample-gb18030.md
--------------------------------------------------------------------------------
/MarkEditCore/Tests/Files/sample-japanese-euc.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MarkEdit-app/MarkEdit/99d3027c13ec7de8096197b43b4841394cbc4f85/MarkEditCore/Tests/Files/sample-japanese-euc.md
--------------------------------------------------------------------------------
/MarkEditCore/Tests/Files/sample-korean-euc.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MarkEdit-app/MarkEdit/99d3027c13ec7de8096197b43b4841394cbc4f85/MarkEditCore/Tests/Files/sample-korean-euc.md
--------------------------------------------------------------------------------
/MarkEditCore/Tests/Files/sample-utf8.md:
--------------------------------------------------------------------------------
1 | Hello, World!
2 |
--------------------------------------------------------------------------------
/MarkEditKit/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/config/registries.json
8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
9 | .netrc
10 |
--------------------------------------------------------------------------------
/MarkEditKit/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "MarkEditKit",
8 | platforms: [
9 | .iOS(.v17),
10 | .macOS(.v14),
11 | ],
12 | products: [
13 | .library(
14 | name: "MarkEditKit",
15 | targets: ["MarkEditKit"]
16 | ),
17 | ],
18 | dependencies: [
19 | .package(path: "../MarkEditCore"),
20 | .package(path: "../MarkEditTools"),
21 | ],
22 | targets: [
23 | .target(
24 | name: "MarkEditKit",
25 | dependencies: ["MarkEditCore"],
26 | path: "Sources",
27 | swiftSettings: [
28 | .enableExperimentalFeature("StrictConcurrency")
29 | ],
30 | plugins: [
31 | .plugin(name: "SwiftLint", package: "MarkEditTools"),
32 | ]
33 | ),
34 | ]
35 | )
36 |
--------------------------------------------------------------------------------
/MarkEditKit/README.md:
--------------------------------------------------------------------------------
1 | # MarkEditKit
2 |
3 | This package provides most functionalities to use the CoreEditor, including a WKWebView wrapper, and bi-directional communication between web application and native code.
4 |
5 | Most code in this package is automatically generated by inferring the TypeScript code, which is located in the CoreEditor folder. To understand how it works, take a look at [ts-gyb](https://github.com/microsoft/ts-gyb).
6 |
7 | > Ideally, this package should be able to run on both macOS and iOS.
--------------------------------------------------------------------------------
/MarkEditKit/Sources/Bridge/Native/Modules/EditorModulePreview.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorModulePreview.swift
3 | //
4 | // Created by cyan on 1/7/23.
5 | //
6 |
7 | import Foundation
8 | import MarkEditCore
9 |
10 | @MainActor
11 | public protocol EditorModulePreviewDelegate: AnyObject {
12 | func editorPreview(_ sender: EditorModulePreview, show code: String, type: PreviewType, rect: CGRect)
13 | }
14 |
15 | public final class EditorModulePreview: NativeModulePreview {
16 | private weak var delegate: EditorModulePreviewDelegate?
17 |
18 | public init(delegate: EditorModulePreviewDelegate) {
19 | self.delegate = delegate
20 | }
21 |
22 | public func show(code: String, type: PreviewType, rect: WebRect) {
23 | delegate?.editorPreview(self, show: code, type: type, rect: rect.cgRect)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/MarkEditKit/Sources/Bridge/Native/NativeModules.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NativeModules.swift
3 | //
4 | // Created by cyan on 12/24/22.
5 | //
6 |
7 | import Foundation
8 |
9 | /// Native method that will be invoked by JavaScript.
10 | public typealias NativeMethod = (_ parameters: Data) -> Result?
11 |
12 | @MainActor
13 | public protocol NativeBridge: AnyObject {
14 | static var name: String { get }
15 | var methods: [String: NativeMethod] { get }
16 | }
17 |
18 | /**
19 | Native module that implements JavaScript functions.
20 |
21 | Don't implement NativeModule directly with controllers, it will easily introduce retain cycles.
22 | */
23 | @MainActor
24 | public protocol NativeModule: AnyObject {
25 | var bridge: NativeBridge { get }
26 | }
27 |
28 | @MainActor
29 | public struct NativeModules {
30 | private let bridges: [String: NativeBridge]
31 |
32 | public init(modules: [NativeModule]) {
33 | self.bridges = modules.reduce(into: [String: NativeBridge]()) { result, module in
34 | let bridge = module.bridge
35 | result[type(of: bridge).name] = bridge
36 | }
37 | }
38 | }
39 |
40 | // MARK: - Internal
41 |
42 | extension NativeBridge {
43 | subscript(name: String) -> NativeMethod? {
44 | methods[name]
45 | }
46 | }
47 |
48 | extension NativeModules {
49 | subscript(name: String) -> NativeBridge? {
50 | bridges[name]
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/MarkEditKit/Sources/Bridge/Web/Generated/WebBridgeAPI.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WebBridgeAPI.swift
3 | //
4 | // Generated using https://github.com/microsoft/ts-gyb
5 | //
6 | // Don't modify this file manually, it's auto generated.
7 | //
8 | // To make changes, edit template files under /CoreEditor/src/@codegen
9 |
10 | import WebKit
11 | import MarkEditCore
12 |
13 | @MainActor
14 | public final class WebBridgeAPI {
15 | private weak var webView: WKWebView?
16 |
17 | init(webView: WKWebView) {
18 | self.webView = webView
19 | }
20 |
21 | public func handleMainMenuAction(id: String, completion: ((Result) -> Void)? = nil) {
22 | struct Message: Encodable {
23 | let id: String
24 | }
25 |
26 | let message = Message(
27 | id: id
28 | )
29 |
30 | webView?.invoke(path: "webModules.api.handleMainMenuAction", message: message, completion: completion)
31 | }
32 |
33 | public func handleContextMenuAction(id: String, completion: ((Result) -> Void)? = nil) {
34 | struct Message: Encodable {
35 | let id: String
36 | }
37 |
38 | let message = Message(
39 | id: id
40 | )
41 |
42 | webView?.invoke(path: "webModules.api.handleContextMenuAction", message: message, completion: completion)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/MarkEditKit/Sources/Bridge/Web/Generated/WebBridgeDummy.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WebBridgeDummy.swift
3 | //
4 | // Generated using https://github.com/microsoft/ts-gyb
5 | //
6 | // Don't modify this file manually, it's auto generated.
7 | //
8 | // To make changes, edit template files under /CoreEditor/src/@codegen
9 |
10 | import WebKit
11 | import MarkEditCore
12 |
13 | @MainActor
14 | public final class WebBridgeDummy {
15 | private weak var webView: WKWebView?
16 |
17 | init(webView: WKWebView) {
18 | self.webView = webView
19 | }
20 |
21 | /// Don't call this directly, it does nothing.
22 | ///
23 | /// We use this to generate types that are not covered in exposed interfaces, as a workaround.
24 | public func __generateTypes__(arg0: EditorIndentBehavior, completion: ((Result) -> Void)? = nil) {
25 | struct Message: Encodable {
26 | let arg0: EditorIndentBehavior
27 | }
28 |
29 | let message = Message(
30 | arg0: arg0
31 | )
32 |
33 | webView?.invoke(path: "webModules.dummy.__generateTypes__", message: message, completion: completion)
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/MarkEditKit/Sources/Bridge/Web/Generated/WebBridgeLineEndings.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WebBridgeLineEndings.swift
3 | //
4 | // Generated using https://github.com/microsoft/ts-gyb
5 | //
6 | // Don't modify this file manually, it's auto generated.
7 | //
8 | // To make changes, edit template files under /CoreEditor/src/@codegen
9 |
10 | import WebKit
11 | import MarkEditCore
12 |
13 | @MainActor
14 | public final class WebBridgeLineEndings {
15 | private weak var webView: WKWebView?
16 |
17 | init(webView: WKWebView) {
18 | self.webView = webView
19 | }
20 |
21 | public func getLineEndings() async throws -> LineEndings {
22 | return try await withCheckedThrowingContinuation { continuation in
23 | webView?.invoke(path: "webModules.lineEndings.getLineEndings") { result in
24 | Task { @MainActor in
25 | continuation.resume(with: result)
26 | }
27 | }
28 | }
29 | }
30 |
31 | public func setLineEndings(lineEndings: LineEndings, completion: ((Result) -> Void)? = nil) {
32 | struct Message: Encodable {
33 | let lineEndings: LineEndings
34 | }
35 |
36 | let message = Message(
37 | lineEndings: lineEndings
38 | )
39 |
40 | webView?.invoke(path: "webModules.lineEndings.setLineEndings", message: message, completion: completion)
41 | }
42 | }
43 |
44 | public enum LineEndings: Int, Codable {
45 | /// Unspecified, let CodeMirror do the normalization magic.
46 | case unspecified = 0
47 | /// Line Feed, used on macOS and Unix systems.
48 | case lf = 1
49 | /// Carriage Return and Line Feed, used on Windows.
50 | case crlf = 2
51 | /// Carriage Return, previously used on Classic Mac OS.
52 | case cr = 3
53 | }
54 |
--------------------------------------------------------------------------------
/MarkEditKit/Sources/Bridge/Web/Generated/WebBridgeTextChecker.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WebBridgeTextChecker.swift
3 | //
4 | // Generated using https://github.com/microsoft/ts-gyb
5 | //
6 | // Don't modify this file manually, it's auto generated.
7 | //
8 | // To make changes, edit template files under /CoreEditor/src/@codegen
9 |
10 | import WebKit
11 | import MarkEditCore
12 |
13 | @MainActor
14 | public final class WebBridgeTextChecker {
15 | private weak var webView: WKWebView?
16 |
17 | init(webView: WKWebView) {
18 | self.webView = webView
19 | }
20 |
21 | public func update(options: TextCheckerOptions, completion: ((Result) -> Void)? = nil) {
22 | struct Message: Encodable {
23 | let options: TextCheckerOptions
24 | }
25 |
26 | let message = Message(
27 | options: options
28 | )
29 |
30 | webView?.invoke(path: "webModules.textChecker.update", message: message, completion: completion)
31 | }
32 | }
33 |
34 | public struct TextCheckerOptions: Codable {
35 | public var spellcheck: Bool
36 | public var autocorrect: Bool
37 |
38 | public init(spellcheck: Bool, autocorrect: Bool) {
39 | self.spellcheck = spellcheck
40 | self.autocorrect = autocorrect
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/MarkEditKit/Sources/Bridge/Web/WebModuleBridge.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WebModuleBridge.swift
3 | //
4 | // Created by cyan on 12/16/22.
5 | //
6 |
7 | import WebKit
8 |
9 | /**
10 | Wrapper for all web bridges.
11 | */
12 | @MainActor
13 | public struct WebModuleBridge {
14 | public let config: WebBridgeConfig
15 | public let core: WebBridgeCore
16 | public let completion: WebBridgeCompletion
17 | public let history: WebBridgeHistory
18 | public let lineEndings: WebBridgeLineEndings
19 | public let textChecker: WebBridgeTextChecker
20 | public let selection: WebBridgeSelection
21 | public let format: WebBridgeFormat
22 | public let search: WebBridgeSearch
23 | public let toc: WebBridgeTableOfContents
24 | public let api: WebBridgeAPI
25 | public let writingTools: WebBridgeWritingTools
26 |
27 | public init(webView: WKWebView) {
28 | self.config = WebBridgeConfig(webView: webView)
29 | self.core = WebBridgeCore(webView: webView)
30 | self.completion = WebBridgeCompletion(webView: webView)
31 | self.history = WebBridgeHistory(webView: webView)
32 | self.lineEndings = WebBridgeLineEndings(webView: webView)
33 | self.textChecker = WebBridgeTextChecker(webView: webView)
34 | self.selection = WebBridgeSelection(webView: webView)
35 | self.format = WebBridgeFormat(webView: webView)
36 | self.search = WebBridgeSearch(webView: webView)
37 | self.toc = WebBridgeTableOfContents(webView: webView)
38 | self.api = WebBridgeAPI(webView: webView)
39 | self.writingTools = WebBridgeWritingTools(webView: webView)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/MarkEditKit/Sources/EditorLogger.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorLogger.swift
3 | //
4 | // Created by cyan on 12/22/22.
5 | //
6 |
7 | import Foundation
8 | import os.log
9 |
10 | public enum Logger {
11 | public static func log(_ level: OSLogType, _ message: @autoclosure @escaping () -> String, file: StaticString = #file, line: UInt = #line, function: StaticString = #function) {
12 | var file: String = "\(file)"
13 | if let url = URL(string: file) {
14 | file = url.lastPathComponent
15 | }
16 |
17 | os_logger.log(level: level, "\(file):\(line), \(function) -> \(message())")
18 | }
19 |
20 | public static func assertFail(_ message: @autoclosure () -> String, file: StaticString = #file, line: UInt = #line) {
21 | assertionFailure(message(), file: file, line: line)
22 | }
23 |
24 | public static func assert(_ condition: @autoclosure () -> Bool, _ message: @autoclosure () -> String, file: StaticString = #file, line: UInt = #line) {
25 | if !condition() {
26 | assertionFailure(message(), file: file, line: line)
27 | }
28 | }
29 | }
30 |
31 | private let os_logger = os.Logger()
32 |
--------------------------------------------------------------------------------
/MarkEditKit/Sources/Extensions/Array+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Array+Extension.swift
3 | //
4 | // Created by cyan on 2/28/23.
5 | //
6 |
7 | import Foundation
8 |
9 | public extension Array where Element: Hashable {
10 | /// Returns a new array by deduplicating elements and preserving the order.
11 | var deduplicated: [Element] {
12 | var seen = Set()
13 | return filter { seen.insert($0).inserted }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/MarkEditKit/Sources/Extensions/LineEndings+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LineEndings+Extension.swift
3 | //
4 | // Created by cyan on 1/28/23.
5 | //
6 |
7 | import Foundation
8 |
9 | public extension LineEndings {
10 | var characters: String {
11 | switch self {
12 | case .crlf:
13 | return "\r\n"
14 | case .cr:
15 | return "\r"
16 | default:
17 | // LF is the preferred line endings on modern macOS
18 | return "\n"
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/MarkEditKit/Sources/Extensions/URL+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URL+Extension.swift
3 | //
4 | // Created by cyan on 1/15/23.
5 | //
6 |
7 | import Foundation
8 |
9 | public extension URL {
10 | var localizedName: String {
11 | (try? resourceValues(forKeys: Set([.localizedNameKey])))?.name ?? lastPathComponent
12 | }
13 |
14 | var resolvingSymbolicLink: URL {
15 | guard isSymbolicLink else {
16 | return self
17 | }
18 |
19 | do {
20 | let resolvedPath = try FileManager.default.destinationOfSymbolicLink(atPath: path)
21 | return URL(filePath: resolvedPath)
22 | } catch {
23 | return self
24 | }
25 | }
26 |
27 | func replacingPathExtension(_ pathExtension: String) -> URL {
28 | deletingPathExtension().appendingPathExtension(pathExtension)
29 | }
30 | }
31 |
32 | // MARK: - Private
33 |
34 | private extension URL {
35 | var isSymbolicLink: Bool {
36 | (try? resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) ?? false
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/MarkEditKit/Sources/Extensions/UserDefaults+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserDefaults+Extension.swift
3 | //
4 | // Created by cyan on 12/17/22.
5 | //
6 |
7 | import Foundation
8 |
9 | public let NSCloseAlwaysConfirmsChanges = "NSCloseAlwaysConfirmsChanges"
10 | public let NSQuitAlwaysKeepsWindows = "NSQuitAlwaysKeepsWindows"
11 | public let NSNavLastRootDirectory = "NSNavLastRootDirectory"
12 |
13 | public extension UserDefaults {
14 | static func overwriteTextCheckerOnce() {
15 | let enabledFlag = "editor.overwrite-text-checker"
16 | guard !standard.bool(forKey: enabledFlag) else {
17 | return
18 | }
19 |
20 | // https://github.com/WebKit/WebKit/blob/main/Source/WebKit/UIProcess/mac/TextCheckerMac.mm
21 | let featureKeys = [
22 | // Features we enable once until user explicitly changes the setting
23 | "NSAllowContinuousSpellChecking",
24 | "WebAutomaticSpellingCorrectionEnabled",
25 | "WebContinuousSpellCheckingEnabled",
26 | "WebGrammarCheckingEnabled",
27 | "WebAutomaticLinkDetectionEnabled",
28 | "WebAutomaticTextReplacementEnabled",
29 |
30 | // Features that respect the system settings
31 | // "WebSmartInsertDeleteEnabled",
32 | // "WebAutomaticQuoteSubstitutionEnabled",
33 | // "WebAutomaticDashSubstitutionEnabled",
34 | ]
35 |
36 | featureKeys.forEach {
37 | standard.setValue(true, forKey: $0)
38 | }
39 |
40 | standard.setValue(true, forKey: enabledFlag)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/MarkEditKit/Sources/Extensions/WKWebViewConfiguration+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WKWebViewConfiguration+Extension.swift
3 | //
4 | // Created by cyan on 1/7/23.
5 | //
6 |
7 | import WebKit
8 |
9 | public extension WKWebViewConfiguration {
10 | static func newConfig(disableCors: Bool = false) -> WKWebViewConfiguration {
11 | class Configuration: WKWebViewConfiguration {
12 | // To mimic settable isOpaque on iOS,
13 | // which is required for the background color and initial white flash in dark mode
14 | @objc func _drawsBackground() -> Bool { false }
15 | }
16 |
17 | let config = Configuration()
18 | if config.preferences.responds(to: sel_getUid("_developerExtrasEnabled")) {
19 | config.preferences.setValue(true, forKey: "developerExtrasEnabled")
20 | } else {
21 | Logger.assertFail("Failed to overwrite developerExtrasEnabled in WKPreferences")
22 | }
23 |
24 | // Disable CORS checks entirely, allowing fetch() in user scripts to do lots of things.
25 | //
26 | // This shouldn't raise security issues, as we're not a browser that can load arbitrary URLs.
27 | if disableCors {
28 | if config.preferences.responds(to: sel_getUid("_webSecurityEnabled")) {
29 | config.preferences.setValue(false, forKey: "webSecurityEnabled")
30 | } else {
31 | Logger.assertFail("Failed to overwrite webSecurityEnabled in WKPreferences")
32 | }
33 | }
34 |
35 | return config
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/MarkEditKit/Sources/Extensions/WebPoint+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WebPoint+Extension.swift
3 | //
4 | // Created by cyan on 10/4/24.
5 | //
6 |
7 | import Foundation
8 | import MarkEditCore
9 |
10 | public extension WebPoint {
11 | var cgPoint: CGPoint {
12 | CGPoint(x: x, y: y)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/MarkEditKit/Sources/Extensions/WebRect+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // WebRect+Extension.swift
3 | //
4 | // Created by cyan on 1/7/23.
5 | //
6 |
7 | import Foundation
8 | import MarkEditCore
9 |
10 | public extension WebRect {
11 | var cgRect: CGRect {
12 | CGRect(x: x, y: y, width: width, height: height)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/MarkEditMac/Info.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.files.user-selected.read-write
8 |
9 | com.apple.security.network.client
10 |
11 | com.apple.security.print
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/config/registries.json
8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
9 | .netrc
10 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/README.md:
--------------------------------------------------------------------------------
1 | # MarkEditMac.Modules
2 |
3 | Isolated modules that serve the MarkEditMac business logic, including AppKit extensions, customized UI controls, etc.
4 |
5 | Each folder in this package produces a standalone target, they can be built independently.
6 |
7 | > Everything in this package is written specifically for macOS, not verified on iOS.
8 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitControls/BackgroundTheming.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BackgroundTheming.swift
3 | //
4 | // Created by cyan on 1/31/23.
5 | //
6 |
7 | import AppKit
8 | import AppKitExtensions
9 |
10 | public protocol BackgroundTheming: NSView {}
11 |
12 | public extension BackgroundTheming {
13 | @MainActor
14 | func setBackgroundColor(_ color: NSColor) {
15 | layerBackgroundColor = color
16 | needsDisplay = true
17 |
18 | enumerateDescendants { (button: NonBezelButton) in
19 | button.layerBackgroundColor = color
20 | button.needsDisplay = true
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitControls/BezelView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BezelView.swift
3 | //
4 | // Created by cyan on 8/20/23.
5 | //
6 |
7 | import AppKit
8 |
9 | /**
10 | Draw bezels to replace the system one, due to different reasons.
11 | */
12 | public final class BezelView: NSView {
13 | private let borderColor: NSColor
14 |
15 | public init(borderColor: NSColor = .separatorColor, cornerRadius: Double = 6) {
16 | self.borderColor = borderColor
17 | super.init(frame: .zero)
18 |
19 | wantsLayer = true
20 | layer?.cornerCurve = .continuous
21 | layer?.cornerRadius = cornerRadius
22 | layer?.borderWidth = 1
23 | }
24 |
25 | @available(*, unavailable)
26 | required init?(coder: NSCoder) {
27 | fatalError("init(coder:) has not been implemented")
28 | }
29 |
30 | override public func draw(_ dirtyRect: NSRect) {
31 | super.draw(dirtyRect)
32 | layer?.borderColor = borderColor.cgColor
33 | }
34 |
35 | override public func hitTest(_ point: NSPoint) -> NSView? {
36 | // Only visually draw a bezel, not clickable
37 | nil
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitControls/Buttons/IconOnlyButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IconOnlyButton.swift
3 | //
4 | // Created by cyan on 12/17/22.
5 | //
6 |
7 | import AppKit
8 |
9 | public final class IconOnlyButton: NonBezelButton {
10 | public init(
11 | symbolName: String,
12 | iconWidth: Double? = nil,
13 | iconHeight: Double? = nil,
14 | accessibilityLabel: String? = nil
15 | ) {
16 | super.init(frame: .zero)
17 | toolTip = accessibilityLabel
18 |
19 | if let iconImage = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
20 | let iconView = NSImageView(image: iconImage)
21 | iconView.contentTintColor = .labelColor
22 | iconView.translatesAutoresizingMaskIntoConstraints = false
23 | addSubview(iconView)
24 |
25 | NSLayoutConstraint.activate([
26 | iconView.widthAnchor.constraint(equalToConstant: iconWidth ?? iconImage.size.width),
27 | iconView.heightAnchor.constraint(equalToConstant: iconHeight ?? iconImage.size.height),
28 | iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
29 | iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
30 | ])
31 | }
32 | }
33 |
34 | override public func accessibilityLabel() -> String? {
35 | toolTip
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitControls/Buttons/NonBezelButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NonBezelButton.swift
3 | //
4 | // Created by cyan on 12/17/22.
5 | //
6 |
7 | import AppKit
8 |
9 | public class NonBezelButton: NSButton {
10 | override init(frame: CGRect) {
11 | super.init(frame: frame)
12 | }
13 |
14 | @available(*, unavailable)
15 | required init?(coder: NSCoder) {
16 | fatalError("init(coder:) has not been implemented")
17 | }
18 |
19 | override public func draw(_ dirtyRect: CGRect) {
20 | super.draw(dirtyRect)
21 | layerBackgroundColor?.setFill()
22 |
23 | let rectPath = NSBezierPath(rect: bounds)
24 | rectPath.fill()
25 |
26 | if isHighlighted {
27 | NSColor.plainButtonHighlighted.setFill()
28 | rectPath.fill()
29 | }
30 | }
31 |
32 | override public func resetCursorRects() {
33 | addCursorRect(bounds, cursor: .arrow)
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitControls/Buttons/TitleOnlyButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TitleOnlyButton.swift
3 | //
4 | // Created by cyan on 12/27/22.
5 | //
6 |
7 | import AppKit
8 |
9 | public final class TitleOnlyButton: NonBezelButton {
10 | public let labelView = LabelView(frame: .zero)
11 |
12 | public init(title: String? = nil, fontSize: Double? = nil) {
13 | super.init(frame: .zero)
14 |
15 | labelView.stringValue = title ?? ""
16 | labelView.translatesAutoresizingMaskIntoConstraints = false
17 | addSubview(labelView)
18 |
19 | if let fontSize {
20 | labelView.font = .systemFont(ofSize: fontSize)
21 | }
22 |
23 | NSLayoutConstraint.activate([
24 | labelView.centerXAnchor.constraint(equalTo: centerXAnchor),
25 | labelView.centerYAnchor.constraint(equalTo: centerYAnchor),
26 | ])
27 | }
28 |
29 | override public func accessibilityLabel() -> String? {
30 | labelView.stringValue
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitControls/DividerView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DividerView.swift
3 | //
4 | // Created by cyan on 12/17/22.
5 | //
6 |
7 | import AppKit
8 |
9 | /**
10 | Hairline-width divider, it requires manual layout to be correctly rendered.
11 | */
12 | public final class DividerView: NSView {
13 | public var length: Double {
14 | hairlineWidth ? (1.0 / (window?.screen?.backingScaleFactor ?? 1)) : 1
15 | }
16 |
17 | private let color: NSColor
18 | private let hairlineWidth: Bool
19 |
20 | public init(color: NSColor = .separatorColor, hairlineWidth: Bool = true) {
21 | self.color = color
22 | self.hairlineWidth = hairlineWidth
23 | super.init(frame: .zero)
24 | }
25 |
26 | @available(*, unavailable)
27 | required init?(coder: NSCoder) {
28 | fatalError("init(coder:) has not been implemented")
29 | }
30 |
31 | override public func updateLayer() {
32 | layerBackgroundColor = color
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitControls/FocusTrackingView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FocusTrackingView.swift
3 | //
4 | // Created by cyan on 1/7/23.
5 | //
6 |
7 | import AppKit
8 |
9 | /**
10 | Tracks the focus rect to help us present popovers.
11 | */
12 | public final class FocusTrackingView: NSView {
13 | override public func hitTest(_ point: NSPoint) -> NSView? {
14 | nil
15 | }
16 |
17 | override public func isAccessibilityHidden() -> Bool {
18 | true
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitControls/GotoLine/GotoLineWindow.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GotoLineWindow.swift
3 | //
4 | // Created by cyan on 1/17/23.
5 | //
6 |
7 | import AppKit
8 |
9 | public final class GotoLineWindow: NSWindow {
10 | private enum Constants {
11 | // Values are copied from Xcode
12 | static let width: Double = 456
13 | static let height: Double = 48
14 | }
15 |
16 | public init(
17 | relativeTo parentRect: CGRect,
18 | placeholder: String,
19 | accessibilityHelp: String,
20 | iconName: String,
21 | defaultLineNumber: Int? = nil,
22 | handler: @escaping (Int) -> Void
23 | ) {
24 | let rect = CGRect(
25 | x: parentRect.minX + (parentRect.width - Constants.width) * 0.5,
26 | y: parentRect.minY + parentRect.height - Constants.height - 150,
27 | width: Constants.width,
28 | height: Constants.height
29 | )
30 |
31 | super.init(
32 | contentRect: rect,
33 | styleMask: .borderless,
34 | backing: .buffered,
35 | defer: false
36 | )
37 |
38 | self.contentView = GotoLineView(
39 | frame: rect,
40 | placeholder: placeholder,
41 | accessibilityHelp: accessibilityHelp,
42 | iconName: iconName,
43 | defaultLineNumber: defaultLineNumber,
44 | handler: handler
45 | )
46 |
47 | self.isMovableByWindowBackground = true
48 | self.isOpaque = false
49 | self.hasShadow = true
50 | self.backgroundColor = .clear
51 | }
52 |
53 | override public var canBecomeKey: Bool {
54 | true
55 | }
56 |
57 | override public func resignKey() {
58 | orderOut(self)
59 | }
60 |
61 | override public func cancelOperation(_ sender: Any?) {
62 | orderOut(self)
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitControls/LabelView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LabelView.swift
3 | //
4 | // Created by cyan on 12/19/22.
5 | //
6 |
7 | import AppKit
8 |
9 | public final class LabelView: NSTextField {
10 | override init(frame: CGRect) {
11 | super.init(frame: frame)
12 | backgroundColor = .clear
13 | isBordered = false
14 | isEditable = false
15 | }
16 |
17 | @available(*, unavailable)
18 | required init?(coder: NSCoder) {
19 | fatalError("init(coder:) has not been implemented")
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitControls/RoundedNavigateButtons.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorFindButtons.swift
3 | //
4 | // Created by cyan on 12/17/22.
5 | //
6 |
7 | import AppKit
8 |
9 | public final class RoundedNavigateButtons: RoundedButtonGroup {
10 | private enum Constants {
11 | static let chevronLeft = "chevron.left"
12 | static let chevronRight = "chevron.right"
13 | static let iconWidth: Double = 9
14 | static let iconHeight: Double = 9
15 | }
16 |
17 | public init(
18 | leftAction: @escaping (() -> Void),
19 | rightAction: @escaping (() -> Void),
20 | leftAccessibilityLabel: String,
21 | rightAccessibilityLabel: String
22 | ) {
23 | let leftButton = IconOnlyButton(symbolName: Constants.chevronLeft, iconWidth: Constants.iconWidth, iconHeight: Constants.iconHeight, accessibilityLabel: leftAccessibilityLabel)
24 | leftButton.addAction(leftAction)
25 |
26 | let rightButton = IconOnlyButton(symbolName: Constants.chevronRight, iconWidth: Constants.iconWidth, iconHeight: Constants.iconHeight, accessibilityLabel: rightAccessibilityLabel)
27 | rightButton.addAction(rightAction)
28 |
29 | super.init(leftButton: leftButton, rightButton: rightButton)
30 | self.frame = CGRect(x: 0, y: 0, width: 72, height: 0)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/Foundation/Bundle+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Bundle+Extension.swift
3 | //
4 | // Created by cyan on 11/1/23.
5 | //
6 |
7 | import Foundation
8 |
9 | public extension Bundle {
10 | var shortVersionString: String? {
11 | infoDictionary?["CFBundleShortVersionString"] as? String
12 | }
13 |
14 | var userAgent: String {
15 | "MarkEdit/\(shortVersionString ?? "0.0.0")"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/Foundation/Data+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Data+Extension.swift
3 | //
4 | // Created by cyan on 5/29/25.
5 | //
6 |
7 | import Foundation
8 |
9 | public extension Data {
10 | func decodeToDataArray() -> [Data]? {
11 | try? PropertyListDecoder().decode([Data].self, from: self)
12 | }
13 | }
14 |
15 | public extension [Data] {
16 | func encodeToData() -> Data? {
17 | try? PropertyListEncoder().encode(self)
18 | }
19 |
20 | func appendingData(_ data: Data) -> Self {
21 | if contains(data) {
22 | return self
23 | }
24 |
25 | return self + [data]
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/Foundation/NSDataDetector+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSDataDetector+Extension.swift
3 | //
4 | // Created by cyan on 1/4/23.
5 | //
6 |
7 | import Foundation
8 |
9 | public extension NSDataDetector {
10 | static func extractURL(from string: String) -> String? {
11 | let range = NSRange(location: 0, length: string.utf16.count)
12 | let detector = try? Self(types: NSTextCheckingResult.CheckingType.link.rawValue)
13 | return detector?.firstMatch(in: string, range: range)?.url?.absoluteString
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/Foundation/NSDocument+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSDocument+Extension.swift
3 | //
4 | // Created by cyan on 1/21/23.
5 | //
6 |
7 | import AppKit
8 |
9 | public extension NSDocument {
10 | var folderURL: URL? {
11 | fileURL?.deletingLastPathComponent()
12 | }
13 |
14 | func markContentDirty(_ isDirty: Bool) {
15 | // The undo stack is implemented in CoreEditor entirely,
16 | // there are only two meaningful change count values: 0 (saved) or 1 (dirty).
17 | updateChangeCount(isDirty ? .changeDone : .changeCleared)
18 | }
19 |
20 | func otherVersions(olderThanDays days: Int) -> [NSFileVersion] {
21 | guard let url = fileURL else {
22 | return []
23 | }
24 |
25 | guard let cutoffDate = Calendar.current.date(byAdding: .day, value: -days, to: .now) else {
26 | return []
27 | }
28 |
29 | let all = NSFileVersion.otherVersionsOfItem(at: url) ?? []
30 | return days == 0 ? all : all.filter {
31 | ($0.modificationDate ?? .distantFuture) < cutoffDate
32 | }
33 | }
34 |
35 | func otherVersions(olderThanMaxLength maxLength: Int) -> [NSFileVersion] {
36 | guard let url = fileURL else {
37 | return []
38 | }
39 |
40 | let all = NSFileVersion.otherVersionsOfItem(at: url) ?? []
41 | let sorted = all.newestToOldest(throttle: false)
42 | return maxLength > (sorted.count - 1) ? [] : Array(sorted.suffix(from: maxLength))
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/Foundation/NSEvent+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSEvent+Extension.swift
3 | //
4 | //
5 | // Created by cyan on 3/8/24.
6 | //
7 |
8 | import AppKit
9 |
10 | public extension NSEvent {
11 | var deviceIndependentFlags: NSEvent.ModifierFlags {
12 | modifierFlags.intersection(.deviceIndependentFlagsMask)
13 | }
14 | }
15 |
16 | public extension NSEvent.ModifierFlags {
17 | private static let mapping: [String: NSEvent.ModifierFlags] = [
18 | "Shift": .shift,
19 | "Control": .control,
20 | "Option": .option,
21 | "Command": .command,
22 | ]
23 |
24 | init(stringValues: [String]) {
25 | var modifiers: NSEvent.ModifierFlags = []
26 | stringValues.forEach {
27 | if let modifier = Self.mapping[$0] {
28 | modifiers.insert(modifier)
29 | }
30 | }
31 |
32 | self = modifiers
33 | }
34 | }
35 |
36 | // https://gist.github.com/eegrok/949034
37 | public extension UInt16 {
38 | static let kVK_ANSI_F: Self = 0x03
39 | static let kVK_ANSI_I: Self = 0x22
40 | static let kVK_Return: Self = 0x24
41 | static let kVK_Tab: Self = 0x30
42 | static let kVK_Space: Self = 0x31
43 | static let kVK_Delete: Self = 0x33
44 | static let kVK_Option: Self = 0x3A
45 | static let kVK_RightOption: Self = 0x3D
46 | static let kVK_F3: Self = 0x63
47 | static let kVK_LeftArrow: Self = 0x7B
48 | static let kVK_RightArrow: Self = 0x7C
49 | static let kVK_DownArrow: Self = 0x7D
50 | static let kVK_UpArrow: Self = 0x7E
51 | }
52 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/Foundation/NSFileVersion+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSFileVersion+Extension.swift
3 | //
4 | // Created by cyan on 10/16/24.
5 | //
6 |
7 | import AppKit
8 |
9 | public extension NSFileVersion {
10 | var needsDownloading: Bool {
11 | !hasLocalContents && !FileManager.default.fileExists(atPath: url.path)
12 | }
13 |
14 | @MainActor
15 | func fetchLocalContents(
16 | startedDownloading: @Sendable @MainActor @escaping () -> Void,
17 | contentsFetched: @Sendable @MainActor @escaping () -> Void
18 | ) {
19 | guard needsDownloading else {
20 | return contentsFetched()
21 | }
22 |
23 | startedDownloading()
24 | DispatchQueue.global(qos: .userInitiated).async {
25 | let coordinator = NSFileCoordinator()
26 | coordinator.coordinate(readingItemAt: self.url, error: nil) { _ in
27 | DispatchQueue.main.async(execute: contentsFetched)
28 | }
29 | }
30 | }
31 | }
32 |
33 | public extension [NSFileVersion] {
34 | func newestToOldest(throttle: Bool = true) -> [Self.Element] {
35 | let comparator: (Self.Element, Self.Element) -> Bool = { lhs, rhs in
36 | (lhs.modificationDate ?? .distantPast) > (rhs.modificationDate ?? .distantPast)
37 | }
38 |
39 | guard throttle else {
40 | return sorted(by: comparator)
41 | }
42 |
43 | var seen = Set()
44 | return filter {
45 | // If multiple versions are created within one second, only keep the first one
46 | let id = Int(($0.modificationDate ?? .distantPast).timeIntervalSinceReferenceDate)
47 | return seen.insert(id).inserted
48 | }
49 | .sorted(by: comparator)
50 | }
51 | }
52 |
53 | extension NSFileVersion: @unchecked @retroactive Sendable {}
54 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/Foundation/NSObject+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSObject+Extension.swift
3 | //
4 | // Created by cyan on 10/25/24.
5 | //
6 |
7 | import Foundation
8 |
9 | public extension NSObject {
10 | /**
11 | Private accessibility bundle class, used to work around performance issues.
12 | */
13 | static var axbbmClass: AnyClass? {
14 | // Joined as: /System/Library/PrivateFrameworks/AccessibilityBundles.framework
15 | let path = [
16 | "",
17 | "System",
18 | "Library",
19 | "PrivateFrameworks",
20 | "AccessibilityBundles.framework",
21 | ].joined(separator: "/")
22 |
23 | Bundle(path: path)?.load()
24 | return NSClassFromString("AXBBundleManager")
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/Foundation/NSSavePanel+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSSavePanel+Extension.swift
3 | //
4 | // Created by cyan on 12/22/24.
5 | //
6 |
7 | import AppKit
8 | import UniformTypeIdentifiers
9 |
10 | public extension NSSavePanel {
11 | func enforceUniformType(_ type: UTType) {
12 | let otherFileTypesWereAllowed = allowsOtherFileTypes
13 | allowsOtherFileTypes = false // Must turn this off temporarily to enforce the file type
14 | allowedContentTypes = [type]
15 |
16 | DispatchQueue.main.async {
17 | self.allowsOtherFileTypes = otherFileTypesWereAllowed
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/Foundation/ProcessInfo+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProcessInfo+Extension.swift
3 | //
4 | // Created by cyan on 10/13/24.
5 | //
6 |
7 | import AppKit
8 |
9 | public extension ProcessInfo {
10 | var semanticOSVer: String {
11 | let version = operatingSystemVersion
12 | return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
13 | }
14 |
15 | var userAgent: String {
16 | "macOS/\(semanticOSVer)"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/Foundation/URLComponents+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URLComponents+Extension.swift
3 | //
4 | // Created by cyan on 1/24/25.
5 | //
6 |
7 | import Foundation
8 |
9 | public extension URLComponents {
10 | var queryDict: [String: String]? {
11 | queryItems?.reduce(into: [:]) { result, item in
12 | result[item.name] = item.value
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/UI/NSAnimationContext+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSAnimationContext+Extension.swift
3 | //
4 | // Created by cyan on 12/16/22.
5 | //
6 |
7 | import AppKit
8 |
9 | public extension NSAnimationContext {
10 | static func runAnimationGroup(duration: TimeInterval, changes: (NSAnimationContext) -> Void, completionHandler: (() -> Void)? = nil) {
11 | runAnimationGroup({ context in
12 | context.duration = duration
13 | changes(context)
14 | }, completionHandler: completionHandler)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/UI/NSAppearance+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSAppearance+Extension.swift
3 | //
4 | // Created by cyan on 1/24/23.
5 | //
6 |
7 | import AppKit
8 |
9 | public extension NSAppearance {
10 | var isDarkMode: Bool {
11 | switch name {
12 | case .darkAqua, .vibrantDark, .accessibilityHighContrastDarkAqua, .accessibilityHighContrastVibrantDark:
13 | return true
14 | default:
15 | return false
16 | }
17 | }
18 |
19 | func resolvedName(isDarkMode: Bool) -> NSAppearance.Name {
20 | switch name {
21 | case .aqua, .darkAqua:
22 | // Aqua
23 | return isDarkMode ? .darkAqua : .aqua
24 | case .vibrantLight, .vibrantDark:
25 | // Vibrant
26 | return isDarkMode ? .vibrantDark : .vibrantLight
27 | case .accessibilityHighContrastAqua, .accessibilityHighContrastDarkAqua:
28 | // High contrast
29 | return isDarkMode ? .accessibilityHighContrastDarkAqua : .accessibilityHighContrastAqua
30 | case .accessibilityHighContrastVibrantLight, .accessibilityHighContrastVibrantDark:
31 | // High contrast vibrant
32 | return isDarkMode ? .accessibilityHighContrastVibrantDark : .accessibilityHighContrastVibrantLight
33 | default:
34 | return .aqua
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/UI/NSControl+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSControl+Extension.swift
3 | //
4 | // Created by cyan on 1/3/23.
5 | //
6 |
7 | import AppKit
8 |
9 | /**
10 | Closure-based handlers to replace target-action.
11 | */
12 | @MainActor
13 | public protocol ClosureActionable: AnyObject {
14 | var target: AnyObject? { get set }
15 | var action: Selector? { get set }
16 | }
17 |
18 | public extension ClosureActionable {
19 | func addAction(_ action: @escaping () -> Void) {
20 | let target = Handler(action)
21 | objc_setAssociatedObject(self, UUID().uuidString, target, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN)
22 |
23 | self.target = target
24 | self.action = #selector(Handler.invoke)
25 | }
26 | }
27 |
28 | extension NSButton: ClosureActionable {}
29 | extension NSMenuItem: ClosureActionable {}
30 | extension NSToolbarItem: ClosureActionable {}
31 |
32 | // MARK: - Private
33 |
34 | private class Handler: NSObject {
35 | private let action: () -> Void
36 |
37 | init(_ action: @escaping () -> Void) {
38 | self.action = action
39 | }
40 |
41 | @objc func invoke() {
42 | action()
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/UI/NSImage+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSImage+Extension.swift
3 | //
4 | // Created by cyan on 1/15/23.
5 | //
6 |
7 | import AppKit
8 |
9 | public extension NSImage {
10 | static func with(
11 | symbolName: String,
12 | pointSize: Double,
13 | weight: NSFont.Weight = .regular,
14 | accessibilityLabel: String? = nil
15 | ) -> NSImage {
16 | let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel)
17 | let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: weight)
18 |
19 | guard let image = image?.withSymbolConfiguration(config) else {
20 | assertionFailure("Failed to create image with symbol \"\(symbolName)\"")
21 | return NSImage()
22 | }
23 |
24 | return image
25 | }
26 |
27 | func resized(with size: CGSize) -> NSImage {
28 | let frame = CGRect(x: 0, y: 0, width: size.width, height: size.height)
29 | guard let representation = bestRepresentation(for: frame, context: nil, hints: nil) else {
30 | return self
31 | }
32 |
33 | let image = NSImage(size: size, flipped: false) { _ in
34 | representation.draw(in: frame)
35 | }
36 |
37 | return image
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/UI/NSMenu+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSMenu+Extension.swift
3 | //
4 | // Created by cyan on 12/26/22.
5 | //
6 |
7 | import AppKit
8 |
9 | public extension NSMenu {
10 | var superMenuItem: NSMenuItem? {
11 | supermenu?.items.first { $0.submenu === self }
12 | }
13 |
14 | var copiedMenu: NSMenu? {
15 | copy() as? NSMenu
16 | }
17 |
18 | @discardableResult
19 | func addItem(withTitle string: String, action selector: Selector? = nil) -> NSMenuItem {
20 | addItem(withTitle: string, action: selector, keyEquivalent: "")
21 | }
22 |
23 | @MainActor
24 | @discardableResult
25 | func addItem(withTitle string: String, action: @escaping () -> Void) -> NSMenuItem {
26 | let item = addItem(withTitle: string, action: nil)
27 | item.addAction(action)
28 | return item
29 | }
30 |
31 | /// Force an update, the .update() method doesn't work reliably.
32 | func reloadItems() {
33 | let item = NSMenuItem.separator()
34 | addItem(item)
35 | removeItem(item)
36 | }
37 |
38 | func isDescendantOf(menu: NSMenu?) -> Bool {
39 | guard let menu else {
40 | return false
41 | }
42 |
43 | var node: NSMenu? = self
44 | while node != nil {
45 | if node === menu {
46 | return true
47 | }
48 |
49 | node = node?.supermenu
50 | }
51 |
52 | return false
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/UI/NSMenuItem+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSMenuItem+Extension.swift
3 | //
4 | // Created by cyan on 12/25/22.
5 | //
6 |
7 | import AppKit
8 |
9 | public extension NSMenuItem {
10 | convenience init(title: String) {
11 | self.init(title: title, action: nil, keyEquivalent: "")
12 | }
13 |
14 | var copiedItem: NSMenuItem? {
15 | copy() as? NSMenuItem
16 | }
17 |
18 | func setOn(_ on: Bool) {
19 | state = on ? .on : .off
20 | }
21 |
22 | func toggle() {
23 | state.toggle()
24 | }
25 |
26 | /**
27 | Enable or disable an item, recursively if it contains a submenu.
28 |
29 | This is useful for disabling a menu while still allowing its items to be viewed.
30 | */
31 | func setEnabledRecursively(isEnabled: Bool) {
32 | if let submenu {
33 | submenu.autoenablesItems = false
34 | submenu.items.forEach {
35 | $0.setEnabledRecursively(isEnabled: isEnabled)
36 | }
37 | } else {
38 | self.isEnabled = isEnabled && target != nil && action != nil
39 | }
40 | }
41 | }
42 |
43 | extension NSControl.StateValue {
44 | mutating func toggle() {
45 | self = self == .on ? .off : .on
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/UI/NSScrollView+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSScrollView+Extension.swift
3 | //
4 | // Created by cyan on 10/17/24.
5 | //
6 |
7 | import AppKit
8 |
9 | public extension NSScrollView {
10 | var textView: NSTextView? {
11 | documentView as? NSTextView
12 | }
13 |
14 | func scrollTextViewDown() {
15 | textView?.scrollPageDown(nil)
16 | }
17 |
18 | func scrollTextViewUp() {
19 | textView?.scrollPageUp(nil)
20 | }
21 |
22 | func setContentOffset(_ offset: CGPoint) {
23 | contentView.scroll(to: offset)
24 | }
25 |
26 | func setAttributedText(_ text: NSAttributedString) {
27 | textView?.textStorage?.setAttributedString(text)
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/UI/NSSearchField+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSSearchField+Extension.swift
3 | //
4 | // Created by cyan on 12/19/22.
5 | //
6 |
7 | import AppKit
8 |
9 | public extension NSSearchField {
10 | var clipView: NSView? {
11 | // _NSKeyboardFocusClipView
12 | subviews.first { $0.className.hasSuffix("FocusClipView") }
13 | }
14 |
15 | func addToRecents(searchTerm: String) {
16 | guard !searchTerm.isEmpty else {
17 | return
18 | }
19 |
20 | let recents = recentSearches.filter { $0 != searchTerm }
21 | recentSearches = [searchTerm] + recents
22 | }
23 |
24 | func setIconTintColor(_ tintColor: NSColor?) {
25 | guard let buttonCell = (cell as? NSSearchFieldCell)?.searchButtonCell else {
26 | return
27 | }
28 |
29 | guard let iconImage = buttonCell.image else {
30 | return
31 | }
32 |
33 | guard iconImage.responds(to: sel_getUid("_setTintColor:")) else {
34 | return
35 | }
36 |
37 | iconImage.perform(sel_getUid("_setTintColor:"), with: tintColor)
38 | buttonCell.image = iconImage
39 | needsDisplay = true
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/UI/NSTextField+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSTextField+Extension.swift
3 | //
4 | // Created by cyan on 12/28/22.
5 | //
6 |
7 | import AppKit
8 |
9 | public extension NSTextField {
10 | func startEditing(in window: NSWindow?, alwaysRefocus: Bool = false) {
11 | guard alwaysRefocus || !isFirstResponder(in: window) else {
12 | return
13 | }
14 |
15 | window?.makeFirstResponder(self)
16 | }
17 |
18 | func selectAll() {
19 | currentEditor()?.selectAll(self)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/AppKitExtensions/UI/NSViewController+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSViewController+Extension.swift
3 | //
4 | // Created by cyan on 1/8/23.
5 | //
6 |
7 | import AppKit
8 |
9 | public extension NSViewController {
10 | var popover: NSPopover? {
11 | view.window?.value(forKey: "_popover") as? NSPopover
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/FileVersion/FileVersionLocalizable.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FileVersionLocalizable.swift
3 | //
4 | // Created by cyan on 10/17/24.
5 | //
6 |
7 | import Foundation
8 |
9 | public struct FileVersionLocalizable: Sendable {
10 | let previous: String
11 | let next: String
12 | let cancel: String
13 | let revertTitle: String
14 | let modeTitles: [String]
15 |
16 | public init(
17 | previous: String,
18 | next: String,
19 | cancel: String,
20 | revertTitle: String,
21 | modeTitles: [String]
22 | ) {
23 | self.previous = previous
24 | self.next = next
25 | self.cancel = cancel
26 | self.revertTitle = revertTitle
27 | self.modeTitles = modeTitles
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/FileVersion/Internal/NSButton+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSButton+Extension.swift
3 | //
4 | // Created by cyan on 10/17/24.
5 | //
6 |
7 | import AppKit
8 |
9 | extension NSButton {
10 | func setTitle(_ title: String, font: NSFont = .systemFont(ofSize: 12)) {
11 | attributedTitle = NSAttributedString(
12 | string: title,
13 | attributes: [.font: font]
14 | )
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/FileVersion/Internal/NSColor+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSColor+Extension.swift
3 | //
4 | // Created by cyan on 10/17/24.
5 | //
6 |
7 | import AppKit
8 |
9 | extension NSColor {
10 | static let addedText: NSColor = .theme(lightHexCode: 0x007757, darkHexCode: 0x3fb950)
11 | static let addedBackground: NSColor = .theme(lightHexCode: 0xe8fcf3, darkHexCode: 0x14261f)
12 | static let removedText: NSColor = .theme(lightHexCode: 0xc71f24, darkHexCode: 0xf85149)
13 | static let removedBackground: NSColor = .theme(lightHexCode: 0xffebeb, darkHexCode: 0x311b1f)
14 | }
15 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/FontPicker/Extensions/Notification+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Notification+Extension.swift
3 | //
4 | // Created by cyan on 1/30/23.
5 | //
6 |
7 | import Foundation
8 |
9 | public extension Notification.Name {
10 | static let fontSizeChanged = Self("fontSizeChanged")
11 | }
12 |
13 | extension NotificationCenter {
14 | var fontSizePublisher: NotificationCenter.Publisher {
15 | publisher(for: .fontSizeChanged)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/FontPicker/FontPickerHandlers.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FontPickerHandlers.swift
3 | //
4 | // Created by cyan on 1/30/23.
5 | //
6 |
7 | import Foundation
8 |
9 | public struct FontPickerHandlers {
10 | let fontStyleDidChange: (FontStyle) -> Void
11 | let fontSizeDidChange: (Double) -> Void
12 |
13 | public init(fontStyleDidChange: @escaping (FontStyle) -> Void, fontSizeDidChange: @escaping (Double) -> Void) {
14 | self.fontStyleDidChange = fontStyleDidChange
15 | self.fontSizeDidChange = fontSizeDidChange
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/FontPicker/Internal/FontManagerDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FontManagerDelegate.swift
3 | //
4 | // Created by cyan on 1/30/23.
5 | //
6 |
7 | import AppKit
8 |
9 | /**
10 | Shared delegate to handle font changes sent by NSFontManager.
11 | */
12 | @MainActor
13 | final class FontManagerDelegate {
14 | static let shared = FontManagerDelegate()
15 | var fontDidChange: ((NSFont) -> Void)?
16 |
17 | @objc func changeFont(_ sender: NSFontManager?) {
18 | guard let newFont = sender?.convert(.systemFont(ofSize: NSFont.systemFontSize)) else {
19 | return
20 | }
21 |
22 | fontDidChange?(newFont)
23 | }
24 |
25 | // MARK: - Private
26 |
27 | private init() {}
28 | }
29 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/Previewer/Resources/katex.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | katex
7 |
8 |
19 |
20 |
21 |
22 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/Previewer/Resources/mermaid.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | mermaid
7 |
18 |
19 |
20 |
21 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/Previewer/Resources/table.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | table
7 |
49 |
50 |
51 |
52 |
53 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/Previewer/Unchecked.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Unchecked.swift
3 | //
4 | // Created by cyan on 4/17/24.
5 | //
6 |
7 | import WebKit
8 |
9 | extension WKScriptMessage: @unchecked @retroactive Sendable {}
10 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/SettingsUI/Extensions/NSCursor+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSCursor+Extension.swift
3 | //
4 | // Created by cyan on 2/15/23.
5 | //
6 |
7 | import AppKit
8 |
9 | /**
10 | Calling deprecated methods is usually considered dangerous and is not recommended,
11 | use this protocol to suppress the warning while keeping compile-time safety.
12 | */
13 | protocol NSCursorDeprecated: AnyObject {
14 | /**
15 | Apple says this method is "unused and should not be called", Apple lied.
16 |
17 | This method does have effects and it fixes some weird cursor style issues.
18 | */
19 | func setOnMouseEntered(_ flag: Bool)
20 | }
21 |
22 | extension NSCursor: NSCursorDeprecated {}
23 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/SettingsUI/Extensions/View+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // View+Extension.swift
3 | //
4 | // Created by cyan on 1/26/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | /**
10 | View extension for form building.
11 | */
12 | public extension View {
13 | func formLabel(alignment: VerticalAlignment = .center, _ text: String) -> some View {
14 | formLabel(alignment: alignment, Text(text))
15 | }
16 |
17 | func formLabel(alignment: VerticalAlignment = .center, _ content: V) -> some View {
18 | HStack(alignment: alignment) {
19 | content
20 | self.frame(maxWidth: .infinity, alignment: .leading)
21 | .alignmentGuide(.controlAlignment) { $0[.leading] }
22 | }
23 | .alignmentGuide(.leading) { $0[.controlAlignment] }
24 | }
25 |
26 | func formMenuPicker(minWidth: Double = 280) -> some View {
27 | pickerStyle(.menu).frame(minWidth: minWidth)
28 | }
29 |
30 | func formHorizontalRadio() -> some View {
31 | pickerStyle(.radioGroup).horizontalRadioGroupLayout()
32 | }
33 |
34 | func formDescription(fontSize: Double = 12) -> some View {
35 | font(.system(size: fontSize)).foregroundStyle(.secondary)
36 | }
37 | }
38 |
39 | // MARK: - Private
40 |
41 | private extension HorizontalAlignment {
42 | enum ControlAlignment: AlignmentID {
43 | static func defaultValue(in context: ViewDimensions) -> CGFloat {
44 | return context[HorizontalAlignment.center]
45 | }
46 | }
47 |
48 | static let controlAlignment = HorizontalAlignment(ControlAlignment.self)
49 | }
50 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/SettingsUI/SettingsForm.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsForm.swift
3 | //
4 | // Created by cyan on 1/27/23.
5 | //
6 |
7 | import SwiftUI
8 |
9 | /**
10 | Lightweight form builder for SwiftUI.
11 | */
12 | public struct SettingsForm: View {
13 | // Generally speaking, we should avoid AnyView,
14 | // but here we wanted to erase the type so badly.
15 | public typealias TypedView = AnyView
16 |
17 | @resultBuilder
18 | public enum Builder {
19 | public static func buildBlock(_ sections: any View...) -> [TypedView] {
20 | sections.map { TypedView($0) }
21 | }
22 | }
23 |
24 | private let padding: Double
25 | private let builder: () -> [TypedView]
26 |
27 | public init(padding: Double = 20, @Builder builder: @escaping () -> [TypedView]) {
28 | self.padding = padding
29 | self.builder = builder
30 | }
31 |
32 | public var body: some View {
33 | let sections = builder()
34 | Form {
35 | ForEach(0.. String? {
14 | guard let filePath = fileURL?.path else {
15 | return nil
16 | }
17 |
18 | guard let attributes = try? FileManager.default.attributesOfItem(atPath: filePath) else {
19 | return nil
20 | }
21 |
22 | guard let fileSize = attributes[.size] as? Int64 else {
23 | return nil
24 | }
25 |
26 | return ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/Statistics/Utilities/ReadTime.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ReadTime.swift
3 | //
4 | // Created by cyan on 8/26/23.
5 | //
6 |
7 | import Foundation
8 |
9 | /**
10 | Utility to estimate time needed for reading.
11 | */
12 | enum ReadTime {
13 | static func estimated(of numberOfWords: Int) -> String? {
14 | let seconds = ceil((Double(numberOfWords) / 225) * 60)
15 | let formatter = DateComponentsFormatter()
16 |
17 | formatter.unitsStyle = .short
18 | formatter.allowedUnits = [.hour, .minute, .second]
19 | formatter.zeroFormattingBehavior = .dropAll
20 | formatter.maximumUnitCount = 2
21 |
22 | return formatter.string(from: seconds)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/MarkEditMac/Modules/Sources/Statistics/Utilities/Tokenizer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Tokenizer.swift
3 | //
4 | // Created by cyan on 8/26/23.
5 | //
6 |
7 | import Foundation
8 | import NaturalLanguage
9 |
10 | /**
11 | NLP based tokenizer to count words, sentences, etc.
12 | */
13 | enum Tokenizer {
14 | static func count(text: String, unit: NLTokenUnit) -> Int {
15 | let tokenizer = NLTokenizer(unit: unit)
16 | tokenizer.string = text
17 |
18 | let tokens = tokenizer.tokens(for: text.startIndex.. 1) ? defaultTitle : text
21 |
22 | // Try our best to guess from selection and clipboard
23 | bridge.format.insertHyperLink(
24 | title: prefersURL ? defaultTitle : title,
25 | url: prefersURL ? text : (await NSPasteboard.general.url() ?? "https://"),
26 | prefix: prefix
27 | )
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Editor/Controllers/EditorViewController+LineEndings.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorViewController+LineEndings.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 1/28/23.
6 | //
7 |
8 | import AppKit
9 | import MarkEditKit
10 |
11 | extension EditorViewController {
12 | @IBAction func setLineEndings(_ sender: Any?) {
13 | guard let item = sender as? NSMenuItem else {
14 | return Logger.assertFail("Invalid sender")
15 | }
16 |
17 | guard let lineEndings = LineEndings(rawValue: item.tag) else {
18 | return Logger.assertFail("Invalid lineEndings: \(item.tag)")
19 | }
20 |
21 | document?.save(sender)
22 | bridge.lineEndings.setLineEndings(lineEndings: lineEndings)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Editor/Controllers/EditorViewController+Pandoc.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorViewController+Pandoc.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 1/21/23.
6 | //
7 |
8 | import AppKit
9 | import MarkEditKit
10 |
11 | extension EditorViewController {
12 | /// https://pandoc.org/
13 | func copyPandocCommand(document: EditorDocument, format: String) {
14 | guard let inputURL = document.textFileURL else {
15 | Logger.log(.error, "Failed to copy pandoc command")
16 | return
17 | }
18 |
19 | let configPath = AppCustomization.pandoc.fileURL.escapedFilePath
20 | let outputPath = inputURL.replacingPathExtension(format).escapedFilePath
21 |
22 | let command = [
23 | "pandoc",
24 | inputURL.escapedFilePath,
25 | "-t \(format)",
26 | "-d \(configPath)",
27 | "-o \(outputPath)",
28 | "&& open -R \(outputPath)",
29 | ].joined(separator: " ")
30 |
31 | NSPasteboard.general.overwrite(string: command)
32 | NSWorkspace.shared.openTerminal()
33 | }
34 | }
35 |
36 | // MARK: - Private
37 |
38 | private extension URL {
39 | var escapedFilePath: String {
40 | path.replacingOccurrences(of: " ", with: "\\ ")
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Editor/Controllers/EditorViewController+Preview.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorViewController+Preview.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 1/7/23.
6 | //
7 |
8 | import AppKit
9 | import Previewer
10 | import MarkEditKit
11 |
12 | extension EditorViewController {
13 | func showPreview(code: String, type: PreviewType, rect: CGRect) {
14 | if removePresentedPopovers(contentClass: Previewer.self) {
15 | return
16 | }
17 |
18 | let previewer = Previewer(code: code, type: type)
19 | presentAsPopover(contentViewController: previewer, rect: rect)
20 | }
21 | }
22 |
23 | // MARK: - Private
24 |
25 | private extension EditorViewController {
26 | func presentAsPopover(contentViewController: Previewer, rect: CGRect) {
27 | if focusTrackingView.superview == nil {
28 | webView.addSubview(focusTrackingView)
29 | }
30 |
31 | // The origin has to be inside the viewport
32 | focusTrackingView.frame = CGRect(
33 | x: max(0, rect.minX),
34 | y: max(0, rect.minY),
35 | width: rect.width,
36 | height: rect.height
37 | )
38 |
39 | present(
40 | contentViewController,
41 | asPopoverRelativeTo: focusTrackingView.bounds,
42 | of: focusTrackingView,
43 | preferredEdge: .maxX,
44 | behavior: .transient
45 | )
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Editor/EditorWindow.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorWindow.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 1/12/23.
6 | //
7 |
8 | import AppKit
9 |
10 | final class EditorWindow: NSWindow {
11 | var toolbarMode: ToolbarMode? {
12 | didSet {
13 | toolbarStyle = toolbarMode == .compact ? .unifiedCompact : .unified
14 | super.toolbar = toolbarMode == .hidden ? nil : cachedToolbar
15 | }
16 | }
17 |
18 | // swiftlint:disable:next discouraged_optional_boolean
19 | var reduceTransparency: Bool? {
20 | didSet {
21 | layoutIfNeeded()
22 | }
23 | }
24 |
25 | var prefersTintedToolbar: Bool = false {
26 | didSet {
27 | layoutIfNeeded()
28 | }
29 | }
30 |
31 | override var toolbar: NSToolbar? {
32 | get {
33 | super.toolbar
34 | }
35 | set {
36 | cachedToolbar = newValue
37 | super.toolbar = toolbarMode == .hidden ? nil : newValue
38 | }
39 | }
40 |
41 | private var cachedToolbar: NSToolbar?
42 |
43 | override func awakeFromNib() {
44 | super.awakeFromNib()
45 | toolbar = NSToolbar() // Required for multi-tab layout
46 | toolbarMode = AppPreferences.Window.toolbarMode
47 | tabbingMode = AppPreferences.Window.tabbingMode
48 | reduceTransparency = AppPreferences.Window.reduceTransparency
49 | }
50 |
51 | override func layoutIfNeeded() {
52 | super.layoutIfNeeded()
53 |
54 | // Slightly change the toolbar effect to match editor better
55 | if let view = toolbarEffectView {
56 | view.alphaValue = prefersTintedToolbar ? 0.3 : 0.7
57 | view.isHidden = reduceTransparency == true
58 |
59 | // Blend the color of contents behind the window
60 | view.blendingMode = .behindWindow
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Editor/Models/EditorMenuItem.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorMenuItem.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 10/5/24.
6 | //
7 |
8 | import Foundation
9 | import MarkEditKit
10 |
11 | /**
12 | User defined menu that will be added to the main menu bar.
13 | */
14 | struct EditorMenuItem: Equatable {
15 | static let uniquePrefix = "userDefinedMenuItem"
16 | static let specialDivider = "extensionsMenuDivider"
17 |
18 | let id: String
19 | let item: WebMenuItem
20 |
21 | static func == (lhs: Self, rhs: Self) -> Bool {
22 | lhs.id == rhs.id
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Editor/Models/EditorReusePool.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorReusePool.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 12/15/22.
6 | //
7 |
8 | import AppKit
9 | import WebKit
10 |
11 | /**
12 | Reuse pool for editors to keep WebViews in memory.
13 | */
14 | @MainActor
15 | final class EditorReusePool {
16 | static let shared = EditorReusePool()
17 | let processPool = WKProcessPool()
18 |
19 | func warmUp() {
20 | if controllerPool.isEmpty {
21 | controllerPool.append(EditorViewController())
22 | }
23 | }
24 |
25 | func dequeueViewController() -> EditorViewController {
26 | if let reusable = (controllerPool.first { $0.view.window == nil }) {
27 | return reusable
28 | }
29 |
30 | let controller = EditorViewController()
31 | if controllerPool.count < 2 {
32 | // The theory here is that loading resources from WKWebViews is expensive,
33 | // we make a pool that always keeps two instances in memory,
34 | // if users open more than two editors, it's expected to be slower.
35 | controllerPool.append(controller)
36 | }
37 |
38 | return controller
39 | }
40 |
41 | /// All editors, whether with or without a visible window.
42 | func viewControllers() -> [EditorViewController] {
43 | controllerPool + {
44 | let windows = NSApplication.shared.windows.compactMap { $0 as? EditorWindow }
45 | let controllers = windows.compactMap { $0.contentViewController as? EditorViewController }
46 | return controllers.filter { !controllerPool.contains($0) }
47 | }()
48 | }
49 |
50 | // MARK: - Private
51 |
52 | private var controllerPool = [EditorViewController]()
53 |
54 | private init() {}
55 | }
56 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Editor/Views/EditorPanelView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorPanelView.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 12/27/22.
6 | //
7 |
8 | import AppKit
9 | import AppKitControls
10 |
11 | class EditorPanelView: NSView, BackgroundTheming {
12 | init() {
13 | super.init(frame: .zero)
14 | }
15 |
16 | @available(*, unavailable)
17 | required init?(coder: NSCoder) {
18 | fatalError("init(coder:) has not been implemented")
19 | }
20 |
21 | override func resetCursorRects() {
22 | addCursorRect(bounds, cursor: .arrow)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Editor/Views/EditorTextInput.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorTextInput.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 8/23/23.
6 | //
7 |
8 | import AppKit
9 |
10 | enum EditorTextAction {
11 | case undo
12 | case redo
13 | case selectAll
14 | }
15 |
16 | /**
17 | Abstraction of text input actions for `NSText` and `EditorWebView`.
18 |
19 | For `NSText`, extend AppKit to provide an implementation.
20 |
21 | For `EditorWebView`, delegate actions to controller that has access to the bridge.
22 | */
23 | @MainActor
24 | protocol EditorTextInput {
25 | func performTextAction(_ action: EditorTextAction, sender: Any?)
26 | }
27 |
28 | extension NSText: EditorTextInput {
29 | func performTextAction(_ action: EditorTextAction, sender: Any?) {
30 | switch action {
31 | case .undo:
32 | undoManager?.undo()
33 | case .redo:
34 | undoManager?.redo()
35 | case .selectAll:
36 | selectAll(sender)
37 | }
38 | }
39 | }
40 |
41 | extension EditorWebView: EditorTextInput {
42 | func performTextAction(_ action: EditorTextAction, sender: Any?) {
43 | actionDelegate?.editorWebView(self, didPerform: action, sender: sender)
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Extensions/NSApplication+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSApplication+Extension.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 12/13/22.
6 | //
7 |
8 | import AppKit
9 | import MarkEditKit
10 |
11 | extension NSApplication {
12 | var appDelegate: AppDelegate? {
13 | guard let delegate = delegate as? AppDelegate else {
14 | Logger.assert(delegate != nil, "Expected to get AppDelegate")
15 | return nil
16 | }
17 |
18 | return delegate
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Extensions/NSDocumentController+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSDocumentController+Extension.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 10/14/24.
6 | //
7 |
8 | import AppKit
9 | import MarkEditKit
10 |
11 | extension NSDocumentController {
12 | var hasDirtyDocuments: Bool {
13 | !dirtyDocuments.isEmpty
14 | }
15 |
16 | func saveDirtyDocuments(userInitiated: Bool = false) async {
17 | await withTaskGroup(of: Void.self) { group in
18 | for document in dirtyDocuments {
19 | group.addTask {
20 | await document.waitUntilSaveCompleted(userInitiated: userInitiated)
21 | }
22 | }
23 | }
24 | }
25 |
26 | /**
27 | Force the override of the last root directory for NSOpenPanel and NSSavePanel.
28 | */
29 | func setOpenPanelDirectory(_ directory: String) {
30 | UserDefaults.standard.setValue(directory, forKey: NSNavLastRootDirectory)
31 | }
32 | }
33 |
34 | // MARK: - Private
35 |
36 | private extension NSDocumentController {
37 | var dirtyDocuments: [EditorDocument] {
38 | NSDocumentController.shared.documents.compactMap {
39 | guard let document = $0 as? EditorDocument, document.isContentDirty else {
40 | return nil
41 | }
42 |
43 | return document
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Extensions/NSMenu+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSMenu+Extension.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 5/26/25.
6 | //
7 |
8 | import AppKit
9 |
10 | extension NSMenu {
11 | /**
12 | Hook this method to work around the **Populating a menu window that is already visible** crash.
13 | */
14 | static let swizzleIsUpdatedExcludingContentTypesOnce: () = {
15 | NSMenu.exchangeInstanceMethods(
16 | originalSelector: sel_getUid("_isUpdatedExcludingContentTypes:"),
17 | swizzledSelector: #selector(swizzled_isUpdatedExcludingContentTypes(_:))
18 | )
19 | }()
20 |
21 | /**
22 | The swizzled method handles differently when `needsHack` is flagged true.
23 | */
24 | var needsHack: Bool {
25 | get {
26 | (objc_getAssociatedObject(self, &AssociatedObjects.needsHack) as? Bool) ?? false
27 | }
28 | set {
29 | objc_setAssociatedObject(
30 | self,
31 | &AssociatedObjects.needsHack,
32 | newValue,
33 | objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC
34 | )
35 | }
36 | }
37 | }
38 |
39 | // MARK: - Private
40 |
41 | private extension NSMenu {
42 | enum AssociatedObjects {
43 | static var needsHack: UInt8 = 0
44 | }
45 |
46 | @objc func swizzled_isUpdatedExcludingContentTypes(_ contentTypes: Int) -> Bool {
47 | if needsHack {
48 | // The original implementation contains an invalid assertion that causes a crash.
49 | // Based on testing, it would return false anyway, so we simply return false to bypass the assertion.
50 | return false
51 | }
52 |
53 | return self.swizzled_isUpdatedExcludingContentTypes(contentTypes)
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Extensions/NSPopover+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSPopover+Extension.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 8/22/24.
6 | //
7 |
8 | import AppKit
9 |
10 | extension NSPopover {
11 | var sourceView: NSView? {
12 | value(forKey: "positioningView") as? NSView
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Main/AppDocumentController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDocumentController.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 10/14/24.
6 | //
7 |
8 | import AppKit
9 | import MarkEditKit
10 |
11 | /**
12 | Subclass of `NSDocumentController` to allow customizations.
13 |
14 | NSDocumentController.shared will be an instance of `AppDocumentController` at runtime.
15 | */
16 | final class AppDocumentController: NSDocumentController {
17 | static var suggestedTextEncoding: EditorTextEncoding?
18 | static var suggestedFilename: String?
19 |
20 | override func beginOpenPanel(_ openPanel: NSOpenPanel, forTypes inTypes: [String]?) async -> Int {
21 | if let defaultDirectory = AppRuntimeConfig.defaultOpenDirectory {
22 | setOpenPanelDirectory(defaultDirectory)
23 | }
24 |
25 | openPanel.accessoryView = EditorSaveOptionsView.wrapper(for: .textEncoding) { result in
26 | if case .textEncoding(let value) = result {
27 | Self.suggestedTextEncoding = value
28 | }
29 | }
30 |
31 | Self.suggestedTextEncoding = nil
32 | return await super.beginOpenPanel(openPanel, forTypes: inTypes)
33 | }
34 |
35 | override func saveAllDocuments(_ sender: Any?) {
36 | // The default implementation doesn't work
37 | documents.forEach { $0.save(sender) }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/ObjC/MarkEditMac-Bridging-Header.h:
--------------------------------------------------------------------------------
1 | //
2 | // Use this file to import your target's public headers that you would like to expose to Swift.
3 | //
4 |
5 | #import "MarkEditWritingTools.h"
6 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/ObjC/MarkEditWritingTools.h:
--------------------------------------------------------------------------------
1 | //
2 | // MarkEditWritingTools.h
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 8/14/24.
6 | //
7 |
8 | #import
9 |
10 | NS_ASSUME_NONNULL_BEGIN
11 |
12 | typedef NS_ENUM(long long, WritingTool) {
13 | WritingToolPanel = 0,
14 | WritingToolProofread = 1,
15 | WritingToolRewrite = 2,
16 | WritingToolMakeFriendly = 11,
17 | WritingToolMakeProfessional = 12,
18 | WritingToolMakeConcise = 13,
19 | WritingToolSummarize = 21,
20 | WritingToolCreateKeyPoints = 22,
21 | WritingToolMakeList = 23,
22 | WritingToolMakeTable = 24,
23 | WritingToolCompose = 201,
24 | } API_AVAILABLE(macos(15.1));
25 |
26 | API_AVAILABLE(macos(15.1))
27 | @interface MarkEditWritingTools : NSObject
28 |
29 | @property (class, readonly, nonatomic) BOOL isAvailable;
30 | @property (class, readonly, nonatomic) WritingTool requestedTool;
31 | @property (class, readonly, nonatomic, nullable) NSImage *affordanceIcon;
32 |
33 | + (void)showTool:(WritingTool)tool
34 | rect:(CGRect)rect
35 | view:(NSView *)view
36 | delegate:(id)delegate;
37 |
38 | + (BOOL)shouldReselectWithItem:(nullable id)item;
39 |
40 | + (BOOL)shouldReselectWithTool:(WritingTool)tool;
41 |
42 | @end
43 |
44 | NS_ASSUME_NONNULL_END
45 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Panels/Find/EditorFindPanel+Delegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorFindPanel+Delegate.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 12/25/22.
6 | //
7 |
8 | import AppKit
9 |
10 | // MARK: - NSSearchFieldDelegate
11 |
12 | extension EditorFindPanel: NSSearchFieldDelegate {
13 | func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
14 | switch (selector, mode) {
15 | case (#selector(insertTab(_:)), .replace):
16 | // Focus on the replace panel
17 | delegate?.editorFindPanelDidPressTabKey(self, isBacktab: false)
18 | return true
19 | case (#selector(insertBacktab(_:)), _):
20 | delegate?.editorFindPanelDidPressTabKey(self, isBacktab: true)
21 | return true
22 | case (#selector(insertNewline(_:)), _):
23 | // Navigate between search results
24 | if NSApplication.shared.shiftKeyIsPressed {
25 | delegate?.editorFindPanelDidClickPrevious(self)
26 | } else {
27 | delegate?.editorFindPanelDidClickNext(self)
28 | }
29 | return true
30 | case (#selector(cancelOperation(_:)), _):
31 | delegate?.editorFindPanel(self, modeDidChange: .hidden)
32 | return true
33 | default:
34 | return false
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Panels/Replace/EditorReplaceButtons.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorReplaceButtons.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 12/27/22.
6 | //
7 |
8 | import AppKit
9 | import AppKitControls
10 |
11 | final class EditorReplaceButtons: RoundedButtonGroup {
12 | private enum Constants {
13 | static let fontSize: Double = 12
14 | }
15 |
16 | init(leftAction: @escaping (() -> Void), rightAction: @escaping (() -> Void)) {
17 | let leftButton = TitleOnlyButton(title: Localized.Search.replace, fontSize: Constants.fontSize)
18 | leftButton.addAction(leftAction)
19 |
20 | let rightButton = TitleOnlyButton(title: Localized.General.all, fontSize: Constants.fontSize)
21 | rightButton.addAction(rightAction)
22 |
23 | super.init(leftButton: leftButton, rightButton: rightButton)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Panels/Replace/EditorReplaceTextField.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditorReplaceTextField.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 8/20/23.
6 | //
7 |
8 | import AppKit
9 | import AppKitControls
10 |
11 | final class EditorReplaceTextField: NSTextField {
12 | private let bezelView = BezelView()
13 |
14 | init() {
15 | super.init(frame: .zero)
16 | usesSingleLineMode = false
17 | bezelStyle = .roundedBezel
18 | placeholderString = Localized.Search.replace
19 | addSubview(bezelView)
20 | }
21 |
22 | @available(*, unavailable)
23 | required init?(coder: NSCoder) {
24 | fatalError("init(coder:) has not been implemented")
25 | }
26 |
27 | override func layout() {
28 | super.layout()
29 | bezelView.frame = bounds
30 | }
31 |
32 | override func draw(_ dirtyRect: NSRect) {
33 | // Ignore the bezel and background color by only drawing interior
34 | cell?.drawInterior(withFrame: bounds, in: self)
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Settings/SettingTabs.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingTabs.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 1/26/23.
6 | //
7 |
8 | import SettingsUI
9 |
10 | extension SettingsTabViewController {
11 | static var editor: Self {
12 | Self(EditorSettingsView(), title: Localized.Settings.editor, icon: Icons.characterCursorIbeam)
13 | }
14 |
15 | static var assistant: Self {
16 | Self(AssistantSettingsView(), title: Localized.Settings.assistant, icon: Icons.wandAndStars)
17 | }
18 |
19 | static var general: Self {
20 | Self(GeneralSettingsView(), title: Localized.Settings.general, icon: Icons.gearshape)
21 | }
22 |
23 | static var window: Self {
24 | Self(WindowSettingsView(), title: Localized.Settings.window, icon: Icons.macwindow)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Shortcuts/Extensions/AppIntent+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppIntent+Extension.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 3/10/23.
6 | //
7 |
8 | import AppIntents
9 |
10 | extension AppIntent {
11 | /// Returns the current active editor, or nil if not applicable.
12 | @MainActor var currentEditor: EditorViewController? {
13 | let orderedControllers = EditorReusePool.shared.viewControllers().sorted {
14 | let lhs = $0.view.window?.orderedIndex ?? .max
15 | let rhs = $1.view.window?.orderedIndex ?? .max
16 | return lhs < rhs
17 | }
18 |
19 | return orderedControllers.first { $0.view.window != nil } ?? (NSApp as? Application)?.currentEditor
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Shortcuts/IntentError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IntentError.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 3/10/23.
6 | //
7 |
8 | import Foundation
9 |
10 | enum IntentError: Error, CustomLocalizedStringResourceConvertible {
11 | case missingDocument
12 |
13 | var localizedStringResource: LocalizedStringResource {
14 | switch self {
15 | case .missingDocument: return "Missing active document to proceed."
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Shortcuts/IntentProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IntentProvider.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 3/10/23.
6 | //
7 |
8 | import AppIntents
9 |
10 | struct IntentProvider: AppShortcutsProvider {
11 | static var appShortcuts: [AppShortcut] {
12 | return [
13 | AppShortcut(
14 | intent: CreateNewDocumentIntent(),
15 | phrases: [
16 | "Create New Document in \(.applicationName)",
17 | ],
18 | shortTitle: "Create New Document",
19 | systemImageName: "plus.square"
20 | ),
21 | AppShortcut(
22 | intent: EvaluateJavaScriptIntent(),
23 | phrases: [
24 | "Evaluate JavaScript in \(.applicationName)",
25 | ],
26 | shortTitle: "Evaluate JavaScript",
27 | systemImageName: "curlybraces.square"
28 | ),
29 | AppShortcut(
30 | intent: GetFileContentIntent(),
31 | phrases: [
32 | "Get File Content in \(.applicationName)",
33 | ],
34 | shortTitle: "Get File Content",
35 | systemImageName: "doc.plaintext"
36 | ),
37 | AppShortcut(
38 | intent: UpdateFileContentIntent(),
39 | phrases: [
40 | "Update File Content in \(.applicationName)",
41 | ],
42 | shortTitle: "Update File Content",
43 | systemImageName: "character.textbox"
44 | ),
45 | ]
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Shortcuts/Intents/CreateNewDocumentIntent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CreateNewDocumentIntent.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 3/10/23.
6 | //
7 |
8 | import AppKit
9 | import AppIntents
10 |
11 | struct CreateNewDocumentIntent: AppIntent {
12 | static let title: LocalizedStringResource = "Create New Document"
13 | static let description = IntentDescription("Create a new document, with optional parameters to set the file name and the initial content.")
14 | static let openAppWhenRun = true
15 | static var parameterSummary: some ParameterSummary {
16 | Summary("New Document named \(\.$fileName) with \(\.$initialContent)")
17 | }
18 |
19 | @Parameter(title: "File Name")
20 | var fileName: String?
21 |
22 | @Parameter(title: "Initial Content")
23 | var initialContent: String?
24 |
25 | @MainActor
26 | func perform() async throws -> some IntentResult {
27 | NSApp.appDelegate?.createNewFile(
28 | fileName: fileName,
29 | initialContent: initialContent,
30 | isIntent: true
31 | )
32 |
33 | return .result()
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Shortcuts/Intents/EvaluateJavaScriptIntent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EvaluateJavaScriptIntent.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 3/14/23.
6 | //
7 |
8 | import AppIntents
9 |
10 | struct EvaluateJavaScriptIntent: AppIntent {
11 | static let title: LocalizedStringResource = "Evaluate JavaScript"
12 | static let description = IntentDescription("Evaluate JavaScript and get the result on the active document, throws an error if no editor is opened.")
13 | static var parameterSummary: some ParameterSummary {
14 | Summary("Evaluate JavaScript with \(\.$content)")
15 | }
16 |
17 | @Parameter(title: "Content", inputOptions: String.IntentInputOptions(capitalizationType: .none, multiline: true, autocorrect: false, smartQuotes: false, smartDashes: false))
18 | var content: String
19 |
20 | @MainActor
21 | func perform() async throws -> some ReturnsValue {
22 | guard let currentEditor else {
23 | throw IntentError.missingDocument
24 | }
25 |
26 | // We do not directly use the async version of evaluateJavaScript,
27 | // mainly because that it **sometimes** emits Optional(nil) unwrapping error.
28 | return try await withCheckedThrowingContinuation { continuation in
29 | currentEditor.webView.evaluateJavaScript(content) { value, error in
30 | // Here we have to deal with this typing hell
31 | continuation.resume(with: .success(.result(value: String(describing: value ?? error ?? "undefined"))))
32 | }
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Shortcuts/Intents/GetFileContentIntent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GetFileContentIntent.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 3/10/23.
6 | //
7 |
8 | import AppIntents
9 |
10 | struct GetFileContentIntent: AppIntent {
11 | static let title: LocalizedStringResource = "Get File Content"
12 | static let description = IntentDescription("Get file content of the active document, throws an error if no editor is opened.")
13 |
14 | @MainActor
15 | func perform() async throws -> some ReturnsValue {
16 | guard let fileURL = currentEditor?.document?.textFileURL else {
17 | throw IntentError.missingDocument
18 | }
19 |
20 | return .result(value: IntentFile(fileURL: fileURL))
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/MarkEditMac/Sources/Updater/AppVersion.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppVersion.swift
3 | // MarkEditMac
4 | //
5 | // Created by cyan on 11/1/23.
6 | //
7 |
8 | import Foundation
9 |
10 | /**
11 | [GitHub Releases API](https://api.github.com/repos/MarkEdit-app/MarkEdit/releases/latest)
12 | */
13 | struct AppVersion: Decodable {
14 | struct Asset: Decodable {
15 | let name: String
16 | let browserDownloadUrl: String
17 | }
18 |
19 | let name: String
20 | let body: String
21 | let htmlUrl: String
22 | let assets: [Asset]?
23 |
24 | /**
25 | Returns true when this version was released to MAS.
26 |
27 | The logic here is, versions up to 1.13.4 were released to MAS, they don't have a meaningful release name. We can use this as a sign to differentiate MAS release and GitHub release.
28 |
29 | For example: https://github.com/MarkEdit-app/MarkEdit/releases/tag/v1.13.4-rc1 (name is empty)
30 | */
31 | var releasedToMAS: Bool {
32 | name.isEmpty
33 | }
34 | }
35 |
36 | /**
37 | ReleaseInfo.json added to GitHub release assets.
38 |
39 | It typically contains extra information for better updating experience.
40 | */
41 | struct ReleaseInfo: Decodable {
42 | let minOSVer: String
43 | }
44 |
--------------------------------------------------------------------------------
/MarkEditTools/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/MarkEditTools/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "MarkEditTools",
8 | platforms: [
9 | .iOS(.v17),
10 | .macOS(.v14),
11 | ],
12 | products: [
13 | .plugin(name: "SwiftLint", targets: ["SwiftLint"]),
14 | ],
15 | targets: [
16 | .binaryTarget(
17 | name: "SwiftLintBinary",
18 | url: "https://github.com/realm/SwiftLint/releases/download/0.59.1/SwiftLintBinary.artifactbundle.zip",
19 | checksum: "b9f915a58a818afcc66846740d272d5e73f37baf874e7809ff6f246ea98ad8a2"
20 | ),
21 | .plugin(
22 | name: "SwiftLint",
23 | capability: .buildTool(),
24 | dependencies: ["SwiftLintBinary"]
25 | ),
26 | ]
27 | )
28 |
--------------------------------------------------------------------------------
/MarkEditTools/Plugins/SwiftLint/main.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftLintPlugin.swift
3 | //
4 | // Created by cyan on 1/30/23.
5 | //
6 |
7 | import PackagePlugin
8 | import XcodeProjectPlugin
9 |
10 | @main
11 | struct Main: BuildToolPlugin {
12 | func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
13 | // XcodeBuildToolPlugin would be good enough
14 | return []
15 | }
16 | }
17 |
18 | extension Main: XcodeBuildToolPlugin {
19 | func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] {
20 | [
21 | .buildCommand(
22 | displayName: "Running SwiftLint for \(target.displayName)",
23 | executable: try context.tool(named: "swiftlint").path,
24 | arguments: [
25 | "lint",
26 | "--strict",
27 | "--config",
28 | "\(context.xcodeProject.directory.string)/.swiftlint.yml",
29 | "--cache-path",
30 | "\(context.pluginWorkDirectory.string)/cache",
31 | context.xcodeProject.directory.string,
32 | ]
33 | ),
34 | ]
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/MarkEditTools/README.md:
--------------------------------------------------------------------------------
1 | # MarkEditTools
2 |
3 | This package provides dev tools for all targets, such as SwiftLint.
4 |
5 | > Note that this package should be platform-independent.
--------------------------------------------------------------------------------
/PreviewExtension/Base.lproj/Main.xib:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/PreviewExtension/Info.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.files.user-selected.read-only
8 |
9 | com.apple.security.network.client
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/PreviewExtension/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSExtension
6 |
7 | NSExtensionAttributes
8 |
9 | QLIsDataBasedPreview
10 |
11 | QLSupportedContentTypes
12 |
13 | net.daringfireball.markdown
14 | org.textbundle.package
15 | net.ia.markdown
16 | app.markedit.md
17 | app.markedit.markdown
18 | app.markedit.txt
19 | public.markdown
20 | org.quarto.qmarkdown
21 | com.unknown.md
22 | com.rstudio.rmarkdown
23 | com.nutstore.down
24 | dyn.ah62d4rv4ge81e5pe
25 | dyn.ah62d4rv4ge8043a
26 | dyn.ah62d4rv4ge81c5pe
27 |
28 | QLSupportsSearchableItems
29 |
30 |
31 | NSExtensionPointIdentifier
32 | com.apple.quicklook.preview
33 | NSExtensionPrincipalClass
34 | $(PRODUCT_MODULE_NAME).PreviewViewController
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/PreviewExtension/mul.lproj/Main.xcstrings:
--------------------------------------------------------------------------------
1 | {
2 | "sourceLanguage" : "en",
3 | "strings" : {
4 |
5 | },
6 | "version" : "1.0"
7 | }
--------------------------------------------------------------------------------
/Screenshots/01.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MarkEdit-app/MarkEdit/99d3027c13ec7de8096197b43b4841394cbc4f85/Screenshots/01.png
--------------------------------------------------------------------------------
/Screenshots/02.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MarkEdit-app/MarkEdit/99d3027c13ec7de8096197b43b4841394cbc4f85/Screenshots/02.png
--------------------------------------------------------------------------------
/Screenshots/03.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MarkEdit-app/MarkEdit/99d3027c13ec7de8096197b43b4841394cbc4f85/Screenshots/03.png
--------------------------------------------------------------------------------
/Screenshots/install.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MarkEdit-app/MarkEdit/99d3027c13ec7de8096197b43b4841394cbc4f85/Screenshots/install.png
--------------------------------------------------------------------------------