feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled

这个提交包含在:
chen
2026-11-04 11:29:39 +08:00
当前提交 301194963b
共有 8663 个文件被更改,包括 1962691 次插入 和 0 次删除

查看文件

@@ -0,0 +1,105 @@
/** @jest-environment jsdom */
import { DialogWebViewApi } from '../types';
import initializeDialogWebView from './initializeDialogWebView';
let mockDialogApi: DialogWebViewApi;
jest.mock('../../../utils/ipc/WebViewToRNMessenger', () => {
return class {
public remoteApi = {
postMessage: jest.fn(),
onMessage: jest.fn(),
onError: jest.fn(),
onLog: jest.fn(),
};
public constructor(_messageChannelId: string, localApi: DialogWebViewApi) {
mockDialogApi = localApi;
}
};
});
jest.mock('./utils/reportUnhandledErrors', () => jest.fn());
jest.mock('./utils/wrapConsoleLog', () => jest.fn());
class MockResizeObserver {
public static instances: MockResizeObserver[] = [];
public observe = jest.fn();
public disconnect = jest.fn();
public constructor(private callback: ResizeObserverCallback) {
MockResizeObserver.instances.push(this);
}
public notify() {
this.callback([], this as unknown as ResizeObserver);
}
}
describe('initializeDialogWebView', () => {
let contentWidth = 0;
let contentHeight = 0;
let viewportWidth = 0;
let innerWidth = 0;
beforeEach(() => {
MockResizeObserver.instances = [];
global.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
document.body.innerHTML = '<div id="joplin-plugin-content"></div>';
const content = document.getElementById('joplin-plugin-content');
Object.defineProperty(content, 'clientWidth', { configurable: true, get: () => contentWidth });
Object.defineProperty(content, 'clientHeight', { configurable: true, get: () => contentHeight });
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, get: () => viewportWidth });
Object.defineProperty(window, 'innerWidth', { configurable: true, get: () => innerWidth });
contentWidth = 320;
contentHeight = 200;
viewportWidth = 1000;
innerWidth = 1000;
initializeDialogWebView('test-channel');
});
afterEach(() => {
window.dispatchEvent(new Event('pagehide'));
});
test('should report width reductions observed without DOM mutations', async () => {
expect(await mockDialogApi.getContentSize(true)).toMatchObject({ width: 320, height: 200 });
contentWidth = 220;
MockResizeObserver.instances[0].notify();
expect(await mockDialogApi.getContentSize(true)).toMatchObject({ width: 220, height: 200 });
});
test('should remain stable while dialog text changes continuously', async () => {
contentWidth = 300;
viewportWidth = 300;
innerWidth = 315;
expect(await mockDialogApi.getContentSize(true)).toMatchObject({ width: 315, height: 200 });
for (let i = 0; i < 5; i++) {
document.getElementById('joplin-plugin-content').textContent = `Update ${i}`;
contentHeight++;
MockResizeObserver.instances[0].notify();
expect(await mockDialogApi.getContentSize(true)).toMatchObject({ width: 315, height: 201 + i });
}
});
test('should only observe while watching for size changes', async () => {
await mockDialogApi.getContentSize(false);
expect(MockResizeObserver.instances).toHaveLength(0);
await mockDialogApi.getContentSize(true);
expect(MockResizeObserver.instances[0].observe).toHaveBeenCalledWith(
document.getElementById('joplin-plugin-content'),
{ box: 'border-box' },
);
await mockDialogApi.getContentSize(false);
expect(MockResizeObserver.instances[0].disconnect).toHaveBeenCalledTimes(1);
await mockDialogApi.getContentSize(true);
window.dispatchEvent(new Event('pagehide'));
expect(MockResizeObserver.instances[1].disconnect).toHaveBeenCalledTimes(1);
});
});

查看文件

@@ -0,0 +1,145 @@
import { DialogWebViewApi, DialogMainProcessApi, WebViewPostMessageCallback, DialogSetOnMessageListenerCallback } from '../types';
import reportUnhandledErrors from './utils/reportUnhandledErrors';
import wrapConsoleLog from './utils/wrapConsoleLog';
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
import getFormData from './utils/getFormData';
interface ExtendedWindow extends Window {
webviewApi: {
postMessage: WebViewPostMessageCallback;
onMessage: DialogSetOnMessageListenerCallback;
};
exports: Record<string, unknown>;
}
declare const window: ExtendedWindow;
let themeCssElement: HTMLStyleElement|null = null;
const initializeDialogWebView = (messageChannelId: string) => {
const loadedPaths: Set<string> = new Set();
let contentResizeObserver: ResizeObserver|null = null;
let observedElement: HTMLElement|null = null;
let observedContentSize: { width: number; height: number }|null = null;
let pageHideListenerRegistered = false;
const measureContent = (element: HTMLElement) => {
const viewportWidth = document.documentElement.clientWidth;
const scrollbarWidth = Math.max(0, window.innerWidth - viewportWidth);
// Include the scrollbar gutter when the content fills the viewport so feeding this
// measurement back into the iframe does not repeatedly shrink it.
const width = element.clientWidth === viewportWidth ? element.clientWidth + scrollbarWidth : element.clientWidth;
return { width, height: element.clientHeight };
};
const stopObservingContentSize = () => {
contentResizeObserver?.disconnect();
contentResizeObserver = null;
observedElement = null;
observedContentSize = null;
if (pageHideListenerRegistered) {
window.removeEventListener('pagehide', stopObservingContentSize);
pageHideListenerRegistered = false;
}
};
const observeContentSize = (element: HTMLElement) => {
if (observedElement === element && contentResizeObserver) return;
stopObservingContentSize();
observedElement = element;
observedContentSize = measureContent(element);
contentResizeObserver = new ResizeObserver(() => {
if (observedElement === element) observedContentSize = measureContent(element);
});
contentResizeObserver.observe(element, { box: 'border-box' });
window.addEventListener('pagehide', stopObservingContentSize);
pageHideListenerRegistered = true;
};
type ScriptType = 'js'|'css';
const includeScriptsOrStyles = (type: ScriptType, paths: string[]) => {
for (const path of paths) {
if (loadedPaths.has(path)) {
continue;
}
loadedPaths.add(path);
if (type === 'css') {
const stylesheetLink = document.createElement('link');
stylesheetLink.rel = 'stylesheet';
stylesheetLink.href = path;
document.head.appendChild(stylesheetLink);
} else {
const script = document.createElement('script');
script.src = path;
document.head.appendChild(script);
}
}
};
const localApi: DialogWebViewApi = {
includeCssFiles: async (paths: string[]) => {
return includeScriptsOrStyles('css', paths);
},
includeJsFiles: async (paths: string[]) => {
return includeScriptsOrStyles('js', paths);
},
runScript: async (key: string, scriptData: string) => {
if (loadedPaths.has(key)) {
return;
}
loadedPaths.add(key);
if (key.endsWith('.css')) {
const stylesheetLink = document.createElement('style');
stylesheetLink.appendChild(document.createTextNode(scriptData));
document.head.appendChild(stylesheetLink);
} else {
const script = document.createElement('script');
script.appendChild(document.createTextNode(scriptData));
document.head.appendChild(script);
}
},
getFormData: async () => {
return getFormData();
},
setThemeCss: async (css: string) => {
themeCssElement?.remove?.();
const styleElement = document.createElement('style');
styleElement.appendChild(document.createTextNode(css));
document.body.appendChild(styleElement);
themeCssElement = styleElement;
},
getContentSize: async (watchForSizeChanges = false) => {
// To convert to React Native pixel units from browser pixel units,
// we need to multiply by the devicePixelRatio:
const dpr = window.devicePixelRatio ?? 1;
const element = document.getElementById('joplin-plugin-content') ?? document.body;
if (watchForSizeChanges) {
observeContentSize(element);
} else {
stopObservingContentSize();
}
const contentSize = observedContentSize ?? measureContent(element);
return {
width: contentSize.width * dpr,
height: contentSize.height * dpr,
};
},
};
const messenger = new WebViewToRNMessenger<DialogWebViewApi, DialogMainProcessApi>(messageChannelId, localApi);
window.webviewApi = {
postMessage: messenger.remoteApi.postMessage,
onMessage: messenger.remoteApi.onMessage,
};
reportUnhandledErrors(messenger.remoteApi.onError);
wrapConsoleLog(messenger.remoteApi.onLog);
// If dialog content scripts were bundled with Webpack for NodeJS,
// they may expect a global "exports" to be present.
window.exports ??= {};
};
export default initializeDialogWebView;

查看文件

@@ -0,0 +1,18 @@
import WindowMessenger from '@joplin/lib/utils/ipc/WindowMessenger';
import { PluginMainProcessApi, PluginWebViewApi } from '../types';
import reportUnhandledErrors from './utils/reportUnhandledErrors';
import wrapConsoleLog from './utils/wrapConsoleLog';
export const initializePluginBackgroundIframe = async (messageChannelId: string) => {
const localApi = { };
const messenger = new WindowMessenger<PluginWebViewApi, PluginMainProcessApi>(messageChannelId, parent, localApi);
await messenger.awaitRemoteReady();
(window as Window & { joplin?: unknown }).joplin = messenger.remoteApi.api.joplin;
reportUnhandledErrors(messenger.remoteApi.onError);
wrapConsoleLog(messenger.remoteApi.onLog);
};
export default initializePluginBackgroundIframe;

查看文件

@@ -0,0 +1,47 @@
export { runPlugin, stopPlugin } from './startStopPlugin';
// Old plugins allowed to import legacy APIs
const legacyPluginIds = [
'outline',
'ylc395.noteLinkSystem',
'com.github.joplin.kanban',
];
const pathLibrary = require('path');
const punycode = require('punycode/');
export const requireModule = (moduleName: string, fromPluginId: string) => {
if (moduleName === 'path') {
return pathLibrary;
}
if (legacyPluginIds.includes(fromPluginId)) {
if (moduleName === 'punycode') {
console.warn('Requiring punycode is deprecated. Please transition to a newer API.');
return punycode;
}
if (moduleName === 'fs' || moduleName === 'fs-extra') {
console.warn('The fs library is unavailable to mobile plugins. A non-functional mock will be returned.');
return {
existsSync: () => false,
pathExists: () => false,
readFileSync: () => '',
readFile: () => '',
writeFileSync: () => '',
writeFile: () => '',
appendFile: () => '',
};
}
if (moduleName === 'process') {
return {};
}
if (moduleName === 'url') {
return { parse: (u: string) => new URL(u) };
}
}
throw new Error(`Unable to require module ${moduleName} on mobile.`);
};
export { default as initializePluginBackgroundIframe } from './initializePluginBackgroundIframe';
export { default as initializeDialogWebView } from './initializeDialogWebView';

查看文件

@@ -0,0 +1,98 @@
import RemoteMessenger from '@joplin/lib/utils/ipc/RemoteMessenger';
import { PluginMainProcessApi, PluginWebViewApi } from '../types';
import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger';
import WindowMessenger from '@joplin/lib/utils/ipc/WindowMessenger';
import makeSandboxedIframe from '@joplin/lib/utils/dom/makeSandboxedIframe';
type PluginRecord = {
iframe: HTMLIFrameElement;
connectionToParent: RemoteMessenger<PluginWebViewApi, PluginMainProcessApi>|null;
connectionToIframe: RemoteMessenger<PluginMainProcessApi, PluginWebViewApi>|null;
};
const loadedPlugins: Record<string, PluginRecord> = Object.create(null);
export const stopPlugin = async (pluginId: string) => {
if (!loadedPlugins[pluginId]) {
return;
}
loadedPlugins[pluginId].connectionToIframe?.closeConnection?.();
loadedPlugins[pluginId].connectionToParent?.closeConnection?.();
const iframe = loadedPlugins[pluginId].iframe;
iframe.srcdoc = '';
iframe.remove();
delete loadedPlugins[pluginId];
};
export const runPlugin = async (
pluginBackgroundScript: string, scriptFilePath: string, messageChannelId: string, pluginId: string, scriptText = '',
) => {
if (loadedPlugins[pluginId]) {
console.warn(`Plugin already running ${pluginId}`);
return;
}
// When scriptText is provided (web), use it directly. Otherwise load
// the plugin script from the filesystem (native mobile). We use
// XMLHttpRequest because fetch() doesn't support file:// URLs on
// Android WebView.
let pluginScript = scriptText;
if (!pluginScript) {
pluginScript = await new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', `file://${scriptFilePath}`, true);
xhr.onload = () => resolve(xhr.responseText);
xhr.onerror = () => reject(new Error(`Failed to load plugin script: ${scriptFilePath}`));
xhr.send();
});
}
const bodyHtml = '';
const initialJavaScript = `
"use strict";
${pluginBackgroundScript}
(async () => {
window.require = function(module) {
return pluginBackgroundPage.requireModule(module, ${JSON.stringify(pluginId)});
};
window.exports = window.exports || {};
await pluginBackgroundPage.initializePluginBackgroundIframe(${JSON.stringify(messageChannelId)});
${pluginScript}
})();
`;
const backgroundIframe = makeSandboxedIframe({ bodyHtml, headHtml: '', scripts: [initialJavaScript] }).iframe;
loadedPlugins[pluginId] = {
iframe: backgroundIframe,
connectionToParent: null,
connectionToIframe: null,
};
backgroundIframe.addEventListener('load', async () => {
if (!loadedPlugins[pluginId]) {
return;
}
const connectionToParent = new WebViewToRNMessenger<PluginWebViewApi, PluginMainProcessApi>(messageChannelId, null);
const connectionToIframe = new WindowMessenger<PluginMainProcessApi, PluginWebViewApi>(
messageChannelId, backgroundIframe.contentWindow, connectionToParent.remoteApi,
);
// The two messengers are intermediate links in a chain (they serve to forward messages
// between the parent and the iframe).
connectionToParent.setIsChainedMessenger(true);
connectionToIframe.setIsChainedMessenger(true);
connectionToParent.setLocalInterface(connectionToIframe.remoteApi);
loadedPlugins[pluginId].connectionToIframe = connectionToIframe;
loadedPlugins[pluginId].connectionToParent = connectionToParent;
}, { once: true });
document.body.appendChild(backgroundIframe);
};

查看文件

@@ -0,0 +1,54 @@
/** @jest-environment jsdom */
import getFormData from './getFormData';
describe('getFormData', () => {
afterEach(() => {
// Remove all forms to prevent tests from conflicting -- getFormData
// uses document.querySelectorAll to select all forms.
document.body.replaceChildren();
});
const addTextInputWithValue = (parent: HTMLElement, value: string, name: string) => {
const input = document.createElement('input');
input.name = name;
input.value = value;
parent.appendChild(input);
};
test('should return data for all forms', () => {
const testForm1 = document.createElement('form');
testForm1.setAttribute('name', 'test-form-1');
addTextInputWithValue(testForm1, 'Test', 'input');
addTextInputWithValue(testForm1, 'Test2', 'another-input');
document.body.appendChild(testForm1);
const testForm2 = document.createElement('form');
testForm2.setAttribute('name', 'another-test-form');
addTextInputWithValue(testForm2, 'Test2', 'text-input');
document.body.appendChild(testForm2);
expect(getFormData()).toMatchObject({
'test-form-1': {
'input': 'Test',
'another-input': 'Test2',
},
'another-test-form': {
'text-input': 'Test2',
},
});
});
test('should auto-number forms without a name', () => {
for (let i = 0; i < 3; i++) {
const testForm = document.createElement('form');
addTextInputWithValue(testForm, `Form ${i}`, 'input');
document.body.appendChild(testForm);
}
const formData = getFormData();
expect(Object.keys(formData)).toHaveLength(3);
expect(formData['form-0']).toMatchObject({ 'input': 'Form 0' });
expect(formData['form-1']).toMatchObject({ 'input': 'Form 1' });
expect(formData['form-2']).toMatchObject({ 'input': 'Form 2' });
});
});

查看文件

@@ -0,0 +1,24 @@
const getFormData = () => {
const forms = document.querySelectorAll('form');
if (forms.length === 0) return null;
const serializeForm = (form: HTMLFormElement) => {
const formData = new FormData(form);
const serializedData: Record<string, FormDataEntryValue | null> = {};
for (const key of formData.keys()) {
serializedData[key] = formData.get(key);
}
return serializedData;
};
const result = Object.create(null);
let untitledFormId = 0;
for (const form of forms) {
const formId = form.getAttribute('name') || `form-${untitledFormId++}`;
result[formId] = serializeForm(form);
}
return result;
};
export default getFormData;

查看文件

@@ -0,0 +1,14 @@
type OnErrorCallback = (errorMessage: string)=> void;
const reportUnhandledErrors = (onError: OnErrorCallback) => {
window.addEventListener('unhandledrejection', (event) => {
onError(`Unhandled promise rejection: ${event.reason}. Promise: ${event.promise}.`);
});
window.addEventListener('error', (event) => {
onError(`Error: ${event.message}. ${event.error?.stack ?? ''}`);
});
};
export default reportUnhandledErrors;

查看文件

@@ -0,0 +1,54 @@
import { LogLevel } from '../../types';
/* eslint-disable no-console */
type OnLogCallback = (level: LogLevel, message: string)=> void;
const wrapConsoleLog = (onLog: OnLogCallback) => {
// inOnLogCallback keeps track of whether onLog is currently being called or not.
// This helps prevent StackOverflows if onLog itself calls console.log/console.error.
let inOnLogCallback = false;
const wrapLogFunction = (key: keyof typeof console, logLevel: LogLevel) => {
const originalLog = (console[key] as ((...args: unknown[])=> void) | undefined) ?? (()=>{});
console[key] = function(...args: unknown[]) {
originalLog.call(this, ...args);
if (inOnLogCallback) return;
// Work around
// Uncaught (in promise) TypeError: Cannot convert object to primitive value
// when console.log is called with an object that
// can't be converted to a string.
let argsString;
try {
argsString = args.join(' ');
} catch (error) {
argsString = `Error converting console arguments to string: ${error}`;
}
// Long messages can be slow to transfer over IPC and make other messages difficult
// to find/read in React Native's output.
const maxLength = 4096;
if (argsString.length > maxLength) {
argsString = `${argsString.substring(0, maxLength)}...`;
}
try {
inOnLogCallback = true;
onLog(logLevel, argsString);
} finally {
inOnLogCallback = false;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Assigning a generic (...args: unknown[]) function to a specific console method (which is the union of all method types) requires a cast through `any`
} as any;
};
wrapLogFunction('timeLog', LogLevel.Debug);
wrapLogFunction('log', LogLevel.Debug);
wrapLogFunction('warn', LogLevel.Warn);
wrapLogFunction('info', LogLevel.Info);
wrapLogFunction('error', LogLevel.Error);
};
export default wrapConsoleLog;