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,290 @@
|
||||
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<NoteViewerControl>,
|
||||
currentRevId: string,
|
||||
revisions: RevisionEntity[],
|
||||
themeId: number,
|
||||
customCss: string,
|
||||
scrollbarSize: ScrollbarSize,
|
||||
fontFamily: string,
|
||||
showNoteLinkIcon: boolean,
|
||||
canDecrypt: boolean,
|
||||
) => {
|
||||
const [note, setNote] = useState<NoteEntity>(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<NoteEntity>(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<Props> = ({ themeId, noteId, onBack, customCss, scrollbarSize, fontFamily, showNoteLinkIcon, noteLockSessionUnlocked, hasNoteLockKey, dispatch }) => {
|
||||
const helpButton_onClick = useCallback(() => {}, []);
|
||||
const viewerRef = useRef<NoteViewerControl|null>(null);
|
||||
const revisionListRef = useRef<HTMLSelectElement|null>(null);
|
||||
|
||||
const [revisions, setRevisions] = useState<RevisionEntity[]>([]);
|
||||
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<HTMLSelectElement> = 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(
|
||||
<option key={rev.id} value={rev.id}>
|
||||
{`${time.formatMsToLocal(rev.item_updated_time)} (${stats})`}
|
||||
</option>,
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<div className='revision-viewer-title'>
|
||||
<button onClick={backButton_click} style={{ ...theme.buttonStyle, marginRight: 10, height: theme.inputStyle.height }}>
|
||||
<i style={theme.buttonIconStyle} className={'fa fa-chevron-left'}></i>{_('Back')}
|
||||
</button>
|
||||
<input readOnly type="text" className='title' style={theme.inputStyle} value={note?.title ?? ''} />
|
||||
<select disabled={!revisions.length} value={currentRevId} className='revisions' style={theme.dropdownList} onChange={revisionList_onChange} ref={revisionListRef}>
|
||||
{revisionListItems}
|
||||
</select>
|
||||
<button disabled={!restoreNote || restoring || showLockPanel} onClick={importButton_onClick} className='restore'style={{ ...theme.buttonStyle, marginLeft: 10, height: theme.inputStyle.height }}>
|
||||
{restoreButtonTitle}
|
||||
</button>
|
||||
<button disabled={!revisions.length || deleting} onClick={deleteHistoryButton_onClick} className='deleteHistory'style={{ ...theme.buttonStyle, marginLeft: 10, height: theme.inputStyle.height }}>
|
||||
{deleteHistoryButtonTitle}
|
||||
</button>
|
||||
<HelpButton tip={helpMessage} id="noteRevisionHelpButton" onClick={helpButton_onClick} />
|
||||
</div>
|
||||
);
|
||||
|
||||
// The viewer stays mounted while the lock panel shows because its dom-ready event loads the revision list.
|
||||
const viewer = <NoteTextViewer themeId={themeId} viewerStyle={{ display: showLockPanel ? 'none' : 'flex', flex: 1, borderLeft: 'none' }} ref={viewerRef} onDomReady={viewer_domReady} onIpcMessage={webview_ipcMessage} />;
|
||||
|
||||
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 (
|
||||
<div className='revision-viewer-root'>
|
||||
{titleInput}
|
||||
{showLockPanel ? <NoteLockPanel
|
||||
noteTitle={note?.title ?? ''}
|
||||
hasNoteLockKey={hasNoteLockKey}
|
||||
dispatch={dispatch}
|
||||
undecryptable={decryptFailed && noteLockSessionUnlocked}
|
||||
/> : null}
|
||||
{viewer}
|
||||
<ReactTooltip place="bottom" delayShow={300} className="help-tooltip" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
在新工单中引用
屏蔽一个用户