1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-06 09:19:22 +02:00

All: Use Lerna to manage monorepo

This commit is contained in:
Laurent Cozic
2020-11-05 16:58:23 +00:00
parent 122f20905c
commit cc07016b07
2839 changed files with 54217 additions and 16111 deletions

View File

@@ -0,0 +1,9 @@
// Just a convenient wrapper to get a typed bridge in TypeScript
import { Bridge } from '../bridge';
const remoteBridge = require('electron').remote.require('./bridge').default;
export default function bridge():Bridge {
return remoteBridge();
}

View File

@@ -0,0 +1,6 @@
import { AppState } from '../../app';
export interface DesktopCommandContext {
state: AppState,
dispatch: Function,
}

View File

@@ -0,0 +1,63 @@
// import { EditorCommand } from '@joplinapp/lib/services/plugins/api/types';
import bridge from '../bridge';
// interface JoplinWorkspace {
// execEditorCommand(command:EditorCommand):Promise<string>
// }
interface JoplinViewsDialogs {
showMessageBox(message:string):Promise<number>;
}
interface JoplinViews {
dialogs: JoplinViewsDialogs
}
interface Joplin {
// workspace: JoplinWorkspace;
views: JoplinViews;
}
interface Components {
[key:string]: any,
}
export default class PlatformImplementation {
private static instance_:PlatformImplementation;
private joplin_:Joplin;
private components_:Components;
public static instance():PlatformImplementation {
if (!this.instance_) this.instance_ = new PlatformImplementation();
return this.instance_;
}
constructor() {
this.components_ = {};
this.joplin_ = {
views: {
dialogs: {
showMessageBox: async function(message:string) {
return bridge().showMessageBox(message);
},
},
},
};
}
registerComponent(name:string, component:any) {
this.components_[name] = component;
}
unregisterComponent(name:string) {
delete this.components_[name];
}
public get joplin():Joplin {
return this.joplin_;
}
}

View File

@@ -0,0 +1,149 @@
import Plugin from '@joplinapp/lib/services/plugins/Plugin';
import BasePluginRunner from '@joplinapp/lib/services/plugins/BasePluginRunner';
import executeSandboxCall from '@joplinapp/lib/services/plugins/utils/executeSandboxCall';
import Global from '@joplinapp/lib/services/plugins/api/Global';
import bridge from '../bridge';
import Setting from '@joplinapp/lib/models/Setting';
import { EventHandlers } from '@joplinapp/lib/services/plugins/utils/mapEventHandlersToIds';
import shim from '@joplinapp/lib/shim';
const ipcRenderer = require('electron').ipcRenderer;
enum PluginMessageTarget {
MainWindow = 'mainWindow',
Plugin = 'plugin',
}
export interface PluginMessage {
target: PluginMessageTarget,
pluginId: string,
callbackId?: string,
path?: string,
args?: any[],
result?: any,
error?: any,
mainWindowCallbackId?: string,
}
let callbackIndex = 1;
const callbackPromises:any = {};
function mapEventIdsToHandlers(pluginId:string, arg:any) {
if (Array.isArray(arg)) {
for (let i = 0; i < arg.length; i++) {
arg[i] = mapEventIdsToHandlers(pluginId, arg[i]);
}
return arg;
} else if (typeof arg === 'string' && arg.indexOf('___plugin_event_') === 0) {
const eventId = arg;
return async (...args:any[]) => {
const callbackId = `cb_${pluginId}_${Date.now()}_${callbackIndex++}`;
const promise = new Promise((resolve, reject) => {
callbackPromises[callbackId] = { resolve, reject };
});
ipcRenderer.send('pluginMessage', {
callbackId: callbackId,
target: PluginMessageTarget.Plugin,
pluginId: pluginId,
eventId: eventId,
args: args,
});
return promise;
};
} else if (arg === null) {
return null;
} else if (arg === undefined) {
return undefined;
} else if (typeof arg === 'object') {
for (const n in arg) {
arg[n] = mapEventIdsToHandlers(pluginId, arg[n]);
}
}
return arg;
}
export default class PluginRunner extends BasePluginRunner {
protected eventHandlers_:EventHandlers = {};
constructor() {
super();
this.eventHandler = this.eventHandler.bind(this);
}
private async eventHandler(eventHandlerId:string, args:any[]) {
const cb = this.eventHandlers_[eventHandlerId];
return cb(...args);
}
async run(plugin:Plugin, pluginApi:Global) {
const scriptPath = `${Setting.value('tempDir')}/plugin_${plugin.id}.js`;
await shim.fsDriver().writeFile(scriptPath, plugin.scriptText, 'utf8');
const pluginWindow = bridge().newBrowserWindow({
show: false,
webPreferences: {
nodeIntegration: true,
},
});
bridge().electronApp().registerPluginWindow(plugin.id, pluginWindow);
pluginWindow.loadURL(`${require('url').format({
pathname: require('path').join(__dirname, 'plugin_index.html'),
protocol: 'file:',
slashes: true,
})}?pluginId=${encodeURIComponent(plugin.id)}&pluginScript=${encodeURIComponent(`file://${scriptPath}`)}`);
pluginWindow.webContents.once('dom-ready', () => {
pluginWindow.webContents.openDevTools();
});
ipcRenderer.on('pluginMessage', async (_event:any, message:PluginMessage) => {
if (message.target !== PluginMessageTarget.MainWindow) return;
if (message.pluginId !== plugin.id) return;
if (message.mainWindowCallbackId) {
const promise = callbackPromises[message.mainWindowCallbackId];
if (!promise) {
console.error('Got a callback without matching promise: ', message);
return;
}
if (message.error) {
promise.reject(message.error);
} else {
promise.resolve(message.result);
}
} else {
const mappedArgs = mapEventIdsToHandlers(plugin.id, message.args);
const fullPath = `joplin.${message.path}`;
this.logger().debug(`PluginRunner: execute call: ${fullPath}: ${mappedArgs}`);
let result:any = null;
let error:any = null;
try {
result = await executeSandboxCall(plugin.id, pluginApi, fullPath, mappedArgs, this.eventHandler);
} catch (e) {
error = e ? e : new Error('Unknown error');
}
ipcRenderer.send('pluginMessage', {
target: PluginMessageTarget.Plugin,
pluginId: plugin.id,
pluginCallbackId: message.callbackId,
result: result,
error: error,
});
}
});
}
}

View File

@@ -0,0 +1 @@
Platform-specific (here desktop) plugin methods should be implemented here.

View File

@@ -0,0 +1,145 @@
import * as React from 'react';
import { useRef, useEffect, useState } from 'react';
import useViewIsReady from './hooks/useViewIsReady';
import useThemeCss from './hooks/useThemeCss';
const styled = require('styled-components').default;
export interface Props {
html:string,
scripts:string[],
onMessage:Function,
pluginId:string,
viewId:string,
themeId:number,
minWidth?: number,
minHeight?: number,
fitToContent?: boolean,
borderBottom?: boolean,
theme?:any,
}
interface Size {
width: number,
height: number,
}
const StyledFrame = styled.iframe`
padding: 0;
margin: 0;
width: ${(props:any) => props.fitToContent ? `${props.width}px` : '100%'};
height: ${(props:any) => props.fitToContent ? `${props.height}px` : '100%'};
border: none;
border-bottom: ${(props:Props) => props.borderBottom ? `1px solid ${props.theme.dividerColor}` : 'none'};
`;
export default function UserWebview(props:Props) {
const minWidth = props.minWidth ? props.minWidth : 200;
const minHeight = props.minHeight ? props.minHeight : 20;
const viewRef = useRef(null);
const isReady = useViewIsReady(viewRef);
const cssFilePath = useThemeCss({ pluginId: props.pluginId, themeId: props.themeId });
const [contentSize, setContentSize] = useState<Size>({ width: minWidth, height: minHeight });
function frameWindow() {
if (!viewRef.current) return null;
return viewRef.current.contentWindow;
}
function postMessage(name:string, args:any = null) {
const win = frameWindow();
if (!win) return;
win.postMessage({ target: 'webview', name, args }, '*');
}
function updateContentSize() {
const win = frameWindow();
if (!win) return null;
const rect = win.document.getElementById('joplin-plugin-content').getBoundingClientRect();
let w = rect.width;
let h = rect.height;
if (w < minWidth) w = minWidth;
if (h < minHeight) h = minHeight;
const newSize = { width: w, height: h };
setContentSize((current:Size) => {
if (current.width === newSize.width && current.height === newSize.height) return current;
return newSize;
});
return newSize;
}
useEffect(() => {
if (!isReady) return () => {};
let cancelled = false;
postMessage('setHtml', { html: props.html });
setTimeout(() => {
if (cancelled) return;
updateContentSize();
}, 100);
return () => {
cancelled = true;
};
}, [props.html, isReady]);
useEffect(() => {
if (!isReady) return;
postMessage('setScripts', { scripts: props.scripts });
}, [props.scripts, isReady]);
useEffect(() => {
if (!isReady || !cssFilePath) return;
postMessage('setScript', { script: cssFilePath, key: 'themeCss' });
}, [isReady, cssFilePath]);
useEffect(() => {
function onMessage(event:any) {
if (!event.data || event.data.target !== 'plugin') return;
props.onMessage({
pluginId: props.pluginId,
viewId: props.viewId,
message: event.data.message,
});
}
viewRef.current.contentWindow.addEventListener('message', onMessage);
return () => {
viewRef.current.contentWindow.removeEventListener('message', onMessage);
};
}, [props.onMessage, props.pluginId, props.viewId]);
useEffect(() => {
if (!props.fitToContent || !isReady) return () => {};
// The only reliable way to make sure that the iframe has the same dimensions
// as its content is to poll the dimensions at regular intervals. Other methods
// work most of the time but will fail in various edge cases. Most reliable way
// is probably iframe-resizer package, but still with 40 unfixed bugs.
//
// Polling in our case is fine since this is only used when displaying plugin
// dialogs, which should be short lived. updateContentSize() is also optimised
// to do nothing when size hasn't changed.
const updateFrameSizeIID = setInterval(updateContentSize, 2000);
return () => {
clearInterval(updateFrameSizeIID);
};
}, [props.fitToContent, isReady, minWidth, minHeight]);
return <StyledFrame
id={props.viewId}
width={contentSize.width}
height={contentSize.height}
fitToContent={props.fitToContent}
ref={viewRef}
src="services/plugins/UserWebviewIndex.html"
borderBottom={props.borderBottom}
></StyledFrame>;
}

View File

@@ -0,0 +1,84 @@
import { ButtonSpec } from '@joplinapp/lib/services/plugins/api/types';
import PluginService from '@joplinapp/lib/services/plugins/PluginService';
import WebviewController from '@joplinapp/lib/services/plugins/WebviewController';
import * as React from 'react';
import UserWebview, { Props as UserWebviewProps } from './UserWebview';
import UserWebviewDialogButtonBar from './UserWebviewDialogButtonBar';
const styled = require('styled-components').default;
interface Props extends UserWebviewProps {
buttons: ButtonSpec[];
}
const StyledRoot = styled.div`
display: flex;
flex: 1;
padding: 0;
margin: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
box-sizing: border-box;
justify-content: center;
align-items: center;
`;
const Dialog = styled.div`
display: flex;
flex-direction: column;
background-color: ${(props:any) => props.theme.backgroundColor};
padding: ${(props:any) => `${props.theme.mainPadding}px`};
border-radius: 4px;
box-shadow: 0 6px 10px #00000077;
`;
const UserWebViewWrapper = styled.div`
display: flex;
flex: 1;
`;
function defaultButtons():ButtonSpec[] {
return [
{
id: 'ok',
},
{
id: 'cancel',
},
];
}
export default function UserWebviewDialog(props:Props) {
function viewController():WebviewController {
return PluginService.instance().pluginById(props.pluginId).viewController(props.viewId) as WebviewController;
}
const buttons:ButtonSpec[] = (props.buttons ? props.buttons : defaultButtons()).map((b:ButtonSpec) => {
return {
...b,
onClick: () => {
viewController().closeWithResponse(b.id);
},
};
});
return (
<StyledRoot>
<Dialog>
<UserWebViewWrapper>
<UserWebview
html={props.html}
scripts={props.scripts}
onMessage={props.onMessage}
pluginId={props.pluginId}
viewId={props.viewId}
themeId={props.themeId}
borderBottom={false}
fitToContent={true}
/>
</UserWebViewWrapper>
<UserWebviewDialogButtonBar buttons={buttons}/>
</Dialog>
</StyledRoot>
);
}

View File

@@ -0,0 +1,52 @@
import * as React from 'react';
import Button from '../../gui/Button/Button';
import { _ } from '@joplinapp/lib/locale';
import { ButtonSpec } from '@joplinapp/lib/services/plugins/api/types';
const styled = require('styled-components').default;
const { space } = require('styled-system');
interface Props {
buttons: ButtonSpec[],
}
const StyledRoot = styled.div`
display: flex;
width: 100%;
box-sizing: border-box;
justify-content: flex-end;
padding-top: ${(props:any) => props.theme.mainPadding}px;
`;
const StyledButton = styled(Button)`${space}`;
function buttonTitle(b:ButtonSpec) {
if (b.title) return b.title;
const defaultTitles:any = {
'ok': _('OK'),
'cancel': _('Cancel'),
'yes': _('Yes'),
'no': _('No'),
'close': _('Close'),
};
return defaultTitles[b.id] ? defaultTitles[b.id] : b.id;
}
export default function UserWebviewDialogButtonBar(props:Props) {
function renderButtons() {
const output = [];
for (let i = 0; i < props.buttons.length; i++) {
const b = props.buttons[i];
const marginRight = i !== props.buttons.length - 1 ? '6px' : '0px';
output.push(<StyledButton key={b.id} onClick={b.onClick} title={buttonTitle(b)} mr={marginRight}/>);
}
return output;
}
return (
<StyledRoot>
{renderButtons()}
</StyledRoot>
);
}

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<script src="UserWebviewIndex.js"></script>
<style>
html {
overflow: hidden;
}
body {
box-sizing: border-box;
padding: 0;
margin: 0;
background-color: var(--joplin-background-color);
color: var(--joplin-color);
font-size: var(--joplin-font-size);
font-family: var(--joplin-font-family);
}
/* We need "display: flex" in order to accurately get the content size */
/* including margin and padding of children */
#joplin-plugin-content {
display: flex;
flex-direction: column;
}
</style>
</head>
<body>
</body>
</html>

View File

@@ -0,0 +1,109 @@
// This is the API that JS files loaded from the webview can see
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const webviewApi = {
postMessage: function(message) {
window.postMessage({ target: 'plugin', message: message }, '*');
},
};
(function() {
function docReady(fn) {
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setTimeout(fn, 1);
} else {
document.addEventListener('DOMContentLoaded', fn);
}
}
function fileExtension(path) {
if (!path) throw new Error('Path is empty');
const output = path.split('.');
if (output.length <= 1) return '';
return output[output.length - 1];
}
docReady(() => {
const rootElement = document.createElement('div');
document.getElementsByTagName('body')[0].appendChild(rootElement);
const contentElement = document.createElement('div');
contentElement.setAttribute('id', 'joplin-plugin-content');
rootElement.appendChild(contentElement);
const headElement = document.getElementsByTagName('head')[0];
const addedScripts = {};
function addScript(scriptPath, id = null) {
const ext = fileExtension(scriptPath).toLowerCase();
if (ext === 'js') {
const script = document.createElement('script');
script.src = scriptPath;
if (id) script.id = id;
headElement.appendChild(script);
} else if (ext === 'css') {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = scriptPath;
if (id) link.id = id;
headElement.appendChild(link);
} else {
throw new Error(`Unsupported script: ${scriptPath}`);
}
}
const ipc = {
setHtml: (args) => {
contentElement.innerHTML = args.html;
},
setScript: (args) => {
const { script, key } = args;
const scriptPath = `file://${script}`;
const elementId = `joplin-script-${key}`;
if (addedScripts[elementId]) {
document.getElementById(elementId).remove();
delete addedScripts[elementId];
}
addScript(scriptPath, elementId);
},
setScripts: (args) => {
const scripts = args.scripts;
if (!scripts) return;
for (let i = 0; i < scripts.length; i++) {
const scriptPath = `file://${scripts[i]}`;
if (addedScripts[scriptPath]) continue;
addedScripts[scriptPath] = true;
addScript(scriptPath);
}
},
};
window.addEventListener('message', ((event) => {
if (!event.data || event.data.target !== 'webview') return;
const callName = event.data.name;
const args = event.data.args;
if (!ipc[callName]) {
console.warn('Missing IPC function:', event.data);
} else {
ipc[callName](args);
}
}));
// Send a message to the containing component to notify
// it that the view content is fully ready.
window.postMessage({ target: 'UserWebview', message: 'ready' }, '*');
});
})();

View File

@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { themeStyle } from '@joplinapp/lib/theme';
import shim from '@joplinapp/lib/shim';
const Setting = require('@joplinapp/lib/models/Setting').default;
const { camelCaseToDash, formatCssSize } = require('@joplinapp/lib/string-utils');
interface HookDependencies {
pluginId: string,
themeId: number,
}
function themeToCssVariables(theme:any) {
const lines = [];
lines.push(':root {');
for (const name in theme) {
const value = theme[name];
if (typeof value === 'object') continue;
if (['appearance', 'codeThemeCss', 'codeMirrorTheme'].indexOf(name) >= 0) continue;
const newName = `--joplin-${camelCaseToDash(name)}`;
const formattedValue = typeof value === 'number' && newName.indexOf('opacity') < 0 ? formatCssSize(value) : value;
lines.push(`\t${newName}: ${formattedValue};`);
}
lines.push('}');
return lines.join('\n');
}
export default function useThemeCss(dep:HookDependencies) {
const { pluginId, themeId } = dep;
const [cssFilePath, setCssFilePath] = useState('');
useEffect(() => {
if (cssFilePath) return () => {};
let cancelled = false;
async function createThemeStyleSheet() {
const theme = themeStyle(themeId);
const css = themeToCssVariables(theme);
const filePath = `${Setting.value('tempDir')}/plugin_${pluginId}_theme_${themeId}.css`;
await shim.fsDriver().writeFile(filePath, css, 'utf8');
if (cancelled) return;
setCssFilePath(filePath);
}
createThemeStyleSheet();
return () => {
cancelled = true;
};
}, [pluginId, themeId, cssFilePath]);
return cssFilePath;
}

View File

@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react';
export default function useViewIsReady(viewRef:any) {
// Just checking if the iframe is ready is not sufficient because its content
// might not be ready (for example, IPC listeners might not be initialised).
// So we also listen to a custom "ready" message coming from the webview content
// (in UserWebviewIndex.js)
const [iframeReady, setIFrameReady] = useState(false);
const [iframeContentReady, setIFrameContentReady] = useState(false);
useEffect(() => {
function onIFrameReady() {
setIFrameReady(true);
}
function onMessage(event:any) {
const data = event.data;
if (!data || data.target !== 'UserWebview') return;
if (data.message === 'ready') {
setIFrameContentReady(true);
}
}
const iframeDocument = viewRef.current.contentWindow.document;
if (iframeDocument.readyState === 'complete') {
onIFrameReady();
}
viewRef.current.addEventListener('dom-ready', onIFrameReady);
viewRef.current.addEventListener('load', onIFrameReady);
viewRef.current.contentWindow.addEventListener('message', onMessage);
return () => {
viewRef.current.removeEventListener('dom-ready', onIFrameReady);
viewRef.current.removeEventListener('load', onIFrameReady);
viewRef.current.contentWindow.removeEventListener('message', onMessage);
};
}, []);
return iframeReady && iframeContentReady;
}

View File

@@ -0,0 +1,13 @@
<html>
<head>
<script src="./plugin_index.js"></script>
<script>
// joplin.plugins.register({
// onStart: async function() {
// alert('PLUGIN STARTED');
// },
// });
</script>
</head>
</html>

View File

@@ -0,0 +1,109 @@
(function(globalObject) {
// TODO: Not sure if that will work once packaged in Electron
const sandboxProxy = require('../../lib/services/plugins/sandboxProxy.js').default;
const ipcRenderer = require('electron').ipcRenderer;
const urlParams = new URLSearchParams(window.location.search);
const pluginId = urlParams.get('pluginId');
let eventId_ = 1;
const eventHandlers_ = {};
function mapEventHandlersToIds(argName, arg) {
if (Array.isArray(arg)) {
for (let i = 0; i < arg.length; i++) {
arg[i] = mapEventHandlersToIds(`${i}`, arg[i]);
}
return arg;
} else if (typeof arg === 'function') {
const id = `___plugin_event_${argName}_${eventId_}`;
eventId_++;
eventHandlers_[id] = arg;
return id;
} else if (arg === null) {
return null;
} else if (arg === undefined) {
return undefined;
} else if (typeof arg === 'object') {
for (const n in arg) {
arg[n] = mapEventHandlersToIds(n, arg[n]);
}
}
return arg;
}
const callbackPromises = {};
let callbackIndex = 1;
const target = (path, args) => {
const callbackId = `cb_${pluginId}_${Date.now()}_${callbackIndex++}`;
const promise = new Promise((resolve, reject) => {
callbackPromises[callbackId] = { resolve, reject };
});
ipcRenderer.send('pluginMessage', {
target: 'mainWindow',
pluginId: pluginId,
callbackId: callbackId,
path: path,
args: mapEventHandlersToIds(null, args),
});
return promise;
};
ipcRenderer.on('pluginMessage', async (_event, message) => {
if (message.eventId) {
const eventHandler = eventHandlers_[message.eventId];
if (!eventHandler) {
console.error('Got an event ID but no matching event handler: ', message);
return;
}
let result = null;
let error = null;
try {
result = await eventHandler(...message.args);
} catch (e) {
error = e;
}
if (message.callbackId) {
ipcRenderer.send('pluginMessage', {
target: 'mainWindow',
pluginId: pluginId,
mainWindowCallbackId: message.callbackId,
result: result,
error: error,
});
}
return;
}
if (message.pluginCallbackId) {
const promise = callbackPromises[message.pluginCallbackId];
if (!promise) {
console.error('Got a callback without matching promise: ', message);
return;
}
if (message.error) {
promise.reject(message.error);
} else {
promise.resolve(message.result);
}
return;
}
console.warn('Unhandled plugin message:', message);
});
const pluginScriptPath = urlParams.get('pluginScript');
const script = document.createElement('script');
script.src = pluginScriptPath;
document.head.appendChild(script);
globalObject.joplin = sandboxProxy(target);
})(window);

View File

@@ -0,0 +1,39 @@
// Provides spell checking feature via the native Electron built-in spell checker
import SpellCheckerServiceDriverBase from '@joplinapp/lib/services/spellChecker/SpellCheckerServiceDriverBase';
import bridge from '../bridge';
export default class SpellCheckerServiceDriverNative extends SpellCheckerServiceDriverBase {
private session():any {
return bridge().window().webContents.session;
}
public get availableLanguages():string[] {
return this.session().availableSpellCheckerLanguages;
}
// Language can be set to '' to disable spell-checking
public setLanguage(v:string) {
// If we pass an empty array, it disables spell checking
// https://github.com/electron/electron/issues/25228
this.session().setSpellCheckerLanguages(v ? [v] : []);
}
public get language():string {
const languages = this.session().getSpellCheckerLanguages();
return languages.length ? languages[0] : '';
}
public makeMenuItem(item:any):any {
const MenuItem = bridge().MenuItem;
return new MenuItem(item);
}
public addWordToSpellCheckerDictionary(_language:string, word:string) {
// Actually on Electron all languages share the same dictionary, or
// perhaps it's added to the currently active language.
this.session().addWordToSpellCheckerDictionary(word);
}
}