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
一些检查失败了
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
这个提交包含在:
@@ -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);
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户