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,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;
|
||||
在新工单中引用
屏蔽一个用户