Files
standardnotes-app-web/app/assets/javascripts/controllers/notes/notes.js
Mo Bitar 8835fd6ebf WIP
2020-04-01 09:53:19 -05:00

741 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import angular from 'angular';
import template from '%/notes.pug';
import { ApplicationEvents, ContentTypes, removeFromArray } from 'snjs';
import { PureCtrl } from '@Controllers';
import { AppStateEvents } from '@/services/state';
import { KeyboardModifiers, KeyboardKeys } from '@/services/keyboardManager';
import {
PrefKeys
} from '@/services/preferencesManager';
import {
PANEL_NAME_NOTES
} from '@/controllers/constants';
import {
SORT_KEY_CREATED_AT,
SORT_KEY_UPDATED_AT,
SORT_KEY_CLIENT_UPDATED_AT,
SORT_KEY_TITLE,
filterAndSortNotes
} from './note_utils';
/**
* This is the height of a note cell with nothing but the title,
* which *is* a display option
*/
const MIN_NOTE_CELL_HEIGHT = 51.0;
const DEFAULT_LIST_NUM_NOTES = 20;
const ELEMENT_ID_SEARCH_BAR = 'search-bar';
const ELEMENT_ID_SCROLL_CONTAINER = 'notes-scrollable';
class NotesCtrl extends PureCtrl {
/* @ngInject */
constructor(
$timeout,
) {
super($timeout);
this.resetPagination();
}
$onInit() {
super.$onInit();
angular.element(document).ready(() => {
this.reloadPreferences();
});
this.panelPuppet = {
onReady: () => this.reloadPreferences()
};
this.onWindowResize = this.onWindowResize.bind(this);
window.addEventListener('resize', this.onWindowResize, true);
this.registerKeyboardShortcuts();
}
onWindowResize() {
this.resetPagination({
keepCurrentIfLarger: true
});
}
deinit() {
this.panelPuppet.onReady = null;
this.panelPuppet = null;
window.removeEventListener('resize', this.onWindowResize, true);
this.onWindowResize = null;
this.onPanelResize = null;
super.deinit();
}
getInitialState() {
return {
notes: [],
renderedNotes: [],
selectedNote: null,
tag: null,
sortBy: null,
showArchived: null,
hidePinned: null,
sortReverse: null,
panelTitle: null,
mutable: { showMenu: false },
noteFilter: { text: '' },
};
}
onAppLaunch() {
super.onAppLaunch();
this.streamNotesAndTags();
this.reloadPreferences();
}
/** @override */
onAppStateEvent(eventName, data) {
if (eventName === AppStateEvents.TagChanged) {
this.handleTagChange(this.application.getAppState().getSelectedTag(), data.previousTag);
} else if (eventName === AppStateEvents.NoteChanged) {
this.handleNoteSelection(this.application.getAppState().getSelectedNote());
} else if (eventName === AppStateEvents.PreferencesChanged) {
this.reloadPreferences();
this.reloadNotes();
} else if (eventName === AppStateEvents.EditorFocused) {
this.setShowMenuFalse();
}
}
/** @override */
async onAppEvent(eventName) {
if (eventName === ApplicationEvents.SignedIn) {
/** Delete dummy note if applicable */
if (this.state.selectedNote && this.state.selectedNote.dummy) {
this.application.deleteItemLocally({ item: this.state.selectedNote });
await this.selectNote(null);
await this.reloadNotes();
}
} else if (eventName === ApplicationEvents.CompletedSync) {
this.getMostValidNotes().then((notes) => {
if (notes.length === 0) {
this.createPlaceholderNote();
}
});
}
}
/**
* @access private
* Access the current state notes without awaiting any potential reloads
* that may be in progress. This is the sync alternative to `async getMostValidNotes`
*/
getPossiblyStaleNotes() {
return this.state.notes;
}
/**
* @access private
* Access the current state notes after waiting for any pending reloads.
* This returns the most up to date notes, but is the asyncronous counterpart
* to `getPossiblyStaleNotes`
*/
async getMostValidNotes() {
await this.reloadNotesPromise;
return this.getPossiblyStaleNotes();
}
/**
* Triggered programatically to create a new placeholder note
* when conditions allow for it. This is as opposed to creating a new note
* as part of user interaction (pressing the + button).
* @access private
*/
async createPlaceholderNote() {
const selectedTag = this.application.getAppState().getSelectedTag();
if (selectedTag.isSmartTag() && !selectedTag.content.isAllTag) {
return;
}
return this.createNewNote();
}
streamNotesAndTags() {
this.application.streamItems({
contentType: [ContentTypes.Note, ContentTypes.Tag],
stream: async ({ items }) => {
await this.reloadNotes();
const selectedNote = this.state.selectedNote;
if (selectedNote) {
const discarded = selectedNote.deleted || selectedNote.content.trashed;
if (discarded) {
this.selectNextOrCreateNew();
}
} else {
this.selectFirstNote();
}
/** Note has changed values, reset its flags */
const notes = items.filter((item) => item.content_type === ContentTypes.Note);
for (const note of notes) {
if(note.deleted) {
continue;
}
this.loadFlagsForNote(note);
note.cachedCreatedAtString = note.createdAtString();
note.cachedUpdatedAtString = note.updatedAtString();
}
}
});
}
async selectNote(note) {
return this.application.getAppState().setSelectedNote(note);
}
async createNewNote() {
const selectedTag = this.application.getAppState().getSelectedTag();
if (!selectedTag) {
throw 'Attempting to create note with no selected tag';
}
let title;
let isDummyNote = true;
if (this.isFiltering()) {
title = this.state.noteFilter.text;
isDummyNote = false;
} else if (this.state.selectedNote && this.state.selectedNote.dummy) {
return;
} else {
title = `Note ${this.state.notes.length + 1}`;
}
const newNote = await this.application.createManagedItem({
contentType: ContentTypes.Note,
content: {
text: '',
title: title
},
override: {
dummy: isDummyNote,
client_updated_at: new Date()
}
});
this.application.setItemNeedsSync({ item: newNote });
if (!selectedTag.isSmartTag()) {
selectedTag.addItemAsRelationship(newNote);
this.application.setItemNeedsSync({ item: selectedTag });
}
this.selectNote(newNote);
}
async handleTagChange(tag, previousTag) {
if (this.state.selectedNote && this.state.selectedNote.dummy) {
await this.application.deleteItemLocally({ item: this.state.selectedNote });
if (previousTag) {
removeFromArray(previousTag.notes, this.state.selectedNote);
}
await this.selectNote(null);
}
await this.setState({ tag: tag });
this.resetScrollPosition();
this.setShowMenuFalse();
await this.setNoteFilterText('');
this.application.getDesktopService().searchText();
this.resetPagination();
/* Capture db load state before beginning reloadNotes, since this status may change during reload */
const dbLoaded = this.application.isDatabaseLoaded();
await this.reloadNotes();
if (this.state.notes.length > 0) {
this.selectFirstNote();
} else if (dbLoaded) {
if (!tag.isSmartTag() || tag.content.isAllTag) {
this.createPlaceholderNote();
} else if (
this.state.selectedNote &&
!this.state.notes.includes(this.state.selectedNote)
) {
this.selectNote(null);
}
}
}
resetScrollPosition() {
const scrollable = document.getElementById(ELEMENT_ID_SCROLL_CONTAINER);
if (scrollable) {
scrollable.scrollTop = 0;
scrollable.scrollLeft = 0;
}
}
async removeNoteFromList(note) {
const notes = this.state.notes;
removeFromArray(notes, note);
await this.setState({
notes: notes,
renderedNotes: notes.slice(0, this.notesToDisplay)
});
}
async reloadNotes() {
this.reloadNotesPromise = this.performPeloadNotes();
return this.reloadNotesPromise;
}
async performPeloadNotes() {
if (!this.state.tag) {
return;
}
const notes = filterAndSortNotes({
notes: this.state.tag.notes,
selectedTag: this.state.tag,
showArchived: this.state.showArchived,
hidePinned: this.state.hidePinned,
filterText: this.state.noteFilter.text.toLowerCase(),
sortBy: this.state.sortBy,
reverse: this.state.sortReverse
});
for (const note of notes) {
if (note.errorDecrypting) {
this.loadFlagsForNote(note);
}
note.shouldShowTags = this.shouldShowTagsForNote(note);
}
await this.setState({
notes: notes,
renderedNotes: notes.slice(0, this.notesToDisplay)
});
this.reloadPanelTitle();
}
setShowMenuFalse() {
this.setState({
mutable: {
...this.state.mutable,
showMenu: false
}
});
}
async handleNoteSelection(note) {
const previousNote = this.state.selectedNote;
if (previousNote === note) {
return;
}
if (previousNote && previousNote.dummy) {
await this.application.deleteItemLocally({ item: previousNote });
this.removeNoteFromList(previousNote);
}
await this.setState({
selectedNote: note
});
if (!note) {
return;
}
this.selectedIndex = Math.max(0, this.displayableNotes().indexOf(note));
if (note.content.conflict_of) {
note.content.conflict_of = null;
this.application.saveItem({ item: note });
}
if (this.isFiltering()) {
this.application.getDesktopService().searchText(this.state.noteFilter.text);
}
}
reloadPreferences() {
const viewOptions = {};
const prevSortValue = this.state.sortBy;
let sortBy = this.application.getPrefsService().getValue(
PrefKeys.SortNotesBy,
SORT_KEY_CREATED_AT
);
if (sortBy === SORT_KEY_UPDATED_AT) {
/** Use client_updated_at instead */
sortBy = SORT_KEY_CLIENT_UPDATED_AT;
}
viewOptions.sortBy = sortBy;
viewOptions.sortReverse = this.application.getPrefsService().getValue(
PrefKeys.SortNotesReverse,
false
);
viewOptions.showArchived = this.application.getPrefsService().getValue(
PrefKeys.NotesShowArchived,
false
);
viewOptions.hidePinned = this.application.getPrefsService().getValue(
PrefKeys.NotesHidePinned,
false
);
viewOptions.hideNotePreview = this.application.getPrefsService().getValue(
PrefKeys.NotesHideNotePreview,
false
);
viewOptions.hideDate = this.application.getPrefsService().getValue(
PrefKeys.NotesHideDate,
false
);
viewOptions.hideTags = this.application.getPrefsService().getValue(
PrefKeys.NotesHideTags,
false
);
this.setState({
...viewOptions
});
if (prevSortValue && prevSortValue !== sortBy) {
this.selectFirstNote();
}
const width = this.application.getPrefsService().getValue(
PrefKeys.NotesPanelWidth
);
if (width && this.panelPuppet.ready) {
this.panelPuppet.setWidth(width);
if (this.panelPuppet.isCollapsed()) {
this.application.getAppState().panelDidResize({
name: PANEL_NAME_NOTES,
collapsed: this.panelPuppet.isCollapsed()
});
}
}
}
onPanelResize = (newWidth, lastLeft, isAtMaxWidth, isCollapsed) => {
this.application.getPrefsService().setUserPrefValue(
PrefKeys.NotesPanelWidth,
newWidth
);
this.application.getPrefsService().syncUserPreferences();
this.application.getAppState().panelDidResize({
name: PANEL_NAME_NOTES,
collapsed: isCollapsed
});
}
paginate() {
this.notesToDisplay += this.pageSize;
this.reloadNotes();
if (this.searchSubmitted) {
this.application.getDesktopService().searchText(this.state.noteFilter.text);
}
}
resetPagination({ keepCurrentIfLarger } = {}) {
const clientHeight = document.documentElement.clientHeight;
this.pageSize = Math.ceil(clientHeight / MIN_NOTE_CELL_HEIGHT);
if (this.pageSize === 0) {
this.pageSize = DEFAULT_LIST_NUM_NOTES;
}
if (keepCurrentIfLarger && this.notesToDisplay > this.pageSize) {
return;
}
this.notesToDisplay = this.pageSize;
}
reloadPanelTitle() {
let title;
if (this.isFiltering()) {
const resultCount = this.state.notes.length;
title = `${resultCount} search results`;
} else if (this.state.tag) {
title = `${this.state.tag.title}`;
}
this.setState({
panelTitle: title
});
}
optionsSubtitle() {
let base = "";
if (this.state.sortBy === 'created_at') {
base += " Date Added";
} else if (this.state.sortBy === 'client_updated_at') {
base += " Date Modified";
} else if (this.state.sortBy === 'title') {
base += " Title";
}
if (this.state.showArchived) {
base += " | + Archived";
}
if (this.state.hidePinned) {
base += " | Pinned";
}
if (this.state.sortReverse) {
base += " | Reversed";
}
return base;
}
loadFlagsForNote(note) {
const flags = [];
if (note.pinned) {
flags.push({
text: "Pinned",
class: 'info'
});
}
if (note.archived) {
flags.push({
text: "Archived",
class: 'warning'
});
}
if (note.content.protected) {
flags.push({
text: "Protected",
class: 'success'
});
}
if (note.locked) {
flags.push({
text: "Locked",
class: 'neutral'
});
}
if (note.content.trashed) {
flags.push({
text: "Deleted",
class: 'danger'
});
}
if (note.content.conflict_of) {
flags.push({
text: "Conflicted Copy",
class: 'danger'
});
}
if (note.errorDecrypting) {
if (note.waitingForKey) {
flags.push({
text: "Waiting For Keys",
class: 'info'
});
} else {
flags.push({
text: "Missing Keys",
class: 'danger'
});
}
}
if (note.deleted) {
flags.push({
text: "Deletion Pending Sync",
class: 'danger'
});
}
note.flags = flags;
return flags;
}
displayableNotes() {
return this.state.notes;
}
getFirstNonProtectedNote() {
const displayableNotes = this.displayableNotes();
let index = 0;
let note = displayableNotes[index];
while (note && note.content.protected) {
index++;
if (index >= displayableNotes.length) {
break;
}
note = displayableNotes[index];
}
return note;
}
selectFirstNote() {
const note = this.getFirstNonProtectedNote();
if (note) {
this.selectNote(note);
}
}
selectNextNote() {
const displayableNotes = this.displayableNotes();
const currentIndex = displayableNotes.indexOf(this.state.selectedNote);
if (currentIndex + 1 < displayableNotes.length) {
this.selectNote(displayableNotes[currentIndex + 1]);
}
}
selectNextOrCreateNew() {
const note = this.getFirstNonProtectedNote();
if (note) {
this.selectNote(note);
} else if (!this.state.tag || !this.state.tag.isSmartTag()) {
this.createPlaceholderNote();
} else {
this.selectNote(null);
}
}
selectPreviousNote() {
const displayableNotes = this.displayableNotes();
const currentIndex = displayableNotes.indexOf(this.state.selectedNote);
if (currentIndex - 1 >= 0) {
this.selectNote(displayableNotes[currentIndex - 1]);
return true;
} else {
return false;
}
}
isFiltering() {
return this.state.noteFilter.text &&
this.state.noteFilter.text.length > 0;
}
async setNoteFilterText(text) {
await this.setState({
noteFilter: {
...this.state.noteFilter,
text: text
}
});
}
async clearFilterText() {
await this.setNoteFilterText('');
this.onFilterEnter();
this.filterTextChanged();
this.resetPagination();
}
async filterTextChanged() {
if (this.searchSubmitted) {
this.searchSubmitted = false;
}
await this.reloadNotes();
}
onFilterEnter() {
/**
* For Desktop, performing a search right away causes
* input to lose focus. We wait until user explicity hits
* enter before highlighting desktop search results.
*/
this.searchSubmitted = true;
this.application.getDesktopService().searchText(this.state.noteFilter.text);
}
selectedMenuItem() {
this.setShowMenuFalse();
}
togglePrefKey(key) {
this.application.getPrefsService().setUserPrefValue(key, !this.state[key]);
this.application.getPrefsService().syncUserPreferences();
}
selectedSortByCreated() {
this.setSortBy(SORT_KEY_CREATED_AT);
}
selectedSortByUpdated() {
this.setSortBy(SORT_KEY_CLIENT_UPDATED_AT);
}
selectedSortByTitle() {
this.setSortBy(SORT_KEY_TITLE);
}
toggleReverseSort() {
this.selectedMenuItem();
this.application.getPrefsService().setUserPrefValue(
PrefKeys.SortNotesReverse,
!this.state.sortReverse
);
this.application.getPrefsService().syncUserPreferences();
}
setSortBy(type) {
this.application.getPrefsService().setUserPrefValue(
PrefKeys.SortNotesBy,
type
);
this.application.getPrefsService().syncUserPreferences();
}
shouldShowTagsForNote(note) {
if (this.state.hideTags || note.content.protected) {
return false;
}
if (this.state.tag.content.isAllTag) {
return note.tags && note.tags.length > 0;
}
if (this.state.tag.isSmartTag()) {
return true;
}
/**
* Inside a tag, only show tags string if
* note contains tags other than this.state.tag
*/
return note.tags && note.tags.length > 1;
}
getSearchBar() {
return document.getElementById(ELEMENT_ID_SEARCH_BAR);
}
registerKeyboardShortcuts() {
/**
* In the browser we're not allowed to override cmd/ctrl + n, so we have to
* use Control modifier as well. These rules don't apply to desktop, but
* probably better to be consistent.
*/
this.newNoteKeyObserver = this.application.getKeyboardService().addKeyObserver({
key: 'n',
modifiers: [
KeyboardModifiers.Meta,
KeyboardModifiers.Ctrl
],
onKeyDown: (event) => {
event.preventDefault();
this.createNewNote();
}
});
this.nextNoteKeyObserver = this.application.getKeyboardService().addKeyObserver({
key: KeyboardKeys.Down,
elements: [
document.body,
this.getSearchBar()
],
onKeyDown: (event) => {
const searchBar = this.getSearchBar();
if (searchBar === document.activeElement) {
searchBar.blur();
}
this.selectNextNote();
}
});
this.nextNoteKeyObserver = this.application.getKeyboardService().addKeyObserver({
key: KeyboardKeys.Up,
element: document.body,
onKeyDown: (event) => {
this.selectPreviousNote();
}
});
this.searchKeyObserver = this.application.getKeyboardService().addKeyObserver({
key: "f",
modifiers: [
KeyboardModifiers.Meta,
KeyboardModifiers.Shift
],
onKeyDown: (event) => {
const searchBar = this.getSearchBar();
if (searchBar) { searchBar.focus(); };
}
});
}
}
export class NotesPanel {
constructor() {
this.template = template;
this.replace = true;
this.controller = NotesCtrl;
this.controllerAs = 'self';
this.bindToController = true;
this.scope = {
application: '='
};
}
}