1277 lines
34 KiB
TypeScript
1277 lines
34 KiB
TypeScript
import { WebApplication } from './../application';
|
|
import { PanelPuppet, WebDirective } from './../types';
|
|
import angular from 'angular';
|
|
import {
|
|
ApplicationEvent,
|
|
isPayloadSourceRetrieved,
|
|
ContentType,
|
|
ProtectedAction,
|
|
SNComponent,
|
|
SNNote,
|
|
SNTag,
|
|
NoteMutator,
|
|
Uuids,
|
|
ComponentArea,
|
|
ComponentAction,
|
|
WebPrefKey,
|
|
ComponentMutator
|
|
} from 'snjs';
|
|
import find from 'lodash/find';
|
|
import { isDesktopApplication } from '@/utils';
|
|
import { KeyboardModifier, KeyboardKey } from '@/services/keyboardManager';
|
|
import template from '%/editor.pug';
|
|
import { PureCtrl } from '@Controllers/abstract/pure_ctrl';
|
|
import { AppStateEvent, EventSource } from '@/services/state';
|
|
import {
|
|
STRING_DELETED_NOTE,
|
|
STRING_INVALID_NOTE,
|
|
STRING_ELLIPSES,
|
|
STRING_DELETE_PLACEHOLDER_ATTEMPT,
|
|
STRING_DELETE_LOCKED_ATTEMPT,
|
|
StringDeleteNote,
|
|
StringEmptyTrash
|
|
} from '@/strings';
|
|
import { RawPayload } from '@/../../../../snjs/dist/@types/protocol/payloads/generator';
|
|
|
|
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`
|
|
};
|
|
|
|
type NoteStatus = {
|
|
message?: string
|
|
date?: Date
|
|
}
|
|
|
|
type EditorState = {
|
|
note: SNNote
|
|
saveError?: any
|
|
selectedEditor?: SNComponent
|
|
noteStatus?: NoteStatus
|
|
tagsAsStrings?: string
|
|
marginResizersEnabled?: boolean
|
|
monospaceEnabled?: boolean
|
|
isDesktop?: boolean
|
|
tagsComponent?: SNComponent
|
|
componentStack?: SNComponent[]
|
|
/** Fields that can be directly mutated by the template */
|
|
mutable: {}
|
|
}
|
|
|
|
type EditorValues = {
|
|
title?: string
|
|
text?: string
|
|
tagsInputValue?: string
|
|
}
|
|
|
|
class EditorCtrl extends PureCtrl {
|
|
/** Passed through template */
|
|
readonly application!: WebApplication
|
|
private leftPanelPuppet?: PanelPuppet
|
|
private rightPanelPuppet?: PanelPuppet
|
|
private unregisterComponent: any
|
|
private saveTimeout?: ng.IPromise<void>
|
|
private statusTimeout?: ng.IPromise<void>
|
|
private lastEditorFocusEventSource?: EventSource
|
|
public editorValues: EditorValues = {}
|
|
onEditorLoad?: () => void
|
|
|
|
private removeAltKeyObserver?: any
|
|
private removeTrashKeyObserver?: any
|
|
private removeDeleteKeyObserver?: any
|
|
private removeTabObserver?: any
|
|
|
|
prefKeyMonospace: string
|
|
prefKeySpellcheck: string
|
|
prefKeyMarginResizers: string
|
|
|
|
/* @ngInject */
|
|
constructor($timeout: ng.ITimeoutService) {
|
|
super($timeout);
|
|
this.leftPanelPuppet = {
|
|
onReady: () => this.reloadPreferences()
|
|
};
|
|
this.rightPanelPuppet = {
|
|
onReady: () => this.reloadPreferences()
|
|
};
|
|
/** Used by .pug template */
|
|
this.prefKeyMonospace = WebPrefKey.EditorMonospaceEnabled;
|
|
this.prefKeySpellcheck = WebPrefKey.EditorSpellcheck;
|
|
this.prefKeyMarginResizers = WebPrefKey.EditorResizersEnabled;
|
|
|
|
this.editorMenuOnSelect = this.editorMenuOnSelect.bind(this);
|
|
this.onPanelResizeFinish = this.onPanelResizeFinish.bind(this);
|
|
this.onEditorLoad = () => {
|
|
this.application!.getDesktopService().redoSearch();
|
|
}
|
|
}
|
|
|
|
deinit() {
|
|
this.removeAltKeyObserver();
|
|
this.removeAltKeyObserver = undefined;
|
|
this.removeTrashKeyObserver();
|
|
this.removeTrashKeyObserver = undefined;
|
|
this.removeDeleteKeyObserver();
|
|
this.removeDeleteKeyObserver = undefined;
|
|
this.removeTabObserver && this.removeTabObserver();
|
|
this.removeTabObserver = undefined;
|
|
this.leftPanelPuppet = undefined;
|
|
this.rightPanelPuppet = undefined;
|
|
this.onEditorLoad = undefined;
|
|
this.unregisterComponent();
|
|
this.unregisterComponent = undefined;
|
|
this.saveTimeout = undefined;
|
|
this.statusTimeout = undefined;
|
|
(this.onPanelResizeFinish as any) = undefined;
|
|
(this.editorMenuOnSelect as any) = undefined;
|
|
super.deinit();
|
|
}
|
|
|
|
getState() {
|
|
return this.state as EditorState;
|
|
}
|
|
|
|
$onInit() {
|
|
super.$onInit();
|
|
this.registerKeyboardShortcuts();
|
|
}
|
|
|
|
/** @override */
|
|
getInitialState() {
|
|
return {
|
|
componentStack: [],
|
|
editorDebounce: EDITOR_DEBOUNCE,
|
|
isDesktop: isDesktopApplication(),
|
|
spellcheck: true,
|
|
mutable: {
|
|
tagsString: ''
|
|
}
|
|
};
|
|
}
|
|
|
|
async onAppLaunch() {
|
|
await super.onAppLaunch();
|
|
this.streamItems();
|
|
this.registerComponentHandler();
|
|
}
|
|
|
|
/** @override */
|
|
onAppStateEvent(eventName: AppStateEvent, data: any) {
|
|
if (eventName === AppStateEvent.NoteChanged) {
|
|
this.handleNoteSelectionChange(
|
|
this.application.getAppState().getSelectedNote()!,
|
|
data.previousNote
|
|
);
|
|
} else if (eventName === AppStateEvent.PreferencesChanged) {
|
|
this.reloadPreferences();
|
|
}
|
|
}
|
|
|
|
/** @override */
|
|
onAppEvent(eventName: ApplicationEvent) {
|
|
if (!this.getState().note) {
|
|
return;
|
|
}
|
|
if (eventName === ApplicationEvent.HighLatencySync) {
|
|
this.setState({ syncTakingTooLong: true });
|
|
} else if (eventName === ApplicationEvent.CompletedSync) {
|
|
this.setState({ syncTakingTooLong: false });
|
|
if (this.getState().note.dirty) {
|
|
/** if we're still dirty, don't change status, a sync is likely upcoming. */
|
|
} else {
|
|
const saved = this.getState().note.lastSyncEnd! > this.getState().note.lastSyncBegan!;
|
|
const isInErrorState = this.getState().saveError;
|
|
if (isInErrorState || saved) {
|
|
this.showAllChangesSavedStatus();
|
|
}
|
|
}
|
|
} else if (eventName === ApplicationEvent.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.getState().note.dirty) {
|
|
this.showErrorStatus();
|
|
}
|
|
} else if (eventName === ApplicationEvent.LocalDatabaseWriteError) {
|
|
this.showErrorStatus({
|
|
message: "Offline Saving Issue",
|
|
desc: "Changes not saved"
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Because note.locked accesses note.content.appData,
|
|
* we do not want to expose the template to direct access to note.locked,
|
|
* otherwise an exception will occur when trying to access note.locked if the note
|
|
* is deleted. There is potential for race conditions to occur with setState, where a
|
|
* previous setState call may have queued a digest cycle, and the digest cycle triggers
|
|
* on a deleted note.
|
|
*/
|
|
get noteLocked() {
|
|
if (!this.getState().note || this.getState().note.deleted) {
|
|
return false;
|
|
}
|
|
return this.getState().note.locked;
|
|
}
|
|
|
|
streamItems() {
|
|
this.application.streamItems(
|
|
ContentType.Note,
|
|
async (items, source) => {
|
|
const currentNote = this.getState().note;
|
|
if (!currentNote) {
|
|
return;
|
|
}
|
|
const matchingNote = items.find((item) => {
|
|
return item.uuid === currentNote.uuid;
|
|
}) as SNNote;
|
|
if (!matchingNote) {
|
|
return;
|
|
}
|
|
if (matchingNote?.deleted) {
|
|
await this.setState({
|
|
note: undefined,
|
|
noteReady: false
|
|
});
|
|
return;
|
|
} else {
|
|
await this.setState({
|
|
note: matchingNote
|
|
});
|
|
}
|
|
if (!isPayloadSourceRetrieved(source!)) {
|
|
return;
|
|
}
|
|
this.editorValues.title = matchingNote.title;
|
|
this.editorValues.text = matchingNote.text;
|
|
this.reloadTagsString();
|
|
}
|
|
);
|
|
|
|
this.application.streamItems(
|
|
ContentType.Tag,
|
|
(items) => {
|
|
if (!this.getState().note) {
|
|
return;
|
|
}
|
|
for (const tag of items) {
|
|
if (
|
|
!this.editorValues.tagsInputValue ||
|
|
tag.deleted ||
|
|
tag.hasRelationshipWithItem(this.getState().note)
|
|
) {
|
|
this.reloadTagsString();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
this.application.streamItems(
|
|
ContentType.Component,
|
|
async (items) => {
|
|
const components = items as SNComponent[];
|
|
if (!this.getState().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 = components.filter((component) => {
|
|
return component.isEditor();
|
|
});
|
|
if (editors.length === 0) {
|
|
return;
|
|
}
|
|
/** Find the most recent editor for note */
|
|
const editor = this.editorForNote(this.getState().note);
|
|
this.setState({
|
|
selectedEditor: editor
|
|
});
|
|
if (!editor) {
|
|
this.reloadFont();
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
async handleNoteSelectionChange(note: SNNote, previousNote?: SNNote) {
|
|
await this.setState({
|
|
note: note,
|
|
showExtensions: false,
|
|
showOptionsMenu: false,
|
|
altKeyDown: false,
|
|
noteStatus: null
|
|
});
|
|
this.editorValues.title = note.title;
|
|
this.editorValues.text = note.text;
|
|
if (!note) {
|
|
this.setState({
|
|
noteReady: false
|
|
});
|
|
return;
|
|
}
|
|
const associatedEditor = this.editorForNote(note);
|
|
if (associatedEditor && associatedEditor !== this.getState().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.focusTitle();
|
|
}
|
|
if (previousNote && previousNote !== note) {
|
|
if (previousNote.dummy) {
|
|
this.performNoteDeletion(previousNote);
|
|
}
|
|
}
|
|
|
|
this.reloadComponentContext();
|
|
}
|
|
|
|
editorForNote(note: SNNote) {
|
|
return this.application.componentManager!.editorForNote(note);
|
|
}
|
|
|
|
setMenuState(menu: string, state: boolean) {
|
|
this.setState({
|
|
[menu]: state
|
|
});
|
|
this.closeAllMenus(menu);
|
|
}
|
|
|
|
toggleMenu(menu: string) {
|
|
this.setMenuState(menu, !this.state[menu]);
|
|
}
|
|
|
|
closeAllMenus(exclude?: string) {
|
|
const allMenus = [
|
|
'showOptionsMenu',
|
|
'showEditorMenu',
|
|
'showExtensions',
|
|
'showSessionHistory'
|
|
];
|
|
const menuState: any = {};
|
|
for (const candidate of allMenus) {
|
|
if (candidate !== exclude) {
|
|
menuState[candidate] = false;
|
|
}
|
|
}
|
|
this.setState(menuState);
|
|
}
|
|
|
|
editorMenuOnSelect(component: SNComponent) {
|
|
if (!component || component.area === 'editor-editor') {
|
|
/** If plain editor or other editor */
|
|
this.setMenuState('showEditorMenu', false);
|
|
const editor = component;
|
|
if (this.getState().selectedEditor && editor !== this.getState().selectedEditor) {
|
|
this.disassociateComponentWithCurrentNote(this.getState().selectedEditor!);
|
|
}
|
|
const note = this.getState().note;
|
|
if (editor) {
|
|
const prefersPlain = note.prefersPlainEditor;
|
|
if (prefersPlain) {
|
|
this.application.changeItem(note.uuid, (mutator) => {
|
|
const noteMutator = mutator as NoteMutator;
|
|
noteMutator.prefersPlainEditor = false;
|
|
})
|
|
}
|
|
this.associateComponentWithCurrentNote(editor);
|
|
} else {
|
|
/** Note prefers plain editor */
|
|
if (!note.prefersPlainEditor) {
|
|
this.application.changeItem(note.uuid, (mutator) => {
|
|
const noteMutator = mutator as NoteMutator;
|
|
noteMutator.prefersPlainEditor = true;
|
|
})
|
|
}
|
|
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.getState().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 = false,
|
|
isUserModified = false,
|
|
dontUpdatePreviews = false,
|
|
customMutate?: (mutator: NoteMutator) => void
|
|
) {
|
|
this.performFirefoxPinnedTabFix();
|
|
const note = this.getState().note;
|
|
|
|
if (note.deleted) {
|
|
this.application.alertService!.alert(
|
|
STRING_DELETED_NOTE
|
|
);
|
|
return;
|
|
}
|
|
if (!this.application.findItem(note.uuid)) {
|
|
this.application.alertService!.alert(
|
|
STRING_INVALID_NOTE
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.showSavingStatus();
|
|
|
|
this.application.changeItem(note.uuid, (mutator) => {
|
|
const noteMutator = mutator as NoteMutator;
|
|
if (customMutate) {
|
|
customMutate(noteMutator);
|
|
}
|
|
noteMutator.title = this.editorValues.title!;
|
|
noteMutator.text = this.editorValues.text!;
|
|
if (!dontUpdatePreviews) {
|
|
const text = this.editorValues.text || '';
|
|
const truncate = text.length > NOTE_PREVIEW_CHAR_LIMIT;
|
|
const substring = text.substring(0, NOTE_PREVIEW_CHAR_LIMIT);
|
|
const previewPlain = substring + (truncate ? STRING_ELLIPSES : '');
|
|
noteMutator.preview_plain = previewPlain;
|
|
noteMutator.preview_html = undefined;
|
|
}
|
|
}, isUserModified)
|
|
|
|
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
|
|
});
|
|
this.setStatus({
|
|
message: 'All changes saved',
|
|
});
|
|
}
|
|
|
|
showErrorStatus(error?: any) {
|
|
if (!error) {
|
|
error = {
|
|
message: "Sync Unreachable",
|
|
desc: "Changes saved offline"
|
|
};
|
|
}
|
|
this.setState({
|
|
saveError: true,
|
|
syncTakingTooLong: false
|
|
});
|
|
this.setStatus(error);
|
|
}
|
|
|
|
setStatus(status: { message: string, date?: Date }, wait = true) {
|
|
let waitForMs;
|
|
if (!this.getState().noteStatus || !this.getState().noteStatus!.date) {
|
|
waitForMs = 0;
|
|
} else {
|
|
waitForMs = MINIMUM_STATUS_DURATION - (
|
|
new Date().getTime() - this.getState().noteStatus!.date!.getTime()
|
|
);
|
|
}
|
|
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(
|
|
false,
|
|
true
|
|
);
|
|
}
|
|
|
|
onTitleEnter($event: Event) {
|
|
($event.target as HTMLInputElement).blur();
|
|
this.onTitleChange();
|
|
this.focusEditor();
|
|
}
|
|
|
|
onTitleChange() {
|
|
this.saveNote(
|
|
false,
|
|
true,
|
|
true,
|
|
);
|
|
}
|
|
|
|
focusEditor() {
|
|
const element = document.getElementById(ElementIds.NoteTextEditor);
|
|
if (element) {
|
|
this.lastEditorFocusEventSource = EventSource.Script;
|
|
element.focus();
|
|
}
|
|
}
|
|
|
|
focusTitle() {
|
|
document.getElementById(ElementIds.NoteTitleEditor)!.focus();
|
|
}
|
|
|
|
clickedTextArea() {
|
|
this.setMenuState('showOptionsMenu', false);
|
|
}
|
|
|
|
onTitleFocus() {
|
|
|
|
}
|
|
|
|
onTitleBlur() {
|
|
|
|
}
|
|
|
|
onContentFocus() {
|
|
this.application.getAppState().editorDidFocus(this.lastEditorFocusEventSource!);
|
|
this.lastEditorFocusEventSource = undefined;
|
|
}
|
|
|
|
selectedMenuItem(hide: boolean) {
|
|
if (hide) {
|
|
this.setMenuState('showOptionsMenu', false);
|
|
}
|
|
}
|
|
|
|
async deleteNote(permanently: boolean) {
|
|
if (this.getState().note.dummy) {
|
|
this.application.alertService!.alert(
|
|
STRING_DELETE_PLACEHOLDER_ATTEMPT
|
|
);
|
|
return;
|
|
}
|
|
const run = () => {
|
|
if (this.getState().note.locked) {
|
|
this.application.alertService!.alert(
|
|
STRING_DELETE_LOCKED_ATTEMPT
|
|
);
|
|
return;
|
|
}
|
|
const title = this.getState().note.safeTitle().length
|
|
? `'${this.getState().note.title}'`
|
|
: "this note";
|
|
const text = StringDeleteNote(
|
|
title,
|
|
permanently
|
|
);
|
|
this.application.alertService!.confirm(
|
|
text,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
() => {
|
|
if (permanently) {
|
|
this.performNoteDeletion(this.getState().note);
|
|
} else {
|
|
this.saveNote(
|
|
true,
|
|
false,
|
|
true,
|
|
(mutator) => {
|
|
mutator.trashed = true;
|
|
}
|
|
);
|
|
}
|
|
this.application.getAppState().setSelectedNote(undefined);
|
|
this.setMenuState('showOptionsMenu', false);
|
|
},
|
|
undefined,
|
|
true,
|
|
);
|
|
};
|
|
const requiresPrivilege = await this.application.privilegesService!.actionRequiresPrivilege(
|
|
ProtectedAction.DeleteNote
|
|
);
|
|
if (requiresPrivilege) {
|
|
this.application.presentPrivilegesModal(
|
|
ProtectedAction.DeleteNote,
|
|
() => {
|
|
run();
|
|
}
|
|
);
|
|
} else {
|
|
run();
|
|
}
|
|
}
|
|
|
|
performNoteDeletion(note: SNNote) {
|
|
this.application.deleteItem(note);
|
|
if (note === this.getState().note) {
|
|
this.setState({
|
|
note: null
|
|
});
|
|
}
|
|
if (note.dummy) {
|
|
this.application.deleteItemLocally(note);
|
|
return;
|
|
}
|
|
this.application.sync();
|
|
}
|
|
|
|
restoreTrashedNote() {
|
|
this.saveNote(
|
|
true,
|
|
false,
|
|
true,
|
|
(mutator) => {
|
|
mutator.trashed = false;
|
|
}
|
|
);
|
|
this.application.getAppState().setSelectedNote(undefined);
|
|
}
|
|
|
|
deleteNotePermanantely() {
|
|
this.deleteNote(true);
|
|
}
|
|
|
|
getTrashCount() {
|
|
return this.application.getTrashedItems().length;
|
|
}
|
|
|
|
emptyTrash() {
|
|
const count = this.getTrashCount();
|
|
this.application.alertService!.confirm(
|
|
StringEmptyTrash(count),
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
() => {
|
|
this.application.emptyTrash();
|
|
this.application.sync();
|
|
},
|
|
undefined,
|
|
true,
|
|
);
|
|
}
|
|
|
|
togglePin() {
|
|
this.saveNote(
|
|
true,
|
|
false,
|
|
true,
|
|
(mutator) => {
|
|
mutator.pinned = !this.getState().note.pinned
|
|
}
|
|
);
|
|
}
|
|
|
|
toggleLockNote() {
|
|
this.saveNote(
|
|
true,
|
|
false,
|
|
true,
|
|
(mutator) => {
|
|
mutator.locked = !this.getState().note.locked
|
|
}
|
|
);
|
|
}
|
|
|
|
toggleProtectNote() {
|
|
this.saveNote(
|
|
true,
|
|
false,
|
|
true,
|
|
(mutator) => {
|
|
mutator.protected = !this.getState().note.protected
|
|
}
|
|
);
|
|
/** Show privileges manager if protection is not yet set up */
|
|
this.application.privilegesService!.actionHasPrivilegesConfigured(
|
|
ProtectedAction.ViewProtectedNotes
|
|
).then((configured) => {
|
|
if (!configured) {
|
|
this.application.presentPrivilegesManagementModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
toggleNotePreview() {
|
|
this.saveNote(
|
|
true,
|
|
false,
|
|
true,
|
|
(mutator) => {
|
|
mutator.hidePreview = !this.getState().note.hidePreview
|
|
}
|
|
);
|
|
}
|
|
|
|
toggleArchiveNote() {
|
|
this.saveNote(
|
|
true,
|
|
false,
|
|
true,
|
|
(mutator) => {
|
|
mutator.archived = !this.getState().note.archived
|
|
}
|
|
);
|
|
}
|
|
|
|
reloadTagsString() {
|
|
const tags = this.appState.getNoteTags(this.getState().note);
|
|
const string = SNTag.arrayToDisplayString(tags);
|
|
this.updateUI(() => {
|
|
this.editorValues.tagsInputValue = string;
|
|
})
|
|
}
|
|
|
|
addTag(tag: SNTag) {
|
|
const tags = this.appState.getNoteTags(this.getState().note);
|
|
const strings = tags.map((currentTag) => {
|
|
return currentTag.title;
|
|
});
|
|
strings.push(tag.title);
|
|
this.saveTagsFromStrings(strings);
|
|
}
|
|
|
|
removeTag(tag: SNTag) {
|
|
const tags = this.appState.getNoteTags(this.getState().note);
|
|
const strings = tags.map((currentTag) => {
|
|
return currentTag.title;
|
|
}).filter((title) => {
|
|
return title !== tag.title;
|
|
});
|
|
this.saveTagsFromStrings(strings);
|
|
}
|
|
|
|
async saveTagsFromStrings(strings?: string[]) {
|
|
if (
|
|
!strings
|
|
&& this.editorValues.tagsInputValue === this.getState().tagsAsStrings
|
|
) {
|
|
return;
|
|
}
|
|
if (!strings) {
|
|
strings = this.editorValues.tagsInputValue!
|
|
.split('#')
|
|
.filter((string) => {
|
|
return string.length > 0;
|
|
})
|
|
.map((string) => {
|
|
return string.trim();
|
|
});
|
|
}
|
|
|
|
const note = this.getState().note;
|
|
const currentTags = this.appState.getNoteTags(note);
|
|
|
|
const removeTags = [];
|
|
for (const tag of currentTags) {
|
|
if (strings.indexOf(tag.title) === -1) {
|
|
removeTags.push(tag);
|
|
}
|
|
}
|
|
for (const tag of removeTags) {
|
|
this.application.changeItem(tag.uuid, (mutator) => {
|
|
mutator.removeItemAsRelationship(note);
|
|
})
|
|
}
|
|
const newRelationships: SNTag[] = [];
|
|
for (const title of strings) {
|
|
const existingRelationship = find(
|
|
currentTags,
|
|
{ title: title }
|
|
);
|
|
if (!existingRelationship) {
|
|
newRelationships.push(
|
|
await this.application.findOrCreateTag(title)
|
|
);
|
|
}
|
|
}
|
|
this.application.changeAndSaveItems(Uuids(newRelationships), (mutator) => {
|
|
mutator.addItemAsRelationship(note);
|
|
})
|
|
this.reloadTagsString();
|
|
}
|
|
|
|
onPanelResizeFinish(width: number, left: number, isMaxWidth: boolean) {
|
|
if (isMaxWidth) {
|
|
this.application.getPrefsService().setUserPrefValue(
|
|
WebPrefKey.EditorWidth,
|
|
null
|
|
);
|
|
} else {
|
|
if (width !== undefined && width !== null) {
|
|
this.application.getPrefsService().setUserPrefValue(
|
|
WebPrefKey.EditorWidth,
|
|
width
|
|
);
|
|
this.leftPanelPuppet!.setWidth!(width);
|
|
}
|
|
}
|
|
if (left !== undefined && left !== null) {
|
|
this.application.getPrefsService().setUserPrefValue(
|
|
WebPrefKey.EditorLeft,
|
|
left
|
|
);
|
|
this.rightPanelPuppet!.setLeft!(left);
|
|
}
|
|
this.application.getPrefsService().syncUserPreferences();
|
|
}
|
|
|
|
reloadPreferences() {
|
|
const monospaceEnabled = this.application.getPrefsService().getValue(
|
|
WebPrefKey.EditorMonospaceEnabled,
|
|
true
|
|
);
|
|
const spellcheck = this.application.getPrefsService().getValue(
|
|
WebPrefKey.EditorSpellcheck,
|
|
true
|
|
);
|
|
const marginResizersEnabled = this.application.getPrefsService().getValue(
|
|
WebPrefKey.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.getState().marginResizersEnabled &&
|
|
this.leftPanelPuppet!.ready &&
|
|
this.rightPanelPuppet!.ready
|
|
) {
|
|
const width = this.application.getPrefsService().getValue(
|
|
WebPrefKey.EditorWidth,
|
|
null
|
|
);
|
|
if (width != null) {
|
|
this.leftPanelPuppet!.setWidth!(width);
|
|
this.rightPanelPuppet!.setWidth!(width);
|
|
}
|
|
const left = this.application.getPrefsService().getValue(
|
|
WebPrefKey.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.getState().monospaceEnabled) {
|
|
if (this.getState().isDesktop) {
|
|
editor.style.fontFamily = Fonts.DesktopMonospaceFamily;
|
|
} else {
|
|
editor.style.fontFamily = Fonts.WebMonospaceFamily;
|
|
}
|
|
} else {
|
|
editor.style.fontFamily = Fonts.SansSerifFamily;
|
|
}
|
|
}
|
|
|
|
async toggleWebPrefKey(key: WebPrefKey) {
|
|
(this as any)[key] = !(this as any)[key];
|
|
this.application.getPrefsService().setUserPrefValue(
|
|
key,
|
|
(this as any)[key],
|
|
true
|
|
);
|
|
this.reloadFont();
|
|
|
|
if (key === WebPrefKey.EditorSpellcheck) {
|
|
/** Allows textarea to reload */
|
|
await this.setState({
|
|
noteReady: false
|
|
});
|
|
this.setState({
|
|
noteReady: true
|
|
});
|
|
this.reloadFont();
|
|
} else if (key === WebPrefKey.EditorResizersEnabled && (this as any)[key] === true) {
|
|
this.$timeout(() => {
|
|
this.leftPanelPuppet!.flash!();
|
|
this.rightPanelPuppet!.flash!();
|
|
});
|
|
}
|
|
}
|
|
|
|
/** @components */
|
|
|
|
registerComponentHandler() {
|
|
this.unregisterComponent = this.application.componentManager!.registerHandler({
|
|
identifier: 'editor',
|
|
areas: [
|
|
ComponentArea.NoteTags,
|
|
ComponentArea.EditorStack,
|
|
ComponentArea.Editor
|
|
],
|
|
activationHandler: (component) => {
|
|
if (component.area === 'note-tags') {
|
|
this.setState({
|
|
tagsComponent: component.active ? component : null
|
|
});
|
|
} else if (component.area === 'editor-editor') {
|
|
if (
|
|
component === this.getState().selectedEditor &&
|
|
!component.active
|
|
) {
|
|
this.setState({ selectedEditor: null });
|
|
}
|
|
else if (this.getState().selectedEditor) {
|
|
if (this.getState().selectedEditor!.active && this.getState().note) {
|
|
if (
|
|
component.isExplicitlyEnabledForItem(this.getState().note)
|
|
&& !this.getState().selectedEditor!.isExplicitlyEnabledForItem(this.getState().note)
|
|
) {
|
|
this.setState({ selectedEditor: component });
|
|
}
|
|
}
|
|
}
|
|
else if (this.getState().note) {
|
|
const enableable = (
|
|
component.isExplicitlyEnabledForItem(this.getState().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.getState().selectedEditor ||
|
|
component === this.getState().tagsComponent ||
|
|
this.getState().componentStack!.includes(component)
|
|
) {
|
|
return this.getState().note;
|
|
}
|
|
},
|
|
focusHandler: (component, focused) => {
|
|
if (component.isEditor() && focused) {
|
|
this.closeAllMenus();
|
|
}
|
|
},
|
|
actionHandler: (component, action, data) => {
|
|
if (action === ComponentAction.SetSize) {
|
|
const setSize = function (element: HTMLElement, size: { width: number, height: number }) {
|
|
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 === ComponentArea.NoteTags) {
|
|
const container = document.getElementById(
|
|
ElementIds.NoteTagsComponentContainer
|
|
);
|
|
setSize(container!, data);
|
|
}
|
|
}
|
|
}
|
|
else if (action === ComponentAction.AssociateItem) {
|
|
if (data.item.content_type === ContentType.Tag) {
|
|
const tag = this.application.findItem(data.item.uuid) as SNTag;
|
|
this.addTag(tag);
|
|
}
|
|
}
|
|
else if (action === ComponentAction.DeassociateItem) {
|
|
const tag = this.application.findItem(data.item.uuid) as SNTag;
|
|
this.removeTag(tag);
|
|
}
|
|
else if (action === ComponentAction.SaveItems) {
|
|
const includesNote = data.items.map((item: RawPayload) => {
|
|
return item.uuid;
|
|
}).includes(this.getState().note.uuid);
|
|
if (includesNote) {
|
|
this.showSavingStatus();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
reloadComponentStackArray() {
|
|
const components = this.application.componentManager!
|
|
.componentsForArea(ComponentArea.EditorStack)
|
|
.sort((a, b) => {
|
|
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
|
});
|
|
|
|
this.setState({
|
|
componentStack: components
|
|
});
|
|
}
|
|
|
|
reloadComponentContext() {
|
|
this.reloadComponentStackArray();
|
|
if (this.getState().note) {
|
|
for (const component of this.getState().componentStack!) {
|
|
if (component.active) {
|
|
this.application.componentManager!.setComponentHidden(
|
|
component,
|
|
!component.isExplicitlyEnabledForItem(this.getState().note)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.application.componentManager!.contextItemDidChangeInArea(ComponentArea.NoteTags);
|
|
this.application.componentManager!.contextItemDidChangeInArea(ComponentArea.EditorStack);
|
|
this.application.componentManager!.contextItemDidChangeInArea(ComponentArea.Editor);
|
|
}
|
|
|
|
toggleStackComponentForCurrentItem(component: SNComponent) {
|
|
const hidden = this.application.componentManager!.isComponentHidden(component);
|
|
if (hidden || !component.active) {
|
|
this.application.componentManager!.setComponentHidden(component, false);
|
|
this.associateComponentWithCurrentNote(component);
|
|
if (!component.active) {
|
|
this.application.componentManager!.activateComponent(component);
|
|
}
|
|
this.application.componentManager!.contextItemDidChangeInArea(ComponentArea.EditorStack);
|
|
} else {
|
|
this.application.componentManager!.setComponentHidden(component, true);
|
|
this.disassociateComponentWithCurrentNote(component);
|
|
}
|
|
}
|
|
|
|
disassociateComponentWithCurrentNote(component: SNComponent) {
|
|
const note = this.getState().note;
|
|
this.application.changeAndSaveItem(component.uuid, (m) => {
|
|
const mutator = m as ComponentMutator;
|
|
mutator.removeAssociatedItemId(note.uuid);
|
|
mutator.disassociateWithItem(note);
|
|
})
|
|
}
|
|
|
|
associateComponentWithCurrentNote(component: SNComponent) {
|
|
const note = this.getState().note;
|
|
this.application.changeAndSaveItem(component.uuid, (m) => {
|
|
const mutator = m as ComponentMutator;
|
|
mutator.removeDisassociatedItemId(note.uuid);
|
|
mutator.associateWithItem(note);
|
|
})
|
|
}
|
|
|
|
registerKeyboardShortcuts() {
|
|
this.removeAltKeyObserver = this.application.getKeyboardService().addKeyObserver({
|
|
modifiers: [
|
|
KeyboardModifier.Alt
|
|
],
|
|
onKeyDown: () => {
|
|
this.setState({
|
|
altKeyDown: true
|
|
});
|
|
},
|
|
onKeyUp: () => {
|
|
this.setState({
|
|
altKeyDown: false
|
|
});
|
|
}
|
|
});
|
|
|
|
this.removeTrashKeyObserver = this.application.getKeyboardService().addKeyObserver({
|
|
key: KeyboardKey.Backspace,
|
|
notElementIds: [
|
|
ElementIds.NoteTextEditor,
|
|
ElementIds.NoteTitleEditor
|
|
],
|
|
modifiers: [KeyboardModifier.Meta],
|
|
onKeyDown: () => {
|
|
this.deleteNote(false);
|
|
},
|
|
});
|
|
|
|
this.removeDeleteKeyObserver = this.application.getKeyboardService().addKeyObserver({
|
|
key: KeyboardKey.Backspace,
|
|
modifiers: [
|
|
KeyboardModifier.Meta,
|
|
KeyboardModifier.Shift,
|
|
KeyboardModifier.Alt
|
|
],
|
|
onKeyDown: (event) => {
|
|
event.preventDefault();
|
|
this.deleteNote(true);
|
|
},
|
|
});
|
|
}
|
|
|
|
onSystemEditorLoad() {
|
|
if (this.removeTabObserver) {
|
|
return;
|
|
}
|
|
/**
|
|
* 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)! as HTMLInputElement;
|
|
this.removeTabObserver = this.application.getKeyboardService().addKeyObserver({
|
|
element: editor,
|
|
key: KeyboardKey.Tab,
|
|
onKeyDown: (event) => {
|
|
if (this.getState().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;
|
|
}
|
|
this.editorValues.text = editor.value;
|
|
this.saveNote(true);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Handles when the editor is destroyed,
|
|
* (and not when our controller is destroyed.)
|
|
*/
|
|
angular.element(editor).one('$destroy', () => {
|
|
this.removeTabObserver();
|
|
this.removeTabObserver = undefined;
|
|
});
|
|
}
|
|
}
|
|
|
|
export class EditorPanel extends WebDirective {
|
|
constructor() {
|
|
super();
|
|
this.restrict = 'E';
|
|
this.scope = {
|
|
application: '='
|
|
};
|
|
this.template = template;
|
|
this.replace = true;
|
|
this.controller = EditorCtrl;
|
|
this.controllerAs = 'self';
|
|
this.bindToController = true;
|
|
}
|
|
}
|