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; 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>({}); // 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 = { }; 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;