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

这个提交包含在:
chen
2026-11-04 11:29:39 +08:00
当前提交 301194963b
共有 8663 个文件被更改,包括 1962691 次插入 和 0 次删除

查看文件

@@ -0,0 +1,4 @@
const getNoteElementIdFromJoplinId = (id: string) => `list-note-${id}`;
export default getNoteElementIdFromJoplinId;

查看文件

@@ -0,0 +1,46 @@
import { htmlentities } from '@joplin/utils/html';
const Mark = require('mark.js/dist/mark.min.js');
const markJsUtils = require('@joplin/lib/markJsUtils');
import { replaceRegexDiacritics, pregQuote } from '@joplin/lib/string-utils';
import { HighlightedWord } from '@joplin/lib/reducer';
const getNoteTitleHtml = (highlightedWords: HighlightedWord[], displayTitle: string) => {
if (highlightedWords.length) {
const titleElement = document.createElement('span');
titleElement.textContent = displayTitle;
const mark = new Mark(titleElement, {
exclude: ['img'],
acrossElements: true,
});
mark.unmark();
try {
for (const wordToBeHighlighted of highlightedWords) {
markJsUtils.markKeyword(mark, wordToBeHighlighted, {
pregQuote: pregQuote,
replaceRegexDiacritics: replaceRegexDiacritics,
});
}
} catch (error) {
if (error.name !== 'SyntaxError') {
throw error;
}
// An error of 'Regular expression too large' might occur in the markJs library
// when the input is really big, this catch is here to avoid the application crashing
// https://github.com/laurent22/joplin/issues/7634
// console.error('Error while trying to highlight words from search: ', error);
}
// Note: in this case it is safe to use dangerouslySetInnerHTML because titleElement
// is a span tag that we created and that contains data that's been inserted as plain text
// with `textContent` so it cannot contain any XSS attacks. We use this feature because
// mark.js can only deal with DOM elements.
// https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
return titleElement.outerHTML;
} else {
return htmlentities(displayTitle);
}
};
export default getNoteTitleHtml;

查看文件

@@ -0,0 +1,109 @@
// Singleton custom tooltip for note list items. Shown when the rendered title
// is truncated by overflow:hidden / text-overflow:ellipsis. A single tooltip
// element is shared by all rows.
let tooltipElement: HTMLDivElement | null = null;
let showTimer: ReturnType<typeof setTimeout> | null = null;
let activeRow: HTMLElement | null = null;
const showDelayMs = 500;
const viewportMargin = 4;
const getTooltipElement = () => {
if (tooltipElement) return tooltipElement;
tooltipElement = document.createElement('div');
tooltipElement.className = 'note-title-tooltip';
tooltipElement.setAttribute('role', 'tooltip');
document.body.appendChild(tooltipElement);
return tooltipElement;
};
// Looks for the element that actually holds the title text. Covers both
// built-in renderers (compact and detailed); returns null for plugin
// renderers that don't use those class hooks.
const findTitleElement = (rowElement: HTMLElement): HTMLElement | null => {
const detailed = rowElement.querySelector<HTMLElement>('[data-name="note.title"] > .content');
if (detailed) return detailed;
const compact = rowElement.querySelector<HTMLElement>('.title');
if (compact) return compact;
return null;
};
const isTruncated = (element: HTMLElement) => {
// Check descendants too, since the title text may live in a child span
// (e.g. mark.js wraps highlighted matches).
if (element.scrollWidth > element.clientWidth) return true;
for (const child of Array.from(element.children) as HTMLElement[]) {
if (child.scrollWidth > child.clientWidth) return true;
}
return false;
};
const positionTooltip = (tooltip: HTMLElement, anchor: HTMLElement) => {
const anchorRect = anchor.getBoundingClientRect();
tooltip.style.visibility = 'hidden';
tooltip.style.display = 'block';
const tooltipRect = tooltip.getBoundingClientRect();
let top = anchorRect.bottom + viewportMargin;
if (top + tooltipRect.height > window.innerHeight - viewportMargin) {
top = Math.max(viewportMargin, anchorRect.top - tooltipRect.height - viewportMargin);
}
let left = anchorRect.left;
if (left + tooltipRect.width > window.innerWidth - viewportMargin) {
left = Math.max(viewportMargin, window.innerWidth - tooltipRect.width - viewportMargin);
}
tooltip.style.top = `${Math.round(top)}px`;
tooltip.style.left = `${Math.round(left)}px`;
tooltip.style.visibility = 'visible';
};
const hideTooltip = () => {
if (showTimer) {
clearTimeout(showTimer);
showTimer = null;
}
activeRow = null;
if (!tooltipElement) return;
tooltipElement.style.display = 'none';
tooltipElement.textContent = '';
};
const scheduleShow = (anchor: HTMLElement, text: string) => {
if (showTimer) clearTimeout(showTimer);
showTimer = setTimeout(() => {
const tooltip = getTooltipElement();
tooltip.textContent = text;
positionTooltip(tooltip, anchor);
showTimer = null;
}, showDelayMs);
};
const attachNoteTitleTooltip = (rowElement: HTMLElement, displayTitle: string) => {
const onPointerEnter = () => {
const titleElement = findTitleElement(rowElement);
if (!titleElement || !isTruncated(titleElement)) {
if (activeRow === rowElement) hideTooltip();
return;
}
activeRow = rowElement;
scheduleShow(titleElement, displayTitle);
};
const onPointerLeave = () => {
if (activeRow === rowElement) hideTooltip();
};
rowElement.addEventListener('mouseenter', onPointerEnter);
rowElement.addEventListener('mouseleave', onPointerLeave);
return () => {
rowElement.removeEventListener('mouseenter', onPointerEnter);
rowElement.removeEventListener('mouseleave', onPointerLeave);
if (activeRow === rowElement) hideTooltip();
};
};
export default attachNoteTitleTooltip;

查看文件

@@ -0,0 +1,186 @@
import { ListRendererDependency } from '@joplin/lib/services/plugins/api/noteListType';
import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/database/types';
import { Size } from '@joplin/utils/types';
import prepareViewProps from './prepareViewProps';
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
// Same as `prepareViewProps` but with default arguments to make testing code simpler.
const prepare = async (
dependencies: ListRendererDependency[],
note: NoteEntity,
itemSize: Size = { width: 100, height: 20 },
selected = false,
noteTitleHtml = '',
noteIsWatched = false,
noteTags: TagEntity[] = [],
folder: FolderEntity = null,
itemIndex = 0,
) => {
return prepareViewProps(
dependencies,
note,
itemSize,
selected,
noteTitleHtml,
noteIsWatched,
noteTags,
folder,
itemIndex,
);
};
describe('prepareViewProps', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
});
it('should prepare note properties', async () => {
const note = await Note.save({ title: 'test' });
expect(await prepare(['note.title', 'note.user_updated_time'], note)).toEqual({
note: {
title: 'test',
user_updated_time: note.user_updated_time,
},
});
expect(await prepare(['item.size.height'], note)).toEqual({
item: {
size: {
height: 20,
},
},
});
expect(await prepare(['item.selected'], note)).toEqual({
item: {
selected: false,
},
});
expect(await prepare(['item.selected'], note, {}, true)).toEqual({
item: {
selected: true,
},
});
expect(await prepare(['note.titleHtml'], note, {}, false, '<b>test</b>')).toEqual({
note: {
titleHtml: '<b>test</b>',
},
});
expect(await prepare(['note.isWatched'], note, {}, false, '', true)).toEqual({
note: {
isWatched: true,
},
});
expect(await prepare(['note.isWatched'], note, {}, false, '', false)).toEqual({
note: {
isWatched: false,
},
});
expect(await prepare(['item.index'], note, {}, false, '', false, [], null, 5)).toEqual({
item: {
index: 5,
},
});
expect(await prepare(['note.tags'], note, {}, false, '', false, [{ id: '1', title: 'one' }])).toEqual({
note: {
tags: [{ id: '1', title: 'one' }],
},
});
});
it('should return checkbox stats only when setting is enabled', async () => {
const note = await Note.save({
title: 'test',
body: '- [ ] task 1\n- [x] task 2\n- [ ] task 3\n- [X] task 4',
});
Setting.setValue('notes.showCheckboxCompletionChart', true);
expect(await prepare(['note.checkboxes'], note)).toEqual({
note: {
checkboxes: {
total: 4,
checked: 2,
percent: 50,
isComplete: false,
},
},
});
Setting.setValue('notes.showCheckboxCompletionChart', false);
expect(await prepare(['note.checkboxes'], note)).toEqual({
note: {
checkboxes: null,
},
});
});
it('should return null for checkbox stats when note has no checkboxes', async () => {
Setting.setValue('notes.showCheckboxCompletionChart', true);
const note = await Note.save({
title: 'test',
body: 'This is a note without any checkboxes.',
});
const result = await prepare(['note.checkboxes'], note);
expect(result).toEqual({
note: {
checkboxes: null,
},
});
});
it.each([
{
label: 'set isComplete to true when all checkboxes are checked',
body: '- [x] task 1\n- [X] task 2\n- [x] task 3',
expectedStatus: {
total: 3,
checked: 3,
percent: 100,
isComplete: true,
},
},
{
label: 'not recognise checkbox markup that isn\'t in a checklist',
body: 'This will not render as a checkbox: "- [x] task 1".',
expectedStatus: null,
},
{
label: 'recognise checkbox markup within block quotes',
body: '> > - [ ] This\n\n> - [ ] is\n> - [x] a test',
expectedStatus: {
total: 3,
checked: 1,
percent: 33,
isComplete: false,
},
},
])('should $label', async ({ body, expectedStatus }) => {
Setting.setValue('notes.showCheckboxCompletionChart', true);
const note = await Note.save({
title: 'test',
body,
});
const result = await prepare(['note.checkboxes'], note);
expect(result).toEqual({
note: {
checkboxes: expectedStatus,
},
});
});
});

查看文件

@@ -0,0 +1,123 @@
import { ListRendererDependency } from '@joplin/lib/services/plugins/api/noteListType';
import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/database/types';
import { Size } from '@joplin/utils/types';
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { _ } from '@joplin/lib/locale';
interface CheckboxStats {
total: number;
checked: number;
percent: number;
isComplete: boolean;
}
const countCheckboxes = (body: string): CheckboxStats | null => {
if (!body) return null;
// Match unchecked: - [ ] and checked: - [x] or - [X]
const uncheckedMatches = body.match(/(^|\n)[ \t>]*- \[ \]/g);
const checkedMatches = body.match(/(^|\n)[ \t>]*- \[[xX]\]/g);
const unchecked = uncheckedMatches ? uncheckedMatches.length : 0;
const checked = checkedMatches ? checkedMatches.length : 0;
const total = unchecked + checked;
if (total === 0) return null;
return {
total,
checked,
percent: Math.round((checked / total) * 100),
isComplete: checked === total,
};
};
const prepareViewProps = async (
dependencies: ListRendererDependency[],
note: NoteEntity,
itemSize: Size,
selected: boolean,
noteTitleHtml: string,
noteIsWatched: boolean,
noteTags: TagEntity[],
folder: FolderEntity | null,
itemIndex: number,
noteIsPublished = false,
) => {
const output: {
note?: Record<string, unknown> & { folder?: Record<string, unknown> };
item?: { size?: Record<string, unknown>; selected?: boolean; index?: number };
} = {};
for (const dep of dependencies) {
if (dep.startsWith('note.')) {
const splitted = dep.split('.');
if (splitted.length <= 1) throw new Error(`Invalid dependency name: ${dep}`);
const propName = splitted.pop();
if (!output.note) output.note = {};
if (dep === 'note.titleHtml') { // For backward compatibility
output.note.titleHtml = noteTitleHtml;
} else if (dep === 'note.isWatched') {
output.note[propName] = noteIsWatched;
} else if (dep === 'note.is_published') {
output.note[propName] = noteIsPublished;
} else if (dep === 'note.is_pinned') {
output.note[propName] = (note as unknown as { is_pinned?: number }).is_pinned || 0;
} else if (dep === 'note.tags') {
output.note[propName] = noteTags;
} else if (dep === 'note.folder.title') {
if (!output.note.folder) output.note.folder = {};
output.note.folder[propName] = folder.title;
} else if (dep === 'note.todoStatusText') {
let taskStatus = '';
if (note.is_todo) {
taskStatus = note.todo_completed ? _('Complete to-do') : _('Incomplete to-do');
}
output.note[propName] = taskStatus;
} else if (dep === 'note.checkboxes') {
// Only load the note body and compute checkbox stats if the setting is enabled
if (Setting.value('notes.showCheckboxCompletionChart')) {
if (!('body' in note)) note = await Note.load(note.id);
output.note[propName] = countCheckboxes(note.body);
} else {
output.note[propName] = null;
}
} else {
// The notes in the state only contain the properties defined in
// Note.previewFields(). It means that if a view request a
// property not present there, we need to load the full note.
// One such missing property is the note body, which we don't
// load by default.
if (!(propName in note)) note = await Note.load(note.id);
if (!(propName in note)) throw new Error(`Invalid dependency name: ${dep}`);
output.note[propName] = (note as unknown as Record<string, unknown>)[propName];
}
}
if (dep.startsWith('item.size.')) {
const splitted = dep.split('.');
if (splitted.length !== 3) throw new Error(`Invalid dependency name: ${dep}`);
const propName = splitted.pop();
if (!output.item) output.item = {};
if (!output.item.size) output.item.size = {};
if (!(propName in itemSize)) throw new Error(`Invalid dependency name: ${dep}`);
output.item.size[propName] = (itemSize as unknown as Record<string, unknown>)[propName];
}
if (dep === 'item.selected') {
if (!output.item) output.item = {};
output.item.selected = selected;
}
if (dep === 'item.index') {
if (!output.item) output.item = {};
output.item.index = itemIndex;
}
}
return output;
};
export default prepareViewProps;

查看文件

@@ -0,0 +1,9 @@
import * as React from 'react';
export type OnInputChange = (event: React.ChangeEvent<HTMLInputElement>)=> void;
export type OnClick = (event: React.MouseEvent<HTMLElement>)=> void;
export type ItemEventHandlers = {
onInputChange: OnInputChange;
onClick: OnClick | null;
};

查看文件

@@ -0,0 +1,67 @@
import { act, renderHook } from '@testing-library/react';
import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType';
import useItemElement from './useItemElement';
import * as React from 'react';
const defaultProps = {
noteId: 'note-1',
noteHtml: '<span>Test content</span>',
focusVisible: false,
style: { height: '24px' } as React.CSSProperties,
itemSize: { width: 200, height: 24 },
onClick: jest.fn(),
onDoubleClick: jest.fn(),
flow: ItemFlow.TopToBottom,
};
const defaultItemEventHandlers = { onInputChange: jest.fn(), onClick: null as import('./types').OnClick | null };
describe('useItemElement', () => {
let rootElement: HTMLDivElement;
beforeEach(() => {
rootElement = document.createElement('div');
document.body.appendChild(rootElement);
defaultProps.onClick.mockClear();
defaultProps.onDoubleClick.mockClear();
});
afterEach(() => {
rootElement.remove();
});
test('returns a ref (no setState, avoids "Maximum update depth exceeded")', () => {
const { result } = renderHook(
() => useItemElement(rootElement, defaultProps.noteId, defaultProps.noteHtml, defaultProps.focusVisible, defaultProps.style, defaultProps.itemSize, defaultProps.onClick, defaultProps.onDoubleClick, defaultProps.flow, defaultItemEventHandlers),
);
expect(result.current).toHaveProperty('current');
});
test('assigns created element to ref.current and cleanup nulls ref and removes element', () => {
const { result, unmount } = renderHook(
() => useItemElement(rootElement, defaultProps.noteId, defaultProps.noteHtml, false, defaultProps.style, defaultProps.itemSize, defaultProps.onClick, defaultProps.onDoubleClick, defaultProps.flow, defaultItemEventHandlers),
);
act(() => {});
const el = result.current.current;
expect(el).toBeInstanceOf(HTMLDivElement);
expect(rootElement.contains(el)).toBe(true);
unmount();
expect(result.current.current).toBeNull();
expect(rootElement.contains(el as Node)).toBe(false);
});
test('does nothing when rootElement is null', () => {
const { result } = renderHook(
() => useItemElement(null, defaultProps.noteId, defaultProps.noteHtml, false, defaultProps.style, defaultProps.itemSize, defaultProps.onClick, defaultProps.onDoubleClick, defaultProps.flow, defaultItemEventHandlers),
);
act(() => {});
expect(result.current.current).toBeNull();
});
});

查看文件

@@ -0,0 +1,99 @@
import * as React from 'react';
import { Size } from '@joplin/utils/types';
import { useEffect, useRef } from 'react';
import { ItemFlow } from '@joplin/lib/services/plugins/api/noteListType';
import { ItemEventHandlers } from './types';
import attachNoteTitleTooltip from './noteTitleTooltip';
const addItemEventListeners = (
element: HTMLElement,
listeners: ItemEventHandlers,
onClick: React.MouseEventHandler<HTMLDivElement>,
onDoubleClick: React.MouseEventHandler<HTMLDivElement>,
): { cleanup: ()=> void } => {
const processedInputs: HTMLInputElement[] = [];
const processedButtons: HTMLButtonElement[] = [];
const inputs = element.getElementsByTagName('input');
for (const input of inputs) {
if (input.type === 'checkbox' || input.type === 'text') {
input.addEventListener('change', listeners.onInputChange as unknown as EventListener);
processedInputs.push(input);
}
}
const buttons = element.getElementsByTagName('button');
if (listeners.onClick) {
for (const button of buttons) {
button.addEventListener('click', listeners.onClick as unknown as EventListener);
processedButtons.push(button);
}
}
const clickHandler = (e: MouseEvent) => onClick(e as unknown as React.MouseEvent<HTMLDivElement>);
const dblclickHandler = (e: MouseEvent) => onDoubleClick(e as unknown as React.MouseEvent<HTMLDivElement>);
element.addEventListener('click', clickHandler);
element.addEventListener('dblclick', dblclickHandler);
return {
cleanup: () => {
for (const input of processedInputs) {
input.removeEventListener('change', listeners.onInputChange as unknown as EventListener);
}
if (listeners.onClick) {
for (const button of processedButtons) {
button.removeEventListener('click', listeners.onClick as unknown as EventListener);
}
}
element.removeEventListener('click', clickHandler);
element.removeEventListener('dblclick', dblclickHandler);
},
};
};
const useItemElement = (
rootElement: HTMLDivElement | null, noteId: string, noteHtml: string, focusVisible: boolean, style: React.CSSProperties, itemSize: Size, onClick: React.MouseEventHandler<HTMLDivElement>, onDoubleClick: React.MouseEventHandler<HTMLDivElement>, flow: ItemFlow, itemEventHandlers: ItemEventHandlers, displayTitle = '',
) => {
const itemElement = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!rootElement) return () => {};
const element = document.createElement('div');
element.setAttribute('data-id', noteId);
element.className = 'note-list-item';
for (const [n, v] of Object.entries(style)) {
(element.style as unknown as Record<string, unknown>)[n] = v;
}
if (flow === ItemFlow.LeftToRight) element.style.width = `${itemSize.width}px`;
element.style.height = `${itemSize.height}px`;
element.innerHTML = noteHtml;
const { cleanup } = addItemEventListeners(element, itemEventHandlers, onClick, onDoubleClick);
const detachTooltip = displayTitle ? attachNoteTitleTooltip(element, displayTitle) : null;
rootElement.appendChild(element);
itemElement.current = element;
return () => {
cleanup();
if (detachTooltip) detachTooltip();
itemElement.current = null;
element.remove();
};
}, [rootElement, itemSize, noteHtml, noteId, flow, style, onClick, onDoubleClick, itemEventHandlers, displayTitle]);
useEffect(() => {
const element = itemElement.current;
if (!element) return;
if (focusVisible) {
element.classList.add('-focus-visible');
} else {
element.classList.remove('-focus-visible');
}
}, [focusVisible]);
return itemElement;
};
export default useItemElement;

查看文件

@@ -0,0 +1,57 @@
import * as React from 'react';
import Folder from '@joplin/lib/models/Folder';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { useCallback } from 'react';
import { Dispatch } from 'redux';
import bridge from '../../../services/bridge';
import NoteListUtils from '../../utils/NoteListUtils';
interface CustomContextMenuEvent {
itemId: string;
currentTarget?: undefined;
preventDefault?: undefined;
}
type ContextMenuEvent = React.MouseEvent|CustomContextMenuEvent;
const useOnContextMenu = (
selectedNoteIds: string[],
selectedFolderId: string,
notes: NoteEntity[],
dispatch: Dispatch,
watchedNoteFiles: string[],
plugins: PluginStates,
customCss: string,
) => {
return useCallback((event: ContextMenuEvent) => {
let currentNoteId = event.currentTarget?.getAttribute('data-id');
if ('itemId' in event) {
currentNoteId = event.itemId;
}
if (!currentNoteId) return;
event.preventDefault?.();
let noteIds = [];
if (selectedNoteIds.indexOf(currentNoteId) < 0) {
noteIds = [currentNoteId];
} else {
noteIds = selectedNoteIds;
}
if (!noteIds.length) return;
const menu = NoteListUtils.makeContextMenu(noteIds, {
notes: notes,
dispatch: dispatch,
watchedNoteFiles: watchedNoteFiles,
plugins: plugins,
inConflictFolder: selectedFolderId === Folder.conflictFolderId(),
customCss: customCss,
});
menu.popup({ window: bridge().mainWindow() });
}, [selectedNoteIds, notes, dispatch, watchedNoteFiles, plugins, selectedFolderId, customCss]);
};
export default useOnContextMenu;

查看文件

@@ -0,0 +1,105 @@
import { useState } from 'react';
import { ListRenderer, ListRendererDependency, NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType';
import Note from '@joplin/lib/models/Note';
import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/database/types';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import renderTemplate from '@joplin/lib/services/noteList/renderTemplate';
import renderViewProps from '@joplin/lib/services/noteList/renderViewProps';
import { createHash } from 'crypto';
import getNoteTitleHtml from './getNoteTitleHtml';
import prepareViewProps from './prepareViewProps';
import Tag from '@joplin/lib/models/Tag';
import { unique } from '@joplin/lib/array';
import Folder from '@joplin/lib/models/Folder';
import { HighlightedWord } from '@joplin/lib/reducer';
interface RenderedNote {
id: string;
hash: string;
html: string;
}
const hashContent = (content: unknown) => {
return createHash('sha1').update(JSON.stringify(content)).digest('hex');
};
export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, isPublished: boolean, listRenderer: ListRenderer, highlightedWords: HighlightedWord[], itemIndex: number, columns: NoteListColumns) => {
const [renderedNote, setRenderedNote] = useState<RenderedNote>(null);
let dependencies = columns && columns.length ? columns.map(c => c.name) as ListRendererDependency[] : [];
if (listRenderer.dependencies) dependencies = dependencies.concat(listRenderer.dependencies);
dependencies = unique(dependencies);
useAsyncEffect(async (event) => {
const renderNote = async (): Promise<void> => {
let noteTags: TagEntity[] = [];
let folder: FolderEntity = null;
if (dependencies.includes('note.tags')) {
noteTags = await Tag.tagsByNoteId(note.id, { fields: ['id', 'title'] });
}
if (dependencies.find(d => d.startsWith('note.folder'))) {
folder = await Folder.load(note.parent_id, { fields: ['id', 'title'] });
}
// Note: with this hash we're assuming that the list renderer
// properties never changes. It means that later if we support
// dynamic list renderers, we should include these into the hash.
const viewHash = hashContent([
listRenderer.id,
note.updated_time,
isSelected,
isWatched,
isPublished,
highlightedWords,
note.encryption_applied,
JSON.stringify(columns),
noteTags.map(t => t.title).sort().join(','),
folder ? folder.title : '',
]);
if (renderedNote && renderedNote.hash === viewHash) return null;
const noteTitleHtml = getNoteTitleHtml(highlightedWords, Note.displayTitle(note));
const viewProps = await prepareViewProps(
dependencies,
note,
listRenderer.itemSize,
isSelected,
noteTitleHtml,
isWatched,
noteTags,
folder,
itemIndex,
isPublished,
);
if (event.cancelled) return null;
const view = await listRenderer.onRenderNote(viewProps);
if (event.cancelled) return null;
await renderViewProps(view, [], { noteTitleHtml });
if (event.cancelled) return null;
setRenderedNote({
id: note.id,
hash: viewHash,
html: renderTemplate(
columns,
listRenderer.itemTemplate,
listRenderer.itemValueTemplates,
view,
),
});
};
void renderNote();
}, [note, isSelected, isWatched, isPublished, listRenderer, renderedNote, columns]);
return renderedNote;
};

查看文件

@@ -0,0 +1,55 @@
import { act, renderHook } from '@testing-library/react';
import useRootElement from './useRootElement';
describe('useRootElement', () => {
beforeEach(() => {
jest.useFakeTimers({ advanceTimers: true });
});
test('should find an element with a matching ID', async () => {
const testElement = document.createElement('div');
testElement.id = 'test-element-id';
document.body.appendChild(testElement);
const { result } = renderHook(useRootElement, {
initialProps: testElement.id,
});
await act(async () => {
await jest.advanceTimersByTimeAsync(100);
});
expect(result.current).toBe(testElement);
testElement.remove();
});
test('should redo the element search when the elementId prop changes', async () => {
const testElement = document.createElement('div');
document.body.appendChild(testElement);
const { rerender, result } = renderHook(useRootElement, {
initialProps: 'some-id-here',
});
await jest.advanceTimersByTimeAsync(100);
expect(result.current).toBe(null);
// Searching for another non-existent ID: Should not match
rerender('updated-id');
await jest.advanceTimersByTimeAsync(100);
expect(result.current).toBe(null);
// Should not match the first element if its ID is set to the original (search
// should be cancelled).
testElement.id = 'some-id-here';
await jest.advanceTimersByTimeAsync(100);
expect(result.current).toBe(null);
// Should match if the element ID changes to the updated ID.
await act(async () => {
testElement.id = 'updated-id';
await jest.advanceTimersByTimeAsync(100);
});
expect(result.current).toBe(testElement);
testElement.remove();
});
});

查看文件

@@ -0,0 +1,17 @@
import { useState } from 'react';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { waitForElement } from '@joplin/lib/dom';
const useRootElement = (elementId: string) => {
const [rootElement, setRootElement] = useState<HTMLDivElement>(null);
useAsyncEffect(async (event) => {
const element = await waitForElement<HTMLDivElement>(document, elementId, event);
if (event.cancelled) return;
setRootElement(element);
}, [document, elementId]);
return rootElement;
};
export default useRootElement;