diff --git a/app/assets/javascripts/app/frontend/controllers/_base.js b/app/assets/javascripts/app/frontend/controllers/_base.js
index 3f802e41d..df67c62ca 100644
--- a/app/assets/javascripts/app/frontend/controllers/_base.js
+++ b/app/assets/javascripts/app/frontend/controllers/_base.js
@@ -1,5 +1,5 @@
class BaseCtrl {
- constructor($rootScope, $scope, syncManager, dbManager, analyticsManager) {
+ constructor($rootScope, $scope, syncManager, dbManager, analyticsManager, componentManager) {
dbManager.openDatabase(null, function(){
// new database, delete syncToken so that items can be refetched entirely from server
syncManager.clearSyncToken();
diff --git a/app/assets/javascripts/app/frontend/controllers/editor.js b/app/assets/javascripts/app/frontend/controllers/editor.js
index cacb679ae..426642ef7 100644
--- a/app/assets/javascripts/app/frontend/controllers/editor.js
+++ b/app/assets/javascripts/app/frontend/controllers/editor.js
@@ -17,7 +17,7 @@ angular.module('app.frontend')
link:function(scope, elem, attrs, ctrl) {
scope.$watch('ctrl.note', function(note, oldNote){
if(note) {
- ctrl.setNote(note, oldNote);
+ ctrl.noteDidChange(note, oldNote);
}
});
@@ -37,7 +37,10 @@ angular.module('app.frontend')
}
}
})
- .controller('EditorCtrl', function ($sce, $timeout, authManager, $rootScope, extensionManager, syncManager, modelManager, editorManager, themeManager) {
+ .controller('EditorCtrl', function ($sce, $timeout, authManager, $rootScope, extensionManager, syncManager, modelManager, editorManager, themeManager, componentManager) {
+
+ this.componentManager = componentManager;
+ this.componentStack = [];
$rootScope.$on("theme-changed", function(){
this.postThemeToExternalEditor();
@@ -51,10 +54,76 @@ angular.module('app.frontend')
this.loadTagsString();
}.bind(this));
+
+ componentManager.registerHandler({identifier: "editor", areas: ["note-tags", "editor-stack"], activationHandler: function(component){
+
+ if(!component.active) {
+ return;
+ }
+
+ if(component.area === "note-tags") {
+ this.tagsComponent = component;
+ } else {
+ // stack
+ if(!_.find(this.componentStack, component)) {
+ this.componentStack.push(component);
+ }
+ }
+
+ $timeout(function(){
+ var iframe = componentManager.iframeForComponent(component);
+ if(iframe) {
+ iframe.onload = function() {
+ componentManager.registerComponentWindow(component, iframe.contentWindow);
+ }.bind(this);
+ }
+ }.bind(this));
+
+ }.bind(this), contextRequestHandler: function(component){
+ return this.note;
+ }.bind(this), actionHandler: function(component, action, data){
+ if(action === "set-size") {
+ var setSize = function(element, size) {
+ var widthString = typeof size.width === 'string' ? size.width : `${data.width}px`;
+ var heightString = typeof size.height === 'string' ? size.height : `${data.height}px`;
+ element.setAttribute("style", `width:${widthString}; height:${heightString}; `);
+ }
+
+ if(data.type === "content") {
+ var iframe = componentManager.iframeForComponent(component);
+ var width = data.width;
+ var height = data.height;
+ iframe.width = width;
+ iframe.height = height;
+
+ setSize(iframe, data);
+ } else {
+ if(component.area == "note-tags") {
+ var container = document.getElementById("note-tags-component-container");
+ setSize(container, data);
+ } else {
+ var container = document.getElementById("component-" + component.uuid);
+ setSize(container, data);
+ }
+ }
+ }
+
+ else if(action === "associate-item") {
+ var tag = modelManager.findItem(data.item.uuid);
+ this.addTag(tag);
+ }
+
+ else if(action === "deassociate-item") {
+ var tag = modelManager.findItem(data.item.uuid);
+ this.removeTag(tag);
+ }
+
+ }.bind(this)});
+
window.addEventListener("message", function(event){
if(event.data.status) {
this.postNoteToExternalEditor();
- } else {
+ } else if(!event.data.api) {
// console.log("Received message", event.data);
var id = event.data.id;
var text = event.data.text;
@@ -75,8 +144,16 @@ angular.module('app.frontend')
}
}.bind(this), false);
+ this.noteDidChange = function(note, oldNote) {
+ this.setNote(note, oldNote);
+ for(var component of this.componentStack) {
+ componentManager.setEventFlowForComponent(component, component.isActiveForItem(this.note));
+ }
+ componentManager.contextItemDidChangeInArea("note-tags");
+ componentManager.contextItemDidChangeInArea("editor-stack");
+ }
+
this.setNote = function(note, oldNote) {
- this.noteReady = false;
var currentEditor = this.editor;
this.editor = null;
this.showExtensions = false;
@@ -90,6 +167,11 @@ angular.module('app.frontend')
}.bind(this)
var editor = this.editorForNote(note);
+ if(editor && !editor.systemEditor) {
+ // 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.noteReady = false;
+ }
if(editor) {
if(currentEditor !== editor) {
// switch after timeout, so that note data isnt posted to current editor
@@ -299,6 +381,27 @@ angular.module('app.frontend')
this.tagsString = string;
}
+ this.addTag = function(tag) {
+ var tags = this.note.tags;
+ var strings = tags.map(function(_tag){
+ return _tag.title;
+ })
+ strings.push(tag.title);
+ this.updateTags()(this.note, strings);
+ this.loadTagsString();
+ }
+
+ this.removeTag = function(tag) {
+ var tags = this.note.tags;
+ var strings = tags.map(function(_tag){
+ return _tag.title;
+ }).filter(function(_tag){
+ return _tag !== tag.title;
+ })
+ this.updateTags()(this.note, strings);
+ this.loadTagsString();
+ }
+
this.updateTagsFromTagsString = function($event) {
$event.target.blur();
@@ -314,4 +417,40 @@ angular.module('app.frontend')
this.updateTags()(this.note, tags);
}
+ /* Components */
+
+ let alertKey = "displayed-component-disable-alert";
+
+ this.disableComponent = function(component) {
+ componentManager.disableComponentForItem(component, this.note);
+ componentManager.setEventFlowForComponent(component, false);
+ if(!localStorage.getItem(alertKey)) {
+ alert("This component will be disabled for this note. You can re-enable this component in the 'Menu' of the editor pane.");
+ localStorage.setItem(alertKey, true);
+ }
+ }
+
+ this.hasDisabledComponents = function() {
+ for(var component of this.componentStack) {
+ if(component.ignoreEvents) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ this.restoreDisabledComponents = function() {
+ var relevantComponents = this.componentStack.filter(function(component){
+ return component.ignoreEvents;
+ })
+
+ componentManager.enableComponentsForItem(relevantComponents, this.note);
+
+ for(var component of relevantComponents) {
+ componentManager.setEventFlowForComponent(component, true);
+ componentManager.contextItemDidChangeInArea("editor-stack");
+ }
+ }
+
});
diff --git a/app/assets/javascripts/app/frontend/controllers/home.js b/app/assets/javascripts/app/frontend/controllers/home.js
index 603e2b57f..4f7dc8a93 100644
--- a/app/assets/javascripts/app/frontend/controllers/home.js
+++ b/app/assets/javascripts/app/frontend/controllers/home.js
@@ -37,6 +37,7 @@ angular.module('app.frontend')
themeManager.activateInitialTheme();
$scope.$apply();
+
syncManager.sync(null);
// refresh every 30s
setInterval(function () {
@@ -56,6 +57,7 @@ angular.module('app.frontend')
*/
$scope.updateTagsForNote = function(note, stringTags) {
+ console.log("Updating tags", stringTags);
var toRemove = [];
for(var tag of note.tags) {
if(stringTags.indexOf(tag.title) === -1) {
diff --git a/app/assets/javascripts/app/frontend/controllers/tags.js b/app/assets/javascripts/app/frontend/controllers/tags.js
index e6eefbec4..1cf690773 100644
--- a/app/assets/javascripts/app/frontend/controllers/tags.js
+++ b/app/assets/javascripts/app/frontend/controllers/tags.js
@@ -33,10 +33,39 @@ angular.module('app.frontend')
}
}
})
- .controller('TagsCtrl', function (modelManager, $timeout) {
+ .controller('TagsCtrl', function ($rootScope, modelManager, $timeout, componentManager) {
var initialLoad = true;
+ componentManager.registerHandler({identifier: "tags", areas: ["tags-list"], activationHandler: function(component){
+ this.component = component;
+
+ if(component.active) {
+ $timeout(function(){
+ var iframe = document.getElementById("tags-list-iframe");
+ iframe.onload = function() {
+ componentManager.registerComponentWindow(this.component, iframe.contentWindow);
+ }.bind(this);
+ }.bind(this));
+ }
+
+ }.bind(this), contextRequestHandler: function(component){
+ return null;
+ }.bind(this), actionHandler: function(component, action, data){
+
+ if(action === "select-item") {
+ var tag = modelManager.findItem(data.item.uuid);
+ if(tag) {
+ this.selectTag(tag);
+ }
+ }
+
+ else if(action === "clear-selection") {
+ this.selectTag(this.allTag);
+ }
+
+ }.bind(this)});
+
this.setAllTag = function(allTag) {
this.selectTag(this.allTag);
}
diff --git a/app/assets/javascripts/app/frontend/models/app/component.js b/app/assets/javascripts/app/frontend/models/app/component.js
new file mode 100644
index 000000000..66d6b5c6e
--- /dev/null
+++ b/app/assets/javascripts/app/frontend/models/app/component.js
@@ -0,0 +1,59 @@
+class Component extends Item {
+
+ constructor(json_obj) {
+ super(json_obj);
+
+ if(!this.componentData) {
+ this.componentData = {};
+ }
+
+ if(!this.disassociatedItemIds) {
+ this.disassociatedItemIds = [];
+ }
+ }
+
+ mapContentToLocalProperties(contentObject) {
+ super.mapContentToLocalProperties(contentObject)
+ this.url = contentObject.url;
+ this.name = contentObject.name;
+
+ // the location in the view this component is located in. Valid values are currently tags-list, note-tags, and editor-stack`
+ this.area = contentObject.area;
+
+ this.permissions = contentObject.permissions;
+ this.active = contentObject.active;
+
+ // custom data that a component can store in itself
+ this.componentData = contentObject.componentData || {};
+
+ // items that have requested a component to be disabled in its context
+ this.disassociatedItemIds = contentObject.disassociatedItemIds || [];
+ }
+
+ structureParams() {
+ var params = {
+ url: this.url,
+ name: this.name,
+ area: this.area,
+ permissions: this.permissions,
+ active: this.active,
+ componentData: this.componentData,
+ disassociatedItemIds: this.disassociatedItemIds
+ };
+
+ _.merge(params, super.structureParams());
+ return params;
+ }
+
+ toJSON() {
+ return {uuid: this.uuid}
+ }
+
+ get content_type() {
+ return "SN|Component";
+ }
+
+ isActiveForItem(item) {
+ return this.disassociatedItemIds.indexOf(item.uuid) === -1;
+ }
+}
diff --git a/app/assets/javascripts/app/frontend/models/local/itemParams.js b/app/assets/javascripts/app/frontend/models/local/itemParams.js
index 790c4c042..61c8b814a 100644
--- a/app/assets/javascripts/app/frontend/models/local/itemParams.js
+++ b/app/assets/javascripts/app/frontend/models/local/itemParams.js
@@ -22,30 +22,26 @@ class ItemParams {
}
paramsForSync() {
- return this.__params(null, false);
+ return this.__params();
}
__params() {
let encryptionVersion = "001";
- var itemCopy = _.cloneDeep(this.item);
-
console.assert(!this.item.dummy, "Item is dummy, should not have gotten here.", this.item.dummy)
var params = {uuid: this.item.uuid, content_type: this.item.content_type, deleted: this.item.deleted, created_at: this.item.created_at};
if(this.keys && !this.item.doNotEncrypt()) {
- EncryptionHelper.encryptItem(itemCopy, this.keys, encryptionVersion);
- params.content = itemCopy.content;
- params.enc_item_key = itemCopy.enc_item_key;
- if(encryptionVersion === "001") {
- params.auth_hash = itemCopy.auth_hash;
- } else {
+ var encryptedParams = EncryptionHelper.encryptItem(this.item, this.keys, encryptionVersion);
+ _.merge(params, encryptedParams);
+
+ if(encryptionVersion !== "001") {
params.auth_hash = null;
}
}
else {
- params.content = this.forExportFile ? itemCopy.createContentJSONFromProperties() : "000" + Neeto.crypto.base64(JSON.stringify(itemCopy.createContentJSONFromProperties()));
+ params.content = this.forExportFile ? this.item.createContentJSONFromProperties() : "000" + Neeto.crypto.base64(JSON.stringify(this.item.createContentJSONFromProperties()));
if(!this.forExportFile) {
params.enc_item_key = null;
params.auth_hash = null;
diff --git a/app/assets/javascripts/app/services/componentManager.js b/app/assets/javascripts/app/services/componentManager.js
new file mode 100644
index 000000000..672880623
--- /dev/null
+++ b/app/assets/javascripts/app/services/componentManager.js
@@ -0,0 +1,499 @@
+class ComponentManager {
+
+ constructor($rootScope, modelManager, syncManager, themeManager, $timeout, $compile) {
+ this.$compile = $compile;
+ this.$rootScope = $rootScope;
+ this.modelManager = modelManager;
+ this.syncManager = syncManager;
+ this.themeManager = themeManager;
+ this.timeout = $timeout;
+ this.streamObservers = [];
+ this.contextStreamObservers = [];
+ this.activeComponents = [];
+
+ this.permissionDialogs = [];
+
+ this.handlers = [];
+
+ $rootScope.$on("theme-changed", function(){
+ this.postThemeToComponents();
+ }.bind(this))
+
+ window.addEventListener("message", function(event){
+ if(this.loggingEnabled) {
+ console.log("Web app: received message", event);
+ }
+ this.handleMessage(this.componentForSessionKey(event.data.sessionKey), event.data);
+ }.bind(this), false);
+
+ this.modelManager.addItemSyncObserver("component-manager", "*", function(items) {
+
+ var syncedComponents = items.filter(function(item){return item.content_type === "SN|Component" });
+ for(var component of syncedComponents) {
+ var activeComponent = _.find(this.activeComponents, {uuid: component.uuid});
+ if(component.active && !activeComponent) {
+ this.activateComponent(component);
+ } else if(!component.active && activeComponent) {
+ this.deactivateComponent(component);
+ }
+ }
+
+ for(let observer of this.streamObservers) {
+ var relevantItems = items.filter(function(item){
+ return observer.contentTypes.indexOf(item.content_type) !== -1;
+ })
+
+ var requiredPermissions = [
+ {
+ name: "stream-items",
+ content_types: observer.contentTypes.sort()
+ }
+ ];
+
+ this.runWithPermissions(observer.component, requiredPermissions, observer.originalMessage.permissions, function(){
+ this.sendItemsInReply(observer.component, relevantItems, observer.originalMessage);
+ }.bind(this))
+ }
+
+ var requiredContextPermissions = [
+ {
+ name: "stream-context-item"
+ }
+ ];
+ for(let observer of this.contextStreamObservers) {
+ this.runWithPermissions(observer.component, requiredContextPermissions, observer.originalMessage.permissions, function(){
+ for(let handler of this.handlers) {
+ if(handler.areas.includes(observer.component.area) === false) {
+ continue;
+ }
+ var itemInContext = handler.contextRequestHandler(observer.component);
+ if(itemInContext) {
+ var matchingItem = _.find(items, {uuid: itemInContext.uuid});
+ if(matchingItem) {
+ this.sendContextItemInReply(observer.component, matchingItem, observer.originalMessage);
+ }
+ }
+ }
+ }.bind(this))
+ }
+
+ }.bind(this))
+ }
+
+ postThemeToComponents() {
+ for(var component of this.components) {
+ if(!component.active || !component.window) {
+ continue;
+ }
+ this.postThemeToComponent(component);
+ }
+ }
+
+ postThemeToComponent(component) {
+ var data = {
+ themes: [this.themeManager.currentTheme ? this.themeManager.currentTheme.url : null]
+ }
+
+ this.sendMessageToComponent(component, {action: "themes", data: data})
+ }
+
+ contextItemDidChangeInArea(area) {
+ for(let handler of this.handlers) {
+ if(handler.areas.includes(area) === false) {
+ continue;
+ }
+ var observers = this.contextStreamObservers.filter(function(observer){
+ return observer.component.area === area;
+ })
+
+ for(let observer of observers) {
+ var itemInContext = handler.contextRequestHandler(observer.component);
+ this.sendContextItemInReply(observer.component, itemInContext, observer.originalMessage);
+ }
+ }
+ }
+
+ jsonForItem(item) {
+ var params = {uuid: item.uuid, content_type: item.content_type, created_at: item.created_at, updated_at: item.updated_at, deleted: item.deleted};
+ params.content = item.createContentJSONFromProperties();
+ return params;
+ }
+
+ sendItemsInReply(component, items, message) {
+ var response = {items: {}};
+ var mapped = items.map(function(item) {
+ return this.jsonForItem(item);
+ }.bind(this));
+
+ response.items = mapped;
+ this.replyToMessage(component, message, response);
+ }
+
+ sendContextItemInReply(component, item, originalMessage) {
+ var response = {item: this.jsonForItem(item)};
+ this.replyToMessage(component, originalMessage, response);
+ }
+
+ get components() {
+ return this.modelManager.itemsForContentType("SN|Component");
+ }
+
+ componentsForStack(stack) {
+ return this.components.filter(function(component){
+ return component.area === stack;
+ })
+ }
+
+ componentForSessionKey(key) {
+ return _.find(this.components, {sessionKey: key});
+ }
+
+ handleMessage(component, message) {
+
+ if(!component) {
+ if(this.loggingEnabled) {
+ console.log("Component not defined, returning");
+ }
+ return;
+ }
+
+ /**
+ Possible Messages:
+ set-size
+ stream-items
+ stream-context-item
+ save-items
+ select-item
+ associate-item
+ deassociate-item
+ clear-selection
+ create-item
+ delete-items
+ set-component-data
+ */
+
+ if(message.action === "stream-items") {
+ this.handleStreamItemsMessage(component, message);
+ }
+
+ else if(message.action === "stream-context-item") {
+ this.handleStreamContextItemMessage(component, message);
+ }
+
+ else if(message.action === "set-component-data") {
+ component.componentData = message.data.componentData;
+ component.setDirty(true);
+ this.syncManager.sync();
+ }
+
+ else if(message.action === "delete-items") {
+ var items = message.data.items;
+ var noun = items.length == 1 ? "item" : "items";
+ if(confirm(`Are you sure you want to delete ${items.length} ${noun}?`)) {
+ for(var item of items) {
+ var model = this.modelManager.findItem(item.uuid);
+ this.modelManager.setItemToBeDeleted(model);
+ }
+
+ this.syncManager.sync();
+ }
+ }
+
+ else if(message.action === "create-item") {
+ var item = this.modelManager.createItem(message.data.item);
+ this.modelManager.addItem(item);
+ item.setDirty(true);
+ this.syncManager.sync();
+ this.replyToMessage(component, message, {item: this.jsonForItem(item)})
+ }
+
+ else if(message.action === "save-items") {
+ var responseItems = message.data.items;
+ var localItems = this.modelManager.mapResponseItemsToLocalModels(responseItems);
+
+ for(var item of localItems) {
+ var responseItem = _.find(responseItems, {uuid: item.uuid});
+ _.merge(item.content, responseItem.content);
+ item.setDirty(true);
+ }
+ this.syncManager.sync();
+ }
+
+ for(let handler of this.handlers) {
+ if(handler.areas.includes(component.area)) {
+ this.timeout(function(){
+ handler.actionHandler(component, message.action, message.data);
+ })
+ }
+ }
+ }
+
+ handleStreamItemsMessage(component, message) {
+ var requiredPermissions = [
+ {
+ name: "stream-items",
+ content_types: message.data.content_types.sort()
+ }
+ ];
+
+ this.runWithPermissions(component, requiredPermissions, message.permissions, function(){
+ if(!_.find(this.streamObservers, {identifier: component.url})) {
+ // for pushing laster as changes come in
+ this.streamObservers.push({
+ identifier: component.url,
+ component: component,
+ originalMessage: message,
+ contentTypes: message.data.content_types
+ })
+ }
+
+
+ // push immediately now
+ var items = [];
+ for(var contentType of message.data.content_types) {
+ items = items.concat(this.modelManager.itemsForContentType(contentType));
+ }
+ this.sendItemsInReply(component, items, message);
+ }.bind(this));
+ }
+
+ handleStreamContextItemMessage(component, message) {
+
+ var requiredPermissions = [
+ {
+ name: "stream-context-item"
+ }
+ ];
+
+ this.runWithPermissions(component, requiredPermissions, message.permissions, function(){
+ if(!_.find(this.contextStreamObservers, {identifier: component.url})) {
+ // for pushing laster as changes come in
+ this.contextStreamObservers.push({
+ identifier: component.url,
+ component: component,
+ originalMessage: message
+ })
+ }
+
+ // push immediately now
+ for(let handler of this.handlers) {
+ if(handler.areas.includes(component.area) === false) {
+ continue;
+ }
+ var itemInContext = handler.contextRequestHandler(component);
+ this.sendContextItemInReply(component, itemInContext, message);
+ }
+ }.bind(this))
+ }
+
+ runWithPermissions(component, requiredPermissions, requestedPermissions, runFunction) {
+
+ var acquiredPermissions = component.permissions;
+
+ var requestedMatchesRequired = true;
+
+ for(var required of requiredPermissions) {
+ var matching = _.find(requestedPermissions, required);
+ if(!matching) {
+ requestedMatchesRequired = false;
+ break;
+ }
+ }
+
+ if(!requestedMatchesRequired) {
+ // Error with Component permissions request
+ console.error("You are requesting permissions", requestedPermissions, "when you need to be requesting", requiredPermissions, ". Component:", component);
+ return;
+ }
+
+ if(!component.permissions) {
+ component.permissions = [];
+ }
+
+ var acquiredMatchesRequested = angular.toJson(component.permissions.sort()) === angular.toJson(requestedPermissions.sort());
+
+ if(!acquiredMatchesRequested) {
+ this.promptForPermissions(component, requestedPermissions, function(approved){
+ if(approved) {
+ runFunction();
+ }
+ });
+ } else {
+ runFunction();
+ }
+ }
+
+ promptForPermissions(component, requestedPermissions, callback) {
+ // since these calls are asyncronous, multiple dialogs may be requested at the same time. We only want to present one and trigger all callbacks based on one modal result
+ var existingDialog = _.find(this.permissionDialogs, {component: component});
+
+ component.trusted = component.url.startsWith("https://standardnotes.org") || component.url.startsWith("https://extensions.standardnotes.org");
+ var scope = this.$rootScope.$new(true);
+ scope.component = component;
+ scope.permissions = requestedPermissions;
+ scope.actionBlock = callback;
+
+ scope.callback = function(approved) {
+ if(approved) {
+ component.permissions = requestedPermissions;
+ component.setDirty(true);
+ this.syncManager.sync();
+ }
+
+ for(var existing of this.permissionDialogs) {
+ if(existing.component === component && existing.actionBlock) {
+ existing.actionBlock(approved);
+ }
+ }
+
+ this.permissionDialogs = this.permissionDialogs.filter(function(dialog){
+ return dialog.component !== component;
+ })
+
+ }.bind(this);
+
+ this.permissionDialogs.push(scope);
+
+ if(!existingDialog) {
+ var el = this.$compile( "" )(scope);
+ angular.element(document.body).append(el);
+ } else {
+ console.log("Existing dialog, not presenting.");
+ }
+ }
+
+ replyToMessage(component, originalMessage, replyData) {
+ var reply = {
+ action: "reply",
+ original: originalMessage,
+ data: replyData
+ }
+
+ this.sendMessageToComponent(component, reply);
+ }
+
+ sendMessageToComponent(component, message) {
+ if(component.ignoreEvents && message.action !== "component-registered") {
+ if(this.loggingEnabled) {
+ console.log("Component disabled for current item, not sending any messages.");
+ }
+ return;
+ }
+ component.window.postMessage(message, "*");
+ }
+
+ installComponent(url) {
+ var name = getParameterByName("name", url);
+ var area = getParameterByName("area", url);
+ var component = this.modelManager.createItem({
+ content_type: "SN|Component",
+ url: url,
+ name: name,
+ area: area
+ })
+
+ this.modelManager.addItem(component);
+ component.setDirty(true);
+ this.syncManager.sync();
+ }
+
+ activateComponent(component) {
+ var didChange = component.active != true;
+
+ component.active = true;
+ for(var handler of this.handlers) {
+ if(handler.areas.includes(component.area)) {
+ handler.activationHandler(component);
+ }
+ }
+
+ if(didChange) {
+ component.setDirty(true);
+ this.syncManager.sync();
+ }
+
+ this.activeComponents.push(component);
+ }
+
+ registerHandler(handler) {
+ this.handlers.push(handler);
+ }
+
+ // Called by other views when the iframe is ready
+ registerComponentWindow(component, componentWindow) {
+ component.window = componentWindow;
+ component.sessionKey = Neeto.crypto.generateUUID();
+ this.sendMessageToComponent(component, {action: "component-registered", sessionKey: component.sessionKey, componentData: component.componentData});
+ this.postThemeToComponent(component);
+ }
+
+ deactivateComponent(component) {
+ var didChange = component.active != false;
+ component.active = false;
+ component.sessionKey = null;
+
+ for(var handler of this.handlers) {
+ if(handler.areas.includes(component.area)) {
+ handler.activationHandler(component);
+ }
+ }
+
+ if(didChange) {
+ component.setDirty(true);
+ this.syncManager.sync();
+ }
+
+ _.pull(this.activeComponents, component);
+
+ this.streamObservers = this.streamObservers.filter(function(o){
+ return o.component !== component;
+ })
+
+ this.contextStreamObservers = this.contextStreamObservers.filter(function(o){
+ return o.component !== component;
+ })
+ }
+
+ deleteComponent(component) {
+ this.modelManager.setItemToBeDeleted(component);
+ this.syncManager.sync();
+ }
+
+ isComponentActive(component) {
+ return component.active;
+ }
+
+ disableComponentForItem(component, item) {
+ if(component.disassociatedItemIds.indexOf(item.uuid) !== -1) {
+ return;
+ }
+ component.disassociatedItemIds.push(item.uuid);
+ component.setDirty(true);
+ this.syncManager.sync();
+ }
+
+ enableComponentsForItem(components, item) {
+ for(var component of components) {
+ _.pull(component.disassociatedItemIds, item.uuid);
+ component.setDirty(true);
+ }
+ this.syncManager.sync();
+ }
+
+ setEventFlowForComponent(component, on) {
+ component.ignoreEvents = !on;
+ }
+
+ iframeForComponent(component) {
+ for(var frame of document.getElementsByTagName("iframe")) {
+ var componentId = frame.dataset.componentId;
+ if(componentId === component.uuid) {
+ return frame;
+ }
+ }
+ }
+
+
+}
+
+angular.module('app.frontend').service('componentManager', ComponentManager);
diff --git a/app/assets/javascripts/app/services/directives/functional/permissionsModal.js b/app/assets/javascripts/app/services/directives/functional/permissionsModal.js
new file mode 100644
index 000000000..ce72dfbdb
--- /dev/null
+++ b/app/assets/javascripts/app/services/directives/functional/permissionsModal.js
@@ -0,0 +1,69 @@
+class PermissionsModal {
+
+ constructor() {
+ this.restrict = "E";
+ this.templateUrl = "frontend/directives/permissions-modal.html";
+ this.scope = {
+ show: "=",
+ component: "=",
+ permissions: "=",
+ callback: "="
+ };
+ }
+
+ link($scope, el, attrs) {
+
+ $scope.dismiss = function() {
+ el.remove();
+ }
+
+ $scope.accept = function() {
+ $scope.callback(true);
+ $scope.dismiss();
+ }
+
+ $scope.deny = function() {
+ $scope.callback(false);
+ $scope.dismiss();
+ }
+
+ $scope.formattedPermissions = $scope.permissions.map(function(permission){
+ if(permission.name === "stream-items") {
+ var title = "Access to ";
+ var types = permission.content_types.map(function(type){
+ return (type + "s").toLowerCase();
+ })
+ var typesString = "";
+ var separator = ", ";
+
+ for(var i = 0;i < types.length;i++) {
+ var type = types[i];
+ if(i == 0) {
+ // first element
+ typesString = typesString + type;
+ } else if(i == types.length - 1) {
+ // last element
+ if(types.length > 2) {
+ typesString += separator + "and " + typesString;
+ } else if(types.length == 2) {
+ typesString = typesString + " and " + type;
+ }
+ } else {
+ typesString += separator + type;
+ }
+ }
+
+ return title + typesString;
+ } else if(permission.name === "stream-context-item") {
+ var mapping = {
+ "editor-stack" : "working note",
+ "note-tags" : "working note"
+ }
+ return "Access to " + mapping[$scope.component.area];
+ }
+ })
+ }
+
+}
+
+angular.module('app.frontend').directive('permissionsModal', () => new PermissionsModal);
diff --git a/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js b/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js
index 0daa18a82..e48ddf9e4 100644
--- a/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js
+++ b/app/assets/javascripts/app/services/directives/views/globalExtensionsMenu.js
@@ -7,7 +7,7 @@ class GlobalExtensionsMenu {
};
}
- controller($scope, extensionManager, syncManager, modelManager, themeManager, editorManager) {
+ controller($scope, extensionManager, syncManager, modelManager, themeManager, editorManager, componentManager) {
'ngInject';
$scope.formData = {};
@@ -15,6 +15,7 @@ class GlobalExtensionsMenu {
$scope.extensionManager = extensionManager;
$scope.themeManager = themeManager;
$scope.editorManager = editorManager;
+ $scope.componentManager = componentManager;
$scope.selectedAction = function(action, extension) {
extensionManager.executeAction(action, extension, null, function(response){
@@ -69,6 +70,21 @@ class GlobalExtensionsMenu {
editorManager.removeDefaultEditor(editor);
}
+
+ // Components
+
+ $scope.revokePermissions = function(component) {
+ component.permissions = [];
+ component.setDirty(true);
+ syncManager.sync();
+ }
+
+ $scope.deleteComponent = function(component) {
+ if(confirm("Are you sure you want to delete this component?")) {
+ componentManager.deleteComponent(component);
+ }
+ }
+
// Installation
$scope.submitInstallLink = function() {
@@ -93,7 +109,11 @@ class GlobalExtensionsMenu {
$scope.handleEditorLink(link, completion);
} else if(link.indexOf(".css") != -1 || type == "theme") {
$scope.handleThemeLink(link, completion);
- } else {
+ } else if(type == "component") {
+ $scope.handleComponentLink(link, completion);
+ }
+
+ else {
$scope.handleActionLink(link, completion);
}
}
@@ -112,6 +132,11 @@ class GlobalExtensionsMenu {
completion();
}
+ $scope.handleComponentLink = function(link, completion) {
+ componentManager.installComponent(link);
+ completion();
+ }
+
$scope.handleActionLink = function(link, completion) {
if(link) {
extensionManager.addExtension(link, function(response){
diff --git a/app/assets/javascripts/app/services/helpers/encryptionHelper.js b/app/assets/javascripts/app/services/helpers/encryptionHelper.js
index 24de8ec7a..88fdb9225 100644
--- a/app/assets/javascripts/app/services/helpers/encryptionHelper.js
+++ b/app/assets/javascripts/app/services/helpers/encryptionHelper.js
@@ -17,13 +17,14 @@ class EncryptionHelper {
}
static encryptItem(item, keys, version) {
+ var params = {};
// encrypt item key
var item_key = Neeto.crypto.generateRandomEncryptionKey();
if(version === "001") {
// legacy
- item.enc_item_key = Neeto.crypto.encryptText(item_key, keys.mk, null);
+ params.enc_item_key = Neeto.crypto.encryptText(item_key, keys.mk, null);
} else {
- item.enc_item_key = this._private_encryptString(item_key, keys.encryptionKey, keys.authKey, version);
+ params.enc_item_key = this._private_encryptString(item_key, keys.encryptionKey, keys.authKey, version);
}
// encrypt content
@@ -32,10 +33,11 @@ class EncryptionHelper {
var ciphertext = this._private_encryptString(JSON.stringify(item.createContentJSONFromProperties()), ek, ak, version);
if(version === "001") {
var authHash = Neeto.crypto.hmac256(ciphertext, ak);
- item.auth_hash = authHash;
+ params.auth_hash = authHash;
}
- item.content = ciphertext;
+ params.content = ciphertext;
+ return params;
}
static encryptionComponentsFromString(string, baseKey, encryptionKey, authKey) {
diff --git a/app/assets/javascripts/app/services/modelManager.js b/app/assets/javascripts/app/services/modelManager.js
index a012cc205..6ddc1ac4f 100644
--- a/app/assets/javascripts/app/services/modelManager.js
+++ b/app/assets/javascripts/app/services/modelManager.js
@@ -8,7 +8,7 @@ class ModelManager {
this.itemChangeObservers = [];
this.items = [];
this._extensions = [];
- this.acceptableContentTypes = ["Note", "Tag", "Extension", "SN|Editor", "SN|Theme"];
+ this.acceptableContentTypes = ["Note", "Tag", "Extension", "SN|Editor", "SN|Theme", "SN|Component"];
}
get allItems() {
@@ -71,29 +71,34 @@ class ModelManager {
}
mapResponseItemsToLocalModelsOmittingFields(items, omitFields) {
- var models = [], processedObjects = [];
+ var models = [], processedObjects = [], allModels = [];
// first loop should add and process items
for (var json_obj of items) {
json_obj = _.omit(json_obj, omitFields || [])
var item = this.findItem(json_obj["uuid"]);
+
+ _.omit(json_obj, omitFields);
+
+ if(item) {
+ item.updateFromJSON(json_obj);
+ }
+
if(json_obj["deleted"] == true || !_.includes(this.acceptableContentTypes, json_obj["content_type"])) {
if(item) {
+ allModels.push(item);
this.removeItemLocally(item)
}
continue;
}
- _.omit(json_obj, omitFields);
-
if(!item) {
item = this.createItem(json_obj);
- } else {
- item.updateFromJSON(json_obj);
}
this.addItem(item);
+ allModels.push(item);
models.push(item);
processedObjects.push(json_obj);
}
@@ -106,14 +111,14 @@ class ModelManager {
}
}
- this.notifySyncObserversOfModels(models);
+ this.notifySyncObserversOfModels(allModels);
return models;
}
notifySyncObserversOfModels(models) {
for(var observer of this.itemSyncObservers) {
- var relevantItems = models.filter(function(item){return item.content_type == observer.type});
+ var relevantItems = models.filter(function(item){return item.content_type == observer.type || observer.type == "*"});
if(relevantItems.length > 0) {
observer.callback(relevantItems);
}
@@ -144,7 +149,11 @@ class ModelManager {
item = new Editor(json_obj);
} else if(json_obj.content_type == "SN|Theme") {
item = new Theme(json_obj);
- } else {
+ } else if(json_obj.content_type == "SN|Component") {
+ item = new Component(json_obj);
+ }
+
+ else {
item = new Item(json_obj);
}
diff --git a/app/assets/stylesheets/app/_editor.scss b/app/assets/stylesheets/app/_editor.scss
index 12d2a1843..ca1c82d6f 100644
--- a/app/assets/stylesheets/app/_editor.scss
+++ b/app/assets/stylesheets/app/_editor.scss
@@ -25,14 +25,19 @@ $heading-height: 75px;
#editor-title-bar {
width: 100%;
padding: 20px;
+ padding-bottom: 10px;
background-color: white;
border-bottom: none;
+ z-index: 100;
- height: $heading-height;
- min-height: $heading-height;
+ height: auto;
+ // height: $heading-height;
+ // min-height: $heading-height;
padding-right: 10px;
+ overflow: visible;
+
&.fullscreen {
position: relative;
}
@@ -76,7 +81,19 @@ $heading-height: 75px;
.editor-tags {
clear: left;
width: 100%;
- height: 25px;
+ // height: 25px;
+ overflow: visible;
+ position: relative;
+
+ #note-tags-component-container {
+ height: 50px;
+ #note-tags-iframe {
+ height: 50px;
+ width: 100%;
+ position: absolute;
+ }
+ }
+
.tags-input {
background-color: transparent;
@@ -125,3 +142,46 @@ $heading-height: 75px;
}
}
}
+
+#editor-pane-component-stack {
+ width: 100%;
+
+ .component {
+ height: 50px;
+ width: 100%;
+ position: relative;
+ &:not(:last-child) {
+ border-bottom: 1px solid $bg-color;
+ }
+
+ &:first-child {
+ border-top: 1px solid $bg-color;
+ }
+
+ .exit-button {
+ width: 15px;
+ height: 100%;
+ position: absolute;
+ right: 0;
+ background-color: transparent;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ color: rgba(black, 0.7);
+ text-align: center;
+ padding-left: 2px;
+
+ .content {
+
+ }
+
+ &:hover {
+ background-color: rgba(gray, 0.3);
+ }
+ }
+
+ iframe {
+ width: 100%;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/app/_permissions-modal.scss b/app/assets/stylesheets/app/_permissions-modal.scss
new file mode 100644
index 000000000..6da432ff5
--- /dev/null
+++ b/app/assets/stylesheets/app/_permissions-modal.scss
@@ -0,0 +1,89 @@
+.permissions-modal {
+ position: fixed;
+ margin-left: auto;
+ margin-right: auto;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ z-index: 10000;
+ width: 100vw;
+ height: 100vh;
+ background-color: rgba(gray, 0.3);
+ color: black;
+ font-size: 16px;
+ display: flex;
+ align-items: center;
+
+ .background {
+ position: absolute;
+ z-index: -1;
+ width: 100%;
+ height: 100%;
+ }
+
+ .content {
+ box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
+ background-color: white;
+ width: 700px;
+ // height: 500px;
+ margin: auto;
+ padding: 10px 30px;
+ padding-bottom: 30px;
+ // position: absolute;
+ // top: 0; left: 0; bottom: 0; right: 0;
+ overflow-y: scroll;
+
+ p {
+ margin-bottom: 8px;
+ margin-top: 0;
+ }
+
+ h3 {
+ margin-bottom: 0;
+ }
+
+ h4 {
+ margin-bottom: 6px;
+ }
+ }
+
+ .learn-more {
+ margin-top: 20px;
+ line-height: 1.3;
+ }
+
+ .status {
+ color: orange;
+ &.trusted {
+ color: $blue-color;
+ }
+ }
+
+ .buttons {
+ margin-top: 35px;
+ }
+
+ button.standard {
+ border-radius: 1px;
+ font-weight: bold;
+ padding: 6px 20px;
+ display: inline-block;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &.blue {
+ background-color: $blue-color;
+ color: white;
+ }
+
+ &.white {
+ color: black;
+ background-color: white;
+ border: 1px solid gray;
+ }
+ }
+
+}
diff --git a/app/assets/stylesheets/app/_tags.scss b/app/assets/stylesheets/app/_tags.scss
index 953fc0881..04f69992b 100644
--- a/app/assets/stylesheets/app/_tags.scss
+++ b/app/assets/stylesheets/app/_tags.scss
@@ -2,6 +2,7 @@
flex: 1 10%;
max-width: 180px;
min-width: 100px;
+ width: 180px;
$tags-title-bar-height: 55px;
diff --git a/app/assets/stylesheets/frontend.css.scss b/app/assets/stylesheets/frontend.css.scss
index 81f725378..4f084485d 100644
--- a/app/assets/stylesheets/frontend.css.scss
+++ b/app/assets/stylesheets/frontend.css.scss
@@ -9,3 +9,4 @@ $dark-gray: #2e2e2e;
@import "app/editor";
@import "app/extensions";
@import "app/menus";
+@import "app/permissions-modal";
diff --git a/app/assets/templates/frontend/directives/global-extensions-menu.html.haml b/app/assets/templates/frontend/directives/global-extensions-menu.html.haml
index 759672467..a194396a8 100644
--- a/app/assets/templates/frontend/directives/global-extensions-menu.html.haml
+++ b/app/assets/templates/frontend/directives/global-extensions-menu.html.haml
@@ -100,6 +100,23 @@
%p.wrap.selectable.small{"ng-if" => "extension.showURL"} {{extension.url}}
%a.block.mt-5{"ng-click" => "deleteActionExtension(extension); $event.stopPropagation();"} Remove extension
+ %div{"ng-if" => "componentManager.components.length > 0"}
+ .container.no-bottom.section-margin
+ %h2 Components
+ %ul
+ %li{"ng-repeat" => "component in componentManager.components", "ng-click" => "component.showDetails = !component.showDetails"}
+ .container
+ %h3 {{component.name}}
+ %a{"ng-if" => "!componentManager.isComponentActive(component)", "ng-click" => "componentManager.activateComponent(component); $event.stopPropagation();"} Activate
+ %a{"ng-if" => "componentManager.isComponentActive(component)", "ng-click" => "componentManager.deactivateComponent(component); $event.stopPropagation();"} Deactivate
+ %div{"ng-if" => "component.showDetails"}
+ .link-group
+ %a.red{"ng-click" => "deleteComponent(component); $event.stopPropagation();"} Delete
+ %a{"ng-click" => "component.showLink = !component.showLink; $event.stopPropagation();"} Show Link
+ %a{"ng-if" => "component.permissions.length", "ng-click" => "revokePermissions(component); $event.stopPropagation();"} Revoke Permissions
+ %p.small.selectable.wrap{"ng-if" => "component.showLink"}
+ {{component.url}}
+
.container.section-margin
%h2.blue Install
%p.faded Enter an install link
diff --git a/app/assets/templates/frontend/directives/permissions-modal.html.haml b/app/assets/templates/frontend/directives/permissions-modal.html.haml
new file mode 100644
index 000000000..43262ea21
--- /dev/null
+++ b/app/assets/templates/frontend/directives/permissions-modal.html.haml
@@ -0,0 +1,25 @@
+.background{"ng-click" => "dismiss()"}
+
+.content
+ %h3 The following extension has requested these permissions:
+
+ %h4 Extension
+ %p Name: {{component.name}}
+ %p.wrap URL: {{component.url}}
+
+ %h4 Permissions
+ .permission{"ng-repeat" => "permission in formattedPermissions"}
+ %p {{permission}}
+
+ %h4 Status
+ %p.status{"ng-class" => "{'trusted' : component.trusted}"} {{component.trusted ? 'Trusted' : 'Untrusted'}}
+
+ .learn-more
+ %h4 Details
+ %p
+ Extensions use an offline messaging system to communicate. With Trusted extensions, data is never sent remotely without your consent. Learn more about extension permissions at
+ %a{"href" => "https://standardnotes.org/permissions", "target" => "_blank"} https://standardnotes.org/permissions.
+
+ .buttons
+ %button.standard.white{"ng-click" => "deny()"} Deny
+ %button.standard.blue{"ng-click" => "accept()"} Accept
diff --git a/app/assets/templates/frontend/editor.html.haml b/app/assets/templates/frontend/editor.html.haml
index 8e517d6bf..f4fba1e34 100644
--- a/app/assets/templates/frontend/editor.html.haml
+++ b/app/assets/templates/frontend/editor.html.haml
@@ -8,7 +8,9 @@
#save-status{"ng-class" => "{'red bold': ctrl.saveError, 'orange bold': ctrl.syncTakingTooLong}", "ng-bind-html" => "ctrl.noteStatus"}
.editor-tags
- %input.tags-input{"type" => "text", "ng-keyup" => "$event.keyCode == 13 && $event.target.blur();",
+ #note-tags-component-container{"ng-if" => "ctrl.tagsComponent && ctrl.tagsComponent.active"}
+ %iframe#note-tags-iframe{"ng-src" => "{{ctrl.tagsComponent.url | trusted}}", "frameBorder" => "0", "sandbox" => "allow-scripts", "data-component-id" => "{{ctrl.tagsComponent.uuid}}"}
+ %input.tags-input{"ng-if" => "!(ctrl.tagsComponent && ctrl.tagsComponent.active)", "type" => "text", "ng-keyup" => "$event.keyCode == 13 && $event.target.blur();",
"ng-model" => "ctrl.tagsString", "placeholder" => "#tags", "ng-blur" => "ctrl.updateTagsFromTagsString($event, ctrl.tagsString)"}
%ul.section-menu-bar{"ng-if" => "ctrl.note"}
%li{"ng-class" => "{'selected' : ctrl.showMenu}", "click-outside" => "ctrl.showMenu = false;", "is-open" => "ctrl.showMenu"}
@@ -19,6 +21,8 @@
%label{"ng-click" => "ctrl.selectedMenuItem($event); ctrl.toggleFullScreen()"} Toggle Fullscreen
%li
%label{"ng-click" => "ctrl.selectedMenuItem($event); ctrl.deleteNote()"} Delete Note
+ %li{"ng-if" => "ctrl.hasDisabledComponents()"}
+ %label{"ng-click" => "ctrl.selectedMenuItem($event); ctrl.restoreDisabledComponents()"} Restore Disabled Components
%li{"ng-class" => "{'selected' : ctrl.showEditorMenu}", "click-outside" => "ctrl.showEditorMenu = false;", "is-open" => "ctrl.showEditorMenu"}
%label{"ng-click" => "ctrl.showEditorMenu = !ctrl.showEditorMenu; ctrl.showMenu = false; ctrl.showExtensions = false;"} Editor
@@ -29,6 +33,12 @@
%contextual-extensions-menu{"ng-if" => "ctrl.showExtensions", "item" => "ctrl.note"}
.editor-content{"ng-if" => "ctrl.noteReady", "ng-class" => "{'fullscreen' : ctrl.fullscreen }"}
- %iframe#editor-iframe{"ng-if" => "ctrl.editor && !ctrl.editor.systemEditor", "ng-src" => "{{ctrl.editor.url | trusted}}", "frameBorder" => "0", "style" => "width: 100%;"}
+ %iframe#editor-iframe{"ng-if" => "ctrl.editor && !ctrl.editor.systemEditor", "ng-src" => "{{ctrl.editor.url | trusted}}", "frameBorder" => "0", "style" => "width: 100%;", "sandbox" => "allow-scripts"}
+ Loading
%textarea.editable#note-text-editor{"ng-if" => "!ctrl.editor || ctrl.editor.systemEditor", "ng-class" => "{'fullscreen' : ctrl.fullscreen }", "ng-model" => "ctrl.note.text",
"ng-change" => "ctrl.contentChanged()", "ng-click" => "ctrl.clickedTextArea()", "ng-focus" => "ctrl.onContentFocus()"}
+
+ #editor-pane-component-stack
+ .component.component-stack-border{"ng-repeat" => "component in ctrl.componentStack", "ng-if" => "component.active", "ng-show" => "!component.ignoreEvents", "id" => "{{'component-' + component.uuid}}", "ng-mouseover" => "component.showExit = true", "ng-mouseleave" => "component.showExit = false"}
+ .exit-button.body-text-color{"ng-if" => "component.showExit", "ng-click" => "ctrl.disableComponent(component)"} ×
+ %iframe#note-tags-iframe{"ng-src" => "{{component.url | trusted}}", "frameBorder" => "0", "sandbox" => "allow-scripts allow-top-navigation-by-user-activation allow-popups allow-popups-to-escape-sandbox allow-modals", "data-component-id" => "{{component.uuid}}"}
diff --git a/app/assets/templates/frontend/tags.html.haml b/app/assets/templates/frontend/tags.html.haml
index 607651ab1..e8a061982 100644
--- a/app/assets/templates/frontend/tags.html.haml
+++ b/app/assets/templates/frontend/tags.html.haml
@@ -1,9 +1,9 @@
-.section.tags
- #tags-content.content
+.section.tags#tags-column
+ %iframe#tags-list-iframe{"ng-if" => "ctrl.component && ctrl.component.active", "ng-src" => "{{ctrl.component.url | trusted}}", "frameBorder" => "0", "style" => "width: 100%; height: 100%;", "sandbox" => "allow-scripts"}
+ #tags-content.content{"ng-if" => "!(ctrl.component && ctrl.component.active)"}
#tags-title-bar.section-title-bar
.title Tags
.add-button#tag-add-button{"ng-click" => "ctrl.clickedAddNewTag()"} +
- {{ctrl.test}}
.scrollable
.tag{"ng-if" => "ctrl.allTag", "ng-click" => "ctrl.selectTag(ctrl.allTag)", "ng-class" => "{'selected' : ctrl.selectedTag == ctrl.allTag}"}