1167 lines
31 KiB
JavaScript
1167 lines
31 KiB
JavaScript
import angular from 'angular';
|
|
import {
|
|
ApplicationEvents,
|
|
isPayloadSourceRetrieved,
|
|
ContentTypes,
|
|
ProtectedActions
|
|
} from 'snjs';
|
|
import find from 'lodash/find';
|
|
import { isDesktopApplication } from '@/utils';
|
|
import { KeyboardModifiers, KeyboardKeys } from '@/services/keyboardManager';
|
|
import template from '%/editor.pug';
|
|
import { PureCtrl } from '@Controllers';
|
|
import { AppStateEvents, EventSources } from '@/state';
|
|
import {
|
|
STRING_DELETED_NOTE,
|
|
STRING_INVALID_NOTE,
|
|
STRING_ELLIPSES,
|
|
STRING_DELETE_PLACEHOLDER_ATTEMPT,
|
|
STRING_DELETE_LOCKED_ATTEMPT,
|
|
StringDeleteNote,
|
|
StringEmptyTrash
|
|
} from '@/strings';
|
|
import { PrefKeys } from '@/services/preferencesManager';
|
|
|
|
const NOTE_PREVIEW_CHAR_LIMIT = 80;
|
|
const MINIMUM_STATUS_DURATION = 400;
|
|
const SAVE_TIMEOUT_DEBOUNCE = 350;
|
|
const SAVE_TIMEOUT_NO_DEBOUNCE = 100;
|
|
const EDITOR_DEBOUNCE = 200;
|
|
|
|
const AppDataKeys = {
|
|
Pinned: 'pinned',
|
|
Locked: 'locked',
|
|
Archived: 'archived',
|
|
PrefersPlainEditor: 'prefersPlainEditor'
|
|
};
|
|
const ElementIds = {
|
|
NoteTextEditor: 'note-text-editor',
|
|
NoteTitleEditor: 'note-title-editor',
|
|
EditorContent: 'editor-content',
|
|
NoteTagsComponentContainer: 'note-tags-component-container'
|
|
};
|
|
const Fonts = {
|
|
DesktopMonospaceFamily: `Menlo,Consolas,'DejaVu Sans Mono',monospace`,
|
|
WebMonospaceFamily: `monospace`,
|
|
SansSerifFamily: `inherit`
|
|
};
|
|
|
|
class EditorCtrl extends PureCtrl {
|
|
/* @ngInject */
|
|
constructor(
|
|
$scope,
|
|
$timeout,
|
|
$rootScope,
|
|
application,
|
|
appState,
|
|
desktopManager,
|
|
keyboardManager,
|
|
preferencesManager,
|
|
) {
|
|
super($scope, $timeout, application, appState);
|
|
this.$rootScope = $rootScope;
|
|
this.desktopManager = desktopManager;
|
|
this.keyboardManager = keyboardManager;
|
|
this.preferencesManager = preferencesManager;
|
|
this.leftPanelPuppet = {
|
|
onReady: () => this.reloadPreferences()
|
|
};
|
|
this.rightPanelPuppet = {
|
|
onReady: () => this.reloadPreferences()
|
|
};
|
|
this.addSyncStatusObserver();
|
|
this.registerKeyboardShortcuts();
|
|
/** Used by .pug template */
|
|
this.prefKeyMonospace = PrefKeys.EditorMonospaceEnabled;
|
|
this.prefKeySpellcheck = PrefKeys.EditorSpellcheck;
|
|
this.prefKeyMarginResizers = PrefKeys.EditorResizersEnabled;
|
|
}
|
|
|
|
/** @override */
|
|
getInitialState() {
|
|
return {
|
|
componentStack: [],
|
|
editorDebounce: EDITOR_DEBOUNCE,
|
|
isDesktop: isDesktopApplication(),
|
|
spellcheck: true,
|
|
mutable: {
|
|
tagsString: ''
|
|
}
|
|
};
|
|
}
|
|
|
|
onAppLaunch() {
|
|
super.onAppLaunch();
|
|
this.streamItems();
|
|
this.registerComponentHandler();
|
|
}
|
|
|
|
/** @override */
|
|
onAppStateEvent(eventName, data) {
|
|
if (eventName === AppStateEvents.NoteChanged) {
|
|
this.handleNoteSelectionChange(
|
|
this.appState.getSelectedNote(),
|
|
data.previousNote
|
|
);
|
|
} else if (eventName === AppStateEvents.PreferencesChanged) {
|
|
this.reloadPreferences();
|
|
}
|
|
}
|
|
|
|
/** @override */
|
|
onAppEvent(eventName) {
|
|
if (!this.state.note) {
|
|
return;
|
|
}
|
|
if (eventName === ApplicationEvents.HighLatencySync) {
|
|
this.setState({ syncTakingTooLong: true });
|
|
} else if (eventName === ApplicationEvents.CompletedSync) {
|
|
this.setState({ syncTakingTooLong: false });
|
|
if (this.state.note.dirty) {
|
|
/** if we're still dirty, don't change status, a sync is likely upcoming. */
|
|
} else {
|
|
const saved = this.state.note.lastSyncEnd > this.state.note.lastSyncBegan;
|
|
const isInErrorState = this.state.saveError;
|
|
if (isInErrorState || saved) {
|
|
this.showAllChangesSavedStatus();
|
|
}
|
|
}
|
|
} else if (eventName === ApplicationEvents.FailedSync) {
|
|
/**
|
|
* Only show error status in editor if the note is dirty.
|
|
* Otherwise, it means the originating sync came from somewhere else
|
|
* and we don't want to display an error here.
|
|
*/
|
|
if (this.state.note.dirty) {
|
|
this.showErrorStatus();
|
|
}
|
|
}
|
|
}
|
|
|
|
streamItems() {
|
|
this.application.streamItems({
|
|
contentType: ContentTypes.Note,
|
|
stream: async ({ items, source }) => {
|
|
if (!this.state.note) {
|
|
return;
|
|
}
|
|
if (this.state.note.deleted || this.state.note.content.trashed) {
|
|
return;
|
|
}
|
|
if (!isPayloadSourceRetrieved(source)) {
|
|
return;
|
|
}
|
|
const matchingNote = items.find((item) => {
|
|
return item.uuid === this.state.note.uuid;
|
|
});
|
|
if (!matchingNote) {
|
|
return;
|
|
}
|
|
this.reloadTagsString();
|
|
}
|
|
});
|
|
|
|
this.application.streamItems({
|
|
contentType: ContentTypes.Tag,
|
|
stream: async ({ items }) => {
|
|
if (!this.state.note) {
|
|
return;
|
|
}
|
|
for (const tag of items) {
|
|
if (
|
|
!this.state.note.savedTagsString ||
|
|
tag.deleted ||
|
|
tag.hasRelationshipWithItem(this.state.note)
|
|
) {
|
|
this.reloadTagsString();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
this.application.streamItems({
|
|
contentType: ContentTypes.Component,
|
|
stream: async ({ items, source }) => {
|
|
if (!this.state.note) {
|
|
return;
|
|
}
|
|
/** Reload componentStack in case new ones were added or removed */
|
|
this.reloadComponentStackArray();
|
|
/** Observe editor changes to see if the current note should update its editor */
|
|
const editors = items.filter(function (item) {
|
|
return item.isEditor();
|
|
});
|
|
if (editors.length === 0) {
|
|
return;
|
|
}
|
|
/** Find the most recent editor for note */
|
|
const editor = this.editorForNote(this.state.note);
|
|
this.setState({
|
|
selectedEditor: editor
|
|
});
|
|
if (!editor) {
|
|
this.reloadFont();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async handleNoteSelectionChange(note, previousNote) {
|
|
this.setState({
|
|
note: this.appState.getSelectedNote(),
|
|
showExtensions: false,
|
|
showOptionsMenu: false,
|
|
altKeyDown: false,
|
|
noteStatus: null
|
|
});
|
|
if (!note) {
|
|
this.setState({
|
|
noteReady: false
|
|
});
|
|
return;
|
|
}
|
|
const associatedEditor = this.editorForNote(note);
|
|
if (associatedEditor && associatedEditor !== this.state.selectedEditor) {
|
|
/**
|
|
* Setting note to not ready will remove the editor from view in a flash,
|
|
* so we only want to do this if switching between external editors
|
|
*/
|
|
this.setState({
|
|
noteReady: false,
|
|
selectedEditor: associatedEditor
|
|
});
|
|
} else if (!associatedEditor) {
|
|
/** No editor */
|
|
this.setState({
|
|
selectedEditor: null
|
|
});
|
|
}
|
|
await this.setState({
|
|
noteReady: true,
|
|
});
|
|
this.reloadTagsString();
|
|
this.reloadPreferences();
|
|
|
|
if (note.dummy) {
|
|
this.focusEditor();
|
|
}
|
|
if (previousNote && previousNote !== note) {
|
|
if (previousNote.dummy) {
|
|
this.performNoteDeletion(previousNote);
|
|
}
|
|
}
|
|
|
|
this.reloadComponentContext();
|
|
}
|
|
|
|
addSyncStatusObserver() {
|
|
/** @todo */
|
|
// this.syncStatusObserver = syncManager.
|
|
// registerSyncStatusObserver((status) => {
|
|
// if (status.localError) {
|
|
// this.$timeout(() => {
|
|
// this.showErrorStatus({
|
|
// message: "Offline Saving Issue",
|
|
// desc: "Changes not saved"
|
|
// });
|
|
// }, 500);
|
|
// }
|
|
// });
|
|
}
|
|
|
|
editorForNote(note) {
|
|
return this.application.componentManager.editorForNote(note);
|
|
}
|
|
|
|
setMenuState(menu, state) {
|
|
this.setState({
|
|
[menu]: state
|
|
});
|
|
this.closeAllMenus({ exclude: menu });
|
|
}
|
|
|
|
toggleMenu(menu) {
|
|
this.setMenuState(menu, !this.state[menu]);
|
|
}
|
|
|
|
closeAllMenus({ exclude } = {}) {
|
|
const allMenus = [
|
|
'showOptionsMenu',
|
|
'showEditorMenu',
|
|
'showExtensions',
|
|
'showSessionHistory'
|
|
];
|
|
const menuState = {};
|
|
for (const candidate of allMenus) {
|
|
if (candidate !== exclude) {
|
|
menuState[candidate] = false;
|
|
}
|
|
}
|
|
this.setState(menuState);
|
|
}
|
|
|
|
editorMenuOnSelect = (component) => {
|
|
if (!component || component.area === 'editor-editor') {
|
|
/** If plain editor or other editor */
|
|
this.setMenuState('showEditorMenu', false);
|
|
const editor = component;
|
|
if (this.state.selectedEditor && editor !== this.state.selectedEditor) {
|
|
this.disassociateComponentWithCurrentNote(this.state.selectedEditor);
|
|
}
|
|
if (editor) {
|
|
const prefersPlain = this.state.note.getAppDataItem(
|
|
AppDataKeys.PrefersPlainEditor
|
|
) === true;
|
|
if (prefersPlain) {
|
|
this.state.note.setAppDataItem(
|
|
AppDataKeys.PrefersPlainEditor,
|
|
false
|
|
);
|
|
this.application.setItemNeedsSync({ item: this.state.note });
|
|
}
|
|
this.associateComponentWithCurrentNote(editor);
|
|
} else {
|
|
/** Note prefers plain editor */
|
|
if (!this.state.note.getAppDataItem(AppDataKeys.PrefersPlainEditor)) {
|
|
this.state.note.setAppDataItem(
|
|
AppDataKeys.PrefersPlainEditor,
|
|
true
|
|
);
|
|
this.application.setItemNeedsSync({ item: this.state.note });
|
|
}
|
|
|
|
this.reloadFont();
|
|
}
|
|
|
|
this.setState({
|
|
selectedEditor: editor
|
|
});
|
|
} else if (component.area === 'editor-stack') {
|
|
this.toggleStackComponentForCurrentItem(component);
|
|
}
|
|
|
|
/** Dirtying can happen above */
|
|
this.application.sync();
|
|
}
|
|
|
|
hasAvailableExtensions() {
|
|
return this.application.actionsManager.extensionsInContextOfItem(this.state.note).length > 0;
|
|
}
|
|
|
|
performFirefoxPinnedTabFix() {
|
|
/**
|
|
* For Firefox pinned tab issue:
|
|
* When a new browser session is started, and SN is in a pinned tab,
|
|
* SN is unusable until the tab is reloaded.
|
|
*/
|
|
if (document.hidden) {
|
|
window.location.reload();
|
|
}
|
|
}
|
|
|
|
saveNote({
|
|
bypassDebouncer,
|
|
updateClientModified,
|
|
dontUpdatePreviews
|
|
}) {
|
|
this.performFirefoxPinnedTabFix();
|
|
const note = this.state.note;
|
|
note.dummy = false;
|
|
if (note.deleted) {
|
|
this.application.alertService.alert({
|
|
text: STRING_DELETED_NOTE
|
|
});
|
|
return;
|
|
}
|
|
if (!this.application.findItem({ uuid: note.uuid })) {
|
|
this.application.alertService.alert({
|
|
text: STRING_INVALID_NOTE
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.showSavingStatus();
|
|
|
|
if (!dontUpdatePreviews) {
|
|
const text = note.text || '';
|
|
const truncate = text.length > NOTE_PREVIEW_CHAR_LIMIT;
|
|
const substring = text.substring(0, NOTE_PREVIEW_CHAR_LIMIT);
|
|
const previewPlain = substring + (truncate ? STRING_ELLIPSES : '');
|
|
note.content.preview_plain = previewPlain;
|
|
note.content.preview_html = null;
|
|
}
|
|
this.application.setItemNeedsSync({
|
|
item: note,
|
|
updateUserModifiedDate: updateClientModified
|
|
});
|
|
if (this.saveTimeout) {
|
|
this.$timeout.cancel(this.saveTimeout);
|
|
}
|
|
|
|
const noDebounce = bypassDebouncer || this.application.noAccount();
|
|
const syncDebouceMs = noDebounce
|
|
? SAVE_TIMEOUT_NO_DEBOUNCE
|
|
: SAVE_TIMEOUT_DEBOUNCE;
|
|
this.saveTimeout = this.$timeout(() => {
|
|
this.application.sync();
|
|
}, syncDebouceMs);
|
|
}
|
|
|
|
showSavingStatus() {
|
|
this.setStatus(
|
|
{ message: "Saving..." },
|
|
false
|
|
);
|
|
}
|
|
|
|
showAllChangesSavedStatus() {
|
|
this.setState({
|
|
saveError: false,
|
|
syncTakingTooLong: false
|
|
});
|
|
let status = "All changes saved";
|
|
if (this.application.noAccount()) {
|
|
status += " (offline)";
|
|
}
|
|
this.setStatus(
|
|
{ message: status }
|
|
);
|
|
}
|
|
|
|
showErrorStatus(error) {
|
|
if (!error) {
|
|
error = {
|
|
message: "Sync Unreachable",
|
|
desc: "Changes saved offline"
|
|
};
|
|
}
|
|
this.setState({
|
|
saveError: true,
|
|
syncTakingTooLong: false
|
|
});
|
|
this.setStatus(error);
|
|
}
|
|
|
|
setStatus(status, wait = true) {
|
|
let waitForMs;
|
|
if (!this.state.noteStatus || !this.state.noteStatus.date) {
|
|
waitForMs = 0;
|
|
} else {
|
|
waitForMs = MINIMUM_STATUS_DURATION - (new Date() - this.state.noteStatus.date);
|
|
}
|
|
if (!wait || waitForMs < 0) {
|
|
waitForMs = 0;
|
|
}
|
|
if (this.statusTimeout) {
|
|
this.$timeout.cancel(this.statusTimeout);
|
|
}
|
|
this.statusTimeout = this.$timeout(() => {
|
|
status.date = new Date();
|
|
this.setState({
|
|
noteStatus: status
|
|
});
|
|
}, waitForMs);
|
|
}
|
|
|
|
contentChanged() {
|
|
this.saveNote({
|
|
updateClientModified: true
|
|
});
|
|
}
|
|
|
|
onTitleEnter($event) {
|
|
$event.target.blur();
|
|
this.onTitleChange();
|
|
this.focusEditor();
|
|
}
|
|
|
|
onTitleChange() {
|
|
this.saveNote({
|
|
dontUpdatePreviews: true,
|
|
updateClientModified: true
|
|
});
|
|
}
|
|
|
|
focusEditor() {
|
|
const element = document.getElementById(ElementIds.NoteTextEditor);
|
|
if (element) {
|
|
this.lastEditorFocusEventSource = EventSources.Script;
|
|
element.focus();
|
|
}
|
|
}
|
|
|
|
focusTitle() {
|
|
document.getElementById(ElementIds.NoteTitleEditor).focus();
|
|
}
|
|
|
|
clickedTextArea() {
|
|
this.setMenuState('showOptionsMenu', false);
|
|
}
|
|
|
|
onNameFocus() {
|
|
this.editingName = true;
|
|
}
|
|
|
|
onContentFocus() {
|
|
this.appState.editorDidFocus(this.lastEditorFocusEventSource);
|
|
this.lastEditorFocusEventSource = null;
|
|
}
|
|
|
|
onNameBlur() {
|
|
this.editingName = false;
|
|
}
|
|
|
|
selectedMenuItem(hide) {
|
|
if (hide) {
|
|
this.setMenuState('showOptionsMenu', false);
|
|
}
|
|
}
|
|
|
|
async deleteNote(permanently) {
|
|
if (this.state.note.dummy) {
|
|
this.application.alertService.alert({
|
|
text: STRING_DELETE_PLACEHOLDER_ATTEMPT
|
|
});
|
|
return;
|
|
}
|
|
const run = () => {
|
|
if (this.state.note.locked) {
|
|
this.application.alertService.alert({
|
|
text: STRING_DELETE_LOCKED_ATTEMPT
|
|
});
|
|
return;
|
|
}
|
|
const title = this.state.note.safeTitle().length
|
|
? `'${this.state.note.title}'`
|
|
: "this note";
|
|
const text = StringDeleteNote({
|
|
title: title,
|
|
permanently: permanently
|
|
});
|
|
this.application.alertService.confirm({
|
|
text: text,
|
|
destructive: true,
|
|
onConfirm: () => {
|
|
if (permanently) {
|
|
this.performNoteDeletion(this.state.note);
|
|
} else {
|
|
this.state.note.content.trashed = true;
|
|
this.saveNote({
|
|
bypassDebouncer: true,
|
|
dontUpdatePreviews: true
|
|
});
|
|
}
|
|
this.appState.setSelectedNote(null);
|
|
this.setMenuState('showOptionsMenu', false);
|
|
}
|
|
});
|
|
};
|
|
const requiresPrivilege = await this.application.privilegesService.actionRequiresPrivilege(
|
|
ProtectedActions.DeleteNote
|
|
);
|
|
if (requiresPrivilege) {
|
|
this.godService.presentPrivilegesModal(
|
|
ProtectedActions.DeleteNote,
|
|
() => {
|
|
run();
|
|
}
|
|
);
|
|
} else {
|
|
run();
|
|
}
|
|
}
|
|
|
|
performNoteDeletion(note) {
|
|
this.application.deleteItem({ item: note });
|
|
if (note === this.state.note) {
|
|
this.setState({
|
|
note: null
|
|
});
|
|
}
|
|
if (note.dummy) {
|
|
this.application.deleteItemLocally({ item: note });
|
|
return;
|
|
}
|
|
this.application.sync();
|
|
}
|
|
|
|
restoreTrashedNote() {
|
|
this.state.note.content.trashed = false;
|
|
this.saveNote({
|
|
bypassDebouncer: true,
|
|
dontUpdatePreviews: true
|
|
});
|
|
this.appState.setSelectedNote(null);
|
|
}
|
|
|
|
deleteNotePermanantely() {
|
|
this.deleteNote(true);
|
|
}
|
|
|
|
getTrashCount() {
|
|
return this.application.getTrashedItems().length;
|
|
}
|
|
|
|
emptyTrash() {
|
|
const count = this.getTrashCount();
|
|
this.application.alertService.confirm({
|
|
text: StringEmptyTrash({ count }),
|
|
destructive: true,
|
|
onConfirm: () => {
|
|
this.application.emptyTrash();
|
|
this.application.sync();
|
|
}
|
|
});
|
|
}
|
|
|
|
togglePin() {
|
|
this.state.note.setAppDataItem(
|
|
AppDataKeys.Pinned,
|
|
!this.state.note.pinned
|
|
);
|
|
this.saveNote({
|
|
bypassDebouncer: true,
|
|
dontUpdatePreviews: true
|
|
});
|
|
}
|
|
|
|
toggleLockNote() {
|
|
this.state.note.setAppDataItem(
|
|
AppDataKeys.Locked,
|
|
!this.state.note.locked
|
|
);
|
|
this.saveNote({
|
|
bypassDebouncer: true,
|
|
dontUpdatePreviews: true
|
|
});
|
|
}
|
|
|
|
toggleProtectNote() {
|
|
this.state.note.content.protected = !this.state.note.content.protected;
|
|
this.saveNote({
|
|
bypassDebouncer: true,
|
|
dontUpdatePreviews: true
|
|
});
|
|
|
|
/** Show privileges manager if protection is not yet set up */
|
|
this.application.privilegesService.actionHasPrivilegesConfigured(
|
|
ProtectedActions.ViewProtectedNotes
|
|
).then((configured) => {
|
|
if (!configured) {
|
|
this.godService.presentPrivilegesManagementModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
toggleNotePreview() {
|
|
this.state.note.content.hidePreview = !this.state.note.content.hidePreview;
|
|
this.saveNote({
|
|
bypassDebouncer: true,
|
|
dontUpdatePreviews: true
|
|
});
|
|
}
|
|
|
|
toggleArchiveNote() {
|
|
this.state.note.setAppDataItem(
|
|
AppDataKeys.Archived,
|
|
!this.state.note.archived
|
|
);
|
|
this.saveNote({
|
|
bypassDebouncer: true,
|
|
dontUpdatePreviews: true
|
|
});
|
|
}
|
|
|
|
reloadTagsString() {
|
|
this.setState({
|
|
mutable: {
|
|
...this.state.mutable,
|
|
tagsString: this.state.note.tagsString()
|
|
}
|
|
});
|
|
}
|
|
|
|
addTag(tag) {
|
|
const strings = this.state.note.tags.map((currentTag) => {
|
|
return currentTag.title;
|
|
});
|
|
strings.push(tag.title);
|
|
this.saveTags({ strings: strings });
|
|
}
|
|
|
|
removeTag(tag) {
|
|
const strings = this.state.note.tags.map((currentTag) => {
|
|
return currentTag.title;
|
|
}).filter((title) => {
|
|
return title !== tag.title;
|
|
});
|
|
this.saveTags({ strings: strings });
|
|
}
|
|
|
|
async saveTags({ strings } = {}) {
|
|
if (!strings && this.state.mutable.tagsString === this.state.note.tagsString()) {
|
|
return;
|
|
}
|
|
if (!strings) {
|
|
strings = this.state.mutable.tagsString.split('#').filter((string) => {
|
|
return string.length > 0;
|
|
}).map((string) => {
|
|
return string.trim();
|
|
});
|
|
}
|
|
this.state.note.dummy = false;
|
|
|
|
const toRemove = [];
|
|
for (const tag of this.state.note.tags) {
|
|
if (strings.indexOf(tag.title) === -1) {
|
|
toRemove.push(tag);
|
|
}
|
|
}
|
|
for (const tagToRemove of toRemove) {
|
|
tagToRemove.removeItemAsRelationship(this.state.note);
|
|
}
|
|
this.application.setItemsNeedsSync({ items: toRemove });
|
|
const tags = [];
|
|
for (const tagString of strings) {
|
|
const existingRelationship = find(
|
|
this.state.note.tags,
|
|
{ title: tagString }
|
|
);
|
|
if (!existingRelationship) {
|
|
tags.push(
|
|
await this.application.findOrCreateTag({ title: tagString })
|
|
);
|
|
}
|
|
}
|
|
for (const tag of tags) {
|
|
tag.addItemAsRelationship(this.state.note);
|
|
}
|
|
this.application.saveItems({ items: tags });
|
|
this.reloadTagsString();
|
|
}
|
|
|
|
onPanelResizeFinish = (width, left, isMaxWidth) => {
|
|
if (isMaxWidth) {
|
|
this.preferencesManager.setUserPrefValue(
|
|
PrefKeys.EditorWidth,
|
|
null
|
|
);
|
|
} else {
|
|
if (width !== undefined && width !== null) {
|
|
this.preferencesManager.setUserPrefValue(
|
|
PrefKeys.EditorWidth,
|
|
width
|
|
);
|
|
this.leftPanelPuppet.setWidth(width);
|
|
}
|
|
}
|
|
if (left !== undefined && left !== null) {
|
|
this.preferencesManager.setUserPrefValue(
|
|
PrefKeys.EditorLeft,
|
|
left
|
|
);
|
|
this.rightPanelPuppet.setLeft(left);
|
|
}
|
|
this.preferencesManager.syncUserPreferences();
|
|
}
|
|
|
|
reloadPreferences() {
|
|
const monospaceEnabled = this.preferencesManager.getValue(
|
|
PrefKeys.EditorMonospaceEnabled,
|
|
true
|
|
);
|
|
const spellcheck = this.preferencesManager.getValue(
|
|
PrefKeys.EditorSpellcheck,
|
|
true
|
|
);
|
|
const marginResizersEnabled = this.preferencesManager.getValue(
|
|
PrefKeys.EditorResizersEnabled,
|
|
true
|
|
);
|
|
this.setState({
|
|
monospaceEnabled,
|
|
spellcheck,
|
|
marginResizersEnabled
|
|
});
|
|
|
|
if (!document.getElementById(ElementIds.EditorContent)) {
|
|
/** Elements have not yet loaded due to ng-if around wrapper */
|
|
return;
|
|
}
|
|
|
|
this.reloadFont();
|
|
|
|
if (
|
|
this.state.marginResizersEnabled &&
|
|
this.leftPanelPuppet.ready &&
|
|
this.rightPanelPuppet.ready
|
|
) {
|
|
const width = this.preferencesManager.getValue(
|
|
PrefKeys.EditorWidth,
|
|
null
|
|
);
|
|
if (width != null) {
|
|
this.leftPanelPuppet.setWidth(width);
|
|
this.rightPanelPuppet.setWidth(width);
|
|
}
|
|
const left = this.preferencesManager.getValue(
|
|
PrefKeys.EditorLeft,
|
|
null
|
|
);
|
|
if (left != null) {
|
|
this.leftPanelPuppet.setLeft(left);
|
|
this.rightPanelPuppet.setLeft(left);
|
|
}
|
|
}
|
|
}
|
|
|
|
reloadFont() {
|
|
const editor = document.getElementById(
|
|
ElementIds.NoteTextEditor
|
|
);
|
|
if (!editor) {
|
|
return;
|
|
}
|
|
if (this.state.monospaceEnabled) {
|
|
if (this.state.isDesktop) {
|
|
editor.style.fontFamily = Fonts.DesktopMonospaceFamily;
|
|
} else {
|
|
editor.style.fontFamily = Fonts.WebMonospaceFamily;
|
|
}
|
|
} else {
|
|
editor.style.fontFamily = Fonts.SansSerifFamily;
|
|
}
|
|
}
|
|
|
|
async toggleKey(key) {
|
|
this[key] = !this[key];
|
|
this.preferencesManager.setUserPrefValue(
|
|
key,
|
|
this[key],
|
|
true
|
|
);
|
|
this.reloadFont();
|
|
|
|
if (key === PrefKeys.EditorSpellcheck) {
|
|
/** Allows textarea to reload */
|
|
await this.setState({
|
|
noteReady: false
|
|
});
|
|
this.setState({
|
|
noteReady: true
|
|
});
|
|
this.reloadFont();
|
|
} else if (key === PrefKeys.EditorResizersEnabled && this[key] === true) {
|
|
this.$timeout(() => {
|
|
this.leftPanelPuppet.flash();
|
|
this.rightPanelPuppet.flash();
|
|
});
|
|
}
|
|
}
|
|
|
|
/** @components */
|
|
|
|
onEditorLoad = (editor) => {
|
|
this.desktopManager.redoSearch();
|
|
}
|
|
|
|
registerComponentHandler() {
|
|
this.application.componentManager.registerHandler({
|
|
identifier: 'editor',
|
|
areas: [
|
|
'note-tags',
|
|
'editor-stack',
|
|
'editor-editor'
|
|
],
|
|
activationHandler: (component) => {
|
|
if (component.area === 'note-tags') {
|
|
this.setState({
|
|
tagsComponent: component.active ? component : null
|
|
});
|
|
} else if (component.area === 'editor-editor') {
|
|
if (
|
|
component === this.state.selectedEditor &&
|
|
!component.active
|
|
) {
|
|
this.setState({ selectedEditor: null });
|
|
}
|
|
else if (this.state.selectedEditor) {
|
|
if (this.state.selectedEditor.active && this.state.note) {
|
|
if (
|
|
component.isExplicitlyEnabledForItem(this.state.note)
|
|
&& !this.state.selectedEditor.isExplicitlyEnabledForItem(this.state.note)
|
|
) {
|
|
this.setState({ selectedEditor: component });
|
|
}
|
|
}
|
|
}
|
|
else if (this.state.note) {
|
|
const enableable = (
|
|
component.isExplicitlyEnabledForItem(this.state.note)
|
|
|| component.isDefaultEditor()
|
|
);
|
|
if (
|
|
component.active
|
|
&& enableable
|
|
) {
|
|
this.setState({ selectedEditor: component });
|
|
} else {
|
|
/**
|
|
* Not a candidate, and no qualified editor.
|
|
* Disable the current editor.
|
|
*/
|
|
this.setState({ selectedEditor: null });
|
|
}
|
|
}
|
|
|
|
} else if (component.area === 'editor-stack') {
|
|
this.reloadComponentContext();
|
|
}
|
|
},
|
|
contextRequestHandler: (component) => {
|
|
if (
|
|
component === this.state.selectedEditor ||
|
|
component === this.state.tagsComponent ||
|
|
this.state.componentStack.includes(component)
|
|
) {
|
|
return this.state.note;
|
|
}
|
|
},
|
|
focusHandler: (component, focused) => {
|
|
if (component.isEditor() && focused) {
|
|
this.closeAllMenus();
|
|
}
|
|
},
|
|
actionHandler: (component, action, data) => {
|
|
if (action === 'set-size') {
|
|
const setSize = function (element, size) {
|
|
const widthString = typeof size.width === 'string'
|
|
? size.width
|
|
: `${data.width}px`;
|
|
const heightString = typeof size.height === 'string'
|
|
? size.height
|
|
: `${data.height}px`;
|
|
element.setAttribute(
|
|
'style',
|
|
`width: ${widthString}; height: ${heightString};`
|
|
);
|
|
};
|
|
if (data.type === 'container') {
|
|
if (component.area === 'note-tags') {
|
|
const container = document.getElementById(
|
|
ElementIds.NoteTagsComponentContainer
|
|
);
|
|
setSize(container, data);
|
|
}
|
|
}
|
|
}
|
|
else if (action === 'associate-item') {
|
|
if (data.item.content_type === 'Tag') {
|
|
const tag = this.application.findItem({ uuid: data.item.uuid });
|
|
this.addTag(tag);
|
|
}
|
|
}
|
|
else if (action === 'deassociate-item') {
|
|
const tag = this.application.findItem({ uuid: data.item.uuid });
|
|
this.removeTag(tag);
|
|
}
|
|
else if (action === 'save-items') {
|
|
const includesNote = data.items.map((item) => {
|
|
return item.uuid;
|
|
}).includes(this.state.note.uuid);
|
|
if (includesNote) {
|
|
this.showSavingStatus();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
reloadComponentStackArray() {
|
|
const components = this.application.componentManager.componentsForArea('editor-stack')
|
|
.sort((a, b) => {
|
|
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
|
});
|
|
|
|
this.setState({
|
|
componentStack: components
|
|
});
|
|
}
|
|
|
|
reloadComponentContext() {
|
|
this.reloadComponentStackArray();
|
|
if (this.state.note) {
|
|
for (const component of this.state.componentStack) {
|
|
if (component.active) {
|
|
this.application.componentManager.setComponentHidden(
|
|
component,
|
|
!component.isExplicitlyEnabledForItem(this.state.note)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.application.componentManager.contextItemDidChangeInArea('note-tags');
|
|
this.application.componentManager.contextItemDidChangeInArea('editor-stack');
|
|
this.application.componentManager.contextItemDidChangeInArea('editor-editor');
|
|
}
|
|
|
|
toggleStackComponentForCurrentItem(component) {
|
|
if (component.hidden || !component.active) {
|
|
this.application.componentManager.setComponentHidden(component, false);
|
|
this.associateComponentWithCurrentNote(component);
|
|
if (!component.active) {
|
|
this.application.componentManager.activateComponent(component);
|
|
}
|
|
this.application.componentManager.contextItemDidChangeInArea('editor-stack');
|
|
} else {
|
|
this.application.componentManager.setComponentHidden(component, true);
|
|
this.disassociateComponentWithCurrentNote(component);
|
|
}
|
|
}
|
|
|
|
disassociateComponentWithCurrentNote(component) {
|
|
component.associatedItemIds = component.associatedItemIds.filter((id) => {
|
|
return id !== this.state.note.uuid;
|
|
});
|
|
|
|
if (!component.disassociatedItemIds.includes(this.state.note.uuid)) {
|
|
component.disassociatedItemIds.push(this.state.note.uuid);
|
|
}
|
|
|
|
this.application.saveItem({ item: component });
|
|
}
|
|
|
|
associateComponentWithCurrentNote(component) {
|
|
component.disassociatedItemIds = component.disassociatedItemIds
|
|
.filter((id) => {
|
|
return id !== this.state.note.uuid;
|
|
});
|
|
|
|
if (!component.associatedItemIds.includes(this.state.note.uuid)) {
|
|
component.associatedItemIds.push(this.state.note.uuid);
|
|
}
|
|
|
|
this.application.saveItem({ item: component });
|
|
}
|
|
|
|
registerKeyboardShortcuts() {
|
|
this.altKeyObserver = this.keyboardManager.addKeyObserver({
|
|
modifiers: [
|
|
KeyboardModifiers.Alt
|
|
],
|
|
onKeyDown: () => {
|
|
this.setState({
|
|
altKeyDown: true
|
|
});
|
|
},
|
|
onKeyUp: () => {
|
|
this.setState({
|
|
altKeyDown: false
|
|
});
|
|
}
|
|
});
|
|
|
|
this.trashKeyObserver = this.keyboardManager.addKeyObserver({
|
|
key: KeyboardKeys.Backspace,
|
|
notElementIds: [
|
|
ElementIds.NoteTextEditor,
|
|
ElementIds.NoteTitleEditor
|
|
],
|
|
modifiers: [KeyboardModifiers.Meta],
|
|
onKeyDown: () => {
|
|
this.deleteNote();
|
|
},
|
|
});
|
|
|
|
this.deleteKeyObserver = this.keyboardManager.addKeyObserver({
|
|
key: KeyboardKeys.Backspace,
|
|
modifiers: [
|
|
KeyboardModifiers.Meta,
|
|
KeyboardModifiers.Shift,
|
|
KeyboardModifiers.Alt
|
|
],
|
|
onKeyDown: (event) => {
|
|
event.preventDefault();
|
|
this.deleteNote(true);
|
|
},
|
|
});
|
|
}
|
|
|
|
onSystemEditorLoad() {
|
|
if (this.loadedTabListener) {
|
|
return;
|
|
}
|
|
this.loadedTabListener = true;
|
|
/**
|
|
* Insert 4 spaces when a tab key is pressed,
|
|
* only used when inside of the text editor.
|
|
* If the shift key is pressed first, this event is
|
|
* not fired.
|
|
*/
|
|
const editor = document.getElementById(
|
|
ElementIds.NoteTextEditor
|
|
);
|
|
this.tabObserver = this.keyboardManager.addKeyObserver({
|
|
element: editor,
|
|
key: KeyboardKeys.Tab,
|
|
onKeyDown: (event) => {
|
|
if (this.state.note.locked || event.shiftKey) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
/** Using document.execCommand gives us undo support */
|
|
const insertSuccessful = document.execCommand(
|
|
'insertText',
|
|
false,
|
|
'\t'
|
|
);
|
|
if (!insertSuccessful) {
|
|
/** document.execCommand works great on Chrome/Safari but not Firefox */
|
|
const start = editor.selectionStart;
|
|
const end = editor.selectionEnd;
|
|
const spaces = ' ';
|
|
/** Insert 4 spaces */
|
|
editor.value = editor.value.substring(0, start)
|
|
+ spaces + editor.value.substring(end);
|
|
/** Place cursor 4 spaces away from where the tab key was pressed */
|
|
editor.selectionStart = editor.selectionEnd = start + 4;
|
|
}
|
|
|
|
const note = this.state.note;
|
|
note.text = editor.value;
|
|
this.setState({
|
|
note: note
|
|
});
|
|
this.saveNote({
|
|
bypassDebouncer: true
|
|
});
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Handles when the editor is destroyed,
|
|
* (and not when our controller is destroyed.)
|
|
*/
|
|
angular.element(editor).on('$destroy', () => {
|
|
if (this.tabObserver) {
|
|
this.keyboardManager.removeKeyObserver(this.tabObserver);
|
|
this.loadedTabListener = false;
|
|
}
|
|
});
|
|
};
|
|
}
|
|
|
|
export class EditorPanel {
|
|
constructor() {
|
|
this.restrict = 'E';
|
|
this.scope = {};
|
|
this.template = template;
|
|
this.replace = true;
|
|
this.controller = EditorCtrl;
|
|
this.controllerAs = 'self';
|
|
this.bindToController = true;
|
|
}
|
|
}
|