You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-10 22:11:50 +02:00
This commit is contained in:
@@ -1059,7 +1059,10 @@ packages/editor/ProseMirror/commands.js
|
||||
packages/editor/ProseMirror/createEditor.js
|
||||
packages/editor/ProseMirror/index.js
|
||||
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
|
||||
packages/editor/ProseMirror/plugins/keymapPlugin.js
|
||||
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
|
||||
@@ -1075,6 +1078,9 @@ packages/editor/ProseMirror/types.js
|
||||
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
|
||||
packages/editor/ProseMirror/utils/canReplaceSelectionWith.js
|
||||
packages/editor/ProseMirror/utils/computeSelectionFormatting.js
|
||||
packages/editor/ProseMirror/utils/dom/createTextArea.js
|
||||
packages/editor/ProseMirror/utils/dom/createTextNode.js
|
||||
packages/editor/ProseMirror/utils/dom/createUniqueId.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
|
||||
packages/editor/ProseMirror/utils/jumpToHash.js
|
||||
|
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1032,7 +1032,10 @@ packages/editor/ProseMirror/commands.js
|
||||
packages/editor/ProseMirror/createEditor.js
|
||||
packages/editor/ProseMirror/index.js
|
||||
packages/editor/ProseMirror/plugins/inputRulesPlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js
|
||||
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
|
||||
packages/editor/ProseMirror/plugins/keymapPlugin.js
|
||||
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
|
||||
@@ -1048,6 +1051,9 @@ packages/editor/ProseMirror/types.js
|
||||
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
|
||||
packages/editor/ProseMirror/utils/canReplaceSelectionWith.js
|
||||
packages/editor/ProseMirror/utils/computeSelectionFormatting.js
|
||||
packages/editor/ProseMirror/utils/dom/createTextArea.js
|
||||
packages/editor/ProseMirror/utils/dom/createTextNode.js
|
||||
packages/editor/ProseMirror/utils/dom/createUniqueId.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
|
||||
packages/editor/ProseMirror/utils/jumpToHash.js
|
||||
|
@@ -410,6 +410,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
onSelectPastBeginning={onSelectPastBeginning}
|
||||
externalSearch={props.searchMarkers}
|
||||
useLocalSearch={props.useLocalSearch}
|
||||
onLocalize={_}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@@ -132,6 +132,7 @@ const useRerenderHandler = (props: Props) => {
|
||||
highlightedKeywords: props.highlightedKeywords,
|
||||
resources: props.noteResources,
|
||||
pluginAssetContainerSelector: '#joplin-container-pluginAssetsContainer',
|
||||
removeUnusedPluginAssets: true,
|
||||
|
||||
// If the hash changed, we don't set initial scroll -- we want to scroll to the hash
|
||||
// instead.
|
||||
|
@@ -121,6 +121,7 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
|
||||
initialText: props.initialText,
|
||||
initialNoteId: props.noteId,
|
||||
settings: props.editorSettings,
|
||||
onLocalize: _,
|
||||
},
|
||||
webviewRef,
|
||||
});
|
||||
|
@@ -11,6 +11,7 @@ export const initializeEditor = ({
|
||||
initialText,
|
||||
initialNoteId,
|
||||
settings,
|
||||
onLocalize,
|
||||
}: EditorProps) => {
|
||||
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', null);
|
||||
|
||||
@@ -23,6 +24,7 @@ export const initializeEditor = ({
|
||||
initialText,
|
||||
initialNoteId,
|
||||
settings,
|
||||
onLocalize,
|
||||
|
||||
onPasteFile: async (data) => {
|
||||
const base64 = await readFileToBase64(data);
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import { EditorControl, EditorSettings } from '@joplin/editor/types';
|
||||
import { EditorControl, EditorSettings, OnLocalize } from '@joplin/editor/types';
|
||||
|
||||
export interface EditorProcessApi {
|
||||
editor: EditorControl;
|
||||
@@ -14,6 +14,7 @@ export interface EditorProps {
|
||||
parentElementClassName: string;
|
||||
initialText: string;
|
||||
initialNoteId: string;
|
||||
onLocalize: OnLocalize;
|
||||
settings: EditorSettings;
|
||||
}
|
||||
|
||||
|
@@ -12,6 +12,7 @@ const defaultRendererSettings: RenderSettings = {
|
||||
noteHash: '',
|
||||
initialScroll: 0,
|
||||
readAssetBlob: async (_path: string) => new Blob(),
|
||||
removeUnusedPluginAssets: true,
|
||||
|
||||
createEditPopupSyntax: '',
|
||||
destroyEditPopupSyntax: '',
|
||||
|
@@ -23,6 +23,7 @@ export interface RenderSettings {
|
||||
initialScroll: number;
|
||||
// If [null], plugin assets are not added to the document
|
||||
pluginAssetContainerSelector: string|null;
|
||||
removeUnusedPluginAssets: boolean;
|
||||
|
||||
splitted?: boolean; // Move CSS into a separate output
|
||||
mapsToLine?: boolean; // Sourcemaps
|
||||
@@ -156,6 +157,7 @@ export default class Renderer {
|
||||
inlineAssets: this.setupOptions_.useTransferredFiles,
|
||||
readAssetBlob: settings.readAssetBlob,
|
||||
container: document.querySelector(settings.pluginAssetContainerSelector),
|
||||
removeUnusedPluginAssets: settings.removeUnusedPluginAssets,
|
||||
});
|
||||
|
||||
// Some plugins require this event to be dispatched just after being added.
|
||||
|
@@ -39,6 +39,7 @@ const rewriteInternalAssetLinks = async (asset: RenderResultPluginAsset, content
|
||||
|
||||
interface Options {
|
||||
inlineAssets: boolean;
|
||||
removeUnusedPluginAssets: boolean;
|
||||
container: HTMLElement;
|
||||
readAssetBlob?(path: string): Promise<Blob>;
|
||||
}
|
||||
@@ -137,16 +138,22 @@ const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Optio
|
||||
// light to dark theme, and then back to light theme - in that case
|
||||
// the viewer would remain dark because it would use the dark
|
||||
// stylesheet that would still be in the DOM.
|
||||
for (const [assetId, asset] of Object.entries(pluginAssetsAdded_)) {
|
||||
if (!processedAssetIds.includes(assetId)) {
|
||||
try {
|
||||
asset.element.remove();
|
||||
} catch (error) {
|
||||
// We don't throw an exception but we log it since
|
||||
// it shouldn't happen
|
||||
console.warn('Tried to remove an asset but got an error', error);
|
||||
//
|
||||
// In some cases, however, we only want to rerender part of the document.
|
||||
// In this case, old plugin assets may have been from the last full-page
|
||||
// render and should not be removed.
|
||||
if (options.removeUnusedPluginAssets) {
|
||||
for (const [assetId, asset] of Object.entries(pluginAssetsAdded_)) {
|
||||
if (!processedAssetIds.includes(assetId)) {
|
||||
try {
|
||||
asset.element.remove();
|
||||
} catch (error) {
|
||||
// We don't throw an exception but we log it since
|
||||
// it shouldn't happen
|
||||
console.warn('Tried to remove an asset but got an error', error);
|
||||
}
|
||||
pluginAssetsAdded_[assetId] = null;
|
||||
}
|
||||
pluginAssetsAdded_[assetId] = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@@ -54,8 +54,13 @@ export interface RenderOptions {
|
||||
highlightedKeywords: string[];
|
||||
resources: ResourceInfos;
|
||||
themeOverrides: Record<string, string|number>;
|
||||
|
||||
// If null, plugin assets will not be added to the document.
|
||||
pluginAssetContainerSelector: string|null;
|
||||
// When true, plugin assets are removed from the container when not used by the render result.
|
||||
// This should be true for full-page renders.
|
||||
removeUnusedPluginAssets: boolean;
|
||||
|
||||
noteHash: string;
|
||||
initialScroll: number;
|
||||
|
||||
|
@@ -219,6 +219,7 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
|
||||
}
|
||||
return shim.fsDriver().fileAtPath(resolvedPath);
|
||||
},
|
||||
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
|
||||
};
|
||||
|
||||
await transferResources(options.resources);
|
||||
|
@@ -16,22 +16,6 @@ const postprocessHtml = (html: HTMLElement) => {
|
||||
resource.src = `:/${resourceId}`;
|
||||
}
|
||||
|
||||
// Re-add newlines to data-joplin-source-* that were removed
|
||||
// by ProseMirror.
|
||||
// TODO: Try to find a better solution
|
||||
const sourceBlocks = html.querySelectorAll<HTMLPreElement>(
|
||||
'pre[data-joplin-source-open][data-joplin-source-close].joplin-source',
|
||||
);
|
||||
for (const sourceBlock of sourceBlocks) {
|
||||
const isBlock = sourceBlock.parentElement.tagName !== 'SPAN';
|
||||
if (isBlock) {
|
||||
const originalOpen = sourceBlock.getAttribute('data-joplin-source-open');
|
||||
const originalClose = sourceBlock.getAttribute('data-joplin-source-close');
|
||||
sourceBlock.setAttribute('data-joplin-source-open', `${originalOpen}\n`);
|
||||
sourceBlock.setAttribute('data-joplin-source-close', `\n${originalClose}`);
|
||||
}
|
||||
}
|
||||
|
||||
return html;
|
||||
};
|
||||
|
||||
@@ -73,6 +57,7 @@ export const initialize = async ({
|
||||
settings,
|
||||
initialText,
|
||||
initialNoteId,
|
||||
onLocalize: messenger.remoteApi.onLocalize,
|
||||
|
||||
onPasteFile: async (data) => {
|
||||
const base64 = await readFileToBase64(data);
|
||||
@@ -85,14 +70,20 @@ export const initialize = async ({
|
||||
void messenger.remoteApi.onEditorEvent(event);
|
||||
},
|
||||
}, {
|
||||
renderMarkupToHtml: async (markup) => {
|
||||
renderMarkupToHtml: async (markup, options) => {
|
||||
let language = MarkupLanguage.Markdown;
|
||||
if (settings.language === EditorLanguageType.Html && !options.forceMarkdown) {
|
||||
language = MarkupLanguage.Html;
|
||||
}
|
||||
|
||||
return await messenger.remoteApi.onRender({
|
||||
markup,
|
||||
language: settings.language === EditorLanguageType.Html ? MarkupLanguage.Html : MarkupLanguage.Markdown,
|
||||
language,
|
||||
}, {
|
||||
pluginAssetContainerSelector: `#${assetContainer.id}`,
|
||||
splitted: true,
|
||||
mapsToLine: true,
|
||||
removeUnusedPluginAssets: options.isFullPageRender,
|
||||
});
|
||||
},
|
||||
renderHtmlToMarkup: (node) => {
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { EditorEvent } from '@joplin/editor/events';
|
||||
import { EditorControl, EditorSettings, SearchState } from '@joplin/editor/types';
|
||||
import { EditorControl, EditorSettings, OnLocalize, SearchState } from '@joplin/editor/types';
|
||||
import { MarkupRecord, RendererControl } from '../rendererBundle/types';
|
||||
import { RenderResult } from '@joplin/renderer/types';
|
||||
|
||||
@@ -19,6 +19,7 @@ type RenderOptionsSlice = {
|
||||
pluginAssetContainerSelector: string;
|
||||
splitted: boolean;
|
||||
mapsToLine: true;
|
||||
removeUnusedPluginAssets: boolean;
|
||||
};
|
||||
|
||||
export interface MainProcessApi {
|
||||
@@ -26,6 +27,7 @@ export interface MainProcessApi {
|
||||
logMessage(message: string): Promise<void>;
|
||||
onRender(markup: MarkupRecord, options: RenderOptionsSlice): Promise<RenderResult>;
|
||||
onPasteFile(type: string, base64: string): Promise<void>;
|
||||
onLocalize: OnLocalize;
|
||||
}
|
||||
|
||||
export interface RichTextEditorControl {
|
||||
|
@@ -11,6 +11,7 @@ import shim from '@joplin/lib/shim';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import { RendererControl, RenderOptions } from '../rendererBundle/types';
|
||||
import { ResourceInfos } from '@joplin/renderer/types';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { defaultSearchState } from '../../components/NoteEditor/SearchPanel';
|
||||
|
||||
const logger = Logger.create('useWebViewSetup');
|
||||
@@ -50,6 +51,7 @@ const useMessenger = (props: UseMessengerProps) => {
|
||||
noteHash: '',
|
||||
initialScroll: 0,
|
||||
pluginAssetContainerSelector: null,
|
||||
removeUnusedPluginAssets: true,
|
||||
};
|
||||
|
||||
return useMemo(() => {
|
||||
@@ -70,6 +72,7 @@ const useMessenger = (props: UseMessengerProps) => {
|
||||
splitted: options.splitted,
|
||||
pluginAssetContainerSelector: options.pluginAssetContainerSelector,
|
||||
mapsToLine: options.mapsToLine,
|
||||
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
|
||||
},
|
||||
);
|
||||
return renderResult;
|
||||
@@ -77,6 +80,7 @@ const useMessenger = (props: UseMessengerProps) => {
|
||||
onPasteFile: async (type: string, base64: string) => {
|
||||
onAttachRef.current(type, base64);
|
||||
},
|
||||
onLocalize: _,
|
||||
};
|
||||
|
||||
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
|
||||
|
@@ -40,6 +40,7 @@ describe('createEditor', () => {
|
||||
settings: editorSettings,
|
||||
onEvent: _event => {},
|
||||
onLogMessage: _message => {},
|
||||
onLocalize: input => input,
|
||||
onPasteFile: null,
|
||||
});
|
||||
|
||||
@@ -69,6 +70,7 @@ describe('createEditor', () => {
|
||||
settings: editorSettings,
|
||||
onEvent: _event => {},
|
||||
onLogMessage: _message => {},
|
||||
onLocalize: input => input,
|
||||
onPasteFile: null,
|
||||
});
|
||||
|
||||
@@ -138,6 +140,7 @@ describe('createEditor', () => {
|
||||
settings: editorSettings,
|
||||
onEvent: _event => {},
|
||||
onLogMessage: _message => {},
|
||||
onLocalize: input => input,
|
||||
onPasteFile: null,
|
||||
});
|
||||
|
||||
@@ -188,6 +191,7 @@ describe('createEditor', () => {
|
||||
settings: editorSettings,
|
||||
onEvent: () => {},
|
||||
onLogMessage: () => {},
|
||||
onLocalize: input => input,
|
||||
onPasteFile: null,
|
||||
});
|
||||
const editorState = editor.editor.state;
|
||||
|
@@ -12,6 +12,7 @@ const createEditorControl = (initialText: string) => {
|
||||
onEvent: _event => {},
|
||||
onLogMessage: _message => {},
|
||||
onPasteFile: null,
|
||||
onLocalize: input=>input,
|
||||
});
|
||||
};
|
||||
|
||||
|
@@ -99,7 +99,10 @@ const commands: Record<EditorCommandType, ExtendedCommand|null> = {
|
||||
if (canReplaceSelectionWith(state.selection, nodeType)) {
|
||||
void (async () => {
|
||||
const separator = block ? '$$' : '$';
|
||||
const rendered = await renderer.renderMarkupToHtml(`${separator}${selectedText}${separator}`);
|
||||
const rendered = await renderer.renderMarkupToHtml(`${separator}${selectedText}${separator}`, {
|
||||
forceMarkdown: true,
|
||||
isFullPageRender: false,
|
||||
});
|
||||
|
||||
if (view) {
|
||||
view.pasteHTML(rendered.html);
|
||||
|
@@ -11,7 +11,7 @@ import { EditorEventType } from '../events';
|
||||
import UndoStackSynchronizer from './utils/UndoStackSynchronizer';
|
||||
import computeSelectionFormatting from './utils/computeSelectionFormatting';
|
||||
import { defaultSelectionFormatting, selectionFormattingEqual } from '../SelectionFormatting';
|
||||
import joplinEditablePlugin from './plugins/joplinEditablePlugin';
|
||||
import joplinEditablePlugin from './plugins/joplinEditablePlugin/joplinEditablePlugin';
|
||||
import keymapExtension from './plugins/keymapPlugin';
|
||||
import inputRulesExtension from './plugins/inputRulesPlugin';
|
||||
import originalMarkupPlugin from './plugins/originalMarkupPlugin';
|
||||
@@ -44,7 +44,10 @@ const createEditor = async (
|
||||
const { plugin: searchPlugin, updateState: updateSearchState } = searchExtension(props.onEvent);
|
||||
|
||||
const renderAndPostprocessHtml = async (markup: string) => {
|
||||
const renderResult = await renderer.renderMarkupToHtml(markup);
|
||||
const renderResult = await renderer.renderMarkupToHtml(markup, {
|
||||
forceMarkdown: false,
|
||||
isFullPageRender: true,
|
||||
});
|
||||
|
||||
const dom = new DOMParser().parseFromString(renderResult.html, 'text/html');
|
||||
preprocessEditorInput(dom, markup);
|
||||
@@ -81,10 +84,20 @@ const createEditor = async (
|
||||
].flat(),
|
||||
});
|
||||
|
||||
const cachedLocalizations = new Map<string, string|Promise<string>>();
|
||||
state = state.apply(
|
||||
setEditorApi(state.tr, {
|
||||
onEvent: props.onEvent,
|
||||
renderer,
|
||||
localize: async (input: string) => {
|
||||
if (cachedLocalizations.has(input)) {
|
||||
return cachedLocalizations.get(input);
|
||||
}
|
||||
|
||||
const result = props.onLocalize(input);
|
||||
cachedLocalizations.set(input, result);
|
||||
return result;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
@@ -1,69 +0,0 @@
|
||||
import { Plugin } from 'prosemirror-state';
|
||||
import { Node, NodeSpec } from 'prosemirror-model';
|
||||
import { NodeView } from 'prosemirror-view';
|
||||
import sanitizeHtml from '../utils/sanitizeHtml';
|
||||
|
||||
// See the fold example for more information about
|
||||
// writing similar ProseMirror plugins:
|
||||
// https://prosemirror.net/examples/fold/
|
||||
|
||||
|
||||
const makeJoplinEditableSpec = (inline: boolean): NodeSpec => ({
|
||||
group: inline ? 'inline' : 'block',
|
||||
inline: inline,
|
||||
draggable: true,
|
||||
attrs: {
|
||||
contentHtml: { default: '', validate: 'string' },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: `${inline ? 'span' : 'div'}.joplin-editable`,
|
||||
getAttrs: node => ({
|
||||
contentHtml: node.innerHTML,
|
||||
}),
|
||||
},
|
||||
],
|
||||
toDOM: node => {
|
||||
const content = document.createElement(inline ? 'span' : 'div');
|
||||
content.classList.add('joplin-editable');
|
||||
content.innerHTML = sanitizeHtml(node.attrs.contentHtml);
|
||||
return content;
|
||||
},
|
||||
});
|
||||
|
||||
export const nodeSpecs = {
|
||||
joplinEditableInline: makeJoplinEditableSpec(true),
|
||||
joplinEditableBlock: makeJoplinEditableSpec(false),
|
||||
};
|
||||
|
||||
class EditableSourceBlockView implements NodeView {
|
||||
public readonly dom: HTMLElement;
|
||||
public constructor(node: Node, inline: boolean) {
|
||||
if ((node.attrs.contentHtml ?? undefined) === undefined) {
|
||||
throw new Error(`Unable to create a SourceBlockView for a node lacking contentHtml. Node: ${node}.`);
|
||||
}
|
||||
|
||||
this.dom = document.createElement(inline ? 'span' : 'div');
|
||||
this.dom.classList.add('joplin-editable');
|
||||
this.dom.innerHTML = sanitizeHtml(node.attrs.contentHtml);
|
||||
}
|
||||
|
||||
public selectNode() {
|
||||
this.dom.classList.add('-selected');
|
||||
}
|
||||
|
||||
public deselectNode() {
|
||||
this.dom.classList.remove('-selected');
|
||||
}
|
||||
}
|
||||
|
||||
const joplinEditablePlugin = new Plugin({
|
||||
props: {
|
||||
nodeViews: {
|
||||
joplinEditableInline: node => new EditableSourceBlockView(node, true),
|
||||
joplinEditableBlock: node => new EditableSourceBlockView(node, false),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default joplinEditablePlugin;
|
@@ -0,0 +1,70 @@
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import createTextNode from '../../utils/dom/createTextNode';
|
||||
import createTextArea from '../../utils/dom/createTextArea';
|
||||
|
||||
interface SourceBlockData {
|
||||
start: string;
|
||||
content: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
editorLabel: string|Promise<string>;
|
||||
doneLabel: string|Promise<string>;
|
||||
block: SourceBlockData;
|
||||
onSave: (newContent: SourceBlockData)=> void;
|
||||
}
|
||||
|
||||
const createEditorDialog = ({ editorLabel, doneLabel, block, onSave }: Options) => {
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.classList.add('editor-dialog', '-visible');
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
dialog.onclose = () => {
|
||||
dialog.remove();
|
||||
};
|
||||
|
||||
const { textArea, label: textAreaLabel } = createTextArea({
|
||||
label: editorLabel,
|
||||
initialContent: block.content,
|
||||
onChange: (newContent) => {
|
||||
block = {
|
||||
...block,
|
||||
content: newContent,
|
||||
};
|
||||
onSave(block);
|
||||
},
|
||||
spellCheck: false,
|
||||
});
|
||||
|
||||
|
||||
const submitButton = document.createElement('button');
|
||||
submitButton.appendChild(createTextNode(doneLabel));
|
||||
submitButton.classList.add('submit');
|
||||
submitButton.onclick = () => {
|
||||
if (dialog.close) {
|
||||
dialog.close();
|
||||
} else {
|
||||
// .remove the dialog in browsers with limited support for
|
||||
// HTMLDialogElement (and in JSDOM).
|
||||
dialog.remove();
|
||||
}
|
||||
};
|
||||
|
||||
dialog.appendChild(textAreaLabel);
|
||||
dialog.appendChild(textArea);
|
||||
dialog.appendChild(submitButton);
|
||||
|
||||
|
||||
// .showModal is not defined in JSDOM and some older (pre-2022) browsers
|
||||
if (dialog.showModal) {
|
||||
dialog.showModal();
|
||||
} else {
|
||||
dialog.classList.add('-fake-modal');
|
||||
focus('createEditorDialog/legacy', textArea);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
export default createEditorDialog;
|
@@ -0,0 +1,102 @@
|
||||
import { htmlentities } from '@joplin/utils/html';
|
||||
import { RenderResult } from '../../../../renderer/types';
|
||||
import createTestEditor from '../../testing/createTestEditor';
|
||||
import joplinEditorApiPlugin, { getEditorApi, setEditorApi } from '../joplinEditorApiPlugin';
|
||||
import joplinEditablePlugin from './joplinEditablePlugin';
|
||||
import { Second } from '@joplin/utils/time';
|
||||
|
||||
const createEditor = (html: string) => {
|
||||
return createTestEditor({
|
||||
plugins: [joplinEditablePlugin, joplinEditorApiPlugin],
|
||||
html,
|
||||
});
|
||||
};
|
||||
|
||||
const findEditButton = (ancestor: Element): HTMLButtonElement => {
|
||||
return ancestor.querySelector('.joplin-editable > button.edit');
|
||||
};
|
||||
|
||||
const findEditorDialog = () => {
|
||||
const dialog = document.querySelector('dialog.editor-dialog');
|
||||
if (!dialog) {
|
||||
throw new Error('Could not find an open editor dialog.');
|
||||
}
|
||||
|
||||
const editor = dialog.querySelector('textarea');
|
||||
const submitButton = dialog.querySelector('button');
|
||||
|
||||
return {
|
||||
dialog,
|
||||
editor,
|
||||
submitButton,
|
||||
};
|
||||
};
|
||||
|
||||
describe('joplinEditablePlugin', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
test.each([
|
||||
// Inline
|
||||
'<span class="joplin-editable"><pre class="joplin-source">test</pre>rendered</span>',
|
||||
// Block
|
||||
'<div class="joplin-editable"><pre class="joplin-source">test</pre>rendered</div>',
|
||||
// Nested inline
|
||||
'<p>Test: <mark><span class="joplin-editable"><pre class="joplin-source">test</pre>rendered</span></mark></p>',
|
||||
])('should show an edit button on source blocks (case %#)', (htmlSource) => {
|
||||
const editor = createEditor(htmlSource);
|
||||
const editButton = findEditButton(editor.dom);
|
||||
expect(editButton.textContent).toBe('Edit');
|
||||
});
|
||||
|
||||
test('clicking the edit button should show an editor dialog', () => {
|
||||
const editor = createEditor('<span class="joplin-editable"><pre class="joplin-source">test source</pre>rendered</span>');
|
||||
const editButton = findEditButton(editor.dom);
|
||||
editButton.click();
|
||||
|
||||
// Should show the dialog
|
||||
const dialog = findEditorDialog();
|
||||
expect(dialog.editor).toBeTruthy();
|
||||
expect(dialog.submitButton).toBeTruthy();
|
||||
});
|
||||
|
||||
test('editing the content of an editor dialog should update the source block', async () => {
|
||||
const editor = createEditor('<div class="joplin-editable"><pre class="joplin-source">test source</pre>rendered</div>');
|
||||
|
||||
// Mock render functions:
|
||||
editor.dispatch(setEditorApi(editor.state.tr, {
|
||||
...getEditorApi(editor.state),
|
||||
renderer: {
|
||||
renderMarkupToHtml: jest.fn(async source => ({
|
||||
html: `<pre class="joplin-source">${htmlentities(source)}</pre><p class="test-content">Mocked!</p></div>`,
|
||||
} as RenderResult)),
|
||||
renderHtmlToMarkup: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const editButton = findEditButton(editor.dom);
|
||||
editButton.click();
|
||||
|
||||
const dialog = findEditorDialog();
|
||||
dialog.editor.value = 'Updated!';
|
||||
dialog.editor.dispatchEvent(new Event('input'));
|
||||
|
||||
// Should update the editor state with the new source immediately.
|
||||
expect(editor.state.doc.toJSON()).toMatchObject({
|
||||
content: [{
|
||||
type: 'joplinEditableBlock',
|
||||
attrs: {
|
||||
source: 'Updated!',
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
// Should render and update the display within a short amount of time
|
||||
await jest.advanceTimersByTimeAsync(Second);
|
||||
const renderedEditable = editor.dom.querySelector('.joplin-editable');
|
||||
// Should render the updated content
|
||||
expect(renderedEditable.querySelector('.test-content').innerHTML).toBe('Mocked!');
|
||||
});
|
||||
});
|
@@ -0,0 +1,182 @@
|
||||
import { Plugin } from 'prosemirror-state';
|
||||
import { Node, NodeSpec } from 'prosemirror-model';
|
||||
import { EditorView, NodeView } from 'prosemirror-view';
|
||||
import sanitizeHtml from '../../utils/sanitizeHtml';
|
||||
import createEditorDialog from './createEditorDialog';
|
||||
import { getEditorApi } from '../joplinEditorApiPlugin';
|
||||
import { msleep } from '@joplin/utils/time';
|
||||
import createTextNode from '../../utils/dom/createTextNode';
|
||||
import postProcessRenderedHtml from './postProcessRenderedHtml';
|
||||
|
||||
// See the fold example for more information about
|
||||
// writing similar ProseMirror plugins:
|
||||
// https://prosemirror.net/examples/fold/
|
||||
|
||||
|
||||
const makeJoplinEditableSpec = (inline: boolean): NodeSpec => ({
|
||||
group: inline ? 'inline' : 'block',
|
||||
inline: inline,
|
||||
draggable: true,
|
||||
attrs: {
|
||||
contentHtml: { default: '', validate: 'string' },
|
||||
source: { default: '', validate: 'string' },
|
||||
language: { default: '', validate: 'string' },
|
||||
openCharacters: { default: '', validate: 'string' },
|
||||
closeCharacters: { default: '', validate: 'string' },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: `${inline ? 'span' : 'div'}.joplin-editable`,
|
||||
getAttrs: node => {
|
||||
const sourceNode = node.querySelector('.joplin-source');
|
||||
return {
|
||||
contentHtml: node.innerHTML,
|
||||
source: sourceNode?.textContent,
|
||||
openCharacters: sourceNode?.getAttribute('data-joplin-source-open'),
|
||||
closeCharacters: sourceNode?.getAttribute('data-joplin-source-close'),
|
||||
language: sourceNode?.getAttribute('data-joplin-language'),
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
toDOM: node => {
|
||||
const content = document.createElement(inline ? 'span' : 'div');
|
||||
content.classList.add('joplin-editable');
|
||||
content.innerHTML = sanitizeHtml(node.attrs.contentHtml);
|
||||
|
||||
const sourceNode = content.querySelector('.joplin-source');
|
||||
if (sourceNode) {
|
||||
sourceNode.textContent = node.attrs.source;
|
||||
sourceNode.setAttribute('data-joplin-source-open', node.attrs.openCharacters);
|
||||
sourceNode.setAttribute('data-joplin-source-close', node.attrs.closeCharacters);
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
});
|
||||
|
||||
export const nodeSpecs = {
|
||||
joplinEditableInline: makeJoplinEditableSpec(true),
|
||||
joplinEditableBlock: makeJoplinEditableSpec(false),
|
||||
};
|
||||
|
||||
type GetPosition = ()=> number;
|
||||
|
||||
class EditableSourceBlockView implements NodeView {
|
||||
public readonly dom: HTMLElement;
|
||||
public constructor(private node: Node, inline: boolean, private view: EditorView, private getPosition: GetPosition) {
|
||||
if ((node.attrs.contentHtml ?? undefined) === undefined) {
|
||||
throw new Error(`Unable to create a SourceBlockView for a node lacking contentHtml. Node: ${node}.`);
|
||||
}
|
||||
|
||||
this.dom = document.createElement(inline ? 'span' : 'div');
|
||||
this.dom.classList.add('joplin-editable');
|
||||
this.updateContent_();
|
||||
}
|
||||
|
||||
private showEditDialog_() {
|
||||
const { localize: _ } = getEditorApi(this.view.state);
|
||||
|
||||
let saveCounter = 0;
|
||||
createEditorDialog({
|
||||
doneLabel: _('Done'),
|
||||
editorLabel: _('Code:'),
|
||||
block: {
|
||||
content: this.node.attrs.source,
|
||||
start: this.node.attrs.openCharacters,
|
||||
end: this.node.attrs.closeCharacters,
|
||||
},
|
||||
onSave: async (block) => {
|
||||
this.view.dispatch(
|
||||
this.view.state.tr.setNodeAttribute(
|
||||
this.getPosition(), 'source', block.content,
|
||||
).setNodeAttribute(
|
||||
this.getPosition(), 'openCharacters', block.start,
|
||||
).setNodeAttribute(
|
||||
this.getPosition(), 'closeCharacters', block.end,
|
||||
),
|
||||
);
|
||||
|
||||
saveCounter ++;
|
||||
const initialSaveCounter = saveCounter;
|
||||
const cancelled = () => saveCounter !== initialSaveCounter;
|
||||
|
||||
// Debounce rendering
|
||||
await msleep(400);
|
||||
if (cancelled()) return;
|
||||
|
||||
const rendered = await getEditorApi(this.view.state).renderer.renderMarkupToHtml(
|
||||
`${block.start}${block.content}${block.end}`,
|
||||
{ forceMarkdown: true, isFullPageRender: false },
|
||||
);
|
||||
if (cancelled()) return;
|
||||
|
||||
const html = postProcessRenderedHtml(rendered.html, this.node.isInline);
|
||||
this.view.dispatch(
|
||||
this.view.state.tr.setNodeAttribute(
|
||||
this.getPosition(), 'contentHtml', html,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private updateContent_() {
|
||||
const setDomContentSafe = (html: string) => {
|
||||
this.dom.innerHTML = sanitizeHtml(html);
|
||||
};
|
||||
|
||||
const addEditButton = () => {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.classList.add('edit');
|
||||
|
||||
const { localize: _ } = getEditorApi(this.view.state);
|
||||
|
||||
editButton.appendChild(createTextNode(_('Edit')));
|
||||
editButton.onclick = (event) => {
|
||||
this.showEditDialog_();
|
||||
event.preventDefault();
|
||||
};
|
||||
this.dom.appendChild(editButton);
|
||||
};
|
||||
|
||||
setDomContentSafe(this.node.attrs.contentHtml);
|
||||
postProcessRenderedHtml(this.dom, this.node.isInline);
|
||||
addEditButton();
|
||||
}
|
||||
|
||||
public selectNode() {
|
||||
this.dom.classList.add('-selected');
|
||||
}
|
||||
|
||||
public deselectNode() {
|
||||
this.dom.classList.remove('-selected');
|
||||
}
|
||||
|
||||
public stopEvent(event: Event) {
|
||||
// Allow using the keyboard to activate the "edit" button:
|
||||
return event.target === this.dom.querySelector('button.edit');
|
||||
}
|
||||
|
||||
public update(node: Node) {
|
||||
if (node.type.spec !== this.node.type.spec) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.node = node;
|
||||
this.updateContent_();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const joplinEditablePlugin = new Plugin({
|
||||
props: {
|
||||
nodeViews: {
|
||||
joplinEditableInline: (node, view, getPos) => new EditableSourceBlockView(node, true, view, getPos),
|
||||
joplinEditableBlock: (node, view, getPos) => new EditableSourceBlockView(node, false, view, getPos),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default joplinEditablePlugin;
|
@@ -0,0 +1,42 @@
|
||||
// If renderedHtml is an HTMLElement, the content is modified in-place.
|
||||
const postProcessRenderedHtml = <InputType extends string|HTMLElement> (renderedHtml: InputType, isInline: boolean): InputType => {
|
||||
let rootElement;
|
||||
if (typeof renderedHtml === 'string') {
|
||||
const parser = new DOMParser();
|
||||
const parsed = parser.parseFromString(
|
||||
`<!DOCTYPE html><html><body>${renderedHtml}</body></html>`,
|
||||
'text/html',
|
||||
);
|
||||
rootElement = parsed.body;
|
||||
} else {
|
||||
rootElement = renderedHtml;
|
||||
}
|
||||
|
||||
const replaceChildMatching = (selector: string) => {
|
||||
const toReplace = [...rootElement.children].find(
|
||||
child => child.matches(selector),
|
||||
);
|
||||
toReplace?.replaceWith(...toReplace.childNodes);
|
||||
};
|
||||
// If the original HTML is from .renderToMarkup, it may have a <div> wrapper:
|
||||
replaceChildMatching('#rendered-md');
|
||||
|
||||
if (rootElement.children.length === 1 && isInline) {
|
||||
replaceChildMatching('p, div');
|
||||
}
|
||||
|
||||
// Remove the 'joplin-editable' container if it's the only thing in the content
|
||||
// (since this.dom is itself a joplin-editable)
|
||||
if (rootElement.children.length === 1) {
|
||||
replaceChildMatching('.joplin-editable');
|
||||
}
|
||||
|
||||
// Match the input type
|
||||
if (typeof renderedHtml === 'string') {
|
||||
return rootElement.innerHTML as InputType;
|
||||
} else {
|
||||
return rootElement as InputType;
|
||||
}
|
||||
};
|
||||
|
||||
export default postProcessRenderedHtml;
|
@@ -1,10 +1,11 @@
|
||||
import { EditorState, Plugin, Transaction } from 'prosemirror-state';
|
||||
import { OnEventCallback } from '../../types';
|
||||
import { OnEventCallback, OnLocalize } from '../../types';
|
||||
import { RendererControl } from '../types';
|
||||
|
||||
export interface EditorApi {
|
||||
renderer: RendererControl;
|
||||
onEvent: OnEventCallback;
|
||||
localize: OnLocalize;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +30,8 @@ const joplinEditorApiPlugin = new Plugin<EditorApi>({
|
||||
throw new Error('Not initialized');
|
||||
},
|
||||
},
|
||||
settings: null,
|
||||
localize: input => input,
|
||||
}),
|
||||
apply: (tr, value) => {
|
||||
const proposedValue = tr.getMeta(joplinEditorApiPlugin);
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { AttributeSpec, DOMOutputSpec, MarkSpec, NodeSpec, Schema } from 'prosemirror-model';
|
||||
import { nodeSpecs as joplinEditableNodes } from './plugins/joplinEditablePlugin';
|
||||
import { nodeSpecs as joplinEditableNodes } from './plugins/joplinEditablePlugin/joplinEditablePlugin';
|
||||
import { tableNodes } from 'prosemirror-tables';
|
||||
import { nodeSpecs as listNodes } from './plugins/listPlugin';
|
||||
import { nodeSpecs as resourcePlaceholderNodes } from './plugins/resourcePlaceholderPlugin';
|
||||
|
@@ -2,7 +2,9 @@
|
||||
import 'prosemirror-view/style/prosemirror.css';
|
||||
import 'prosemirror-search/style/search.css';
|
||||
import './styles/joplin-editable.css';
|
||||
import './styles/editor-dialog.css';
|
||||
import './styles/prosemirror-editor.css';
|
||||
import './styles/table.css';
|
||||
import './styles/checklist-item.css';
|
||||
import './styles/link-tooltip.css';
|
||||
|
||||
|
30
packages/editor/ProseMirror/styles/editor-dialog.css
Normal file
30
packages/editor/ProseMirror/styles/editor-dialog.css
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
.editor-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--joplin-background-color);
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0px 0px 2px var(--joplin-color);
|
||||
color: var(--joplin-color);
|
||||
|
||||
width: min(80vw, 600px);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.editor-dialog > textarea {
|
||||
flex-grow: 1;
|
||||
min-height: min(70vh, 400px);
|
||||
resize: none;
|
||||
color: var(--joplin-color);
|
||||
background-color: var(--joplin-background-color);
|
||||
}
|
||||
|
||||
.editor-dialog > button {
|
||||
color: var(--joplin-color);
|
||||
background-color: var(--joplin-background-color3);
|
||||
border: none;
|
||||
|
||||
border-radius: 8px;
|
||||
height: 38px;
|
||||
}
|
@@ -1,4 +1,28 @@
|
||||
|
||||
.joplin-editable {
|
||||
position: relative;
|
||||
/* Override the "white-space" setting for the editor */
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.joplin-editable > .edit {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
|
||||
display: none;
|
||||
|
||||
transition-behavior: allow-discrete;
|
||||
transition-duration: 0.2s;
|
||||
transition-property: opacity, display;
|
||||
}
|
||||
|
||||
.joplin-editable.-selected {
|
||||
outline: 4px solid var(--joplin-text-selection-color);
|
||||
}
|
||||
}
|
||||
|
||||
.joplin-editable.-selected > .edit {
|
||||
display: block;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
@@ -1,6 +1,11 @@
|
||||
import { RenderResult } from '../../renderer/types';
|
||||
|
||||
export type MarkupToHtml = (markup: string)=> Promise<RenderResult>;
|
||||
interface MarkupToHtmlOptions {
|
||||
isFullPageRender: boolean;
|
||||
forceMarkdown: boolean;
|
||||
}
|
||||
|
||||
export type MarkupToHtml = (markup: string, options: MarkupToHtmlOptions)=> Promise<RenderResult>;
|
||||
export type HtmlToMarkup = (html: Node|DocumentFragment)=> string;
|
||||
|
||||
export interface RendererControl {
|
||||
|
31
packages/editor/ProseMirror/utils/dom/createTextArea.ts
Normal file
31
packages/editor/ProseMirror/utils/dom/createTextArea.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { LocalizationResult } from '../../../types';
|
||||
import createTextNode from './createTextNode';
|
||||
import createUniqueId from './createUniqueId';
|
||||
|
||||
interface Options {
|
||||
label: LocalizationResult;
|
||||
spellCheck: boolean;
|
||||
initialContent: string;
|
||||
onChange: (newContent: string)=> void;
|
||||
}
|
||||
|
||||
const createTextArea = ({ label, initialContent, spellCheck, onChange }: Options) => {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.spellcheck = spellCheck;
|
||||
textArea.oninput = () => {
|
||||
onChange(textArea.value);
|
||||
};
|
||||
textArea.value = initialContent;
|
||||
textArea.id = createUniqueId();
|
||||
|
||||
const labelElement = document.createElement('label');
|
||||
labelElement.htmlFor = textArea.id;
|
||||
labelElement.appendChild(createTextNode(label));
|
||||
|
||||
return {
|
||||
label: labelElement,
|
||||
textArea,
|
||||
};
|
||||
};
|
||||
|
||||
export default createTextArea;
|
11
packages/editor/ProseMirror/utils/dom/createTextNode.ts
Normal file
11
packages/editor/ProseMirror/utils/dom/createTextNode.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { LocalizationResult } from '../../../types';
|
||||
|
||||
const createTextNode = (content: LocalizationResult) => {
|
||||
const result = document.createTextNode(typeof content === 'string' ? content : '...');
|
||||
void (async () => {
|
||||
result.textContent = await content;
|
||||
})();
|
||||
return result;
|
||||
};
|
||||
|
||||
export default createTextNode;
|
8
packages/editor/ProseMirror/utils/dom/createUniqueId.ts
Normal file
8
packages/editor/ProseMirror/utils/dom/createUniqueId.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
const createUniqueId = () => {
|
||||
return `__joplin-id-${idCounter++}`;
|
||||
};
|
||||
|
||||
export default createUniqueId;
|
@@ -190,6 +190,8 @@ export type LogMessageCallback = (message: string)=> void;
|
||||
export type OnEventCallback = (event: EditorEvent)=> void;
|
||||
export type PasteFileCallback = (data: File)=> Promise<void>;
|
||||
type OnScrollPastBeginningCallback = ()=> void;
|
||||
export type LocalizationResult = Promise<string>|string;
|
||||
export type OnLocalize = (input: string)=> LocalizationResult;
|
||||
|
||||
interface Localisations {
|
||||
[editorString: string]: string;
|
||||
@@ -199,6 +201,7 @@ export interface EditorProps {
|
||||
settings: EditorSettings;
|
||||
initialText: string;
|
||||
initialNoteId: string;
|
||||
onLocalize: OnLocalize;
|
||||
// Used mostly for internal editor library strings
|
||||
localisations?: Localisations;
|
||||
|
||||
|
@@ -1,9 +1,9 @@
|
||||
import * as React from 'react';
|
||||
import { NoteEntity, ResourceEntity } from './services/database/types';
|
||||
import type * as React from 'react';
|
||||
import type { NoteEntity, ResourceEntity } from './services/database/types';
|
||||
import type FsDriverBase from './fs-driver-base';
|
||||
import type FileApiDriverLocal from './file-api-driver-local';
|
||||
import { Crypto } from './services/e2ee/types';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import type { Crypto } from './services/e2ee/types';
|
||||
import type { MarkupLanguage } from '@joplin/renderer';
|
||||
|
||||
export interface CreateResourceFromPathOptions {
|
||||
resizeLargeImages?: 'always' | 'never' | 'ask';
|
||||
|
Reference in New Issue
Block a user