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,15 @@
|
||||
import shim from '../../shim';
|
||||
|
||||
|
||||
// Returns the Document that contains [elementRef]. This is useful when a component
|
||||
// can be rendered in secondary windows and needs to access the component's container
|
||||
// document.
|
||||
const useDocument = (elementRef: Element|null) => {
|
||||
const { useMemo } = shim.react();
|
||||
|
||||
return useMemo(() => {
|
||||
return (elementRef ?? null)?.getRootNode() as Document|null;
|
||||
}, [elementRef]);
|
||||
};
|
||||
|
||||
export default useDocument;
|
||||
@@ -0,0 +1,43 @@
|
||||
import PluginService from '../../services/plugins/PluginService';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import shim from '../../shim';
|
||||
|
||||
const logger = Logger.create('usePlugin');
|
||||
|
||||
const usePlugin = (pluginId: string) => {
|
||||
const React = shim.react();
|
||||
const [pluginReloadCounter, setPluginReloadCounter] = React.useState(0);
|
||||
|
||||
const plugin = React.useMemo(() => {
|
||||
if (!PluginService.instance().pluginIds.includes(pluginId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pluginReloadCounter > 0) {
|
||||
logger.debug('Reloading plugin', pluginId, 'because the set of loaded plugins changed.');
|
||||
}
|
||||
|
||||
return PluginService.instance().pluginById(pluginId);
|
||||
// The dependency on pluginReloadCounter is important -- it ensures that the plugin
|
||||
// matches the one loaded in the PluginService.
|
||||
}, [pluginId, pluginReloadCounter]);
|
||||
|
||||
const reloadCounterRef = React.useRef(0);
|
||||
reloadCounterRef.current = pluginReloadCounter;
|
||||
|
||||
// The plugin may need to be re-fetched from the PluginService. When a plugin is reloaded,
|
||||
// its Plugin object is replaced with a new one.
|
||||
React.useEffect(() => {
|
||||
const { remove } = PluginService.instance().addLoadedPluginsChangeListener(() => {
|
||||
setPluginReloadCounter(reloadCounterRef.current + 1);
|
||||
});
|
||||
|
||||
return () => {
|
||||
remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return plugin;
|
||||
};
|
||||
|
||||
export default usePlugin;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PluginStates } from '../../services/plugins/reducer';
|
||||
import getActivePluginEditorViews from '../../services/plugins/utils/getActivePluginEditorViews';
|
||||
import shim from '../../shim';
|
||||
const { useMemo } = shim.react();
|
||||
|
||||
const useVisiblePluginEditorViewIds = (plugins: PluginStates, windowId: string) => {
|
||||
return useMemo(() => {
|
||||
const visibleViews = getActivePluginEditorViews(plugins, windowId, { mustBeVisible: true });
|
||||
return visibleViews.flatMap(({ editorView }) => editorView.id);
|
||||
}, [plugins, windowId]);
|
||||
};
|
||||
|
||||
export default useVisiblePluginEditorViewIds;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type DependencyList } from 'react';
|
||||
import shim from '../shim';
|
||||
const { useEffect } = shim.react();
|
||||
|
||||
type CleanupCallback = ()=> void;
|
||||
|
||||
export interface AsyncEffectEvent {
|
||||
cancelled: boolean;
|
||||
onCleanup: (callback: CleanupCallback)=> void;
|
||||
}
|
||||
|
||||
export type EffectFunction = (event: AsyncEffectEvent)=> Promise<void>;
|
||||
|
||||
export default function(effect: EffectFunction, dependencies: DependencyList) {
|
||||
useEffect(() => {
|
||||
const onCleanupCallbacks: CleanupCallback[] = [];
|
||||
const event: AsyncEffectEvent = {
|
||||
cancelled: false,
|
||||
onCleanup: (callback) => {
|
||||
if (event.cancelled) {
|
||||
callback();
|
||||
} else {
|
||||
onCleanupCallbacks.push(callback);
|
||||
}
|
||||
},
|
||||
};
|
||||
void effect(event);
|
||||
return () => {
|
||||
event.cancelled = true;
|
||||
|
||||
for (const callback of onCleanupCallbacks) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, dependencies);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { type RefObject } from 'react';
|
||||
import shim from '../shim';
|
||||
const { useCallback, useEffect, useState } = shim.react();
|
||||
import useEventListener from './useEventListener';
|
||||
|
||||
interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function useElementSize(elementRef: RefObject<HTMLElement>): Size {
|
||||
const [size, setSize] = useState({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
// Prevent too many rendering using useCallback
|
||||
const updateSize = useCallback(() => {
|
||||
const node = elementRef?.current;
|
||||
if (node) {
|
||||
setSize({
|
||||
width: node.offsetWidth || 0,
|
||||
height: node.offsetHeight || 0,
|
||||
});
|
||||
}
|
||||
}, [elementRef]);
|
||||
|
||||
// Initial size on mount
|
||||
useEffect(() => {
|
||||
updateSize();
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
||||
}, []);
|
||||
|
||||
useEventListener('resize', updateSize);
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
export default useElementSize;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { type RefObject } from 'react';
|
||||
import shim from '../shim';
|
||||
const { useEffect, useRef } = shim.react();
|
||||
|
||||
type EventHandler = (event: Event)=> void;
|
||||
|
||||
function useEventListener(
|
||||
eventName: string,
|
||||
handler: EventHandler,
|
||||
element?: RefObject<EventTarget | null>,
|
||||
) {
|
||||
// Create a ref that stores handler
|
||||
const savedHandler = useRef<EventHandler | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
// Define the listening target
|
||||
const targetElement = element?.current || window;
|
||||
if (!(targetElement && targetElement.addEventListener)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update saved handler if necessary
|
||||
if (savedHandler.current !== handler) {
|
||||
savedHandler.current = handler;
|
||||
}
|
||||
|
||||
// Create event listener that calls handler function stored in ref
|
||||
const eventListener = (event: Event) => {
|
||||
// eslint-disable-next-line no-extra-boolean-cast
|
||||
if (!!savedHandler?.current) {
|
||||
savedHandler.current(event);
|
||||
}
|
||||
};
|
||||
|
||||
targetElement.addEventListener(eventName, eventListener);
|
||||
|
||||
// Remove event listener on cleanup
|
||||
return () => {
|
||||
targetElement.removeEventListener(eventName, eventListener);
|
||||
};
|
||||
}, [eventName, element, handler]);
|
||||
}
|
||||
|
||||
export default useEventListener;
|
||||
@@ -0,0 +1,30 @@
|
||||
import '../testing/dom-test-environment';
|
||||
import type * as React from 'react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import useNowEffect from './useNowEffect';
|
||||
|
||||
describe('useNowEffect', () => {
|
||||
test('should call the cleanup callback when the effect function is called and after unmount', async () => {
|
||||
const cleanupFunction = jest.fn(() => {});
|
||||
const effectFunction = jest.fn(() => cleanupFunction);
|
||||
const useTestHook = (dependencies: React.DependencyList) => {
|
||||
return useNowEffect(effectFunction, dependencies);
|
||||
};
|
||||
|
||||
const hook = renderHook(useTestHook, { initialProps: [0] });
|
||||
expect(cleanupFunction).not.toHaveBeenCalled();
|
||||
expect(effectFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
hook.rerender([0]);
|
||||
expect(cleanupFunction).not.toHaveBeenCalled();
|
||||
expect(effectFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
hook.rerender([1]);
|
||||
expect(cleanupFunction).toHaveBeenCalledTimes(1);
|
||||
expect(effectFunction).toHaveBeenCalledTimes(2);
|
||||
|
||||
hook.unmount();
|
||||
expect(cleanupFunction).toHaveBeenCalledTimes(2);
|
||||
expect(effectFunction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type * as React from 'react';
|
||||
import shim from '../shim';
|
||||
|
||||
type CleanupCallback = (()=> void)|null;
|
||||
export type EffectFunction = ()=> CleanupCallback;
|
||||
const { useRef, useMemo, useEffect } = shim.react();
|
||||
|
||||
// Like useEffect, but runs as soon as possible.
|
||||
const useNowEffect = (effect: EffectFunction, dependencies: React.DependencyList) => {
|
||||
const lastCleanup = useRef<CleanupCallback>(null);
|
||||
|
||||
const cleanupCallback = useMemo(() => {
|
||||
lastCleanup.current?.();
|
||||
lastCleanup.current = null;
|
||||
|
||||
return effect() ?? null;
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- This is a custom hook
|
||||
}, dependencies);
|
||||
lastCleanup.current = cleanupCallback;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
lastCleanup.current?.();
|
||||
lastCleanup.current = null;
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
export default useNowEffect;
|
||||
@@ -0,0 +1,13 @@
|
||||
import shim from '../shim';
|
||||
|
||||
const { useRef, useEffect } = shim.react();
|
||||
|
||||
const usePrevious = <T> (value: T, initialValue: T = null) => {
|
||||
const ref = useRef<T>(initialValue);
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
return ref.current;
|
||||
};
|
||||
|
||||
export default usePrevious;
|
||||
@@ -0,0 +1,36 @@
|
||||
import '../testing/dom-test-environment';
|
||||
import type * as React from 'react';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import useQueuedAsyncEffect from './useQueuedAsyncEffect';
|
||||
import { runWithFakeTimers } from '../testing/test-utils';
|
||||
|
||||
describe('useQueuedAsyncEffect', () => {
|
||||
test('should debounce effect updates', async () => {
|
||||
const effectFunction = jest.fn(async () => { });
|
||||
const useTestHook = (dependencies: React.DependencyList) => {
|
||||
return useQueuedAsyncEffect(effectFunction, dependencies);
|
||||
};
|
||||
|
||||
await runWithFakeTimers(async () => {
|
||||
const result = renderHook(useTestHook, { initialProps: ['test'] });
|
||||
|
||||
// Should pause to allow debouncing.
|
||||
expect(effectFunction).not.toHaveBeenCalled();
|
||||
|
||||
await jest.advanceTimersByTimeAsync(12500);
|
||||
expect(effectFunction).toHaveBeenCalledTimes(1);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// Changing twice quickly: Should only update once
|
||||
result.rerender(['changed']);
|
||||
expect(effectFunction).toHaveBeenCalledTimes(1);
|
||||
result.rerender(['changed again']);
|
||||
await jest.advanceTimersByTimeAsync(500);
|
||||
expect(effectFunction).toHaveBeenCalledTimes(2);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(500);
|
||||
expect(effectFunction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type * as React from 'react';
|
||||
import shim from '../shim';
|
||||
import AsyncActionQueue from '../AsyncActionQueue';
|
||||
const { useEffect, useState } = shim.react();
|
||||
|
||||
interface AsyncEffectEvent {
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
export type EffectFunction = (event: AsyncEffectEvent)=> void|Promise<void>;
|
||||
|
||||
export interface Options {
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export default (
|
||||
effect: EffectFunction,
|
||||
dependencies: React.DependencyList,
|
||||
{ interval = undefined }: Options = {},
|
||||
) => {
|
||||
const [queue] = useState(() => new AsyncActionQueue(interval));
|
||||
useEffect(() => {
|
||||
const event: AsyncEffectEvent = { cancelled: false };
|
||||
queue.push(() => effect(event));
|
||||
return () => {
|
||||
event.cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- This is a custom hook
|
||||
}, dependencies);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
void queue.reset();
|
||||
};
|
||||
}, [queue]);
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户