import * as React from 'react'; import { useEffect } from 'react'; import { themeStyle } from '@joplin/lib/theme'; import { _ } from '@joplin/lib/locale'; import NoteTextViewer, { NoteViewerControl } from './NoteTextViewer'; import HelpButton from './HelpButton'; import BaseModel from '@joplin/lib/BaseModel'; import Revision from '@joplin/lib/models/Revision'; import RevisionService from '@joplin/lib/services/RevisionService'; import { MarkupLanguage } from '@joplin/renderer'; import time from '@joplin/lib/time'; import bridge from '../services/bridge'; import { NoteEntity, RevisionEntity } from '@joplin/lib/services/database/types'; import { AppState } from '../app.reducer'; import * as urlUtils from '@joplin/lib/urlUtils'; const ReactTooltip = require('react-tooltip'); import { connect } from 'react-redux'; import shared from '@joplin/lib/components/shared/note-screen-shared'; import getHelpMessage from '@joplin/lib/components/shared/NoteRevisionViewer/getHelpMessage'; import shim, { MessageBoxType } from '@joplin/lib/shim'; import { RefObject, useCallback, useRef, useState } from 'react'; import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect'; import useMarkupToHtml from './hooks/useMarkupToHtml'; import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; import { ScrollbarSize } from '@joplin/lib/models/settings/builtInMetadata'; import { focus } from '@joplin/lib/utils/focusHandler'; import useDeleteHistoryClick from '@joplin/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick'; import { getGlobalSettings } from '@joplin/renderer/types'; import Setting from '@joplin/lib/models/Setting'; import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled'; import NoteLockService from '@joplin/lib/services/noteLock/NoteLockService'; import NoteLockPanel from './NoteEditor/NoteLockPanel/NoteLockPanel'; import hasNoteLockKey from './utils/hasNoteLockKey'; import { Dispatch } from 'redux'; interface Props { themeId: number; noteId: string; onBack: ()=> void; customCss: string; scrollbarSize: ScrollbarSize; fontFamily: string; showNoteLinkIcon: boolean; noteLockSessionUnlocked: boolean; hasNoteLockKey: boolean; dispatch: Dispatch; } const useNoteContent = ( viewerRef: RefObject, currentRevId: string, revisions: RevisionEntity[], themeId: number, customCss: string, scrollbarSize: ScrollbarSize, fontFamily: string, showNoteLinkIcon: boolean, canDecrypt: boolean, ) => { const [note, setNote] = useState(null); // The revision note as merged from the diffs, with a locked body still encrypted. Restoring // this keeps the restored copy a valid locked note, so it is never decrypted for restore. const [restoreNote, setRestoreNote] = useState(null); const [decryptFailed, setDecryptFailed] = useState(false); const markupToHtml = useMarkupToHtml({ themeId, customCss, plugins: {}, whiteBackgroundNoteRendering: false, scrollbarSize, baseFontFamily: fontFamily, }); useAsyncEffect(async (event) => { // Cleared before the load so a switch never keeps the previous revision's failure or restore target. setRestoreNote(null); setDecryptFailed(false); if (!revisions.length || !currentRevId) { setNote(null); } else { const revIndex = BaseModel.modelIndexById(revisions, currentRevId); const note = await RevisionService.instance().revisionNote(revisions, revIndex); if (!note || event.cancelled) return; if (isNoteLockEnabled() && revisions[revIndex].is_locked) { // A revision is a JSON diff object, so a gated load is not possible: the diffs are // merged first and the resulting body is decrypted manually here. let displayBody = ''; let failed = false; if (canDecrypt) { try { displayBody = await NoteLockService.instance().decryptString(note.body ?? ''); } catch (error) { console.warn('Could not decrypt revision content:', error); failed = true; } if (event.cancelled) return; } setDecryptFailed(failed); setNote({ ...note, body: displayBody }); } else { setNote(note); } setRestoreNote(note); } }, [revisions, currentRevId, themeId, customCss, viewerRef, canDecrypt]); useQueuedAsyncEffect(async () => { const noteBody = note?.body ?? _('This note has no history'); const markupLanguage = note?.markup_language ?? MarkupLanguage.Markdown; const result = await markupToHtml(markupLanguage, noteBody, { resources: await shared.attachedResources(noteBody), whiteBackgroundNoteRendering: markupLanguage === MarkupLanguage.Html, globalSettings: getGlobalSettings(Setting), showNoteLinkIcon, }); viewerRef.current.setHtml(result.html, { pluginAssets: result.pluginAssets, }); }, [note, viewerRef, markupToHtml, showNoteLinkIcon]); return { note, restoreNote, decryptFailed }; }; const NoteRevisionViewerComponent: React.FC = ({ themeId, noteId, onBack, customCss, scrollbarSize, fontFamily, showNoteLinkIcon, noteLockSessionUnlocked, hasNoteLockKey, dispatch }) => { const helpButton_onClick = useCallback(() => {}, []); const viewerRef = useRef(null); const revisionListRef = useRef(null); const [revisions, setRevisions] = useState([]); const [currentRevId, setCurrentRevId] = useState(''); const [restoring, setRestoring] = useState(false); const [deleting, setDeleting] = useState(false); const canDecrypt = noteLockSessionUnlocked && hasNoteLockKey; const { note, restoreNote, decryptFailed } = useNoteContent( viewerRef, currentRevId, revisions, themeId, customCss, scrollbarSize, fontFamily, showNoteLinkIcon, canDecrypt, ); const viewer_domReady = useCallback(async () => { // this.viewerRef_.current.openDevTools(); const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, noteId); setRevisions(revisions); setCurrentRevId(revisions.length ? revisions[revisions.length - 1].id : ''); }, [noteId]); const importButton_onClick = useCallback(async () => { if (!restoreNote) return; setRestoring(true); await RevisionService.instance().importRevisionNote(restoreNote); setRestoring(false); await shim.showMessageBox(RevisionService.instance().restoreSuccessMessage(restoreNote), { type: MessageBoxType.Info }); }, [restoreNote]); const resetScreenState = useCallback(() => { setRevisions([]); setCurrentRevId(null); }, []); const deleteHistoryButton_onClick = useDeleteHistoryClick({ noteId: note?.id, setDeleting, resetScreenState, }); const backButton_click = useCallback(() => { if (onBack) onBack(); }, [onBack]); const revisionList_onChange: React.ChangeEventHandler = useCallback((event) => { const value = event.target.value; if (!value) { if (onBack) onBack(); } else { setCurrentRevId(value); } }, [onBack]); const webview_ipcMessage = useCallback(async (event: { channel?: string; args?: unknown[] }) => { // For the revision view, we only support a minimal subset of the IPC messages. // For example, we don't need interactive checkboxes or sync between viewer and editor view. // We try to get most links work though, except for internal (joplin://) links. const msg = event.channel ? event.channel : ''; // const args = event.args; // if (msg !== 'percentScroll') console.info(`Got ipc-message: ${msg}`, args); try { if (msg.indexOf('checkboxclick:') === 0) { // Revision previews are read-only. Ignore checkbox toggle IPC messages so they // don't fall through to URL handling (`checkboxclick:` looks like a protocol). } else if (msg.indexOf('joplin://') === 0) { throw new Error(_('Unsupported link or message: %s', msg)); } else if (urlUtils.urlProtocol(msg)) { await bridge().openExternal(msg); } else if (msg.indexOf('#') === 0) { // This is an internal anchor, which is handled by the WebView so skip this case } else { console.warn(`Unsupported message in revision view: ${msg}`); } } catch (error) { console.warn(error); bridge().showErrorMessageBox(error.message); } }, []); const theme = themeStyle(themeId); const revisionListItems = []; const revs = revisions.slice().reverse(); for (let i = 0; i < revs.length; i++) { const rev = revs[i]; const stats = Revision.revisionPatchStatsText(rev); revisionListItems.push( , ); } const revisionLocked = isNoteLockEnabled() && revisions.some(r => r.id === currentRevId && !!r.is_locked); const showLockPanel = revisionLocked && (!canDecrypt || decryptFailed); const restoreButtonTitle = _('Restore'); const deleteHistoryButtonTitle = _('Delete history'); const helpMessage = getHelpMessage(restoreButtonTitle); const titleInput = (
); // The viewer stays mounted while the lock panel shows because its dom-ready event loads the revision list. const viewer = ; useEffect(() => { // We need to force focus here because otherwise the focus is lost and goes back // to the start of the document. See https://github.com/laurent22/joplin/pull/11769 focus('NoteRevisionViewer', revisionListRef.current); }, [revisionListRef, revisions]); return (
{titleInput} {showLockPanel ? : null} {viewer}
); }; const mapStateToProps = (state: AppState) => { return { themeId: state.settings.theme, scrollbarSize: state.settings['style.scrollbarSize'], fontFamily: state.settings['style.viewer.fontFamily'], showNoteLinkIcon: state.settings['notes.showNoteLinkIcon'], noteLockSessionUnlocked: state.noteLockSessionUnlocked, hasNoteLockKey: hasNoteLockKey(state.settings['syncInfoCache']), }; }; const NoteRevisionViewer = connect(mapStateToProps)(NoteRevisionViewerComponent); export default NoteRevisionViewer;