├── .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 [![NPM version](https://img.shields.io/npm/v/@codemirror/lang-markdown.svg)](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 [![NPM version](https://img.shields.io/npm/v/@codemirror/language-data.svg)](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 --------------------------------------------------------------------------------