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, source }) => { 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; } }