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