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,311 @@
|
||||
import { EditorView, KeyBinding, keymap } from '@codemirror/view';
|
||||
import { EditorCommandType, EditorControl, EditorSettings, LogMessageCallback, ContentScriptData, SearchState, UserEventSource, UpdateBodyOptions } from '../types';
|
||||
import CodeMirror5Emulation from './CodeMirror5Emulation/CodeMirror5Emulation';
|
||||
import editorCommands from './editorCommands/editorCommands';
|
||||
import { Compartment, EditorSelection, Extension, StateEffect } from '@codemirror/state';
|
||||
import { updateLink } from './editorCommands/markdownCommands';
|
||||
import { searchPanelOpen, SearchQuery, setSearchQuery } from '@codemirror/search';
|
||||
import PluginLoader from './pluginApi/PluginLoader';
|
||||
import customEditorCompletion, { editorCompletionSource, enableLanguageDataAutocomplete } from './pluginApi/customEditorCompletion';
|
||||
import { CompletionSource } from '@codemirror/autocomplete';
|
||||
import { RegionSpec } from './utils/formatting/RegionSpec';
|
||||
import toggleInlineSelectionFormat from './utils/formatting/toggleInlineSelectionFormat';
|
||||
import getSearchState from './utils/getSearchState';
|
||||
import { noteIdFacet, setNoteIdEffect } from './extensions/selectedNoteIdExtension';
|
||||
import jumpToHash from './editorCommands/jumpToHash';
|
||||
import { resetImageResourceEffect } from './extensions/rendering/renderBlockImages';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { searchChangeSourceEffect } from './extensions/searchExtension';
|
||||
import cutOrCopyText, { ClipboardAction } from './editorCommands/cutOrCopyText';
|
||||
|
||||
const logger = Logger.create('CodeMirrorControl');
|
||||
|
||||
interface Callbacks {
|
||||
onUndoRedo(): void;
|
||||
onSettingsChange(newSettings: EditorSettings): void;
|
||||
onClearHistory(): void;
|
||||
onRemove(): void;
|
||||
onLogMessage: LogMessageCallback;
|
||||
}
|
||||
|
||||
type EditorUserCommand = (...args: unknown[])=> unknown;
|
||||
|
||||
// Copied from CodeMirror source code since type is not exported
|
||||
export type ScrollStrategy = 'nearest' | 'start' | 'end' | 'center';
|
||||
|
||||
export default class CodeMirrorControl extends CodeMirror5Emulation implements EditorControl {
|
||||
private _pluginControl: PluginLoader;
|
||||
private _userCommands: Map<string, EditorUserCommand> = new Map();
|
||||
|
||||
public constructor(
|
||||
editor: EditorView,
|
||||
private _callbacks: Callbacks,
|
||||
) {
|
||||
super(editor, _callbacks.onLogMessage);
|
||||
|
||||
this._pluginControl = new PluginLoader(this, _callbacks.onLogMessage);
|
||||
|
||||
this.addExtension(customEditorCompletion());
|
||||
}
|
||||
|
||||
public supportsCommand(name: string) {
|
||||
return name in editorCommands || this._userCommands.has(name) || super.commandExists(name);
|
||||
}
|
||||
|
||||
public override execCommand(name: string, ...args: unknown[]) {
|
||||
let commandOutput;
|
||||
if (this._userCommands.has(name)) {
|
||||
commandOutput = this._userCommands.get(name)(...args);
|
||||
} else if (name in editorCommands) {
|
||||
commandOutput = editorCommands[name as EditorCommandType](this.editor, ...args);
|
||||
} else if (super.commandExists(name)) {
|
||||
commandOutput = super.execCommand(name, ...args);
|
||||
} else if (super.supportsJoplinCommand(name)) {
|
||||
commandOutput = super.execJoplinCommand(name);
|
||||
} else {
|
||||
logger.warn('Unknown command', name);
|
||||
}
|
||||
|
||||
if (name === EditorCommandType.Undo || name === EditorCommandType.Redo) {
|
||||
this._callbacks.onUndoRedo();
|
||||
}
|
||||
|
||||
return commandOutput;
|
||||
}
|
||||
|
||||
public registerCommand(name: string, command: EditorUserCommand) {
|
||||
this._userCommands.set(name, command);
|
||||
}
|
||||
|
||||
public undo() {
|
||||
this.execCommand(EditorCommandType.Undo);
|
||||
this._callbacks.onUndoRedo();
|
||||
}
|
||||
|
||||
public redo() {
|
||||
this.execCommand(EditorCommandType.Redo);
|
||||
this._callbacks.onUndoRedo();
|
||||
}
|
||||
|
||||
public select(anchor: number, head: number) {
|
||||
const maximumPosition = this.editor.state.doc.length;
|
||||
this.editor.dispatch(this.editor.state.update({
|
||||
selection: {
|
||||
// Ensure that (anchor, head) are in range.
|
||||
// (CodeMirror throws when (anchor, head) are out-of-range.)
|
||||
anchor: Math.min(anchor, maximumPosition),
|
||||
head: Math.min(head, maximumPosition),
|
||||
},
|
||||
scrollIntoView: true,
|
||||
}));
|
||||
}
|
||||
|
||||
public clearHistory() {
|
||||
this._callbacks.onClearHistory();
|
||||
}
|
||||
|
||||
public setScrollPercent(fraction: number) {
|
||||
const maxScroll = this.editor.scrollDOM.scrollHeight - this.editor.scrollDOM.clientHeight;
|
||||
this.editor.scrollDOM.scrollTop = fraction * maxScroll;
|
||||
}
|
||||
|
||||
public insertText(text: string, userEvent?: UserEventSource) {
|
||||
this.editor.dispatch(this.editor.state.replaceSelection(text), { userEvent });
|
||||
}
|
||||
|
||||
public wrapSelections(start: string, end: string) {
|
||||
const regionSpec = RegionSpec.of({ template: { start, end } });
|
||||
|
||||
this.editor.dispatch(
|
||||
this.editor.state.changeByRange(range => {
|
||||
const update = toggleInlineSelectionFormat(this.editor.state, regionSpec, range);
|
||||
if (!update.range.empty) {
|
||||
// Deselect the start and end characters (roughly preserve the original
|
||||
// selection).
|
||||
update.range = EditorSelection.range(
|
||||
update.range.from + start.length,
|
||||
update.range.to - end.length,
|
||||
);
|
||||
}
|
||||
return update;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public updateBody(newBody: string, { noteId: newNoteId }: UpdateBodyOptions = {}) {
|
||||
const state = this.editor.state;
|
||||
const noteIdChanged = newNoteId && newNoteId !== state.facet(noteIdFacet);
|
||||
const updateNoteIdEffects = noteIdChanged ? [setNoteIdEffect.of(newNoteId)] : [];
|
||||
|
||||
// TODO: doc.toString() can be slow for large documents.
|
||||
const currentBody = state.doc.toString();
|
||||
|
||||
if (newBody !== currentBody) {
|
||||
// For now, collapse the selection to a single cursor
|
||||
// to ensure that the selection stays within the document
|
||||
// (and thus avoids an exception).
|
||||
const mainCursorPosition = state.selection.main.anchor;
|
||||
|
||||
// The maximum cursor position needs to be calculated using the EditorState,
|
||||
// to correctly account for line endings.
|
||||
const maxCursorPosition = state.toText(newBody).length;
|
||||
const newCursorPosition = Math.min(mainCursorPosition, maxCursorPosition);
|
||||
|
||||
this.editor.dispatch(state.update({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: state.doc.length,
|
||||
insert: newBody,
|
||||
},
|
||||
selection: EditorSelection.cursor(newCursorPosition),
|
||||
scrollIntoView: true,
|
||||
effects: [...updateNoteIdEffects],
|
||||
}));
|
||||
|
||||
return true;
|
||||
} else if (updateNoteIdEffects.length) {
|
||||
this.editor.dispatch(state.update({
|
||||
effects: updateNoteIdEffects,
|
||||
}));
|
||||
|
||||
// Although the note ID field changed, the body is the same:
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public updateLink(newLabel: string, newUrl: string) {
|
||||
updateLink(newLabel, newUrl)(this.editor);
|
||||
}
|
||||
|
||||
public updateSettings(newSettings: EditorSettings) {
|
||||
this._callbacks.onSettingsChange(newSettings);
|
||||
}
|
||||
|
||||
public getSearchState(): SearchState {
|
||||
return getSearchState(this.editor.state);
|
||||
}
|
||||
|
||||
public setSearchState(newState: SearchState, changeSource = 'setSearchState') {
|
||||
if (newState.dialogVisible !== searchPanelOpen(this.editor.state)) {
|
||||
this.execCommand(newState.dialogVisible ? EditorCommandType.ShowSearch : EditorCommandType.HideSearch);
|
||||
}
|
||||
|
||||
const query = new SearchQuery({
|
||||
search: newState.searchText,
|
||||
caseSensitive: newState.caseSensitive,
|
||||
regexp: newState.useRegex,
|
||||
replace: newState.replaceText,
|
||||
});
|
||||
this.editor.dispatch({
|
||||
effects: [
|
||||
searchChangeSourceEffect.of(changeSource),
|
||||
setSearchQuery.of(query),
|
||||
],
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public scrollToText(text: string, scrollStrategy: ScrollStrategy) {
|
||||
const doc = this.editor.state.doc;
|
||||
const index = doc.toString().indexOf(text);
|
||||
const textFound = index >= 0;
|
||||
|
||||
if (textFound) {
|
||||
this.editor.dispatch({
|
||||
effects: EditorView.scrollIntoView(index, { y: scrollStrategy }),
|
||||
});
|
||||
}
|
||||
|
||||
return textFound;
|
||||
}
|
||||
|
||||
public jumpToHash(hash: string) {
|
||||
return jumpToHash(this.editor, hash);
|
||||
}
|
||||
|
||||
public addStyles(...styles: Parameters<typeof EditorView.theme>) {
|
||||
const compartment = new Compartment();
|
||||
this.editor.dispatch({
|
||||
effects: StateEffect.appendConfig.of(
|
||||
compartment.of(EditorView.theme(...styles)),
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
remove: () => {
|
||||
this.editor.dispatch({
|
||||
effects: compartment.reconfigure([]),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public onResourceChanged(id: string) {
|
||||
this.editor.dispatch({
|
||||
effects: [
|
||||
resetImageResourceEffect.of({ id }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
public setContentScripts(plugins: ContentScriptData[]) {
|
||||
return this._pluginControl.setPlugins(plugins);
|
||||
}
|
||||
|
||||
public remove() {
|
||||
this._pluginControl.remove();
|
||||
this._callbacks.onRemove();
|
||||
}
|
||||
|
||||
public cutText(writeClipboard: (text: string)=> void) {
|
||||
return cutOrCopyText(writeClipboard, ClipboardAction.Cut)(this.editor);
|
||||
}
|
||||
|
||||
public copyText(writeClipboard: (text: string)=> void) {
|
||||
return cutOrCopyText(writeClipboard, ClipboardAction.Copy)(this.editor);
|
||||
}
|
||||
|
||||
//
|
||||
// CodeMirror-specific methods
|
||||
//
|
||||
|
||||
public prependKeymap(bindings: readonly KeyBinding[]) {
|
||||
const compartment = new Compartment();
|
||||
this.editor.dispatch({
|
||||
effects: StateEffect.appendConfig.of([
|
||||
compartment.of(keymap.of(bindings)),
|
||||
]),
|
||||
});
|
||||
|
||||
return {
|
||||
remove: () => {
|
||||
this.editor.dispatch({
|
||||
effects: compartment.reconfigure([]),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public joplinExtensions = {
|
||||
// Some plugins want to enable autocompletion from *just* that plugin, without also
|
||||
// enabling autocompletion for text within code blocks (and other built-in completion
|
||||
// sources).
|
||||
// To support this, we need to provide extensions that wrap the built-in autocomplete.
|
||||
// See https://discuss.codemirror.net/t/autocompletion-merging-override-in-config/7853
|
||||
completionSource: (completionSource: CompletionSource) => editorCompletionSource.of(completionSource),
|
||||
enableLanguageDataAutocomplete: enableLanguageDataAutocomplete,
|
||||
|
||||
// Allow accessing the current note ID
|
||||
noteIdFacet: noteIdFacet.reader,
|
||||
// Allows watching the note ID for changes
|
||||
setNoteIdEffect,
|
||||
};
|
||||
|
||||
public addExtension(extension: Extension) {
|
||||
this.editor.dispatch({
|
||||
effects: StateEffect.appendConfig.of([extension]),
|
||||
});
|
||||
}
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户