diff --git a/CoreEditor/index.html b/CoreEditor/index.html index 74d52356..9d10531a 100644 --- a/CoreEditor/index.html +++ b/CoreEditor/index.html @@ -19,6 +19,7 @@ } diff --git a/CoreEditor/index.ts b/CoreEditor/index.ts index c5a7923b..57d12ea6 100644 --- a/CoreEditor/index.ts +++ b/CoreEditor/index.ts @@ -21,10 +21,14 @@ import { NativeModulePreview } from './src/bridge/native/preview'; import { NativeModuleTokenizer } from './src/bridge/native/tokenizer'; import { resetEditor } from './src/core'; +import { initMarkEditModules } from './src/api/modules'; import { setUp } from './src/styling/config'; import { loadTheme } from './src/styling/themes'; import { startObserving } from './src/events'; +// Initialize and inject modules to the global MarkEdit object +initMarkEditModules(); + // In release mode, window.config = "{{EDITOR_CONFIG}}" will be replaced with a JSON literal const config = isReleaseMode ? window.config : { text: pseudoDocument, diff --git a/CoreEditor/package.json b/CoreEditor/package.json index 77d99e7f..1399ab12 100644 --- a/CoreEditor/package.json +++ b/CoreEditor/package.json @@ -1,5 +1,5 @@ { - "name": "mark-edit", + "name": "markedit-app", "version": "1.0.0", "description": "Just like TextEdit on Mac but dedicated to Markdown.", "scripts": { @@ -35,6 +35,7 @@ "eslint-plugin-promise": "^6.6.0", "jest": "^29.6.4", "jest-environment-jsdom": "^29.6.4", + "markedit-api": "https://github.com/MarkEdit-app/MarkEdit-api#v0.4.0", "rollup": "^4.0.0", "ts-gyb": "^0.12.0", "ts-jest": "^29.1.1", diff --git a/CoreEditor/src/@types/global.d.ts b/CoreEditor/src/@types/global.d.ts index 34f62329..0a0f0a69 100644 --- a/CoreEditor/src/@types/global.d.ts +++ b/CoreEditor/src/@types/global.d.ts @@ -6,6 +6,10 @@ import { NativeModuleCore } from '../bridge/native/core'; import { NativeModuleCompletion } from '../bridge/native/completion'; import { NativeModulePreview } from '../bridge/native/preview'; import { NativeModuleTokenizer } from '../bridge/native/tokenizer'; +import { TextEditor } from '../api/editor'; + +import type { Extension } from '@codemirror/state'; +import type { MarkdownConfig } from '@lezer/markdown'; declare global { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -23,6 +27,17 @@ interface WebKit { } declare global { + // https://github.com/MarkEdit-app/MarkEdit-api + const MarkEdit: { + editorView: EditorView; + editorAPI: TextEditor; + codemirror: { view: Module; state: Module; language: Module; commands: Module; search: Module }; + lezer: { common: Module; highlight: Module; markdown: Module; lr: Module }; + onEditorReady: (listener: (editorView: EditorView) => void) => void; + addExtension: (extension: Extension) => void; + addMarkdownConfig: (config: MarkdownConfig) => void; + }; + interface Window { webkit?: WebKit; editor: EditorView; diff --git a/CoreEditor/src/api/editor.ts b/CoreEditor/src/api/editor.ts new file mode 100644 index 00000000..bfdf25eb --- /dev/null +++ b/CoreEditor/src/api/editor.ts @@ -0,0 +1,83 @@ +import { EditorView } from '@codemirror/view'; +import { EditorSelection, EditorState, Text } from '@codemirror/state'; +import { syntaxTree } from '@codemirror/language'; +import { TextEditable, TextRange } from 'markedit-api'; +import { redo, undo } from '../@vendor/commands/history'; + +/** + * TextEditable implementation to provide convenient text editing interfaces. + */ +export class TextEditor implements TextEditable { + setView(view: EditorView) { + this.view = view; + } + + getText(range?: TextRange): string { + if (range === undefined) { + return this.doc.toString(); + } + + const { from, to } = range; + return this.doc.sliceString(from, to); + } + + setText(text: string, range?: TextRange): void { + const from = range === undefined ? 0 : range.from; + const to = range === undefined ? this.doc.length : range.to; + this.view.dispatch({ + changes: { from, to, insert: text }, + }); + } + + getSelections(): TextRange[] { + return this.state.selection.ranges.map(({ from, to }) => ({ from, to })); + } + + setSelections(ranges: TextRange[]): void { + const selections = ranges.map(({ from, to }) => EditorSelection.range(from, to)); + this.view.dispatch({ + selection: EditorSelection.create(selections), + }); + } + + getLineNumber(position: number): number { + return this.doc.lineAt(position).number - 1; + } + + getLineRange(row: number): TextRange { + const { from, to } = this.doc.line(row + 1); + return { from, to }; + } + + getLineCount(): number { + return this.doc.lines; + } + + getLineBreak(): string { + return this.state.lineBreak; + } + + getNodeName(position: number): string { + return syntaxTree(this.state).resolve(position).name; + } + + undo(): void { + undo(this.view); + } + + redo(): void { + redo(this.view); + } + + // MARK: - Private + + private view = window.editor; + + private get state(): EditorState { + return this.view.state; + } + + private get doc(): Text { + return this.state.doc; + } +} diff --git a/CoreEditor/src/api/methods.ts b/CoreEditor/src/api/methods.ts new file mode 100644 index 00000000..b8effe17 --- /dev/null +++ b/CoreEditor/src/api/methods.ts @@ -0,0 +1,58 @@ +import { EditorView } from '@codemirror/view'; +import { Extension } from '@codemirror/state'; +import { MarkdownConfig } from '@lezer/markdown'; +import { markdownExtensionBundle } from '../extensions'; + +export function onEditorReady(listener: (editorView: EditorView) => void) { + storage.editorReadyListeners.push(listener); + + if (isEditorReady()) { + listener(window.editor); + } +} + +export function addExtension(extension: Extension) { + storage.extensions.push(extension); + + if (isEditorReady()) { + window.editor.dispatch({ + effects: window.dynamics.extensionConfigurator?.reconfigure(userExtensions()), + }); + } +} + +export function addMarkdownConfig(config: MarkdownConfig) { + storage.markdownConfigs.push(config); + + if (isEditorReady()) { + window.editor.dispatch({ + effects: window.dynamics.markdownConfigurator?.reconfigure(markdownExtensionBundle()), + }); + } +} + +export function editorReadyListeners() { + return storage.editorReadyListeners; +} + +export function userExtensions(): Extension[] { + return storage.extensions; +} + +export function userMarkdownConfigs(): MarkdownConfig[] { + return storage.markdownConfigs; +} + +function isEditorReady() { + return typeof window.editor.dispatch === 'function'; +} + +const storage: { + editorReadyListeners: ((editorView: EditorView) => void)[]; + extensions: Extension[]; + markdownConfigs: MarkdownConfig[]; +} = { + editorReadyListeners: [], + extensions: [], + markdownConfigs: [], +}; diff --git a/CoreEditor/src/api/modules.ts b/CoreEditor/src/api/modules.ts new file mode 100644 index 00000000..cb2b4d5a --- /dev/null +++ b/CoreEditor/src/api/modules.ts @@ -0,0 +1,69 @@ +import * as cmView from '@codemirror/view'; +import * as cmState from '@codemirror/state'; +import * as cmLanguage from '@codemirror/language'; +import * as cmCommands from '@codemirror/commands'; +import * as cmSearch from '@codemirror/search'; + +import * as lezerCommon from '@lezer/common'; +import * as lezerHighlight from '@lezer/highlight'; +import * as lezerMarkdown from '@lezer/markdown'; +import * as lezerLr from '@lezer/lr'; + +import * as customHistory from '../@vendor/commands/history'; + +import { TextEditor } from './editor'; +import { onEditorReady, addExtension, addMarkdownConfig } from './methods'; + +export function initMarkEditModules() { + const codemirror = { + view: cmView, + state: cmState, + language: cmLanguage, + commands: { + ...cmCommands, + ...customHistory, + }, + search: cmSearch, + }; + + const lezer = { + common: lezerCommon, + highlight: lezerHighlight, + markdown: lezerMarkdown, + lr: lezerLr, + }; + + MarkEdit.editorAPI = new TextEditor(); + MarkEdit.codemirror = codemirror; + MarkEdit.lezer = lezer; + + MarkEdit.onEditorReady = onEditorReady; + MarkEdit.addExtension = addExtension; + MarkEdit.addMarkdownConfig = addMarkdownConfig; + + const modules = { + 'markedit-api': { MarkEdit }, + '@codemirror/view': codemirror.view, + '@codemirror/state': codemirror.state, + '@codemirror/language': codemirror.language, + '@codemirror/commands': codemirror.commands, + '@codemirror/search': codemirror.search, + '@lezer/common': lezer.common, + '@lezer/highlight': lezer.highlight, + '@lezer/markdown': lezer.markdown, + '@lezer/lr': lezer.lr, + }; + + const require = (id: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const module = (modules as any)[id]; + if (module !== undefined) { + return module; + } + + console.error(`Failed to require module: "${id}", supported modules: ${Object.keys(modules).join(', ')}`); + return {}; + }; + + window.require = require as NodeRequire; +} diff --git a/CoreEditor/src/bridge/native/core.ts b/CoreEditor/src/bridge/native/core.ts index a30c2176..05e40c7c 100644 --- a/CoreEditor/src/bridge/native/core.ts +++ b/CoreEditor/src/bridge/native/core.ts @@ -11,6 +11,7 @@ export interface NativeModuleCore extends NativeModule { notifyBackgroundColorDidChange({ color }: { color: CodeGen_Int }): void; notifyViewportScaleDidChange(): void; notifyViewDidUpdate(args: { contentEdited: boolean; compositionEnded: boolean; isDirty: boolean; selectedLineColumn: LineColumnInfo }): void; + notifyContentHeightDidChange({ bottomPanelHeight }: { bottomPanelHeight: number }): void; notifyContentOffsetDidChange(): void; notifyCompositionEnded({ selectedLineColumn }: { selectedLineColumn: LineColumnInfo }): void; notifyLinkClicked({ link }: { link: string }): void; diff --git a/CoreEditor/src/config.ts b/CoreEditor/src/config.ts index 2a832252..5e6636dc 100644 --- a/CoreEditor/src/config.ts +++ b/CoreEditor/src/config.ts @@ -71,6 +71,8 @@ export interface Dynamics { lineEndings?: Compartment; indentUnit?: Compartment; selectionHighlight?: Compartment; + extensionConfigurator?: Compartment; + markdownConfigurator?: Compartment; } export type { WebFontFace }; diff --git a/CoreEditor/src/core.ts b/CoreEditor/src/core.ts index c76baded..a0775cb8 100644 --- a/CoreEditor/src/core.ts +++ b/CoreEditor/src/core.ts @@ -12,6 +12,7 @@ import { getLineBreak, normalizeLineBreaks } from './modules/lineEndings'; import { generateDiffs } from './modules/diff'; import { scrollCaretToVisible, scrollIntoView } from './modules/selection'; import { markContentClean } from './modules/history'; +import { editorReadyListeners } from './api/methods'; export enum ReplaceGranularity { wholeDocument = 'wholeDocument', @@ -62,6 +63,9 @@ export function resetEditor( editor.focus(); window.editor = editor; + MarkEdit.editorView = editor; + MarkEdit.editorAPI.setView(editor); + const ensureLineHeight = () => { // coordsAtPos ensures the line number height scrollCaretToVisible(); @@ -80,6 +84,8 @@ export function resetEditor( const scrollDOM = editor.scrollDOM; scrollDOM.scrollTo({ top: 0 }); // scrollIntoView doesn't work when the app is idle + + observeContentHeightChanges(scrollDOM); fixWebKitWheelIssues(scrollDOM); scrollDOM.addEventListener('scroll', () => { @@ -123,6 +129,9 @@ export function resetEditor( // The content should be initially clean markContentClean(); + + // For user scripts, notify the editor is ready + editorReadyListeners().forEach(listener => listener(editor)); } /** @@ -181,6 +190,26 @@ export function handleMouseExited(_clientX: number, _clientY: number) { setGutterHovered(false); } +function observeContentHeightChanges(scrollDOM: HTMLElement) { + const notifyIfChanged = () => { + const panel = window.editor.dom.querySelector('.cm-panels-bottom'); + const height = panel === null ? 0 : panel.getBoundingClientRect().height; + if (Math.abs(storage.bottomPanelHeight - height) < 0.001) { + return; + } + + storage.bottomPanelHeight = height; + window.nativeModules.core.notifyContentHeightDidChange({ + bottomPanelHeight: height, + }); + }; + + // eslint-disable-next-line compat/compat + const observer = new ResizeObserver(notifyIfChanged); + observer.observe(scrollDOM); + notifyIfChanged(); +} + function fixWebKitWheelIssues(scrollDOM: HTMLElement) { // Fix the vertical scrollbar initially visible for short documents scrollDOM.style.overflow = 'hidden'; @@ -220,7 +249,9 @@ function fixWebKitWheelIssues(scrollDOM: HTMLElement) { const storage: { scrollTimer: ReturnType | undefined; viewportScale: number; + bottomPanelHeight: number; } = { scrollTimer: undefined, viewportScale: 1.0, + bottomPanelHeight: 0.0, }; diff --git a/CoreEditor/src/extensions.ts b/CoreEditor/src/extensions.ts index 52dbeca9..8bc3965c 100644 --- a/CoreEditor/src/extensions.ts +++ b/CoreEditor/src/extensions.ts @@ -29,6 +29,7 @@ import { localizePhrases } from './modules/localization'; import { indentationKeymap } from './modules/indentation'; import { wordTokenizer, observeChanges, interceptInputs } from './modules/input'; import { tocKeymap } from './modules/toc'; +import { userExtensions, userMarkdownConfigs } from './api/methods'; // Revision mode import { inlineCodeStyle, codeBlockStyle } from './styling/nodes/code'; @@ -46,6 +47,8 @@ const lineWrapping = new Compartment; const lineEndings = new Compartment; const indentUnit = new Compartment; const selectionHighlight = new Compartment; +const extensionConfigurator = new Compartment; +const markdownConfigurator = new Compartment; window.dynamics = { theme, @@ -58,6 +61,8 @@ window.dynamics = { lineEndings, indentUnit, selectionHighlight, + extensionConfigurator, + markdownConfigurator, }; // Make this a function because some resources (e.g., phrases) require lazy loading @@ -72,8 +77,22 @@ export function extensions(options: { } } +export function markdownExtensionBundle() { + return markdown({ + base: markdownLanguage, + codeLanguages: languages, + extensions: [ + ...markdownExtensions, + ...userMarkdownConfigs(), + ], + }); +} + function fullExtensions(options: { lineBreak?: string }) { return [ + // Extensions created by user scripts + extensionConfigurator.of(userExtensions()), + // Read-only readOnly.of(window.config.readOnlyMode ? [EditorView.editable.of(false), EditorState.readOnly.of(true)] : []), EditorState.transactionFilter.of(transaction => { @@ -129,11 +148,7 @@ function fullExtensions(options: { lineBreak?: string }) { ]), // Markdown - markdown({ - base: markdownLanguage, - codeLanguages: languages, - extensions: markdownExtensions, - }), + markdownConfigurator.of(markdownExtensionBundle()), // Styling classHighlighters, diff --git a/CoreEditor/yarn.lock b/CoreEditor/yarn.lock index d0897893..3dd441cd 100644 --- a/CoreEditor/yarn.lock +++ b/CoreEditor/yarn.lock @@ -3176,6 +3176,10 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" +"markedit-api@https://github.com/MarkEdit-app/MarkEdit-api#v0.4.0": + version "0.4.0" + resolved "https://github.com/MarkEdit-app/MarkEdit-api#e39ff4dc72142aca197bf18a1d75bc7a5b477f62" + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" diff --git a/MarkEditKit/Sources/Bridge/Native/Generated/NativeModuleCore.swift b/MarkEditKit/Sources/Bridge/Native/Generated/NativeModuleCore.swift index f6d8a9c0..1069a676 100644 --- a/MarkEditKit/Sources/Bridge/Native/Generated/NativeModuleCore.swift +++ b/MarkEditKit/Sources/Bridge/Native/Generated/NativeModuleCore.swift @@ -16,6 +16,7 @@ public protocol NativeModuleCore: NativeModule { func notifyBackgroundColorDidChange(color: Int) func notifyViewportScaleDidChange() func notifyViewDidUpdate(contentEdited: Bool, compositionEnded: Bool, isDirty: Bool, selectedLineColumn: LineColumnInfo) + func notifyContentHeightDidChange(bottomPanelHeight: Double) func notifyContentOffsetDidChange() func notifyCompositionEnded(selectedLineColumn: LineColumnInfo) func notifyLinkClicked(link: String) @@ -41,6 +42,9 @@ final class NativeBridgeCore: NativeBridge { "notifyViewDidUpdate": { [weak self] in self?.notifyViewDidUpdate(parameters: $0) }, + "notifyContentHeightDidChange": { [weak self] in + self?.notifyContentHeightDidChange(parameters: $0) + }, "notifyContentOffsetDidChange": { [weak self] in self?.notifyContentOffsetDidChange(parameters: $0) }, @@ -106,6 +110,23 @@ final class NativeBridgeCore: NativeBridge { return .success(nil) } + private func notifyContentHeightDidChange(parameters: Data) -> Result? { + struct Message: Decodable { + var bottomPanelHeight: Double + } + + let message: Message + do { + message = try decoder.decode(Message.self, from: parameters) + } catch { + Logger.assertFail("Failed to decode parameters: \(parameters)") + return .failure(error) + } + + module.notifyContentHeightDidChange(bottomPanelHeight: message.bottomPanelHeight) + return .success(nil) + } + private func notifyContentOffsetDidChange(parameters: Data) -> Result? { module.notifyContentOffsetDidChange() return .success(nil) diff --git a/MarkEditKit/Sources/Bridge/Native/Modules/EditorModuleCore.swift b/MarkEditKit/Sources/Bridge/Native/Modules/EditorModuleCore.swift index fdcf9332..715a92af 100644 --- a/MarkEditKit/Sources/Bridge/Native/Modules/EditorModuleCore.swift +++ b/MarkEditKit/Sources/Bridge/Native/Modules/EditorModuleCore.swift @@ -18,6 +18,7 @@ public protocol EditorModuleCoreDelegate: AnyObject { isDirty: Bool, selectedLineColumn: LineColumnInfo ) + func editorCoreContentHeightDidChange(_ sender: EditorModuleCore, bottomPanelHeight: Double) func editorCoreContentOffsetDidChange(_ sender: EditorModuleCore) func editorCoreCompositionEnded(_ sender: EditorModuleCore, selectedLineColumn: LineColumnInfo) func editorCoreLinkClicked(_ sender: EditorModuleCore, link: String) @@ -57,6 +58,10 @@ public final class EditorModuleCore: NativeModuleCore { ) } + public func notifyContentHeightDidChange(bottomPanelHeight: Double) { + delegate?.editorCoreContentHeightDidChange(self, bottomPanelHeight: bottomPanelHeight) + } + public func notifyContentOffsetDidChange() { delegate?.editorCoreContentOffsetDidChange(self) } diff --git a/MarkEditMac/Sources/Editor/Controllers/EditorViewController+Delegate.swift b/MarkEditMac/Sources/Editor/Controllers/EditorViewController+Delegate.swift index 00f92e44..345f1f85 100644 --- a/MarkEditMac/Sources/Editor/Controllers/EditorViewController+Delegate.swift +++ b/MarkEditMac/Sources/Editor/Controllers/EditorViewController+Delegate.swift @@ -146,6 +146,11 @@ extension EditorViewController: EditorModuleCoreDelegate { } } + func editorCoreContentHeightDidChange(_ sender: EditorModuleCore, bottomPanelHeight: Double) { + self.bottomPanelHeight = bottomPanelHeight + self.layoutStatusView() + } + func editorCoreContentOffsetDidChange(_ sender: EditorModuleCore) { // Remove all floating UI elements since view coordinates are changed removeFloatingUIElements() diff --git a/MarkEditMac/Sources/Editor/Controllers/EditorViewController+UI.swift b/MarkEditMac/Sources/Editor/Controllers/EditorViewController+UI.swift index cfbebe75..200b9e92 100644 --- a/MarkEditMac/Sources/Editor/Controllers/EditorViewController+UI.swift +++ b/MarkEditMac/Sources/Editor/Controllers/EditorViewController+UI.swift @@ -153,7 +153,7 @@ extension EditorViewController { func layoutStatusView() { statusView.frame = CGRect( x: view.bounds.width - statusView.frame.width - 6, - y: 8, // Vertical margins are intentionally larger to visually look the same + y: bottomPanelHeight + 8, // Vertical margins are intentionally larger to visually look the same width: statusView.frame.width, height: statusView.frame.height ) diff --git a/MarkEditMac/Sources/Editor/Controllers/EditorViewController.swift b/MarkEditMac/Sources/Editor/Controllers/EditorViewController.swift index 19c25b1f..11094369 100644 --- a/MarkEditMac/Sources/Editor/Controllers/EditorViewController.swift +++ b/MarkEditMac/Sources/Editor/Controllers/EditorViewController.swift @@ -17,6 +17,7 @@ final class EditorViewController: NSViewController { var hasUnfinishedAnimations = false var hasBeenEdited = false var mouseExitedWindow = false + var bottomPanelHeight: Double = 0 var webBackgroundColor: NSColor? var safeAreaObservation: NSKeyValueObservation? diff --git a/README.md b/README.md index 3c645529..eb02cada 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ MarkEdit is a free and **open-source** Markdown editor, for macOS. It's just lik - Lightweight: installer size is about 3 MB - Extensible: seamless Shortcuts integration -To learn more, refer to [Philosophy](https://github.com/MarkEdit-app/MarkEdit/wiki/Philosophy) and [Why MarkEdit](https://github.com/MarkEdit-app/MarkEdit/wiki/Why-MarkEdit). +MarkEdit is designed to be simple and easy to use. You can also customize its behavior by adding your own scripts, including creating CodeMirror extensions. + +To learn more, refer to [Philosophy](https://github.com/MarkEdit-app/MarkEdit/wiki/Philosophy), [Why MarkEdit](https://github.com/MarkEdit-app/MarkEdit/wiki/Why-MarkEdit) and [MarkEdit-api](https://github.com/MarkEdit-app/MarkEdit-api). ## Installation