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,317 @@
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import { ContentScriptData, EditorCommandType, EditorControl, EditorProps, EditorSettings, SearchState, UpdateBodyOptions, UserEventSource } from '../types';
|
||||
import { EditorState, TextSelection, Transaction } from 'prosemirror-state';
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
import { DOMParser as ProseMirrorDomParser } from 'prosemirror-model';
|
||||
import { history } from 'prosemirror-history';
|
||||
import commands from './commands/commands';
|
||||
import schema from './schema';
|
||||
import { gapCursor } from 'prosemirror-gapcursor';
|
||||
import { dropCursor } from 'prosemirror-dropcursor';
|
||||
import { EditorEventType } from '../events';
|
||||
import UndoStackSynchronizer from './utils/UndoStackSynchronizer';
|
||||
import computeSelectionFormatting from './utils/computeSelectionFormatting';
|
||||
import { defaultSelectionFormatting, selectionFormattingEqual } from '../SelectionFormatting';
|
||||
import joplinEditablePlugin from './plugins/joplinEditablePlugin/joplinEditablePlugin';
|
||||
import keymapExtension from './plugins/keymapPlugin';
|
||||
import inputRulesExtension from './plugins/inputRulesPlugin';
|
||||
import originalMarkupPlugin from './plugins/originalMarkupPlugin';
|
||||
import preprocessEditorInput from './utils/preprocessEditorInput';
|
||||
import listPlugin from './plugins/listPlugin';
|
||||
import searchExtension from './plugins/searchPlugin';
|
||||
import joplinEditorApiPlugin, { setEditorApi } from './plugins/joplinEditorApiPlugin';
|
||||
import linkTooltipPlugin from './plugins/linkTooltipPlugin';
|
||||
import { OnCreateCodeEditor as OnCreateCodeEditor, RendererControl } from './types';
|
||||
import imagePlugin, { onResourceDownloaded } from './plugins/imagePlugin';
|
||||
import getFileFromPasteEvent from '../utils/getFileFromPasteEvent';
|
||||
import { RenderResult } from '../../renderer/types';
|
||||
import postprocessEditorOutput from './utils/postprocessEditorOutput';
|
||||
import detailsPlugin from './plugins/detailsPlugin';
|
||||
import tablePlugin from './plugins/tablePlugin';
|
||||
import clampPointToDocument from './utils/clampPointToDocument';
|
||||
|
||||
interface ProseMirrorControl extends EditorControl {
|
||||
getSettings(): EditorSettings;
|
||||
}
|
||||
|
||||
|
||||
const createEditor = async (
|
||||
parentElement: HTMLElement,
|
||||
props: EditorProps,
|
||||
renderer: RendererControl,
|
||||
createCodeEditor: OnCreateCodeEditor,
|
||||
): Promise<ProseMirrorControl> => {
|
||||
const renderNodeToMarkup = (node: Node|DocumentFragment) => {
|
||||
return renderer.renderHtmlToMarkup(
|
||||
postprocessEditorOutput(node),
|
||||
);
|
||||
};
|
||||
|
||||
const proseMirrorParser = ProseMirrorDomParser.fromSchema(schema);
|
||||
|
||||
const cssContainer = document.createElement('style');
|
||||
parentElement.appendChild(cssContainer);
|
||||
|
||||
const { plugin: markupTracker, stateToMarkup } = originalMarkupPlugin(renderNodeToMarkup);
|
||||
const { plugin: searchPlugin, updateState: updateSearchState } = searchExtension(props.onEvent);
|
||||
|
||||
const renderAndPostprocessHtml = async (markup: string) => {
|
||||
const renderResult = await renderer.renderMarkupToHtml(markup, {
|
||||
forceMarkdown: false,
|
||||
isFullPageRender: true,
|
||||
});
|
||||
|
||||
const dom = new DOMParser().parseFromString(renderResult.html, 'text/html');
|
||||
preprocessEditorInput(dom, markup);
|
||||
|
||||
return { renderResult, dom };
|
||||
};
|
||||
const updateGlobalCss = (renderResult: RenderResult) => {
|
||||
cssContainer.replaceChildren(
|
||||
document.createTextNode(renderResult.cssStrings.join('\n')),
|
||||
);
|
||||
};
|
||||
|
||||
let settings = props.settings;
|
||||
const createInitialState = async (markup: string) => {
|
||||
const { renderResult, dom } = await renderAndPostprocessHtml(markup);
|
||||
updateGlobalCss(renderResult);
|
||||
|
||||
let state = EditorState.create({
|
||||
doc: proseMirrorParser.parse(dom),
|
||||
plugins: [
|
||||
inputRulesExtension,
|
||||
keymapExtension,
|
||||
gapCursor(),
|
||||
dropCursor(),
|
||||
history(),
|
||||
detailsPlugin,
|
||||
searchPlugin,
|
||||
joplinEditablePlugin,
|
||||
markupTracker,
|
||||
listPlugin,
|
||||
linkTooltipPlugin,
|
||||
tablePlugin,
|
||||
joplinEditorApiPlugin,
|
||||
imagePlugin,
|
||||
].flat(),
|
||||
});
|
||||
|
||||
const cachedLocalizations = new Map<string, string|Promise<string>>();
|
||||
state = state.apply(
|
||||
setEditorApi(state.tr, {
|
||||
onEvent: props.onEvent,
|
||||
renderer,
|
||||
createCodeEditor: createCodeEditor,
|
||||
localize: async (input: string) => {
|
||||
if (cachedLocalizations.has(input)) {
|
||||
return cachedLocalizations.get(input);
|
||||
}
|
||||
|
||||
const result = props.onLocalize(input);
|
||||
cachedLocalizations.set(input, result);
|
||||
return result;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const undoStackSynchronizer = new UndoStackSynchronizer(props.onEvent);
|
||||
const onDocumentUpdate = (newState: EditorState) => {
|
||||
props.onEvent({
|
||||
kind: EditorEventType.Change,
|
||||
value: stateToMarkup(newState),
|
||||
});
|
||||
};
|
||||
let lastSelectionFormatting = defaultSelectionFormatting;
|
||||
const onUpdateSelection = (newState: EditorState) => {
|
||||
const selectionFormatting = computeSelectionFormatting(newState, settings);
|
||||
if (!selectionFormattingEqual(lastSelectionFormatting, selectionFormatting)) {
|
||||
lastSelectionFormatting = selectionFormatting;
|
||||
props.onEvent({
|
||||
kind: EditorEventType.SelectionFormattingChange,
|
||||
formatting: selectionFormatting,
|
||||
});
|
||||
}
|
||||
|
||||
props.onEvent({
|
||||
kind: EditorEventType.SelectionRangeChange,
|
||||
anchor: newState.selection.anchor,
|
||||
head: newState.selection.head,
|
||||
from: newState.selection.from,
|
||||
to: newState.selection.to,
|
||||
});
|
||||
};
|
||||
|
||||
const view = new EditorView(parentElement, {
|
||||
state: await createInitialState(props.initialText),
|
||||
editable: () => !settings.readOnly,
|
||||
dispatchTransaction: transaction => {
|
||||
const newState = view.state.apply(transaction);
|
||||
|
||||
if (transaction.docChanged) {
|
||||
onDocumentUpdate(newState);
|
||||
}
|
||||
|
||||
if (transaction.selectionSet || transaction.docChanged || transaction.storedMarksSet) {
|
||||
onUpdateSelection(newState);
|
||||
}
|
||||
|
||||
undoStackSynchronizer.schedulePostUndoRedoDepthChange(view);
|
||||
|
||||
view.updateState(newState);
|
||||
},
|
||||
attributes: {
|
||||
'aria-label': settings.editorLabel,
|
||||
class: 'prosemirror-editor',
|
||||
},
|
||||
handleDOMEvents: {
|
||||
paste: (_view, event) => {
|
||||
const fileToPaste = getFileFromPasteEvent(event);
|
||||
if (fileToPaste) {
|
||||
event.preventDefault();
|
||||
void props.onPasteFile(fileToPaste);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const editorControl: ProseMirrorControl = {
|
||||
supportsCommand: (name: EditorCommandType | string) => {
|
||||
return name in commands && !!commands[name as keyof typeof commands];
|
||||
},
|
||||
execCommand: (name: EditorCommandType | string, ...args) => {
|
||||
if (!editorControl.supportsCommand(name)) {
|
||||
throw new Error(`Unsupported command: ${name}`);
|
||||
}
|
||||
|
||||
commands[name as keyof typeof commands](view.state, view.dispatch, view, args);
|
||||
},
|
||||
undo: () => {
|
||||
void editorControl.execCommand(EditorCommandType.Undo);
|
||||
},
|
||||
redo: () => {
|
||||
void editorControl.execCommand(EditorCommandType.Redo);
|
||||
},
|
||||
select: (anchor: number, head: number) => {
|
||||
const transaction = view.state.tr;
|
||||
transaction.setSelection(
|
||||
TextSelection.create(
|
||||
transaction.doc,
|
||||
clampPointToDocument(view.state, anchor),
|
||||
clampPointToDocument(view.state, head),
|
||||
),
|
||||
);
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
setScrollPercent: (fraction: number) => {
|
||||
// TODO: Handle this in a better way?
|
||||
document.scrollingElement.scrollTop = fraction * document.scrollingElement.scrollHeight;
|
||||
},
|
||||
insertText: async (text: string, _source?: UserEventSource) => {
|
||||
const { dom } = await renderAndPostprocessHtml(text);
|
||||
view.pasteHTML(new XMLSerializer().serializeToString(dom));
|
||||
},
|
||||
updateBody: async (newBody: string, _updateBodyOptions?: UpdateBodyOptions) => {
|
||||
view.updateState(await createInitialState(newBody));
|
||||
},
|
||||
getSettings: () => {
|
||||
return settings;
|
||||
},
|
||||
updateSettings: async (newSettings: EditorSettings) => {
|
||||
const oldSettings = settings;
|
||||
settings = newSettings;
|
||||
if (oldSettings.readOnly !== newSettings.readOnly) {
|
||||
view.setProps({ editable: () => !settings.readOnly });
|
||||
}
|
||||
|
||||
if (oldSettings.themeData.themeId !== newSettings.themeData.themeId) {
|
||||
// Refresh global CSS when the theme changes -- render the full document
|
||||
// to avoid required CSS being omitted due to missing markup.
|
||||
const { renderResult } = await renderAndPostprocessHtml(stateToMarkup(view.state));
|
||||
updateGlobalCss(renderResult);
|
||||
}
|
||||
},
|
||||
updateLink: (label: string, url: string) => {
|
||||
const doc = view.state.doc;
|
||||
const selection = view.state.selection;
|
||||
let transaction: Transaction = view.state.tr;
|
||||
|
||||
let linkFrom = selection.from;
|
||||
let linkTo = selection.to;
|
||||
doc.nodesBetween(selection.from, selection.to, (node, position) => {
|
||||
const linkMark = node.marks.find(mark => mark.type === schema.marks.link);
|
||||
if (linkMark) {
|
||||
linkFrom = position;
|
||||
linkTo = position + node.nodeSize;
|
||||
transaction = transaction.removeMark(
|
||||
position, position + node.nodeSize, schema.marks.link,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions that return a point at the current stage of
|
||||
// the transaction:
|
||||
const map = (position: number, associativity: number) => transaction.mapping.map(position, associativity);
|
||||
|
||||
// Update the link text -- if an existing link, replace just the text
|
||||
// in that link.
|
||||
if (label !== transaction.doc.textBetween(linkFrom, linkTo)) {
|
||||
transaction = transaction.insertText(
|
||||
label,
|
||||
linkFrom,
|
||||
linkTo,
|
||||
);
|
||||
linkFrom = map(linkFrom, -1); // Ensure that linkFrom is to the left of the text
|
||||
linkTo = map(linkTo, 1); // Ensure that linkTo is to the right of the text
|
||||
}
|
||||
|
||||
// Add the URL
|
||||
if (url) {
|
||||
transaction = transaction.addMark(
|
||||
// Use the entire selection,
|
||||
linkFrom,
|
||||
linkTo,
|
||||
schema.mark(schema.marks.link, { href: url }),
|
||||
);
|
||||
}
|
||||
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
setSearchState: (newState: SearchState, changeSource = 'setSearchState') => {
|
||||
view.dispatch(updateSearchState(view.state, newState, changeSource));
|
||||
},
|
||||
setContentScripts: (_plugins: ContentScriptData[]) => {
|
||||
throw new Error('setContentScripts not implemented.');
|
||||
},
|
||||
onResourceChanged: async (resourceId: string) => {
|
||||
const rendered = await renderAndPostprocessHtml(`<img src=":/${resourceId}"/>`);
|
||||
const renderedImage = rendered.dom.querySelector('img');
|
||||
|
||||
// The resource might not be an image. If so, skip.
|
||||
if (!renderedImage) {
|
||||
return;
|
||||
}
|
||||
const stillNotLoaded = renderedImage.classList.contains('not-loaded-resource');
|
||||
if (stillNotLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceSrc = renderedImage?.src;
|
||||
// TODO: Handle the more general case where the resource changed externally
|
||||
onResourceDownloaded(view, resourceId, resourceSrc);
|
||||
},
|
||||
remove: () => {
|
||||
view.dom.remove();
|
||||
props.onEvent({ kind: EditorEventType.Remove });
|
||||
},
|
||||
focus: () => focus('createEditor', view),
|
||||
};
|
||||
return editorControl;
|
||||
};
|
||||
|
||||
export default createEditor;
|
||||
在新工单中引用
屏蔽一个用户