mirror of
https://github.com/laurent22/joplin.git
synced 2026-10-25 02:47:28 +00:00
318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
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;
|