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
一些检查失败了
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
这个提交包含在:
@@ -0,0 +1,107 @@
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import { dirname } from '@joplin/lib/path-utils';
|
||||
import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { useRef, useState } from 'react';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { ExtraContentScriptSource } from '../types';
|
||||
|
||||
const logger = Logger.create('NoteBodyViewer/hooks/useContentScripts');
|
||||
|
||||
// Most of the time, we don't actually need to reload the content scripts from a file,
|
||||
// which can be slow.
|
||||
//
|
||||
// As such, we cache content scripts and do two renders:
|
||||
// 1. The first render uses the cached content scripts.
|
||||
// While the first render is happening, we load content scripts from disk and compare them
|
||||
// to the cache.
|
||||
// If the same, we skip the second render.
|
||||
// 2. The second render happens only if the cached content scripts changed.
|
||||
//
|
||||
type ContentScriptsCache = Record<string, ExtraContentScriptSource[]>;
|
||||
let contentScriptsCache: ContentScriptsCache = {};
|
||||
|
||||
const useContentScripts = (pluginStates: PluginStates) => {
|
||||
const [contentScripts, setContentScripts] = useState(() => {
|
||||
const initialContentScripts = [];
|
||||
|
||||
for (const pluginId in pluginStates) {
|
||||
if (pluginId in contentScriptsCache) {
|
||||
initialContentScripts.push(...contentScriptsCache[pluginId]);
|
||||
}
|
||||
}
|
||||
|
||||
return initialContentScripts;
|
||||
});
|
||||
|
||||
const contentScriptsRef = useRef(null);
|
||||
contentScriptsRef.current = contentScripts;
|
||||
|
||||
// We load content scripts asynchronously because dynamic require doesn't
|
||||
// work in React Native.
|
||||
useAsyncEffect(async (event) => {
|
||||
const newContentScripts: ExtraContentScriptSource[] = [];
|
||||
const oldContentScripts = contentScriptsRef.current;
|
||||
let differentFromLastContentScripts = false;
|
||||
const newContentScriptsCache: ContentScriptsCache = {};
|
||||
|
||||
logger.debug('Loading content scripts...');
|
||||
|
||||
for (const pluginId in pluginStates) {
|
||||
const markdownItContentScripts = pluginStates[pluginId].contentScripts[ContentScriptType.MarkdownItPlugin];
|
||||
if (!markdownItContentScripts) continue;
|
||||
const loadedPluginContentScripts: ExtraContentScriptSource[] = [];
|
||||
|
||||
for (const contentScript of markdownItContentScripts) {
|
||||
logger.info('Loading content script from', contentScript.path);
|
||||
const content = await shim.fsDriver().readFile(contentScript.path, 'utf8');
|
||||
if (event.cancelled) return;
|
||||
|
||||
const contentScriptModule = `(function () {
|
||||
const exports = {};
|
||||
const module = { exports: exports };
|
||||
|
||||
${content}
|
||||
|
||||
return (module.exports || exports).default;
|
||||
})()`;
|
||||
|
||||
if (contentScriptModule.length > 1024 * 1024) {
|
||||
const size = Math.round(contentScriptModule.length / 1024) / 1024;
|
||||
logger.warn(
|
||||
`Plugin ${pluginId}:`,
|
||||
`Loaded large content script with size ${size} MiB and ID ${contentScript.id}.`,
|
||||
'Large content scripts can slow down the renderer.',
|
||||
);
|
||||
}
|
||||
|
||||
if (oldContentScripts[newContentScripts.length]?.js !== contentScriptModule) {
|
||||
differentFromLastContentScripts = true;
|
||||
}
|
||||
|
||||
loadedPluginContentScripts.push({
|
||||
id: contentScript.id,
|
||||
js: contentScriptModule,
|
||||
assetPath: dirname(contentScript.path),
|
||||
pluginId: pluginId,
|
||||
});
|
||||
}
|
||||
|
||||
newContentScriptsCache[pluginId] = loadedPluginContentScripts;
|
||||
newContentScripts.push(...loadedPluginContentScripts);
|
||||
}
|
||||
|
||||
differentFromLastContentScripts ||= newContentScripts.length !== oldContentScripts.length;
|
||||
if (differentFromLastContentScripts) {
|
||||
contentScriptsCache = newContentScriptsCache;
|
||||
setContentScripts(newContentScripts);
|
||||
} else {
|
||||
logger.debug(`Re-using all ${oldContentScripts.length} content scripts.`);
|
||||
}
|
||||
}, [pluginStates, setContentScripts]);
|
||||
|
||||
return contentScripts;
|
||||
};
|
||||
|
||||
export default useContentScripts;
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import lightTheme from '@joplin/lib/themes/light';
|
||||
import { editPopupClass, getEditPopupSource } from './useEditPopup';
|
||||
import { describe, it, expect, beforeAll, jest } from '@jest/globals';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
|
||||
const createEditPopup = (target: HTMLElement) => {
|
||||
const { createEditPopupSyntax } = getEditPopupSource(lightTheme);
|
||||
eval(`(${createEditPopupSyntax})`)(target, 'someresourceid', '() => {}');
|
||||
};
|
||||
|
||||
const destroyEditPopup = () => {
|
||||
const { destroyEditPopupSyntax } = getEditPopupSource(lightTheme);
|
||||
eval(`(${destroyEditPopupSyntax})`)();
|
||||
};
|
||||
|
||||
describe('useEditPopup', () => {
|
||||
beforeAll(async () => {
|
||||
// useEditPopup relies on the resourceDir setting, which is set by
|
||||
// switchClient.
|
||||
await setupDatabaseAndSynchronizer(0);
|
||||
await switchClient(0);
|
||||
});
|
||||
|
||||
it('should attach an edit popup to an image', () => {
|
||||
const container = document.createElement('div');
|
||||
const targetImage = document.createElement('img');
|
||||
container.appendChild(targetImage);
|
||||
|
||||
createEditPopup(targetImage);
|
||||
|
||||
// Popup should be present in the document
|
||||
expect(container.querySelector(`.${editPopupClass}`)).not.toBeNull();
|
||||
|
||||
// Destroy the edit popup
|
||||
jest.useFakeTimers();
|
||||
destroyEditPopup();
|
||||
|
||||
// Give time for the popup's fade out animation to run.
|
||||
jest.advanceTimersByTime(1000 * 10);
|
||||
|
||||
// Popup should be destroyed.
|
||||
expect(container.querySelector(`.${editPopupClass}`)).toBeNull();
|
||||
|
||||
targetImage.remove();
|
||||
});
|
||||
|
||||
it('should auto-remove the edit popup after a delay', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const container = document.createElement('div');
|
||||
const targetImage = document.createElement('img');
|
||||
container.appendChild(targetImage);
|
||||
|
||||
jest.useFakeTimers();
|
||||
createEditPopup(targetImage);
|
||||
|
||||
|
||||
expect(container.querySelector(`.${editPopupClass}`)).not.toBeNull();
|
||||
jest.advanceTimersByTime(1000 * 20); // ms
|
||||
expect(container.querySelector(`.${editPopupClass}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { Theme } from '@joplin/lib/themes/type';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export const editPopupClass = 'joplin-editPopup';
|
||||
|
||||
const getEditIconSrc = (theme: Theme) => {
|
||||
const svgData = `
|
||||
<svg viewBox="-103 60 180 180" width="30" height="30" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m 100,19 c -11.7,0 -21.1,9.5 -21.2,21.2 0,0 42.3,0 42.3,0 0,-11.7 -9.5,-21.2 -21.2,-21.2 z M 79,43 v 143 l 21.3,26.4 21,-26.5 V 42.8 Z" style="transform: rotate(45deg)" fill=${JSON.stringify(theme.color2)}/>
|
||||
</svg>
|
||||
`.replace(/[ \t\n]+/, ' ');
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svgData)}`;
|
||||
};
|
||||
|
||||
// Creates JavaScript/CSS that can be used to create an "Edit" button.
|
||||
// Exported to facilitate testing.
|
||||
export const getEditPopupSource = (theme: Theme) => {
|
||||
const fadeOutDelay = 400;
|
||||
const editPopupDestroyDelay = 5000;
|
||||
|
||||
const editPopupCss = `
|
||||
@keyframes fade-in {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.${editPopupClass} {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
||||
/* Don't take up any space in the line, overlay the button */
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
|
||||
--edit-popup-width: 40px;
|
||||
--edit-popup-padding: 10px;
|
||||
|
||||
/* Shift the popup such that it overlaps with the previous element. */
|
||||
left: calc(0px - var(--edit-popup-width));
|
||||
|
||||
/* Match the top of the image */
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.${editPopupClass} > button {
|
||||
padding: var(--edit-popup-padding);
|
||||
width: var(--edit-popup-width);
|
||||
|
||||
animation: fade-in 0.4s ease;
|
||||
|
||||
background-color: ${theme.backgroundColor2};
|
||||
color: ${theme.color2};
|
||||
|
||||
border: none;
|
||||
}
|
||||
|
||||
.${editPopupClass} img {
|
||||
/* Make the image take up as much space as possible (minus padding) */
|
||||
width: calc(var(--edit-popup-width) - var(--edit-popup-padding));
|
||||
}
|
||||
|
||||
.${editPopupClass}.fadeOut {
|
||||
animation: fade-out ${fadeOutDelay}ms ease;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
const destroyEditPopupSyntax = `() => {
|
||||
if (!window.editPopup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const popup = editPopup;
|
||||
popup.classList.add('fadeOut');
|
||||
window.editPopup = null;
|
||||
|
||||
setTimeout(() => {
|
||||
popup.remove();
|
||||
}, ${fadeOutDelay});
|
||||
}`;
|
||||
|
||||
const createEditPopupSyntax = `(parent, resourceId, onclick) => {
|
||||
if (window.editPopupTimeout) {
|
||||
clearTimeout(window.editPopupTimeout);
|
||||
window.editPopupTimeout = undefined;
|
||||
}
|
||||
|
||||
window.editPopupTimeout = setTimeout(${destroyEditPopupSyntax}, ${editPopupDestroyDelay});
|
||||
|
||||
if (window.lastEditPopupTarget !== parent) {
|
||||
(${destroyEditPopupSyntax})();
|
||||
} else if (window.editPopup) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.editPopup = document.createElement('div');
|
||||
const popupButton = document.createElement('button');
|
||||
|
||||
const popupIcon = new Image();
|
||||
popupIcon.alt = ${JSON.stringify(_('Edit'))};
|
||||
popupIcon.title = popupIcon.alt;
|
||||
popupIcon.src = ${JSON.stringify(getEditIconSrc(theme))};
|
||||
popupButton.appendChild(popupIcon);
|
||||
|
||||
popupButton.onclick = onclick;
|
||||
editPopup.appendChild(popupButton);
|
||||
|
||||
editPopup.classList.add(${JSON.stringify(editPopupClass)});
|
||||
parent.insertAdjacentElement('afterEnd', editPopup);
|
||||
|
||||
// Ensure that the edit popup is focused immediately by screen
|
||||
// readers.
|
||||
editPopup.focus();
|
||||
window.lastEditPopupTarget = parent;
|
||||
}`;
|
||||
|
||||
return { createEditPopupSyntax, destroyEditPopupSyntax, editPopupCss };
|
||||
};
|
||||
|
||||
const useEditPopup = (themeId: number) => {
|
||||
return useMemo(() => {
|
||||
return getEditPopupSource(themeStyle(themeId));
|
||||
}, [themeId]);
|
||||
};
|
||||
|
||||
export default useEditPopup;
|
||||
在新工单中引用
屏蔽一个用户