You've already forked joplin
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:
9
packages/app-desktop/services/bridge.ts
Normal file
9
packages/app-desktop/services/bridge.ts
Normal 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();
|
||||
}
|
||||
6
packages/app-desktop/services/commands/types.ts
Normal file
6
packages/app-desktop/services/commands/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { AppState } from '../../app';
|
||||
|
||||
export interface DesktopCommandContext {
|
||||
state: AppState,
|
||||
dispatch: Function,
|
||||
}
|
||||
@@ -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_;
|
||||
}
|
||||
|
||||
}
|
||||
149
packages/app-desktop/services/plugins/PluginRunner.ts
Normal file
149
packages/app-desktop/services/plugins/PluginRunner.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
1
packages/app-desktop/services/plugins/README.md
Normal file
1
packages/app-desktop/services/plugins/README.md
Normal file
@@ -0,0 +1 @@
|
||||
Platform-specific (here desktop) plugin methods should be implemented here.
|
||||
145
packages/app-desktop/services/plugins/UserWebview.tsx
Normal file
145
packages/app-desktop/services/plugins/UserWebview.tsx
Normal 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>;
|
||||
}
|
||||
84
packages/app-desktop/services/plugins/UserWebviewDialog.tsx
Normal file
84
packages/app-desktop/services/plugins/UserWebviewDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
32
packages/app-desktop/services/plugins/UserWebviewIndex.html
Normal file
32
packages/app-desktop/services/plugins/UserWebviewIndex.html
Normal 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>
|
||||
109
packages/app-desktop/services/plugins/UserWebviewIndex.js
Normal file
109
packages/app-desktop/services/plugins/UserWebviewIndex.js
Normal 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' }, '*');
|
||||
});
|
||||
})();
|
||||
60
packages/app-desktop/services/plugins/hooks/useThemeCss.ts
Normal file
60
packages/app-desktop/services/plugins/hooks/useThemeCss.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
13
packages/app-desktop/services/plugins/plugin_index.html
Normal file
13
packages/app-desktop/services/plugins/plugin_index.html
Normal 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>
|
||||
109
packages/app-desktop/services/plugins/plugin_index.js
Normal file
109
packages/app-desktop/services/plugins/plugin_index.js
Normal 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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user