1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Merge branch 'master' of github.com:laurent22/joplin

This commit is contained in:
Laurent Cozic 2020-05-27 17:22:13 +01:00
commit 03948d185d
14 changed files with 743 additions and 168 deletions

View File

@ -84,6 +84,7 @@ ElectronClient/gui/NoteEditor/utils/useMessageHandler.js
ElectronClient/gui/NoteEditor/utils/useNoteSearchBar.js
ElectronClient/gui/NoteEditor/utils/useSearchMarkers.js
ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.js
ElectronClient/gui/NoteListItem.js
ElectronClient/gui/NoteToolbar/NoteToolbar.js
ElectronClient/gui/ResourceScreen.js
ElectronClient/gui/ShareNoteDialog.js

1
.gitignore vendored
View File

@ -74,6 +74,7 @@ ElectronClient/gui/NoteEditor/utils/useMessageHandler.js
ElectronClient/gui/NoteEditor/utils/useNoteSearchBar.js
ElectronClient/gui/NoteEditor/utils/useSearchMarkers.js
ElectronClient/gui/NoteEditor/utils/useWindowCommandHandler.js
ElectronClient/gui/NoteListItem.js
ElectronClient/gui/NoteToolbar/NoteToolbar.js
ElectronClient/gui/ResourceScreen.js
ElectronClient/gui/ShareNoteDialog.js

View File

@ -21,7 +21,7 @@ Please check that your request has not already been posted on the forum or the [
Avoid listing multiple requests in one topic. One topic per request makes it easier to track and discuss it.
Finally, when submitting a pull request, don't forget to [test your code](#unit-tests).
Finally, when submitting a pull request, don't forget to [test your code](#automated-tests).
# Contributing to Joplin's translation
@ -49,9 +49,9 @@ For changes made to the Desktop client that affect the user interface, refer to
## Automated tests
When submitting a pull request for a new feature or bug fixes, please add automated tests for your code whenever possible. Tests in Joplin are divided in **unit tests** and **feature tests**.
When submitting a pull request for a new feature or a bug fix, please add automated tests for your code whenever possible. Tests in Joplin are divided into **unit tests** and **feature tests**.
* **Unit tests** are used to tests models, services or utility classes - they are relatively low level. Unit tests should be prefixed with the type of class that is being tested - for example "models_Folder" or "services_SearchEngine".
* **Unit tests** are used to test models, services or utility classes - they are relatively low level. Unit tests should be prefixed with the type of class that is being tested - for example "models_Folder" or "services_SearchEngine".
* **Feature tests** on the other hand are to test higher level functionalities such as interactions with the GUI and how they affect the underlying model. Often these tests would dispatch Redux actions, and inspect how the application state has been changed. The feature tests should be prefixed with "feature_", for example "feature_TagList". There's a good explanation on what qualifies as a feature test in [this post](https://github.com/laurent22/joplin/pull/2819#issuecomment-603502230).

View File

@ -0,0 +1,188 @@
/* eslint-disable no-unused-vars */
require('app-module-path').addPath(__dirname);
const { time } = require('lib/time-utils.js');
const { sortedIds, createNTestNotes, asyncTest, fileContentEqual, setupDatabase, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync } = require('test-utils.js');
const Folder = require('lib/models/Folder.js');
const Note = require('lib/models/Note.js');
const Setting = require('lib/models/Setting.js');
const BaseModel = require('lib/BaseModel.js');
const ArrayUtils = require('lib/ArrayUtils.js');
const { shim } = require('lib/shim');
process.on('unhandledRejection', (reason, p) => {
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
});
async function allItems() {
const folders = await Folder.all();
const notes = await Note.all();
return folders.concat(notes);
}
describe('models_Note_CustomSortOrder', function() {
beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
done();
});
it('should set the order property when saving a note', asyncTest(async () => {
const now = Date.now();
const n1 = await Note.save({ title: 'testing' });
expect(n1.order).toBeGreaterThanOrEqual(now);
const n2 = await Note.save({ title: 'testing', order: 0 });
expect(n2.order).toBe(0);
}));
it('should insert notes at the specified position (order 0)', asyncTest(async () => {
// Notes always had an "order" property, but for a long time it wasn't used, and
// set to 0. For custom sorting to work though, it needs to be set to some number
// (which normally is the creation timestamp). So if the user tries to move notes
// in the middle of other notes with order = 0, the order of all these notes is
// initialised at this point.
const folder1 = await Folder.save({});
const folder2 = await Folder.save({});
const notes1 = [];
notes1.push(await Note.save({ order: 0, parent_id: folder1.id })); await time.msleep(2);
notes1.push(await Note.save({ order: 0, parent_id: folder1.id })); await time.msleep(2);
notes1.push(await Note.save({ order: 0, parent_id: folder1.id })); await time.msleep(2);
const notes2 = [];
notes2.push(await Note.save({ parent_id: folder2.id })); await time.msleep(2);
notes2.push(await Note.save({ parent_id: folder2.id })); await time.msleep(2);
const originalTimestamps = {};
for (const n of notes1) {
originalTimestamps[n.id] = {
user_created_time: n.user_created_time,
user_updated_time: n.user_updated_time,
};
}
await Note.insertNotesAt(folder1.id, notes2.map(n => n.id), 1);
const newNotes1 = [
await Note.load(notes1[0].id),
await Note.load(notes1[1].id),
await Note.load(notes1[2].id),
];
// Check that timestamps haven't changed - moving a note should not change the user timestamps
for (let i = 0; i < newNotes1.length; i++) {
const n = newNotes1[i];
expect(n.user_created_time).toBe(originalTimestamps[n.id].user_created_time);
expect(n.user_updated_time).toBe(originalTimestamps[n.id].user_updated_time);
}
const sortedNotes = await Note.previews(folder1.id, {
order: Note.customOrderByColumns(),
});
expect(sortedNotes.length).toBe(5);
expect(sortedNotes[0].id).toBe(notes1[2].id);
expect(sortedNotes[1].id).toBe(notes2[0].id);
expect(sortedNotes[2].id).toBe(notes2[1].id);
expect(sortedNotes[3].id).toBe(notes1[1].id);
expect(sortedNotes[4].id).toBe(notes1[0].id);
}));
it('should insert notes at the specified position (targets with same orders)', asyncTest(async () => {
// If the target notes all have the same order, inserting a note should work
// anyway, because the order of the other notes will be updated as needed.
const folder1 = await Folder.save({});
const notes = [];
notes.push(await Note.save({ order: 1000, parent_id: folder1.id })); await time.msleep(2);
notes.push(await Note.save({ order: 1000, parent_id: folder1.id })); await time.msleep(2);
notes.push(await Note.save({ order: 1000, parent_id: folder1.id })); await time.msleep(2);
notes.push(await Note.save({ order: 1000, parent_id: folder1.id })); await time.msleep(2);
await Note.insertNotesAt(folder1.id, [notes[0].id], 1);
const sortedNotes = await Note.previews(folder1.id, {
order: Note.customOrderByColumns(),
});
expect(sortedNotes.length).toBe(4);
expect(sortedNotes[0].id).toBe(notes[3].id);
expect(sortedNotes[1].id).toBe(notes[0].id);
expect(sortedNotes[2].id).toBe(notes[2].id);
expect(sortedNotes[3].id).toBe(notes[1].id);
}));
it('should insert notes at the specified position (insert at end)', asyncTest(async () => {
const folder1 = await Folder.save({});
const notes = [];
notes.push(await Note.save({ order: 1000, parent_id: folder1.id })); await time.msleep(2);
notes.push(await Note.save({ order: 1000, parent_id: folder1.id })); await time.msleep(2);
notes.push(await Note.save({ order: 1000, parent_id: folder1.id })); await time.msleep(2);
notes.push(await Note.save({ order: 1000, parent_id: folder1.id })); await time.msleep(2);
await Note.insertNotesAt(folder1.id, [notes[1].id], 4);
const sortedNotes = await Note.previews(folder1.id, {
fields: ['id', 'order', 'user_created_time'],
order: Note.customOrderByColumns(),
});
expect(sortedNotes.length).toBe(4);
expect(sortedNotes[0].id).toBe(notes[3].id);
expect(sortedNotes[1].id).toBe(notes[2].id);
expect(sortedNotes[2].id).toBe(notes[0].id);
expect(sortedNotes[3].id).toBe(notes[1].id);
}));
it('should insert notes at the specified position (insert at beginning)', asyncTest(async () => {
const folder1 = await Folder.save({});
const notes = [];
notes.push(await Note.save({ order: 1000, parent_id: folder1.id })); await time.msleep(2);
notes.push(await Note.save({ order: 1000, parent_id: folder1.id })); await time.msleep(2);
notes.push(await Note.save({ order: 1000, parent_id: folder1.id })); await time.msleep(2);
notes.push(await Note.save({ order: 1000, parent_id: folder1.id })); await time.msleep(2);
await Note.insertNotesAt(folder1.id, [notes[2].id], 0);
const sortedNotes = await Note.previews(folder1.id, {
fields: ['id', 'order', 'user_created_time'],
order: Note.customOrderByColumns(),
});
expect(sortedNotes.length).toBe(4);
expect(sortedNotes[0].id).toBe(notes[2].id);
expect(sortedNotes[1].id).toBe(notes[3].id);
expect(sortedNotes[2].id).toBe(notes[1].id);
expect(sortedNotes[3].id).toBe(notes[0].id);
}));
it('should insert notes even if sources are not adjacent', asyncTest(async () => {
const folder1 = await Folder.save({});
const notes = [];
notes.push(await Note.save({ order: 1000, parent_id: folder1.id })); await time.msleep(2);
notes.push(await Note.save({ order: 1000, parent_id: folder1.id })); await time.msleep(2);
notes.push(await Note.save({ order: 1000, parent_id: folder1.id })); await time.msleep(2);
notes.push(await Note.save({ order: 1000, parent_id: folder1.id })); await time.msleep(2);
await Note.insertNotesAt(folder1.id, [notes[1].id, notes[3].id], 0);
const sortedNotes = await Note.previews(folder1.id, {
fields: ['id', 'order', 'user_created_time'],
order: Note.customOrderByColumns(),
});
expect(sortedNotes.length).toBe(4);
expect(sortedNotes[0].id).toBe(notes[1].id);
expect(sortedNotes[1].id).toBe(notes[3].id);
expect(sortedNotes[2].id).toBe(notes[2].id);
expect(sortedNotes[3].id).toBe(notes[0].id);
}));
});

View File

@ -369,6 +369,7 @@ class Application extends BaseApplication {
sortItems.push({ type: 'separator' });
sortItems.push({
id: `sort:${type}:reverse`,
label: Setting.settingMetadata(`${type}.sortOrder.reverse`).label(),
type: 'checkbox',
checked: Setting.value(`${type}.sortOrder.reverse`),
@ -1275,6 +1276,9 @@ class Application extends BaseApplication {
menuItem.enabled = selectedNoteIds.length === 1;
}
const sortNoteReverseItem = Menu.getApplicationMenu().getMenuItemById('sort:notes:reverse');
sortNoteReverseItem.enabled = state.settings['notes.sortOrder.field'] !== 'order';
const menuItem = Menu.getApplicationMenu().getMenuItemById('help:toggleDevTools');
menuItem.checked = state.devToolsVisible;
}

View File

@ -32,6 +32,10 @@ class ItemList extends React.Component {
});
}
offsetTop() {
return this.listRef.current ? this.listRef.current.offsetTop : 0;
}
UNSAFE_componentWillMount() {
this.updateStateItemIndexes();
}
@ -70,6 +74,22 @@ class ItemList extends React.Component {
this.updateStateItemIndexes();
}
// shouldComponentUpdate(nextProps, nextState) {
// for (const n in this.props) {
// if (this.props[n] !== nextProps[n]) {
// console.info('Props', n, nextProps[n]);
// }
// }
// for (const n in this.state) {
// if (this.state[n] !== nextState[n]) {
// console.info('State', n, nextState[n]);
// }
// }
// return true;
// }
render() {
const items = this.props.items;
const style = Object.assign({}, this.props.style, {
@ -77,6 +97,8 @@ class ItemList extends React.Component {
overflowY: 'auto',
});
// if (this.props.disabled) style.opacity = 0.5;
if (!this.props.itemHeight) throw new Error('itemHeight is required');
const blankItem = function(key, height) {
@ -86,7 +108,7 @@ class ItemList extends React.Component {
const itemComps = [blankItem('top', this.state.topItemIndex * this.props.itemHeight)];
for (let i = this.state.topItemIndex; i <= this.state.bottomItemIndex; i++) {
const itemComp = this.props.itemRenderer(items[i]);
const itemComp = this.props.itemRenderer(items[i], i);
itemComps.push(itemComp);
}

View File

@ -4,43 +4,52 @@ const { connect } = require('react-redux');
const { time } = require('lib/time-utils.js');
const { themeStyle } = require('../theme.js');
const BaseModel = require('lib/BaseModel');
const markJsUtils = require('lib/markJsUtils');
const { _ } = require('lib/locale.js');
const { bridge } = require('electron').remote.require('./bridge');
const eventManager = require('../eventManager');
const Mark = require('mark.js/dist/mark.min.js');
const SearchEngine = require('lib/services/SearchEngine');
const Note = require('lib/models/Note');
const Setting = require('lib/models/Setting');
const NoteListUtils = require('./utils/NoteListUtils');
const { replaceRegexDiacritics, pregQuote } = require('lib/string-utils');
const NoteListItem = require('./NoteListItem').default;
class NoteListComponent extends React.Component {
constructor() {
super();
this.itemHeight = 34;
this.state = {
dragOverTargetNoteIndex: null,
};
this.itemListRef = React.createRef();
this.itemAnchorRefs_ = {};
this.itemRenderer = this.itemRenderer.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.noteItem_titleClick = this.noteItem_titleClick.bind(this);
this.noteItem_noteDragOver = this.noteItem_noteDragOver.bind(this);
this.noteItem_noteDrop = this.noteItem_noteDrop.bind(this);
this.noteItem_checkboxClick = this.noteItem_checkboxClick.bind(this);
this.noteItem_dragStart = this.noteItem_dragStart.bind(this);
this.onGlobalDrop_ = this.onGlobalDrop_.bind(this);
this.registerGlobalDragEndEvent_ = this.registerGlobalDragEndEvent_.bind(this);
this.unregisterGlobalDragEndEvent_ = this.unregisterGlobalDragEndEvent_.bind(this);
}
style() {
if (this.styleCache_ && this.styleCache_[this.props.theme]) return this.styleCache_[this.props.theme];
const theme = themeStyle(this.props.theme);
const itemHeight = 34;
// Note: max-width is used to specifically prevent horizontal scrolling on Linux when the scrollbar is present in the note list.
// Pull request: https://github.com/laurent22/joplin/pull/2062
const itemWidth = '100%';
const style = {
root: {
backgroundColor: theme.backgroundColor,
},
listItem: {
maxWidth: itemWidth,
height: itemHeight,
maxWidth: '100%',
height: this.itemHeight,
boxSizing: 'border-box',
display: 'flex',
alignItems: 'stretch',
@ -68,6 +77,9 @@ class NoteListComponent extends React.Component {
},
};
this.styleCache_ = {};
this.styleCache_[this.props.theme] = style;
return style;
}
@ -93,160 +105,152 @@ class NoteListComponent extends React.Component {
menu.popup(bridge().window());
}
itemRenderer(item) {
const theme = themeStyle(this.props.theme);
const width = this.props.style.width;
onGlobalDrop_() {
this.unregisterGlobalDragEndEvent_();
this.setState({ dragOverTargetNoteIndex: null });
}
const onTitleClick = async (event, item) => {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
this.props.dispatch({
type: 'NOTE_SELECT_TOGGLE',
id: item.id,
});
} else if (event.shiftKey) {
event.preventDefault();
this.props.dispatch({
type: 'NOTE_SELECT_EXTEND',
id: item.id,
});
} else {
this.props.dispatch({
type: 'NOTE_SELECT',
id: item.id,
});
}
};
registerGlobalDragEndEvent_() {
if (this.globalDragEndEventRegistered_) return;
this.globalDragEndEventRegistered_ = true;
document.addEventListener('dragend', this.onGlobalDrop_);
}
const onDragStart = event => {
let noteIds = [];
unregisterGlobalDragEndEvent_() {
this.globalDragEndEventRegistered_ = false;
document.removeEventListener('dragend', this.onGlobalDrop_);
}
// Here there is two cases:
// - If multiple notes are selected, we drag the group
// - If only one note is selected, we drag the note that was clicked on (which might be different from the currently selected note)
if (this.props.selectedNoteIds.length >= 2) {
noteIds = this.props.selectedNoteIds;
} else {
const clickedNoteId = event.currentTarget.getAttribute('data-id');
if (clickedNoteId) noteIds.push(clickedNoteId);
}
dragTargetNoteIndex_(event) {
return Math.abs(Math.round((event.clientY - this.itemListRef.current.offsetTop()) / this.itemHeight));
}
if (!noteIds.length) return;
noteItem_noteDragOver(event) {
if (this.props.notesParentType !== 'Folder') return;
event.dataTransfer.setDragImage(new Image(), 1, 1);
event.dataTransfer.clearData();
event.dataTransfer.setData('text/x-jop-note-ids', JSON.stringify(noteIds));
};
const dt = event.dataTransfer;
const onCheckboxClick = async event => {
const checked = event.target.checked;
const newNote = {
id: item.id,
todo_completed: checked ? time.unixMs() : 0,
};
await Note.save(newNote, { userSideValidation: true });
eventManager.emit('todoToggle', { noteId: item.id, note: newNote });
};
const hPadding = 10;
let highlightedWords = [];
if (this.props.notesParentType === 'Search') {
const query = BaseModel.byId(this.props.searches, this.props.selectedSearchId);
if (query) {
const parsedQuery = SearchEngine.instance().parseQuery(query.query_pattern);
highlightedWords = SearchEngine.instance().allParsedQueryTerms(parsedQuery);
}
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
event.preventDefault();
const newIndex = this.dragTargetNoteIndex_(event);
if (this.state.dragOverTargetNoteIndex === newIndex) return;
this.registerGlobalDragEndEvent_();
this.setState({ dragOverTargetNoteIndex: newIndex });
}
}
let style = Object.assign({ width: width, opacity: this.props.provisionalNoteIds.includes(item.id) ? 0.5 : 1 }, this.style().listItem);
async noteItem_noteDrop(event) {
if (this.props.notesParentType !== 'Folder') return;
if (this.props.selectedNoteIds.indexOf(item.id) >= 0) {
style = Object.assign(style, this.style().listItemSelected);
}
// Setting marginBottom = 1 because it makes the checkbox looks more centered, at least on Windows
// but don't know how it will look in other OSes.
const checkbox = item.is_todo ? (
<div style={{ display: 'flex', height: style.height, alignItems: 'center', paddingLeft: hPadding }}>
<input
style={{ margin: 0, marginBottom: 1, marginRight: 5 }}
type="checkbox"
defaultChecked={!!item.todo_completed}
onClick={event => {
onCheckboxClick(event, item);
}}
/>
</div>
) : null;
let listItemTitleStyle = Object.assign({}, this.style().listItemTitle);
listItemTitleStyle.paddingLeft = !checkbox ? hPadding : 4;
if (item.is_todo && !!item.todo_completed) listItemTitleStyle = Object.assign(listItemTitleStyle, this.style().listItemTitleCompleted);
const displayTitle = Note.displayTitle(item);
let titleComp = null;
if (highlightedWords.length) {
const titleElement = document.createElement('span');
titleElement.textContent = displayTitle;
const mark = new Mark(titleElement, {
exclude: ['img'],
acrossElements: true,
if (this.props.noteSortOrder !== 'order') {
const doIt = await bridge().showConfirmMessageBox(_('To manually sort the notes, the sort order must be changed to "%s" in the menu "%s" > "%s"', _('Custom order'), _('View'), _('Sort notes by')), {
buttons: [_('Do it now'), _('Cancel')],
});
if (!doIt) return;
mark.unmark();
for (let i = 0; i < highlightedWords.length; i++) {
const w = highlightedWords[i];
markJsUtils.markKeyword(mark, w, {
pregQuote: pregQuote,
replaceRegexDiacritics: replaceRegexDiacritics,
});
}
// 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
titleComp = <span dangerouslySetInnerHTML={{ __html: titleElement.outerHTML }}></span>;
} else {
titleComp = <span>{displayTitle}</span>;
Setting.setValue('notes.sortOrder.field', 'order');
return;
}
const watchedIconStyle = {
paddingRight: 4,
color: theme.color,
// TODO: check that parent type is folder
const dt = event.dataTransfer;
this.unregisterGlobalDragEndEvent_();
this.setState({ dragOverTargetNoteIndex: null });
const targetNoteIndex = this.dragTargetNoteIndex_(event);
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
Note.insertNotesAt(this.props.selectedFolderId, noteIds, targetNoteIndex);
}
async noteItem_checkboxClick(event, item) {
const checked = event.target.checked;
const newNote = {
id: item.id,
todo_completed: checked ? time.unixMs() : 0,
};
await Note.save(newNote, { userSideValidation: true });
eventManager.emit('todoToggle', { noteId: item.id, note: newNote });
}
async noteItem_titleClick(event, item) {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
this.props.dispatch({
type: 'NOTE_SELECT_TOGGLE',
id: item.id,
});
} else if (event.shiftKey) {
event.preventDefault();
this.props.dispatch({
type: 'NOTE_SELECT_EXTEND',
id: item.id,
});
} else {
this.props.dispatch({
type: 'NOTE_SELECT',
id: item.id,
});
}
}
noteItem_dragStart(event) {
let noteIds = [];
// Here there is two cases:
// - If multiple notes are selected, we drag the group
// - If only one note is selected, we drag the note that was clicked on (which might be different from the currently selected note)
if (this.props.selectedNoteIds.length >= 2) {
noteIds = this.props.selectedNoteIds;
} else {
const clickedNoteId = event.currentTarget.getAttribute('data-id');
if (clickedNoteId) noteIds.push(clickedNoteId);
}
if (!noteIds.length) return;
event.dataTransfer.setDragImage(new Image(), 1, 1);
event.dataTransfer.clearData();
event.dataTransfer.setData('text/x-jop-note-ids', JSON.stringify(noteIds));
}
itemRenderer(item, index) {
const highlightedWords = () => {
if (this.props.notesParentType === 'Search') {
const query = BaseModel.byId(this.props.searches, this.props.selectedSearchId);
if (query) {
const parsedQuery = SearchEngine.instance().parseQuery(query.query_pattern);
return SearchEngine.instance().allParsedQueryTerms(parsedQuery);
}
}
return [];
};
const watchedIcon = this.props.watchedNoteFiles.indexOf(item.id) < 0 ? null : <i style={watchedIconStyle} className={'fa fa-share-square'}></i>;
if (!this.itemAnchorRefs_[item.id]) this.itemAnchorRefs_[item.id] = React.createRef();
const ref = this.itemAnchorRefs_[item.id];
// Need to include "todo_completed" in key so that checkbox is updated when
// item is changed via sync.
return (
<div key={`${item.id}_${item.todo_completed}`} className="list-item-container" style={style}>
{checkbox}
<a
ref={ref}
onContextMenu={event => this.itemContextMenu(event)}
href="#"
draggable={true}
style={listItemTitleStyle}
onClick={event => {
onTitleClick(event, item);
}}
onDragStart={event => onDragStart(event)}
data-id={item.id}
>
{watchedIcon}
{titleComp}
</a>
</div>
);
return <NoteListItem
ref={ref}
key={item.id}
style={this.style(this.props.theme)}
item={item}
index={index}
theme={this.props.theme}
width={this.props.style.width}
dragItemIndex={this.state.dragOverTargetNoteIndex}
highlightedWords={highlightedWords()}
isProvisional={this.props.provisionalNoteIds.includes(item.id)}
isSelected={this.props.selectedNoteIds.indexOf(item.id) >= 0}
isWatched={this.props.watchedNoteFiles.indexOf(item.id) < 0}
itemCount={this.props.notes.length}
onCheckboxClick={this.noteItem_checkboxClick}
onDragStart={this.noteItem_dragStart}
onNoteDragOver={this.noteItem_noteDragOver}
onNoteDrop={this.noteItem_noteDrop}
onTitleClick={this.noteItem_titleClick}
/>;
}
itemAnchorRef(itemId) {
@ -434,9 +438,8 @@ class NoteListComponent extends React.Component {
render() {
const theme = themeStyle(this.props.theme);
const style = this.props.style;
const notes = this.props.notes.slice();
if (!notes.length) {
if (!this.props.notes.length) {
const padding = 10;
const emptyDivStyle = Object.assign(
{
@ -453,7 +456,16 @@ class NoteListComponent extends React.Component {
return <div style={emptyDivStyle}>{this.props.folders.length ? _('No notes in here. Create one by clicking on "New note".') : _('There is currently no notebook. Create one by clicking on "New notebook".')}</div>;
}
return <ItemList ref={this.itemListRef} itemHeight={this.style().listItem.height} className={'note-list'} items={notes} style={style} itemRenderer={this.itemRenderer} onKeyDown={this.onKeyDown} />;
return <ItemList
ref={this.itemListRef}
disabled={this.props.isInsertingNotes}
itemHeight={this.style(this.props.theme).listItem.height}
className={'note-list'}
items={this.props.notes}
style={style}
itemRenderer={this.itemRenderer}
onKeyDown={this.onKeyDown}
/>;
}
}
@ -462,6 +474,7 @@ const mapStateToProps = state => {
notes: state.notes,
folders: state.folders,
selectedNoteIds: state.selectedNoteIds,
selectedFolderId: state.selectedFolderId,
theme: state.settings.theme,
notesParentType: state.notesParentType,
searches: state.searches,
@ -469,6 +482,8 @@ const mapStateToProps = state => {
watchedNoteFiles: state.watchedNoteFiles,
windowCommand: state.windowCommand,
provisionalNoteIds: state.provisionalNoteIds,
isInsertingNotes: state.isInsertingNotes,
noteSortOrder: state.settings['notes.sortOrder.field'],
};
};

View File

@ -0,0 +1,144 @@
import * as React from 'react';
import { useRef, forwardRef, useImperativeHandle, useCallback } from 'react';
const { themeStyle } = require('../theme.js');
const Mark = require('mark.js/dist/mark.min.js');
const markJsUtils = require('lib/markJsUtils');
const Note = require('lib/models/Note');
const { replaceRegexDiacritics, pregQuote } = require('lib/string-utils');
interface NoteListItemProps {
theme: number,
width: number,
style: any,
dragItemIndex: number,
highlightedWords: string[],
index: number,
isProvisional: boolean,
isSelected: boolean,
isWatched: boolean
item: any,
itemCount: number,
onCheckboxClick: any,
onDragStart: any,
onNoteDragOver: any,
onNoteDrop: any,
onTitleClick: any,
}
function NoteListItem(props:NoteListItemProps, ref:any) {
const item = props.item;
const theme = themeStyle(props.theme);
const hPadding = 10;
const anchorRef = useRef(null);
useImperativeHandle(ref, () => {
return {
focus: function() {
if (anchorRef.current) anchorRef.current.focus();
},
};
});
let rootStyle = Object.assign({ width: props.width, opacity: props.isProvisional ? 0.5 : 1 }, props.style.listItem);
if (props.isSelected) rootStyle = Object.assign(rootStyle, props.style.listItemSelected);
if (props.dragItemIndex === props.index) {
rootStyle.borderTop = `2px solid ${theme.color}`;
} else if (props.index === props.itemCount - 1 && props.dragItemIndex >= props.itemCount) {
rootStyle.borderBottom = `2px solid ${theme.color}`;
}
const onTitleClick = useCallback((event) => {
props.onTitleClick(event, props.item);
}, [props.onTitleClick, props.item]);
const onCheckboxClick = useCallback((event) => {
props.onCheckboxClick(event, props.item);
}, [props.onCheckboxClick, props.item]);
// Setting marginBottom = 1 because it makes the checkbox looks more centered, at least on Windows
// but don't know how it will look in other OSes.
function renderCheckbox() {
if (!item.is_todo) return null;
return (
<div style={{ display: 'flex', height: rootStyle.height, alignItems: 'center', paddingLeft: hPadding }}>
<input
style={{ margin: 0, marginBottom: 1, marginRight: 5 }}
type="checkbox"
checked={!!item.todo_completed}
onChange={onCheckboxClick}
/>
</div>
);
}
let listItemTitleStyle = Object.assign({}, props.style.listItemTitle);
listItemTitleStyle.paddingLeft = !item.is_todo ? hPadding : 4;
if (item.is_todo && !!item.todo_completed) listItemTitleStyle = Object.assign(listItemTitleStyle, props.style.listItemTitleCompleted);
const displayTitle = Note.displayTitle(item);
let titleComp = null;
if (props.highlightedWords.length) {
const titleElement = document.createElement('span');
titleElement.textContent = displayTitle;
const mark = new Mark(titleElement, {
exclude: ['img'],
acrossElements: true,
});
mark.unmark();
for (let i = 0; i < props.highlightedWords.length; i++) {
const w = props.highlightedWords[i];
markJsUtils.markKeyword(mark, w, {
pregQuote: pregQuote,
replaceRegexDiacritics: replaceRegexDiacritics,
});
}
// 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
titleComp = <span dangerouslySetInnerHTML={{ __html: titleElement.outerHTML }}></span>;
} else {
titleComp = <span>{displayTitle}</span>;
}
const watchedIconStyle = {
paddingRight: 4,
color: theme.color,
};
const watchedIcon = props.isWatched ? null : <i style={watchedIconStyle} className={'fa fa-share-square'}></i>;
// key={`${item.id}_${item.todo_completed}`}
// Need to include "todo_completed" in key so that checkbox is updated when
// item is changed via sync.
return (
<div className="list-item-container" style={rootStyle} onDragOver={props.onNoteDragOver} onDrop={props.onNoteDrop}>
{renderCheckbox()}
<a
ref={anchorRef}
onContextMenu={event => this.itemContextMenu(event)}
href="#"
draggable={true}
style={listItemTitleStyle}
onClick={onTitleClick}
onDragStart={props.onDragStart}
data-id={item.id}
>
{watchedIcon}
{titleComp}
</a>
</div>
);
}
export default forwardRef(NoteListItem);

View File

@ -459,6 +459,10 @@ class BaseApplication {
}
}
if (this.hasGui() && (action.type == 'NOTE_IS_INSERTING_NOTES' && !action.value)) {
refreshNotes = true;
}
if (this.hasGui() && ((action.type == 'SETTING_UPDATE_ONE' && action.key == 'uncompletedTodosOnTop') || action.type == 'SETTING_UPDATE_ALL')) {
refreshNotes = true;
}

View File

@ -182,7 +182,7 @@ class BaseModel {
const items = [];
for (let i = 0; i < options.order.length; i++) {
const o = options.order[i];
let item = o.by;
let item = `\`${o.by}\``;
if (options.caseInsensitive === true) item += ' COLLATE NOCASE';
if (o.dir) item += ` ${o.dir}`;
items.push(item);

View File

@ -314,7 +314,7 @@ class JoplinDatabase extends Database {
// must be set in the synchronizer too.
// Note: v16 and v17 don't do anything. They were used to debug an issue.
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29];
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30];
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
@ -686,6 +686,43 @@ class JoplinDatabase extends Database {
queries.push('ALTER TABLE version ADD COLUMN table_fields_version INT NOT NULL DEFAULT 0');
}
if (targetVersion == 30) {
// Change the type of the "order" field from INT to NUMERIC
// Making it a float provides a much bigger range when inserting notes.
// For example, with an INT, inserting a note C between note A with order 1000 and
// note B with order 1001 wouldn't be possible without changing the order
// value of note A or B. But with a float, we can set the order of note C to 1000.5
queries = queries.concat(
this.alterColumnQueries('notes', {
id: 'TEXT PRIMARY KEY',
parent_id: 'TEXT NOT NULL DEFAULT ""',
title: 'TEXT NOT NULL DEFAULT ""',
body: 'TEXT NOT NULL DEFAULT ""',
created_time: 'INT NOT NULL',
updated_time: 'INT NOT NULL',
is_conflict: 'INT NOT NULL DEFAULT 0',
latitude: 'NUMERIC NOT NULL DEFAULT 0',
longitude: 'NUMERIC NOT NULL DEFAULT 0',
altitude: 'NUMERIC NOT NULL DEFAULT 0',
author: 'TEXT NOT NULL DEFAULT ""',
source_url: 'TEXT NOT NULL DEFAULT ""',
is_todo: 'INT NOT NULL DEFAULT 0',
todo_due: 'INT NOT NULL DEFAULT 0',
todo_completed: 'INT NOT NULL DEFAULT 0',
source: 'TEXT NOT NULL DEFAULT ""',
source_application: 'TEXT NOT NULL DEFAULT ""',
application_data: 'TEXT NOT NULL DEFAULT ""',
order: 'NUMERIC NOT NULL DEFAULT 0', // that's the change!
user_created_time: 'INT NOT NULL DEFAULT 0',
user_updated_time: 'INT NOT NULL DEFAULT 0',
encryption_cipher_text: 'TEXT NOT NULL DEFAULT ""',
encryption_applied: 'INT NOT NULL DEFAULT 0',
markup_language: 'INT NOT NULL DEFAULT 1',
is_shared: 'INT NOT NULL DEFAULT 0',
})
);
}
queries.push({ sql: 'UPDATE version SET version = ?', params: [targetVersion] });
try {

View File

@ -25,6 +25,7 @@ class Note extends BaseItem {
title: _('title'),
user_updated_time: _('updated date'),
user_created_time: _('created date'),
order: _('custom order'),
};
return field in fieldsToLabels ? fieldsToLabels[field] : field;
@ -553,8 +554,10 @@ class Note extends BaseItem {
static async save(o, options = null) {
const isNew = this.isNew(o, options);
const isProvisional = options && !!options.provisional;
const dispatchUpdateAction = options ? options.dispatchUpdateAction !== false : true;
if (isNew && !o.source) o.source = Setting.value('appName');
if (isNew && !o.source_application) o.source_application = Setting.value('appId');
if (isNew && !('order' in o)) o.order = Date.now();
// We only keep the previous note content for "old notes" (see Revision Service for more info)
// In theory, we could simply save all the previous note contents, and let the revision service
@ -572,11 +575,13 @@ class Note extends BaseItem {
const changeSource = options && options.changeSource ? options.changeSource : null;
ItemChange.add(BaseModel.TYPE_NOTE, note.id, isNew ? ItemChange.TYPE_CREATE : ItemChange.TYPE_UPDATE, changeSource, beforeNoteJson);
this.dispatch({
type: 'NOTE_UPDATE_ONE',
note: note,
provisional: isProvisional,
});
if (dispatchUpdateAction) {
this.dispatch({
type: 'NOTE_UPDATE_ONE',
note: note,
provisional: isProvisional,
});
}
if ('todo_due' in o || 'todo_completed' in o || 'is_todo' in o || 'is_conflict' in o) {
this.dispatch({
@ -653,6 +658,138 @@ class Note extends BaseItem {
if (markupLanguageId === MarkupToHtml.MARKUP_LANGUAGE_HTML) return 'HTML';
throw new Error(`Invalid markup language ID: ${markupLanguageId}`);
}
// When notes are sorted in "custom order", they are sorted by the "order" field first and,
// in those cases, where the order field is the same for some notes, by created time.
static customOrderByColumns(type = null) {
if (!type) type = 'object';
if (type === 'object') return [{ by: 'order', dir: 'DESC' }, { by: 'user_created_time', dir: 'DESC' }];
if (type === 'string') return 'ORDER BY `order` DESC, user_created_time DESC';
throw new Error(`Invalid type: ${type}`);
}
// Update the note "order" field without changing the user timestamps,
// which is generally what we want.
static async updateNoteOrder_(note, order) {
return Note.save(Object.assign({}, note, {
order: order,
user_updated_time: note.user_updated_time,
}), { autoTimestamp: false, dispatchUpdateAction: false });
}
// This method will disable the NOTE_UPDATE_ONE action to prevent a lot
// of unecessary updates, so it's the caller's responsability to update
// the UI once the call is finished. This is done by listening to the
// NOTE_IS_INSERTING_NOTES action in the application middleware.
static async insertNotesAt(folderId, noteIds, index) {
if (!noteIds.length) return;
const defer = () => {
this.dispatch({
type: 'NOTE_IS_INSERTING_NOTES',
value: false,
});
};
this.dispatch({
type: 'NOTE_IS_INSERTING_NOTES',
value: true,
});
try {
const noteSql = `
SELECT id, \`order\`, user_created_time, user_updated_time
FROM notes
WHERE is_conflict = 0 AND parent_id = ?
${this.customOrderByColumns('string')}`;
let notes = await this.modelSelectAll(noteSql, [folderId]);
// If the target index is the same as the source note index, exit now
for (let i = 0; i < notes.length; i++) {
const note = notes[i];
if (note.id === noteIds[0] && index === i) return defer();
}
// If some of the target notes have order = 0, set the order field to user_created_time
// (historically, all notes had the order field set to 0)
let hasSetOrder = false;
for (let i = 0; i < notes.length; i++) {
const note = notes[i];
if (!note.order) {
const updatedNote = await this.updateNoteOrder_(note, note.user_created_time);
notes[i] = updatedNote;
hasSetOrder = true;
}
}
if (hasSetOrder) notes = await this.modelSelectAll(noteSql, [folderId]);
// Find the order value for the first note to be inserted,
// and the increment between the order values of each inserted notes.
let newOrder = 0;
let intervalBetweenNotes = 0;
const defaultIntevalBetweeNotes = 60 * 60 * 1000;
if (!notes.length) { // If there's no notes in the target notebook
newOrder = Date.now();
intervalBetweenNotes = defaultIntevalBetweeNotes;
} else if (index >= notes.length) { // Insert at the end
intervalBetweenNotes = notes[notes.length - 1].order / (noteIds.length + 1);
newOrder = notes[notes.length - 1].order - intervalBetweenNotes;
} else if (index === 0) { // Insert at the beginning
const firstNoteOrder = notes[0].order;
if (firstNoteOrder >= Date.now()) {
intervalBetweenNotes = defaultIntevalBetweeNotes;
newOrder = firstNoteOrder + defaultIntevalBetweeNotes;
} else {
intervalBetweenNotes = (Date.now() - firstNoteOrder) / (noteIds.length + 1);
newOrder = firstNoteOrder + intervalBetweenNotes * noteIds.length;
}
} else { // Normal insert
let noteBefore = notes[index - 1];
let noteAfter = notes[index];
if (noteBefore.order === noteAfter.order) {
let previousOrder = noteBefore.order;
for (let i = index; i >= 0; i--) {
const n = notes[i];
if (n.order <= previousOrder) {
const o = previousOrder + defaultIntevalBetweeNotes;
const updatedNote = await this.updateNoteOrder_(n, o);
notes[i] = Object.assign({}, n, updatedNote);
previousOrder = o;
} else {
previousOrder = n.order;
}
}
noteBefore = notes[index - 1];
noteAfter = notes[index];
}
intervalBetweenNotes = (noteBefore.order - noteAfter.order) / (noteIds.length + 1);
newOrder = noteAfter.order + intervalBetweenNotes * noteIds.length;
}
// Set the order value for all the notes to be inserted
for (const noteId of noteIds) {
const note = await Note.load(noteId);
if (!note) throw new Error(`No such note: ${noteId}`);
await this.updateNoteOrder_({
id: noteId,
parent_id: folderId,
user_updated_time: note.user_updated_time,
}, newOrder);
newOrder -= intervalBetweenNotes;
}
} finally {
defer();
}
}
}
Note.updateGeolocationEnabled_ = true;

View File

@ -321,7 +321,7 @@ class Setting extends BaseModel {
label: () => _('Sort notes by'),
options: () => {
const Note = require('lib/models/Note');
const noteSortFields = ['user_updated_time', 'user_created_time', 'title'];
const noteSortFields = ['user_updated_time', 'user_created_time', 'title', 'order'];
const options = {};
for (let i = 0; i < noteSortFields.length; i++) {
options[noteSortFields[i]] = toTitleCase(Note.fieldToLabel(noteSortFields[i]));

View File

@ -58,6 +58,7 @@ const defaultState = {
plugins: {},
provisionalNoteIds: [],
editorNoteStatuses: {},
isInsertingNotes: false,
};
const MAX_HISTORY = 200;
@ -77,12 +78,25 @@ const cacheEnabledOutput = (key, output) => {
};
stateUtils.notesOrder = function(stateSettings) {
return cacheEnabledOutput('notesOrder', [
{
by: stateSettings['notes.sortOrder.field'],
dir: stateSettings['notes.sortOrder.reverse'] ? 'DESC' : 'ASC',
},
]);
if (stateSettings['notes.sortOrder.field'] === 'order') {
return cacheEnabledOutput('notesOrder', [
{
by: 'order',
dir: 'DESC',
},
{
by: 'user_created_time',
dir: 'DESC',
},
]);
} else {
return cacheEnabledOutput('notesOrder', [
{
by: stateSettings['notes.sortOrder.field'],
dir: stateSettings['notes.sortOrder.reverse'] ? 'DESC' : 'ASC',
},
]);
}
};
stateUtils.foldersOrder = function(stateSettings) {
@ -716,6 +730,14 @@ const reducer = (state = defaultState, action) => {
}
break;
case 'NOTE_IS_INSERTING_NOTES':
if (state.isInsertingNotes !== action.value) {
newState = Object.assign({}, state);
newState.isInsertingNotes = action.value;
}
break;
case 'TAG_DELETE':
newState = handleItemDelete(state, action);
newState.selectedNoteTags = removeItemFromArray(newState.selectedNoteTags.splice(0), 'id', action.id);