feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled

这个提交包含在:
chen
2026-11-04 11:29:39 +08:00
当前提交 301194963b
共有 8663 个文件被更改,包括 1962691 次插入 和 0 次删除

查看文件

@@ -0,0 +1,83 @@
import { Compartment, Extension, RangeSetBuilder, StateEffect } from '@codemirror/state';
import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
const activeLineDecoration = Decoration.line({ class: 'CodeMirror-activeline CodeMirror-activeline-background' });
const optionToExtension: Record<string, Extension> = {
'styleActiveLine': [
ViewPlugin.fromClass(class {
public decorations: DecorationSet;
public constructor(view: EditorView) {
this.updateDecorations(view);
}
public update(update: ViewUpdate) {
this.updateDecorations(update.view);
}
private updateDecorations(view: EditorView) {
const builder = new RangeSetBuilder<Decoration>();
let lastLine = -1;
for (const selection of view.state.selection.ranges) {
const startLine = selection.from;
const line = view.state.doc.lineAt(startLine);
if (line.number !== lastLine) {
builder.add(line.from, line.from, activeLineDecoration);
}
lastLine = line.number;
}
this.decorations = builder.finish();
}
}, {
decorations: plugin => plugin.decorations,
}),
EditorView.baseTheme({
'&dark .CodeMirror-activeline-background': {
background: '#3304',
color: 'white',
},
'&light .CodeMirror-activeline-background': {
background: '#7ff4',
color: 'black',
},
}),
],
};
// Maps several CM5 options to CM6 extensions
export default class CodeMirror5BuiltInOptions {
private activeOptions: string[] = [];
private extensionCompartment: Compartment = new Compartment();
public constructor(private editor: EditorView) {
editor.dispatch({
effects: StateEffect.appendConfig.of(this.extensionCompartment.of([])),
});
}
private updateExtensions() {
const extensions = this.activeOptions.map(option => optionToExtension[option]);
this.editor.dispatch({
effects: this.extensionCompartment.reconfigure(extensions),
});
}
public supportsOption(option: string) {
return optionToExtension.hasOwnProperty(option);
}
public setOption(optionName: string, value: boolean) {
this.activeOptions = this.activeOptions.filter(other => other !== optionName);
if (value) {
this.activeOptions.push(optionName);
}
this.updateExtensions();
}
}

查看文件

@@ -0,0 +1,256 @@
import CodeMirror5Emulation from './CodeMirror5Emulation';
import { EditorView } from '@codemirror/view';
const makeCodeMirrorEmulation = (initialDocText: string) => {
const editorView = new EditorView({
doc: initialDocText,
});
return new CodeMirror5Emulation(editorView, ()=>{});
};
describe('CodeMirror5Emulation', () => {
it('getSearchCursor should support searching for strings', () => {
const codeMirror = makeCodeMirrorEmulation('testing --- this is a test.');
// Should find two matches for "test"
// Note that the CodeMirror documentation specifies that a search cursor
// should return a boolean when calling findNext/findPrevious. However,
// the codemirror-vim adapter returns just a truthy/falsy value.
const testCursor = codeMirror.getSearchCursor('test');
expect(testCursor.findNext()).toBeTruthy();
expect(testCursor.findNext()).toBeTruthy();
// Replace the second match
testCursor.replace('passing test');
expect(codeMirror.getValue()).toBe('testing --- this is a passing test.');
// Should also be able to find previous matches
expect(testCursor.findPrevious()).toBeTruthy();
// Should return a falsy value when attempting to search past the end of
// the document.
expect(testCursor.findPrevious()).toBeFalsy();
});
it('should fire update/change events on change', async () => {
const codeMirror = makeCodeMirrorEmulation('testing --- this is a test.');
const updateCallback = jest.fn();
const changeCallback = jest.fn();
codeMirror.on('update', updateCallback);
codeMirror.on('change', changeCallback);
expect(updateCallback).not.toHaveBeenCalled();
expect(changeCallback).not.toHaveBeenCalled();
jest.useFakeTimers();
// Inserting text should trigger the update and change events
codeMirror.editor.dispatch({
changes: { from: 0, to: 1, insert: 'Test: ' },
});
// Advance timers -- there may be a delay between the CM 6 event
// and the dispatched CM 5 event.
await jest.advanceTimersByTimeAsync(100);
expect(updateCallback).toHaveBeenCalled();
expect(changeCallback).toHaveBeenCalled();
// The change callback should be given two arguments:
// - the CodeMirror emulation object
// - a description of the changes
expect(changeCallback.mock.lastCall[0]).toBe(codeMirror);
expect(changeCallback.mock.lastCall[1]).toMatchObject({
from: { line: 0, ch: 0 },
to: { line: 0, ch: 1 },
// Arrays of lines
text: ['Test: '],
removed: ['t'],
});
});
it('defineOption should fire the option\'s update callback on change', () => {
const codeMirror = makeCodeMirrorEmulation('Test 1\nTest 2');
const onOptionUpdate = jest.fn();
codeMirror.defineOption('an-option!', 'test', onOptionUpdate);
const onOtherOptionUpdate = jest.fn();
codeMirror.defineOption('an-option 2', 1, onOtherOptionUpdate);
// onUpdate should be called once initially
expect(onOtherOptionUpdate).toHaveBeenCalledTimes(1);
expect(onOptionUpdate).toHaveBeenCalledTimes(1);
expect(onOptionUpdate).toHaveBeenLastCalledWith(
codeMirror,
// default value -- the new value
'test',
// the original value (none, so given CodeMirror.Init)
codeMirror.Init,
);
// onUpdate should be called each time the option changes
codeMirror.setOption('an-option!', 'test 2');
expect(onOptionUpdate).toHaveBeenCalledTimes(2);
expect(onOptionUpdate).toHaveBeenLastCalledWith(
codeMirror, 'test 2', 'test',
);
codeMirror.setOption('an-option!', 'test...');
expect(onOptionUpdate).toHaveBeenCalledTimes(3);
// The other update callback should not have been triggered
// additional times if its option hasn't updated.
expect(onOtherOptionUpdate).toHaveBeenCalledTimes(1);
});
it('should support running commands registered with defineExtension', () => {
const codeMirror = makeCodeMirrorEmulation('Test 1\nTest 2');
const testExtension = jest.fn((a: number) => `testing${a}`);
codeMirror.defineExtension('testExtension', testExtension);
expect(codeMirror.commandExists('testExtension')).toBe(true);
expect(codeMirror.execCommand('testExtension', 1)).toBe('testing1');
});
it('markText decorations should be removable', () => {
const codeMirror = makeCodeMirrorEmulation('Test 1\nTest 2');
const markDecoration = codeMirror.markText(
{ line: 0, ch: 0 },
{ line: 0, ch: 6 },
{ className: 'test-mark-decoration' },
);
const markDecoration2 = codeMirror.markText(
{ line: 1, ch: 0 },
{ line: 1, ch: 1 },
{ className: 'test-decoration-2' },
);
const editorDom = codeMirror.cm6.dom;
expect(editorDom.querySelectorAll('.test-mark-decoration')).toHaveLength(1);
expect(editorDom.querySelectorAll('.test-decoration-2')).toHaveLength(1);
codeMirror.setCursor(0, 2);
codeMirror.replaceSelection('!Test!');
// Editing the document shouldn't remove the mark
expect(codeMirror.editor.state.doc.toString()).toBe('Te!Test!st 1\nTest 2');
expect(editorDom.querySelectorAll('.test-mark-decoration')).toHaveLength(1);
// Clearing should remove only the decoration that was cleared.
markDecoration.clear();
expect(editorDom.querySelectorAll('.test-mark-decoration')).toHaveLength(0);
expect(editorDom.querySelectorAll('.test-decoration-2')).toHaveLength(1);
markDecoration2.clear();
expect(editorDom.querySelectorAll('.test-decoration-2')).toHaveLength(0);
});
it('defineExtension should override previous extensions with the same name', () => {
const codeMirror = makeCodeMirrorEmulation('Test...');
const testExtensionFn1 = jest.fn();
const testExtensionFn2 = jest.fn();
codeMirror.defineExtension('defineExtensionShouldOverride', testExtensionFn1);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accessing dynamic extension
(codeMirror as any).defineExtensionShouldOverride();
expect(testExtensionFn1).toHaveBeenCalledTimes(1);
expect(testExtensionFn2).toHaveBeenCalledTimes(0);
codeMirror.defineExtension('defineExtensionShouldOverride', testExtensionFn2);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accessing dynamic extension
(codeMirror as any).defineExtensionShouldOverride();
expect(testExtensionFn1).toHaveBeenCalledTimes(1);
expect(testExtensionFn2).toHaveBeenCalledTimes(1);
});
it('defineExtension should register an extension where this points to the editor', () => {
const codeMirror = makeCodeMirrorEmulation('Test...');
let lastThis = null;
codeMirror.defineExtension('testExtension', function() {
lastThis = this;
});
codeMirror.execCommand('testExtension');
expect(lastThis).toBe(codeMirror);
});
it('.markText should support specifying ranges outside the document', () => {
const codeMirror = makeCodeMirrorEmulation('Test...');
const testClassName = 'out-of-range-test-mark';
codeMirror.markText(
// In range
{ line: 0, ch: 4 },
// Out of range
{ line: 0, ch: 1002 },
{ className: testClassName },
);
const dom = codeMirror.editor.dom;
expect(dom.querySelectorAll(`.${testClassName}`)).toHaveLength(1);
});
it('.markText should throw when given non-integer boundaries', () => {
const codeMirror = makeCodeMirrorEmulation('Test...');
expect(() => codeMirror.markText(
{ line: 0, ch: 4.2 },
{ line: 0, ch: 5 },
)).toThrow(/is not an integer/i);
});
it('heightAtLine for a line past the document should be greater than for the last line', () => {
const codeMirror = makeCodeMirrorEmulation('line1\nline2\nline3');
// Mock lineBlockAt to return a block with non-zero height so that the
// distinction between top and top+height is observable in the test.
const mockLineBlock = { top: 900, bottom: 1000, height: 100, from: 0, to: 0 };
jest.spyOn(codeMirror.editor, 'lineBlockAt').mockReturnValue(mockLineBlock as never);
const lineCount = codeMirror.lineCount();
const heightAtLastLine = codeMirror.heightAtLine(lineCount - 1, 'local');
const heightPastEnd = codeMirror.heightAtLine(lineCount, 'local');
expect(heightPastEnd).toBeGreaterThan(heightAtLastLine);
});
it('heightAtLine for a line past the document should be greater than for the last line, given one long line', () => {
const singleLine = 'Very long line of text. '.repeat(400);
const codeMirror = makeCodeMirrorEmulation(`${singleLine}`);
// Mock lineBlockAt to return a block with non-zero height so that the
// distinction between top and top+height is observable in the test.
const mockLineBlock = { top: 900, bottom: 1000, height: 100, from: 0, to: 0 };
jest.spyOn(codeMirror.editor, 'lineBlockAt').mockReturnValue(mockLineBlock as never);
const lineCount = codeMirror.lineCount();
const heightAtLastLine = codeMirror.heightAtLine(lineCount - 1, 'local');
const heightPastEnd = codeMirror.heightAtLine(lineCount, 'local');
expect(heightPastEnd).toBeGreaterThan(heightAtLastLine);
});
it('heightAtLine should return a non-negative value for valid line numbers', () => {
const codeMirror = makeCodeMirrorEmulation('first\nsecond\nthird');
const lineCount = codeMirror.lineCount();
// Test all lines from top to bottom of document
for (let i = 0; i <= lineCount; i++) {
expect(codeMirror.heightAtLine(i, 'local')).toBeGreaterThanOrEqual(0);
}
});
});

查看文件

@@ -0,0 +1,540 @@
import { EditorView, ViewPlugin, ViewUpdate, showPanel } from '@codemirror/view';
import { Extension, Text, Transaction } from '@codemirror/state';
import getScrollFraction from '../getScrollFraction';
import { CodeMirror as BaseCodeMirror5Emulation, Vim } from '@replit/codemirror-vim';
import { LogMessageCallback } from '../../types';
import editorCommands from '../editorCommands/editorCommands';
import { StateEffect } from '@codemirror/state';
import { StreamParser } from '@codemirror/language';
import Decorator, { LineWidgetOptions, MarkTextOptions } from './Decorator';
import insertLineAfter from '../editorCommands/insertLineAfter';
import CodeMirror5BuiltInOptions from './CodeMirror5BuiltInOptions';
const { pregQuote } = require('@joplin/lib/string-utils-common');
type CodeMirror5Command = (codeMirror: CodeMirror5Emulation)=> void;
interface ChangeRecord {
from: DocumentPosition;
to: DocumentPosition;
text: string[];
removed: string[];
transaction: Transaction;
}
type BuiltInEventArgs = {
'paste': [ClipboardEvent];
'mousedown': [MouseEvent];
'blur': [];
'focus': [];
'scroll': [];
'update': [];
'change': [ChangeRecord];
'changes': [ChangeRecord[]];
'inputRead': [ChangeRecord];
'viewportChange': [number, number];
'beforeSelectionChange': [];
};
// Fall back to `unknown[]` for unknown events (e.g. plugin events)
type EventArgs<EventName> =
EventName extends keyof BuiltInEventArgs
? BuiltInEventArgs[EventName]
: unknown[];
type EventType = (keyof BuiltInEventArgs)|string;
type EditorEventCallback<T extends EventType> = (editor: CodeMirror5Emulation, ...args: EventArgs<T>)=> void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
type OptionUpdateCallback = (editor: CodeMirror5Emulation, newVal: any, oldVal: any)=> void;
type OverlayType<State> = StreamParser<State>|{ query: RegExp };
interface CodeMirror5OptionRecord {
onUpdate: OptionUpdateCallback;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror 5 API requires any
value: any;
}
interface DocumentPosition {
line: number;
ch: number;
}
interface DocumentPositionRange {
from: DocumentPosition;
to: DocumentPosition;
}
const clampPositionToDoc = (doc: Text, pos: number) => {
return Math.min(Math.max(0, pos), doc.length);
};
const documentPositionFromPos = (doc: Text, pos: number): DocumentPosition => {
const line = doc.lineAt(pos);
return {
// CM 5 uses 0-based line numbering
line: line.number - 1,
ch: pos - line.from,
};
};
const posFromDocumentPosition = (doc: Text, pos: DocumentPosition) => {
const line = doc.line(pos.line + 1);
const result = line.from + pos.ch;
if (!Number.isInteger(result)) {
throw new Error(`Document position ${result} (${pos.line}:${pos.ch}) is not an integer`);
}
return result;
};
type EventMap = {
[Key in EventType]?: EditorEventCallback<Key>[]
};
export default class CodeMirror5Emulation extends BaseCodeMirror5Emulation {
private _events: EventMap = {};
private _options: Record<string, CodeMirror5OptionRecord> = Object.create(null);
private _decorator: Decorator;
private _decoratorExtension: Extension;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror 5 API requires any
private _userExtensions: Record<string, any> = Object.create(null);
private _builtInOptions: CodeMirror5BuiltInOptions;
// Used by some plugins to store state.
public state: Record<string, unknown> = Object.create(null);
public Vim = Vim;
// Passed as initial state to plugins
public Init = { toString: () => 'CodeMirror.Init' };
public constructor(
public editor: EditorView,
private logMessage: LogMessageCallback,
) {
super(editor);
const { decorator, extension: decoratorExtension } = Decorator.create(editor);
this._decorator = decorator;
this._decoratorExtension = decoratorExtension;
this._builtInOptions = new CodeMirror5BuiltInOptions(editor);
editor.dispatch({
effects: StateEffect.appendConfig.of(this.makeCM6Extensions()),
});
}
private makeCM6Extensions() {
const cm5 = this;
const editor = this.editor;
return [
// Fires events
EditorView.domEventHandlers({
scroll: () => CodeMirror5Emulation.signal(this, 'scroll'),
focus: () => CodeMirror5Emulation.signal(this, 'focus'),
paste: event => CodeMirror5Emulation.signal(this, 'paste', event),
blur: () => CodeMirror5Emulation.signal(this, 'blur'),
mousedown: event => CodeMirror5Emulation.signal(this, 'mousedown', event),
}),
ViewPlugin.fromClass(class {
public update(update: ViewUpdate) {
try {
if (update.viewportChanged) {
CodeMirror5Emulation.signal(
cm5,
'viewportChange',
editor.viewport.from,
editor.viewport.to,
);
}
if (update.docChanged) {
cm5.fireChangeEvents(update);
cm5.onChange(update);
}
if (update.selectionSet) {
cm5.onSelectionChange();
}
CodeMirror5Emulation.signal(cm5, 'update');
// Catch the error -- otherwise, CodeMirror will de-register the update listener.
} catch (error) {
cm5.logMessage(`Error dispatching update: ${error}`);
}
}
}),
// Decorations
this._decoratorExtension,
// Some plugins rely on a CodeMirror-measure element
// to store temporary content.
showPanel.of(() => {
const dom = document.createElement('div');
dom.classList.add('CodeMirror-measure');
// Make invisible, but still measurable
dom.style.visibility = 'hidden';
dom.style.pointerEvents = 'none';
dom.style.height = '0';
dom.style.overflow = 'auto';
return { dom };
}),
// Allows legacy CM5 CSS to apply to the editor:
EditorView.editorAttributes.of({ class: 'CodeMirror' }),
];
}
private isEventHandledBySuperclass(eventName: string) {
return ['beforeSelectionChange'].includes(eventName);
}
public on<T extends EventType>(eventName: T, callback: EditorEventCallback<T>) {
if (this.isEventHandledBySuperclass(eventName)) {
return super.on(eventName, callback);
}
this._events[eventName] ??= [];
this._events[eventName].push(callback as EditorEventCallback<EventType>);
}
public off<T extends EventType>(eventName: T, callback: EditorEventCallback<T>) {
if (!(eventName in this._events)) {
return;
}
this._events[eventName] = this._events[eventName].filter(
otherCallback => otherCallback !== callback,
);
}
public static signal<T extends EventType>(target: CodeMirror5Emulation, eventName: T, ...args: EventArgs<T>) {
const listeners = target._events[eventName] ?? [];
for (const listener of listeners) {
listener(target, ...args);
}
super.signal(target, eventName, ...args);
}
private fireChangeEvents(update: ViewUpdate) {
const changes: ChangeRecord[] = [];
const origDoc = update.startState.doc;
for (const transaction of update.transactions) {
transaction.changes.iterChanges((fromA, toA, _fromB, _toB, inserted: Text) => {
changes.push({
from: documentPositionFromPos(origDoc, fromA),
to: documentPositionFromPos(origDoc, toA),
text: inserted.sliceString(0).split('\n'),
removed: origDoc.sliceString(fromA, toA).split('\n'),
transaction,
});
});
}
// Delay firing events -- event listeners may try to create transactions.
// (this is done by the rich markdown plugin).
setTimeout(() => {
for (const change of changes) {
CodeMirror5Emulation.signal(this, 'change', change);
// If triggered by a user, also send the inputRead event
if (change.transaction.isUserEvent('input')) {
CodeMirror5Emulation.signal(this, 'inputRead', change);
}
}
CodeMirror5Emulation.signal(this, 'changes', changes);
}, 0);
}
// codemirror-vim's adapter doesn't match the CM5 docs -- wrap it.
public getCursor(mode?: 'head' | 'anchor' | 'from' | 'to'| 'start' | 'end') {
if (mode === 'from') {
mode = 'start';
}
if (mode === 'to') {
mode = 'end';
}
return super.getCursor(mode);
}
public override getSearchCursor(query: RegExp|string, pos?: DocumentPosition|null|0) {
// The superclass CodeMirror adapter only supports regular expression
// arguments.
if (typeof query === 'string') {
query = new RegExp(pregQuote(query));
}
return super.getSearchCursor(query, pos || { line: 0, ch: 0 });
}
public lineAtHeight(height: number, _mode?: 'local') {
const lineInfo = this.editor.lineBlockAtHeight(height);
// - 1: Convert to zero-based.
const lineNumber = this.editor.state.doc.lineAt(lineInfo.to).number - 1;
return lineNumber;
}
public heightAtLine(lineNumber: number, mode?: 'local') {
// CodeMirror 5 uses 0-based line numbers. CM6 uses 1-based
// line numbers.
const doc = this.editor.state.doc;
const lineInfo = doc.line(Math.min(lineNumber + 1, doc.lines));
const lineBlock = this.editor.lineBlockAt(lineInfo.from);
let height;
if (lineNumber >= doc.lines) {
// Handle case when lineNumber is at or below the last line
// This ensures ePercentL != ePercentU in translateLE_, which may cause linInterp to be infinity.
// See: https://github.com/laurent22/joplin/issues/14143#issuecomment-3767793559
height = lineBlock.top + lineBlock.height;
} else {
height = lineBlock.top;
}
if (mode === 'local') {
const editorTop = this.editor.lineBlockAt(0).top;
return height - editorTop;
} else {
return height;
}
}
public lineInfo(lineNumber: number) {
const line = this.editor.state.doc.line(lineNumber + 1);
const result = {
line: lineNumber,
// Note: In CM5, a line handle is not just a line number
handle: lineNumber,
text: line.text,
gutterMarkers: [] as unknown[],
textClass: ['cm-line', ...this._decorator.getLineClasses(lineNumber)],
bgClass: '',
wrapClass: '',
widgets: this._decorator.getLineWidgets(lineNumber),
};
return result;
}
public getStateAfter(_line: number) {
// TODO: Should return parser state. Returning an empty object
// allows some plugins to run without crashing, however.
return {};
}
public getScrollPercent() {
return getScrollFraction(this.editor);
}
// CodeMirror-Vim's scrollIntoView only supports pos as a DocumentPosition.
public override scrollIntoView(
pos: DocumentPosition|DocumentPositionRange, margin?: number,
): void {
const isPosition = (arg: unknown): arg is DocumentPosition => {
return (arg as Partial<DocumentPosition>).line !== undefined && (arg as Partial<DocumentPosition>).ch !== undefined;
};
if (isPosition(pos)) {
return super.scrollIntoView(pos, margin);
} else {
return super.scrollIntoView(pos.from, margin);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror 5 API requires any
public defineExtension(name: string, value: any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror 5 API requires any
(CodeMirror5Emulation.prototype as any)[name] = value;
this._userExtensions[name] = value;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror 5 API requires any
public defineOption(name: string, defaultValue: any, onUpdate: OptionUpdateCallback) {
this._options[name] = {
value: defaultValue,
onUpdate,
};
onUpdate(this, defaultValue, this.Init);
}
// Override codemirror-vim's setOption to allow user-defined options
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Must match base class signature
public override setOption(name: string, value: any) {
if (name in this._options) {
const oldValue = this._options[name].value;
this._options[name].value = value;
this._options[name].onUpdate(this, value, oldValue);
} else if (this._builtInOptions.supportsOption(name)) {
this._builtInOptions.setOption(name, value);
} else {
super.setOption(name, value);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Must match base class signature
public override getOption(name: string): any {
if (name in this._options) {
return this._options[name].value;
} else {
return super.getOption(name);
}
}
public override coordsChar(coords: { left: number; top: number }, mode?: 'div' | 'local'): DocumentPosition {
// codemirror-vim's API only supports "div" mode. Thus, we convert
// local to div:
if (mode !== 'div') {
const bbox = this.editor.contentDOM.getBoundingClientRect();
coords = {
left: coords.left - bbox.left,
top: coords.top - bbox.top,
};
}
return super.coordsChar(coords, 'div');
}
// codemirror-vim's API doesn't match the API docs here -- it expects addOverlay
// to return a SearchQuery. As such, this override returns "any".
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public override addOverlay<State>(modeObject: OverlayType<State>): any {
if ('query' in modeObject) {
return super.addOverlay(modeObject);
}
return this._decorator.addOverlay(modeObject);
}
public override removeOverlay(overlay?: OverlayType<unknown>): void {
super.removeOverlay(overlay);
this._decorator.removeOverlay(overlay);
}
public addLineClass(lineNumber: number, where: string, className: string) {
this._decorator.addLineClass(lineNumber, where, className);
}
public removeLineClass(lineNumber: number, where: string, className: string) {
this._decorator.removeLineClass(lineNumber, where, className);
}
public addLineWidget(lineNumber: number, node: HTMLElement, options: LineWidgetOptions) {
return this._decorator.addLineWidget(lineNumber, node, options);
}
public addWidget(pos: DocumentPosition, node: HTMLElement) {
if (node.parentElement) {
node.remove();
}
const loc = posFromDocumentPosition(this.editor.state.doc, pos);
const screenCoords = this.editor.coordsAtPos(loc);
const bbox = this.editor.contentDOM.getBoundingClientRect();
node.style.position = 'absolute';
const left = screenCoords.left - bbox.left;
node.style.left = `${left}px`;
node.style.maxWidth = `${bbox.width - left}px`;
node.style.top = `${screenCoords.top + this.editor.scrollDOM.scrollTop}px`;
this.editor.scrollDOM.appendChild(node);
}
public markText(from: DocumentPosition, to: DocumentPosition, options?: MarkTextOptions) {
const doc = this.editor.state.doc;
return this._decorator.markText(
clampPositionToDoc(doc, posFromDocumentPosition(doc, from)),
clampPositionToDoc(doc, posFromDocumentPosition(doc, to)),
options,
);
}
public getDoc() {
// The emulation layer has several of the methods available on a CodeMirror 5 document.
// For some plugins, `this` is sufficient.
return this;
}
public static commands = (() => {
const commands: Record<string, CodeMirror5Command> = {
...BaseCodeMirror5Emulation.commands,
vimInsertListElement: (codeMirror: BaseCodeMirror5Emulation) => {
insertLineAfter(codeMirror.cm6);
Vim.handleKey(codeMirror, 'i', 'macro');
},
};
for (const commandName in editorCommands) {
const command = editorCommands[commandName as keyof typeof editorCommands];
commands[commandName] = (codeMirror: BaseCodeMirror5Emulation) => command(codeMirror.cm6);
}
// as any: Required to properly extend the base class -- without this,
// the commands dictionary isn't known (by TypeScript) to have the same
// properties as the commands dictionary in the parent class.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
return commands as any;
})();
public commands = CodeMirror5Emulation.commands;
private joplinCommandToCodeMirrorCommand(commandName: string): string|null {
const match = /^editor\.(.*)$/g.exec(commandName);
if (!match || !(match[1] in CodeMirror5Emulation.commands)) {
return null;
}
return match[1] as string;
}
public supportsJoplinCommand(commandName: string): boolean {
return this.joplinCommandToCodeMirrorCommand(commandName) in CodeMirror5Emulation.commands;
}
public execJoplinCommand(joplinCommandName: string) {
const commandName = this.joplinCommandToCodeMirrorCommand(joplinCommandName);
if (commandName === null) {
this.logMessage(`Unsupported Joplin command, ${joplinCommandName}`);
return;
}
if (this.commandExists(commandName)) {
return this.execCommand(commandName);
}
}
public commandExists(commandName: string) {
return commandName in CodeMirror5Emulation.commands || typeof this._userExtensions[commandName] === 'function';
}
public execCommand(name: string, ...args: unknown[]) {
if (!this.commandExists(name)) {
this.logMessage(`Unsupported CodeMirror command, ${name}`);
return;
}
if (name in CodeMirror5Emulation.commands) {
return CodeMirror5Emulation.commands[name as (keyof typeof CodeMirror5Emulation.commands)](this);
} else if (typeof this._userExtensions[name] === 'function') {
return this._userExtensions[name].call(this, ...args);
}
}
}

查看文件

@@ -0,0 +1,459 @@
// Handles adding decorations to the CodeMirror editor -- converts CodeMirror5-style calls
// to input accepted by CodeMirror 6
import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate, WidgetType } from '@codemirror/view';
import { ChangeDesc, Extension, Range, RangeSetBuilder, StateEffect, StateField, Transaction } from '@codemirror/state';
import { StreamParser, StringStream, indentUnit } from '@codemirror/language';
interface DecorationRange {
from: number;
to: number;
}
const mapRangeConfig = {
// Updates a range based on some change to the document
map: <T extends DecorationRange> (range: T, change: ChangeDesc): T => {
const from = change.mapPos(range.from);
const to = change.mapPos(range.to);
return {
...range,
from: Math.min(from, to),
to: Math.max(from, to),
};
},
};
interface CssDecorationSpec extends DecorationRange {
cssClass: string;
id?: number;
}
interface RemoveMarkDecorationSpec {
id: number;
}
const addLineDecorationEffect = StateEffect.define<CssDecorationSpec>(mapRangeConfig);
const removeLineDecorationEffect = StateEffect.define<CssDecorationSpec>(mapRangeConfig);
const addMarkDecorationEffect = StateEffect.define<CssDecorationSpec>(mapRangeConfig);
const removeMarkDecorationEffect = StateEffect.define<RemoveMarkDecorationSpec>();
const refreshOverlaysEffect = StateEffect.define();
export interface LineWidgetOptions {
className?: string;
above?: boolean;
}
interface LineWidgetDecorationSpec extends DecorationRange {
element: HTMLElement;
options: LineWidgetOptions;
}
const addLineWidgetEffect = StateEffect.define<LineWidgetDecorationSpec>(mapRangeConfig);
const removeLineWidgetEffect = StateEffect.define<{ element: HTMLElement }>();
export interface MarkTextOptions {
className: string;
}
class WidgetDecorationWrapper extends WidgetType {
public constructor(
public readonly element: HTMLElement,
public readonly options: LineWidgetOptions,
) {
super();
}
public override toDOM() {
const container = document.createElement('div');
this.element.remove();
container.appendChild(this.element);
if (this.options.className) {
container.classList.add(this.options.className);
}
// Applies margins and related CSS:
container.classList.add('cm-line');
return container;
}
}
interface LineWidgetControl {
node: HTMLElement;
clear(): void;
changed(): void;
className?: string;
}
export default class Decorator {
private _extension: Extension;
private _effectDecorations: DecorationSet = Decoration.none;
private _nextLineWidgetId = 0;
private constructor(private editor: EditorView) {
const decorator = this;
this._extension = [
// Overlay decorations -- recreate all decorations when the editor changes
// (overlay decorations require parsing the document and may change output
// when the editor/view changes.)
ViewPlugin.fromClass(class {
public decorations: DecorationSet;
public constructor(view: EditorView) {
this.decorations = decorator.createOverlayDecorations(view);
}
public update(update: ViewUpdate) {
const updated = false;
const doUpdate = () => {
if (updated) return;
this.decorations = decorator.createOverlayDecorations(update.view);
};
if (update.viewportChanged || update.docChanged) {
doUpdate();
} else {
for (const transaction of update.transactions) {
for (const effect of transaction.effects) {
if (effect.is(refreshOverlaysEffect)) {
doUpdate();
break;
}
}
}
}
}
}, {
decorations: v => v.decorations,
}),
// Other decorations based on effects. See the decoration examples: https://codemirror.net/examples/decoration/
// Note that EditorView.decorations.from is required for block widgets.
StateField.define<DecorationSet>({
create: () => Decoration.none,
update: (_, viewUpdate) => decorator.updateEffectDecorations([viewUpdate]),
provide: field => EditorView.decorations.from(field),
}),
];
}
public static create(editor: EditorView) {
const decorator = new Decorator(editor);
return { decorator, extension: decorator._extension };
}
private _decorationCache: Record<string, Decoration> = Object.create(null);
private _overlays: (StreamParser<unknown>)[] = [];
private classNameToCssDecoration(className: string, isLineDecoration: boolean, id?: number) {
let decoration;
if (className in this._decorationCache && id === undefined) {
decoration = this._decorationCache[className];
} else {
const attributes = { class: className };
if (isLineDecoration) {
decoration = Decoration.line({ attributes, id });
} else {
decoration = Decoration.mark({ attributes, id });
}
this._decorationCache[className] = decoration;
}
return decoration;
}
private updateEffectDecorations(transactions: Transaction[]) {
let decorations = this._effectDecorations;
// Update decoration positions
for (const transaction of transactions) {
decorations = decorations.map(transaction.changes);
// Add or remove decorations
for (const effect of transaction.effects) {
const isMarkDecoration = effect.is(addMarkDecorationEffect);
const isLineDecoration = effect.is(addLineDecorationEffect);
if (isMarkDecoration || isLineDecoration) {
const decoration = this.classNameToCssDecoration(
effect.value.cssClass, isLineDecoration, effect.value.id,
);
const value = effect.value;
const from = effect.value.from;
// Line decorations are specified to have a size-zero range.
const to = isLineDecoration ? from : value.to;
decorations = decorations.update({
add: [decoration.range(from, to)],
});
} else if (effect.is(removeLineDecorationEffect)) {
const doc = transaction.state.doc;
const { from, to } = effect.value;
// Handle the case where { from, to } point to an outdated document
const targetFrom = doc.lineAt(from).from;
const targetTo = doc.lineAt(to).to;
const targetDecoration = this.classNameToCssDecoration(
effect.value.cssClass, effect.is(removeLineDecorationEffect),
);
decorations = decorations.update({
// Returns true only for decorations that should be kept.
filter: (from, to, value) => {
const isInRange = from >= targetFrom && to <= targetTo;
return !isInRange || !value.eq(targetDecoration);
},
});
} else if (effect.is(removeMarkDecorationEffect)) {
decorations = decorations.update({
// Returns true only for decorations that should be kept.
filter: (_from, _to, value) => {
return value.spec.id !== effect.value.id;
},
});
} else if (effect.is(addLineWidgetEffect)) {
const options = effect.value.options;
const decoration = Decoration.widget({
widget: new WidgetDecorationWrapper(effect.value.element, options),
side: options.above ? -1 : 1,
block: true,
});
decorations = decorations.update({
add: [decoration.range(options.above ? effect.value.from : effect.value.to)],
});
} else if (effect.is(removeLineWidgetEffect)) {
decorations = decorations.update({
// Returns true only for decorations that should be kept.
filter: (_from, _to, value) => {
return value.spec.widget?.element !== effect.value.element;
},
});
}
}
}
this._effectDecorations = decorations;
return decorations;
}
private createOverlayDecorations(view: EditorView): DecorationSet {
const makeDecoration = (
tokenName: string, start: number, stop: number,
) => {
const isLineDecoration = tokenName.startsWith('line-');
// CM5 prefixes class names with cm-
tokenName = `cm-${tokenName}`;
const decoration = this.classNameToCssDecoration(tokenName, isLineDecoration);
return decoration.range(start, stop);
};
const indentSize = view.state.facet(indentUnit).length;
const newDecorations: Range<Decoration>[] = [];
for (const overlay of this._overlays) {
const state = overlay.startState?.(indentSize) ?? {};
for (const { from, to } of view.visibleRanges) {
const fromLine = view.state.doc.lineAt(from);
const toLine = view.state.doc.lineAt(to);
const fromLineNumber = fromLine.number;
const toLineNumber = toLine.number;
for (let i = fromLineNumber; i <= toLineNumber; i++) {
const line = view.state.doc.line(i);
const reader = new StringStream(
line.text,
view.state.tabSize,
indentSize,
);
let lastPos = 0;
(reader as unknown as Record<string, unknown>).baseToken ??= (): null => null;
while (!reader.eol()) {
const token = overlay.token(reader, state);
if (token) {
for (const className of token.split(/\s+/)) {
if (className.startsWith('line-')) {
newDecorations.push(makeDecoration(className, line.from, line.from));
} else {
const from = lastPos + line.from;
const to = reader.pos + line.from;
newDecorations.push(makeDecoration(className, from, to));
}
}
}
if (reader.pos === lastPos) {
throw new Error(
'Mark decoration position did not increase -- overlays must advance with each call to .token()',
);
}
lastPos = reader.pos;
}
}
}
}
// Required by CodeMirror:
// Should be sorted by from position, then by length.
newDecorations.sort((a, b) => {
if (a.from !== b.from) {
return a.from - b.from;
}
return a.to - b.to;
});
// Per the documentation, new tokens should be added in
// increasing order.
const decorations = new RangeSetBuilder<Decoration>();
for (const decoration of newDecorations) {
decorations.add(decoration.from, decoration.to, decoration.value);
}
return decorations.finish();
}
public addOverlay<State>(modeObject: StreamParser<State>) {
this._overlays.push(modeObject);
this.editor.dispatch({
effects: [refreshOverlaysEffect.of(null)],
});
return {
clear: () => this.removeOverlay(modeObject),
};
}
public removeOverlay(overlay: unknown) {
this._overlays = this._overlays.filter(other => other !== overlay);
this.editor.dispatch({
effects: [refreshOverlaysEffect.of(null)],
});
}
private addRemoveLineClass(lineNumber: number, className: string, add: boolean) {
// + 1: Convert from zero-indexed to one-indexed
const line = this.editor.state.doc.line(lineNumber + 1);
const effect = add ? addLineDecorationEffect : removeLineDecorationEffect;
this.editor.dispatch({
effects: effect.of({
cssClass: className,
from: line.from,
to: line.to,
}),
});
}
public addLineClass(lineNumber: number, _where: string, className: string) {
this.addRemoveLineClass(lineNumber, className, true);
}
public removeLineClass(lineNumber: number, _where: string, className: string) {
this.addRemoveLineClass(lineNumber, className, false);
}
public getLineClasses(lineNumber: number) {
const line = this.editor.state.doc.line(lineNumber + 1);
const lineClasses: string[] = [];
this._effectDecorations.between(line.from, line.to, (from, to, decoration) => {
if (from === line.from && to === line.to) {
const className = decoration.spec?.class;
if (typeof className === 'string') {
lineClasses.push(className);
}
}
});
return lineClasses;
}
public markText(from: number, to: number, options?: MarkTextOptions) {
const id = this._nextLineWidgetId++;
const effectOptions: CssDecorationSpec = {
cssClass: options.className ?? '',
id,
from,
to,
};
this.editor.dispatch({
effects: addMarkDecorationEffect.of(effectOptions),
});
return {
clear: () => {
this.editor.dispatch({
effects: removeMarkDecorationEffect.of({ id }),
});
},
};
}
private createLineWidgetControl(node: HTMLElement, options: LineWidgetOptions): LineWidgetControl {
return {
node,
clear: () => {
this.editor.dispatch({
effects: removeLineWidgetEffect.of({ element: node }),
});
},
changed: () => {
this.editor.requestMeasure();
},
className: options.className,
};
}
public getLineWidgets(lineNumber: number): LineWidgetControl[] {
const line = this.editor.state.doc.line(lineNumber + 1);
const lineWidgets: LineWidgetControl[] = [];
this._effectDecorations.between(line.from, line.to, (from, to, decoration) => {
if (from >= line.from && from <= line.to && from === to) {
const widget = decoration.spec?.widget;
if (widget && widget instanceof WidgetDecorationWrapper) {
lineWidgets.push(this.createLineWidgetControl(widget.element, widget.options));
}
}
});
return lineWidgets;
}
public addLineWidget(lineNumber: number, node: HTMLElement, options: LineWidgetOptions): LineWidgetControl {
const line = this.editor.state.doc.line(lineNumber + 1);
const lineWidgetOptions = {
from: line.from,
to: line.to,
element: node,
options,
};
this.editor.dispatch({
effects: addLineWidgetEffect.of(lineWidgetOptions),
});
return this.createLineWidgetControl(node, options);
}
}