mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
222 lines
8.1 KiB
TypeScript
222 lines
8.1 KiB
TypeScript
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
|
import usePrevious from '@joplin/lib/hooks/usePrevious';
|
|
import { themeStyle } from '@joplin/lib/theme';
|
|
import { MarkupLanguage } from '@joplin/renderer';
|
|
import useEditPopup from './useEditPopup';
|
|
import Renderer from '../bundledJs/Renderer';
|
|
import { useEffect, useState } from 'react';
|
|
import Logger from '@joplin/utils/Logger';
|
|
import { ExtraContentScriptSource } from '../bundledJs/types';
|
|
import Setting from '@joplin/lib/models/Setting';
|
|
import shim from '@joplin/lib/shim';
|
|
import Resource from '@joplin/lib/models/Resource';
|
|
import { ResourceEntity } from '@joplin/lib/services/database/types';
|
|
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
|
|
|
|
|
|
export interface ResourceInfo {
|
|
localState: unknown;
|
|
item: ResourceEntity;
|
|
}
|
|
|
|
interface Props {
|
|
renderer: Renderer;
|
|
|
|
noteBody: string;
|
|
noteMarkupLanguage: MarkupLanguage;
|
|
themeId: number;
|
|
|
|
highlightedKeywords: string[];
|
|
noteResources: Record<string, ResourceInfo>;
|
|
noteHash: string;
|
|
initialScroll: number|undefined;
|
|
|
|
paddingBottom: number;
|
|
|
|
contentScripts: ExtraContentScriptSource[];
|
|
}
|
|
|
|
const onlyCheckboxHasChangedHack = (previousBody: string, newBody: string) => {
|
|
if (previousBody.length !== newBody.length) return false;
|
|
|
|
for (let i = 0; i < previousBody.length; i++) {
|
|
const c1 = previousBody.charAt(i);
|
|
const c2 = newBody.charAt(i);
|
|
|
|
if (c1 !== c2) {
|
|
if (c1 === ' ' && (c2 === 'x' || c2 === 'X')) continue;
|
|
if (c2 === ' ' && (c1 === 'x' || c1 === 'X')) continue;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const logger = Logger.create('useRerenderHandler');
|
|
|
|
const useRerenderHandler = (props: Props) => {
|
|
const { createEditPopupSyntax, destroyEditPopupSyntax, editPopupCss } = useEditPopup(props.themeId);
|
|
const [lastResourceLoadCounter, setLastResourceLoadCounter] = useState(0);
|
|
const [pluginSettingKeys, setPluginSettingKeys] = useState<Record<string, boolean>>({});
|
|
|
|
// To address https://github.com/laurent22/joplin/issues/433
|
|
//
|
|
// If a checkbox in a note is ticked, the body changes, which normally would
|
|
// trigger a re-render of this component, which has the unfortunate side
|
|
// effect of making the view scroll back to the top. This re-rendering
|
|
// however is unnecessary since the component is already visually updated via
|
|
// JS. So here, if the note has not changed, we prevent the component from
|
|
// updating. This fixes the above issue. A drawback of this is if the note
|
|
// is updated via sync, this change will not be displayed immediately.
|
|
//
|
|
// 2022-05-03: However we sometimes need the HTML to be updated, even when
|
|
// only the body has changed - for example when attaching a resource, or
|
|
// when adding text via speech recognition. So the logic has been narrowed
|
|
// down so that updates are skipped only when checkbox has been changed.
|
|
// Checkboxes still work as expected, without making the note scroll, and
|
|
// other text added to the note is displayed correctly.
|
|
//
|
|
// IMPORTANT: KEEP noteBody AS THE FIRST dependency in the array as the
|
|
// below logic rely on this.
|
|
const effectDependencies = [
|
|
props.noteBody, props.noteMarkupLanguage, props.renderer, props.highlightedKeywords,
|
|
props.noteHash, props.noteResources, props.themeId, props.paddingBottom, lastResourceLoadCounter,
|
|
createEditPopupSyntax, destroyEditPopupSyntax, pluginSettingKeys,
|
|
];
|
|
const previousDeps = usePrevious(effectDependencies, []);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
const changedDeps = effectDependencies.reduce((accum: any, dependency: any, index: any) => {
|
|
if (dependency !== previousDeps[index]) {
|
|
return { ...accum, [index]: true };
|
|
}
|
|
return accum;
|
|
}, {});
|
|
const onlyNoteBodyHasChanged = Object.keys(changedDeps).length === 1 && changedDeps[0];
|
|
const previousBody = previousDeps[0] as string;
|
|
const onlyCheckboxesHaveChanged = previousDeps[0] && changedDeps[0] && onlyCheckboxHasChangedHack(previousBody, props.noteBody);
|
|
const previousHash = usePrevious(props.noteHash, '');
|
|
const hashChanged = previousHash !== props.noteHash;
|
|
|
|
useEffect(() => {
|
|
// Whenever a resource state changes, for example when it goes from "not downloaded" to "downloaded", the "noteResources"
|
|
// props changes, thus triggering a render. The **content** of this noteResources array however is not changed because
|
|
// it doesn't contain info about the resource download state. Because of that, if we were to use the markupToHtml() cache
|
|
// it wouldn't re-render at all.
|
|
props.renderer.clearCache(props.noteMarkupLanguage);
|
|
}, [lastResourceLoadCounter, props.renderer, props.noteMarkupLanguage]);
|
|
|
|
useEffect(() => {
|
|
void props.renderer.setExtraContentScriptsAndRerender(props.contentScripts);
|
|
}, [props.contentScripts, props.renderer]);
|
|
|
|
useAsyncEffect(async event => {
|
|
if (onlyNoteBodyHasChanged && onlyCheckboxesHaveChanged) {
|
|
logger.info('Only a checkbox has changed - not updating HTML');
|
|
return;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
const pluginSettings: Record<string, any> = { };
|
|
for (const key in pluginSettingKeys) {
|
|
pluginSettings[key] = Setting.value(`plugin-${key}`);
|
|
}
|
|
let newPluginSettingKeys = pluginSettingKeys;
|
|
|
|
// On web, resources are virtual files and thus need to be transferred to the WebView.
|
|
if (shim.mobilePlatform() === 'web') {
|
|
for (const [resourceId, resource] of Object.entries(props.noteResources)) {
|
|
try {
|
|
await props.renderer.setResourceFile(
|
|
resourceId,
|
|
await shim.fsDriver().fileAtPath(Resource.fullPath(resource.item)),
|
|
);
|
|
} catch (error) {
|
|
if (error.code !== 'ENOENT') {
|
|
throw error;
|
|
}
|
|
|
|
// This can happen if a resource hasn't been downloaded yet
|
|
logger.warn('Error: Resource file not found (ENOENT)', Resource.fullPath(resource.item), 'for ID', resource.item.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
const theme = themeStyle(props.themeId);
|
|
const config = {
|
|
// We .stringify the theme to avoid a JSON serialization error involving
|
|
// the color package.
|
|
theme: JSON.stringify({
|
|
bodyPaddingTop: '0.8em',
|
|
bodyPaddingBottom: props.paddingBottom,
|
|
|
|
...theme,
|
|
}),
|
|
codeTheme: theme.codeThemeCss,
|
|
|
|
onResourceLoaded: () => {
|
|
// Force a rerender when a resource loads
|
|
setLastResourceLoadCounter(lastResourceLoadCounter + 1);
|
|
},
|
|
highlightedKeywords: props.highlightedKeywords,
|
|
resources: props.noteResources,
|
|
|
|
// If the hash changed, we don't set initial scroll -- we want to scroll to the hash
|
|
// instead.
|
|
initialScroll: (previousHash && hashChanged) ? undefined : props.initialScroll,
|
|
noteHash: props.noteHash,
|
|
|
|
pluginSettings,
|
|
requestPluginSetting: (pluginId: string, settingKey: string) => {
|
|
// Don't trigger additional renders
|
|
if (event.cancelled) return;
|
|
|
|
const key = `${pluginId}.${settingKey}`;
|
|
logger.debug(`Request plugin setting: plugin-${key}`);
|
|
|
|
if (!(key in newPluginSettingKeys)) {
|
|
newPluginSettingKeys = { ...newPluginSettingKeys, [`${pluginId}.${settingKey}`]: true };
|
|
setPluginSettingKeys(newPluginSettingKeys);
|
|
}
|
|
},
|
|
readAssetBlob: (assetPath: string) => {
|
|
// Built-in assets are in resourceDir, external plugin assets are in cacheDir.
|
|
const assetsDirs = [Setting.value('resourceDir'), Setting.value('cacheDir')];
|
|
|
|
let resolvedPath = null;
|
|
for (const assetDir of assetsDirs) {
|
|
resolvedPath ??= resolvePathWithinDir(assetDir, assetPath);
|
|
if (resolvedPath) break;
|
|
}
|
|
|
|
if (!resolvedPath) {
|
|
throw new Error(`Failed to load asset at ${assetPath} -- not in any of the allowed asset directories: ${assetsDirs.join(',')}.`);
|
|
}
|
|
return shim.fsDriver().fileAtPath(resolvedPath);
|
|
},
|
|
|
|
createEditPopupSyntax,
|
|
destroyEditPopupSyntax,
|
|
};
|
|
|
|
try {
|
|
logger.debug('Starting render...');
|
|
|
|
await props.renderer.rerender({
|
|
language: props.noteMarkupLanguage,
|
|
markup: props.noteBody,
|
|
}, config);
|
|
|
|
logger.debug('Render complete.');
|
|
} catch (error) {
|
|
logger.error('Render failed:', error);
|
|
}
|
|
}, effectDependencies);
|
|
|
|
useEffect(() => {
|
|
props.renderer.setExtraCss('edit-popup', editPopupCss);
|
|
}, [editPopupCss, props.renderer]);
|
|
};
|
|
|
|
export default useRerenderHandler;
|