This commit is contained in:
Mo Bitar
2018-01-10 12:25:34 -06:00
parent 4bda20a8d9
commit ae29e502cb
24 changed files with 380 additions and 390 deletions

View File

@@ -58,22 +58,20 @@ angular.module('app.frontend')
}
let associatedEditor = this.editorForNote(note);
if(associatedEditor) {
if(associatedEditor && associatedEditor != this.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.noteReady = false;
} else {
onReady();
}
// Activate new editor if it's different from the one currently activated
if(associatedEditor) {
// switch after timeout, so that note data isnt posted to current editor
// switch after timeout, so that note data isnt posted to current editor
$timeout(() => {
this.selectedEditor = associatedEditor;
onReady();
})
} else if(associatedEditor) {
// Same editor as currently active
onReady();
} else {
// No editor
this.selectedEditor = null;
onReady();
}
@@ -106,7 +104,6 @@ angular.module('app.frontend')
}
this.selectEditor = function(editor) {
console.log("selectEditor", editor);
this.showEditorMenu = false;
if(editor) {
@@ -419,18 +416,6 @@ angular.module('app.frontend')
this.selectedEditor = null;
}
}
if(component.active) {
$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){
@@ -441,21 +426,10 @@ angular.module('app.frontend')
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(data.type == "container") {
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);
}
}
}

View File

@@ -121,46 +121,49 @@ angular.module('app.frontend')
componentManager.registerHandler({identifier: "roomBar", areas: ["rooms"], activationHandler: (component) => {
if(component.active) {
// Show room, if it was not activated manually (in the event of event from componentManager)
if(!component.showRoom) {
this.selectRoom(component);
}
$timeout(() => {
var iframe = componentManager.iframeForComponent(component);
if(iframe) {
var lastSize = component.getRoomLastSize();
if(lastSize) {
componentManager.handleSetSizeEvent(component, lastSize);
}
iframe.onload = function() {
componentManager.registerComponentWindow(component, iframe.contentWindow);
}.bind(this);
var lastSize = component.getRoomLastSize();
if(lastSize) {
componentManager.handleSetSizeEvent(component, lastSize);
}
});
}
}, actionHandler: (component, action, data) => {
if(action == "set-size") {
componentManager.handleSetSizeEvent(component, data);
component.setRoomLastSize(data);
}
}});
this.selectRoom = function(room) {
room.show = !room.show;
// Allows us to send messages to component modal directive
if(!room.directiveController) {
room.directiveController = {};
room.directiveController = {onDismiss: () => {
room.showRoom = false;
}};
}
if(!room.show) {
room.directiveController.dismiss();
}
// Make sure to call dismiss() before setting new showRoom value
// This way the directive stays alive long enough to deactivate the associated component
// (The directive's life is at the mercy of "ng-if" => "room.showRoom")
if(room.showRoom) {
room.directiveController.dismiss(() => {
console.log("Show", room.show);
});
} else {
room.showRoom = true;
}
}
// Handle singleton ProLink instance
singletonManager.registerSingleton({content_type: "SN|Component", package_info: {identifier: "org.standardnotes.prolink"}}, (resolvedSingleton) => {
console.log("Roombar received resolved ProLink", resolvedSingleton);
}, (valueCallback) => {
console.log("Creating prolink");
// Safe to create. Create and return object.
let url = window._prolink_package_url;
packageManager.installPackage(url, (component) => {

View File

@@ -63,19 +63,9 @@ angular.module('app.frontend')
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) {
@@ -86,7 +76,6 @@ angular.module('app.frontend')
else if(action === "clear-selection") {
this.selectTag(this.allTag);
}
}.bind(this)});
this.setAllTag = function(allTag) {

View File

@@ -73,6 +73,14 @@ class Component extends Item {
return "SN|Component";
}
computedUrl() {
if(this.offlineOnly || (isDesktopApplication() && this.local_url)) {
return this.local_url.replace("sn://", this.desktopManager.getApplicationDataPath() + "/");
} else {
return this.url || this.hosted_url;
}
}
isEditor() {
return this.area == "editor-editor";
}

View File

@@ -1,27 +1,9 @@
class Theme extends Item {
class Theme extends Component {
constructor(json_obj) {
super(json_obj);
}
mapContentToLocalProperties(content) {
super.mapContentToLocalProperties(content)
this.url = content.url;
this.name = content.name;
}
structureParams() {
var params = {
url: this.url,
name: this.name
};
_.merge(params, super.structureParams());
return params;
}
toJSON() {
return {uuid: this.uuid}
this.area = "themes";
}
get content_type() {

View File

@@ -296,7 +296,6 @@ angular.module('app.frontend')
let prefsContentType = "SN|UserPreferences";
singletonManager.registerSingleton({content_type: prefsContentType}, (resolvedSingleton) => {
console.log("AuthManager received resolved UserPreferences", resolvedSingleton);
this.userPreferences = resolvedSingleton;
this.userPreferencesDidChange();
}, (valueCallback) => {
@@ -304,7 +303,6 @@ angular.module('app.frontend')
var prefs = new Item({content_type: prefsContentType});
modelManager.addItem(prefs);
prefs.setDirty(true);
console.log("Created new prefs", prefs);
$rootScope.sync();
valueCallback(prefs);
});

View File

@@ -3,12 +3,11 @@ let ClientDataDomain = "org.standardnotes.sn.components";
class ComponentManager {
constructor($rootScope, modelManager, syncManager, desktopManager, themeManager, $timeout, $compile) {
constructor($rootScope, modelManager, syncManager, desktopManager, $timeout, $compile) {
this.$compile = $compile;
this.$rootScope = $rootScope;
this.modelManager = modelManager;
this.syncManager = syncManager;
this.themeManager = themeManager;
this.desktopManager = desktopManager;
this.timeout = $timeout;
this.streamObservers = [];
@@ -21,9 +20,9 @@ class ComponentManager {
this.handlers = [];
$rootScope.$on("theme-changed", function(){
this.postThemeToComponents();
}.bind(this))
// $rootScope.$on("theme-changed", function(){
// this.postThemeToAllComponents();
// }.bind(this))
window.addEventListener("message", function(event){
if(this.loggingEnabled) {
@@ -32,7 +31,7 @@ class ComponentManager {
this.handleMessage(this.componentForSessionKey(event.data.sessionKey), event.data);
}.bind(this), false);
this.modelManager.addItemSyncObserver("component-manager", "*", function(allItems, validItems, deletedItems, source) {
this.modelManager.addItemSyncObserver("component-manager", "*", (allItems, validItems, deletedItems, source) => {
/* If the source of these new or updated items is from a Component itself saving items, we don't need to notify
components again of the same item. Regarding notifying other components than the issuing component, other mapping sources
@@ -42,7 +41,9 @@ class ComponentManager {
return;
}
var syncedComponents = allItems.filter(function(item){return item.content_type === "SN|Component" });
var syncedComponents = allItems.filter(function(item) {
return item.content_type === "SN|Component" || item.content_type == "SN|Theme"
});
/* We only want to sync if the item source is Retrieved, not MappingSourceRemoteSaved to avoid
recursion caused by the component being modified and saved after it is updated.
@@ -77,9 +78,10 @@ class ComponentManager {
}
];
this.runWithPermissions(observer.component, requiredPermissions, observer.originalMessage.permissions, function(){
this.runWithPermissions(observer.component, requiredPermissions, () => {
console.log("Stream observer, sending items", relevantItems);
this.sendItemsInReply(observer.component, relevantItems, observer.originalMessage);
}.bind(this))
})
}
var requiredContextPermissions = [
@@ -89,36 +91,43 @@ class ComponentManager {
];
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)) {
continue;
}
for(let handler of this.handlers) {
if(!handler.areas.includes(observer.component.area) && !handler.areas.includes("*")) {
continue;
}
if(handler.contextRequestHandler) {
var itemInContext = handler.contextRequestHandler(observer.component);
if(itemInContext) {
var matchingItem = _.find(allItems, {uuid: itemInContext.uuid});
if(matchingItem) {
this.sendContextItemInReply(observer.component, matchingItem, observer.originalMessage, source);
this.runWithPermissions(observer.component, requiredContextPermissions, () => {
this.sendContextItemInReply(observer.component, matchingItem, observer.originalMessage, source);
})
}
}
}
}.bind(this))
}
}
}.bind(this))
});
}
postThemeToComponents() {
postThemeToAllComponents() {
for(var component of this.components) {
if(!component.active || !component.window) {
if(component.area == "themes" || !component.active || !component.window) {
continue;
}
this.postThemeToComponent(component);
}
}
getActiveTheme() {
return this.componentsForArea("themes").find((theme) => {return theme.active});
}
postThemeToComponent(component) {
var activeTheme = this.getActiveTheme();
var data = {
themes: [this.themeManager.currentTheme ? this.themeManager.currentTheme.url : null]
themes: [activeTheme ? activeTheme.computedUrl() : null]
}
this.sendMessageToComponent(component, {action: "themes", data: data})
@@ -126,7 +135,7 @@ class ComponentManager {
contextItemDidChangeInArea(area) {
for(let handler of this.handlers) {
if(handler.areas.includes(area) === false) {
if(handler.areas.includes(area) === false && !handler.areas.includes("*")) {
continue;
}
var observers = this.contextStreamObservers.filter(function(observer){
@@ -134,8 +143,10 @@ class ComponentManager {
})
for(let observer of observers) {
var itemInContext = handler.contextRequestHandler(observer.component);
this.sendContextItemInReply(observer.component, itemInContext, observer.originalMessage);
if(handler.contextRequestHandler) {
var itemInContext = handler.contextRequestHandler(observer.component);
this.sendContextItemInReply(observer.component, itemInContext, observer.originalMessage);
}
}
}
}
@@ -176,7 +187,7 @@ class ComponentManager {
}
get components() {
return this.modelManager.itemsForContentType("SN|Component");
return this.modelManager.allItemsMatchingTypes(["SN|Component", "SN|Theme"]);
}
componentsForArea(area) {
@@ -220,7 +231,7 @@ class ComponentManager {
save-context-client-data
get-context-client-data
install-local-component
open-component
toggle-activate-component
*/
if(message.action === "stream-items") {
@@ -237,14 +248,14 @@ class ComponentManager {
this.handleSaveItemsMessage(component, message);
} else if(message.action === "install-local-component") {
this.handleInstallLocalComponentMessage(component, message);
} else if(message.action === "open-component") {
let openComponent = this.modelManager.findItem(message.data.uuid);
this.openModalComponent(openComponent);
} else if(message.action === "toggle-activate-component") {
let componentToToggle = this.modelManager.findItem(message.data.uuid);
this.handleToggleComponentMessage(component, componentToToggle, message);
}
// Notify observers
for(let handler of this.handlers) {
if(handler.areas.includes(component.area)) {
if(handler.areas.includes(component.area) || handler.areas.includes("*")) {
this.timeout(function(){
handler.actionHandler(component, message.action, message.data);
})
@@ -254,7 +265,7 @@ class ComponentManager {
removePrivatePropertiesFromResponseItems(responseItems, includeUrls) {
// Don't allow component to overwrite these properties.
var privateProperties = ["appData", "autoupdate", "permissions", "active"];
var privateProperties = ["appData", "autoupdate", "permissions", "active", "encrypted"];
if(includeUrls) {
privateProperties = privateProperties.concat(["url", "hosted_url", "local_url"]);
}
@@ -278,7 +289,7 @@ class ComponentManager {
}
];
this.runWithPermissions(component, requiredPermissions, message.permissions, function(){
this.runWithPermissions(component, requiredPermissions, () => {
if(!_.find(this.streamObservers, {identifier: component.uuid})) {
// for pushing laster as changes come in
this.streamObservers.push({
@@ -289,14 +300,13 @@ class ComponentManager {
})
}
// 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) {
@@ -307,7 +317,7 @@ class ComponentManager {
}
];
this.runWithPermissions(component, requiredPermissions, message.permissions, function(){
this.runWithPermissions(component, requiredPermissions, function(){
if(!_.find(this.contextStreamObservers, {identifier: component.uuid})) {
// for pushing laster as changes come in
this.contextStreamObservers.push({
@@ -318,27 +328,49 @@ class ComponentManager {
}
// push immediately now
for(let handler of this.handlers) {
if(handler.areas.includes(component.area) === false) {
continue;
}
for(let handler of this.handlersForArea(component.area)) {
var itemInContext = handler.contextRequestHandler(component);
this.sendContextItemInReply(component, itemInContext, message);
}
}.bind(this))
}
handleSaveItemsMessage(component, message) {
var requiredContentTypes = _.uniq(message.data.items.map((i) => {return i.content_type})).sort();
var requiredPermissions = [
{
name: "stream-items",
content_types: requiredContentTypes
isItemWithinComponentContextJurisdiction(item, component) {
for(let handler of this.handlersForArea(component.area)) {
var itemInContext = handler.contextRequestHandler(component);
if(itemInContext.uuid == item.uuid) {
return true;
}
];
}
return false;
}
this.runWithPermissions(component, requiredPermissions, message.permissions, () => {
var responseItems = message.data.items;
handlersForArea(area) {
return this.handlers.filter((candidate) => {return candidate.areas.includes(area)});
}
handleSaveItemsMessage(component, message) {
var responseItems = message.data.items;
var requiredPermissions;
// Check if you're just trying to save the context item, which requires only stream-context-item permissions
if(responseItems.length == 1 && this.isItemWithinComponentContextJurisdiction(responseItems[0], component)) {
requiredPermissions = [
{
name: "stream-context-item"
}
];
} else {
var requiredContentTypes = _.uniq(responseItems.map((i) => {return i.content_type})).sort();
requiredPermissions = [
{
name: "stream-items",
content_types: requiredContentTypes
}
];
}
this.runWithPermissions(component, requiredPermissions, () => {
this.removePrivatePropertiesFromResponseItems(responseItems, {includeUrls: true});
@@ -373,7 +405,7 @@ class ComponentManager {
}
];
this.runWithPermissions(component, requiredPermissions, message.permissions, () => {
this.runWithPermissions(component, requiredPermissions, () => {
var responseItem = message.data.item;
this.removePrivatePropertiesFromResponseItems([responseItem]);
var item = this.modelManager.createItem(responseItem);
@@ -397,7 +429,7 @@ class ComponentManager {
}
];
this.runWithPermissions(component, requiredPermissions, message.permissions, () => {
this.runWithPermissions(component, requiredPermissions, () => {
var items = message.data.items;
var noun = items.length == 1 ? "item" : "items";
if(confirm(`Are you sure you want to delete ${items.length} ${noun}?`)) {
@@ -419,10 +451,8 @@ class ComponentManager {
}
];
this.runWithPermissions(component, requiredPermissions, message.permissions, () => {
console.log("Received install-local-component event");
this.runWithPermissions(component, requiredPermissions, () => {
this.desktopManager.installOfflineComponentFromData(message.data, (response) => {
console.log("componentManager: installed component:", response);
var component = this.modelManager.mapResponseItemsToLocalModels([response], ModelManager.MappingSourceComponentRetrieved)[0];
// Save updated URL
component.setDirty(true);
@@ -432,68 +462,73 @@ class ComponentManager {
}
handleSetComponentDataMessage(component, message) {
var requiredPermissions = [
{
name: "stream-items",
content_types: [component.content_type]
}
];
this.runWithPermissions(component, requiredPermissions, message.permissions, () => {
// A component setting its own data does not require special permissions
this.runWithPermissions(component, [], () => {
component.componentData = message.data.componentData;
component.setDirty(true);
this.syncManager.sync();
});
}
runWithPermissions(component, requiredPermissions, requestedPermissions, runFunction) {
var acquiredPermissions = component.permissions;
var requestedMatchesRequired = true;
for(var required of requiredPermissions) {
var matching = _.find(requestedPermissions, required);
var matching = requestedPermissions.filter((p) => {
var matchesContentTypes = true;
if(p.content_types) {
matchesContentTypes = JSON.stringify(p.content_types.sort()) == JSON.stringify(required.content_types.sort());
}
return p.name == required.name && matchesContentTypes;
})[0];
if(!matching) {
/* Required permissions can be 1 content type, and requestedPermisisons may send an array of content types.
In the case of an array, we can just check to make sure that requiredPermissions content type is found in the array
*/
matching = requestedPermissions.filter((requested) => {
return Array.isArray(requested.content_types) && requested.content_types.containsSubset(required.content_types);
});
console.log("Matching 2nd chance", matching);
if(!matching) {
requestedMatchesRequired = false;
break;
handleToggleComponentMessage(sourceComponent, targetComponent, message) {
if(targetComponent.area == "modal") {
this.openModalComponent(targetComponent);
} else {
if(targetComponent.active) {
this.deactivateComponent(targetComponent);
} else {
if(targetComponent.content_type == "SN|Theme") {
// Deactive currently active theme
var activeTheme = this.getActiveTheme();
if(activeTheme) {
this.deactivateComponent(activeTheme);
}
}
this.activateComponent(targetComponent);
}
}
}
if(!requestedMatchesRequired) {
// Error with Component permissions request
console.error("You are requesting permissions", requestedPermissions, "when you need to be requesting", requiredPermissions, ". Component:", component);
return false;
}
runWithPermissions(component, requiredPermissions, runFunction) {
if(!component.permissions) {
component.permissions = [];
}
var acquiredMatchesRequested = angular.toJson(component.permissions.sort()) === angular.toJson(requestedPermissions.sort());
var acquiredPermissions = component.permissions;
var acquiredMatchesRequired = true;
if(!acquiredMatchesRequested) {
this.promptForPermissions(component, requestedPermissions, function(approved){
for(var required of requiredPermissions) {
var matching = acquiredPermissions.find((candidate) => {
var matchesContentTypes = true;
if(candidate.content_types && required.content_types) {
matchesContentTypes = JSON.stringify(candidate.content_types.sort()) == JSON.stringify(required.content_types.sort());
}
return candidate.name == required.name && matchesContentTypes;
});
if(!matching) {
/* Required permissions can be 1 content type, and requestedPermisisons may send an array of content types.
In the case of an array, we can just check to make sure that requiredPermissions content type is found in the array
*/
matching = acquiredPermissions.find((candidate) => {
return Array.isArray(candidate.content_types)
&& Array.isArray(required.content_types)
&& candidate.content_types.containsSubset(required.content_types);
});
if(!matching) {
acquiredMatchesRequired = false;
break;
}
}
}
// var acquiredMatchesRequested = angular.toJson(component.permissions.sort()) === angular.toJson(requestedPermissions.sort());
if(!acquiredMatchesRequired) {
this.promptForPermissions(component, requiredPermissions, function(approved){
if(approved) {
runFunction();
}
@@ -503,18 +538,22 @@ class ComponentManager {
}
}
promptForPermissions(component, requestedPermissions, callback) {
promptForPermissions(component, permissions, 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});
var scope = this.$rootScope.$new(true);
scope.component = component;
scope.permissions = requestedPermissions;
scope.permissions = permissions;
scope.actionBlock = callback;
scope.callback = function(approved) {
if(approved) {
component.permissions = requestedPermissions;
for(var permission of permissions) {
if(!component.permissions.includes(permission)) {
component.permissions.push(permission);
}
}
component.setDirty(true);
this.syncManager.sync();
}
@@ -544,6 +583,9 @@ class ComponentManager {
openModalComponent(component) {
var scope = this.$rootScope.$new(true);
scope.component = component;
scope.onDismiss = () => {
}
var el = this.$compile( "<component-modal component='component' class='modal'></component-modal>" )(scope);
angular.element(document.body).append(el);
}
@@ -591,7 +633,7 @@ class ComponentManager {
component.active = true;
for(var handler of this.handlers) {
if(handler.areas.includes(component.area)) {
if(handler.areas.includes(component.area) || handler.areas.includes("*")) {
handler.activationHandler(component);
}
}
@@ -604,6 +646,10 @@ class ComponentManager {
if(!this.activeComponents.includes(component)) {
this.activeComponents.push(component);
}
if(component.area == "themes") {
this.postThemeToAllComponents();
}
}
registerHandler(handler) {
@@ -640,12 +686,13 @@ class ComponentManager {
}
deactivateComponent(component) {
console.log("Deactivating component", component);
var didChange = component.active != false;
component.active = false;
component.sessionKey = null;
for(var handler of this.handlers) {
if(handler.areas.includes(component.area)) {
if(handler.areas.includes(component.area) || handler.areas.includes("*")) {
handler.activationHandler(component);
}
}
@@ -664,6 +711,10 @@ class ComponentManager {
this.contextStreamObservers = this.contextStreamObservers.filter(function(o){
return o.component !== component;
})
if(component.area == "themes") {
this.postThemeToAllComponents();
}
}
deleteComponent(component) {
@@ -713,14 +764,6 @@ class ComponentManager {
component.ignoreEvents = !on;
}
urlForComponent(component) {
if(isDesktopApplication() && component.local_url) {
return component.local_url.replace("sn://", this.desktopManager.getApplicationDataPath() + "/");
} else {
return component.url || component.hosted_url;
}
}
iframeForComponent(component) {
for(var frame of document.getElementsByTagName("iframe")) {
var componentId = frame.dataset.componentId;

View File

@@ -6,8 +6,8 @@ class ComponentModal {
this.scope = {
show: "=",
component: "=",
controller: "=",
callback: "="
callback: "=",
onDismiss: "&"
};
}
@@ -18,42 +18,23 @@ class ComponentModal {
controller($scope, $timeout, componentManager) {
'ngInject';
let identifier = "modal-" + $scope.component.uuid;
$scope.component.directiveController.dismiss = function() {
$scope.component.show = false;
componentManager.deactivateComponent($scope.component);
componentManager.deregisterHandler(identifier);
$scope.el.remove();
}
$scope.dismiss = function() {
$scope.component.directiveController.dismiss();
}
$scope.url = function() {
return componentManager.urlForComponent($scope.component);
}
componentManager.registerHandler({identifier: identifier, areas: ["modal"], activationHandler: (component) => {
if(component.active) {
$timeout(function(){
var iframe = componentManager.iframeForComponent(component);
if(iframe) {
iframe.onload = function() {
componentManager.registerComponentWindow(component, iframe.contentWindow);
}.bind(this);
}
}.bind(this));
if($scope.component.directiveController) {
$scope.component.directiveController.dismiss = function(callback) {
$scope.dismiss(callback);
}
},
actionHandler: function(component, action, data) {
if(action == "set-size") {
componentManager.handleSetSizeEvent(component, data);
}
}.bind(this)});
}
$scope.dismiss = function(callback) {
var onDismiss = $scope.component.directiveController && $scope.component.directiveController.onDismiss();
// Setting will null out compinent-view's component, which will handle deactivation
$scope.component = null;
$timeout(() => {
$scope.el.remove();
onDismiss && onDismiss();
callback && callback();
})
}
componentManager.activateComponent($scope.component);
}
}

View File

@@ -1,18 +1,41 @@
class ComponentView {
constructor() {
constructor(componentManager, $timeout) {
this.restrict = "E";
this.templateUrl = "frontend/directives/component-view.html";
this.scope = {
component: "="
};
this.componentManager = componentManager;
this.timeout = $timeout;
}
link($scope, el, attrs, ctrl) {
$scope.el = el;
let identifier = "component-view-" + Math.random();
this.componentManager.registerHandler({identifier: identifier, areas: ["*"], activationHandler: (component) => {
if(component.active) {
this.timeout(function(){
var iframe = this.componentManager.iframeForComponent(component);
if(iframe) {
iframe.onload = function() {
this.componentManager.registerComponentWindow(component, iframe.contentWindow);
}.bind(this);
}
}.bind(this));
}
},
actionHandler: function(component, action, data) {
if(action == "set-size") {
this.componentManager.handleSetSizeEvent(component, data);
}
}.bind(this)});
$scope.$watch('component', function(component, prevComponent){
console.log("Component View Setting Component", component);
// console.log("Component View Setting Component", component);
ctrl.componentValueChanging(component, prevComponent);
});
}
@@ -20,6 +43,8 @@ class ComponentView {
controller($scope, $timeout, componentManager, desktopManager) {
'ngInject';
console.log("Creating New Component View");
this.componentValueChanging = (component, prevComponent) => {
if(prevComponent && component !== prevComponent) {
// Deactive old component
@@ -33,39 +58,15 @@ class ComponentView {
}
}
let identifier = "component-view-" + Math.random();
$scope.url = function() {
if($scope.component.offlineOnly) {
return $scope.component.local_url;
$scope.$on("$destroy", function() {
console.log("DESTROY COMPONENT VIEW");
componentManager.deregisterHandler($scope.identifier);
if($scope.component) {
componentManager.deactivateComponent($scope.component);
}
if(desktopManager.isDesktop && $scope.component.local_url) {
return $scope.component.local_url;
}
return $scope.component.hosted_url || $scope.component.url;
}
componentManager.registerHandler({identifier: identifier, areas: ["*"], activationHandler: (component) => {
if(component.active) {
$timeout(function(){
var iframe = componentManager.iframeForComponent(component);
if(iframe) {
iframe.onload = function() {
componentManager.registerComponentWindow(component, iframe.contentWindow);
}.bind(this);
}
}.bind(this));
}
},
actionHandler: function(component, action, data) {
if(action == "set-size") {
componentManager.handleSetSizeEvent(component, data);
}
}.bind(this)});
});
}
}
angular.module('app.frontend').directive('componentView', () => new ComponentView);
angular.module('app.frontend').directive('componentView', (componentManager, $timeout) => new ComponentView(componentManager, $timeout));

View File

@@ -30,7 +30,6 @@ class EditorMenu {
}
$scope.toggleDefaultForEditor = function(editor) {
console.log("Toggling editor", editor);
if($scope.defaultEditor == editor) {
$scope.removeEditorDefault(editor);
} else {

View File

@@ -30,8 +30,6 @@ class PermissionsModal {
controller($scope, modelManager) {
console.log("permissions", $scope.permissions);
$scope.formattedPermissions = $scope.permissions.map(function(permission){
if(permission.name === "stream-items") {
var types = permission.content_types.map(function(type){

View File

@@ -1,54 +1,65 @@
class ThemeManager {
constructor(modelManager, syncManager, $rootScope, storageManager) {
constructor(modelManager, syncManager, $rootScope, storageManager, componentManager) {
this.syncManager = syncManager;
this.modelManager = modelManager;
this.$rootScope = $rootScope;
this.storageManager = storageManager;
componentManager.registerHandler({identifier: "themeManager", areas: ["themes"], activationHandler: (component) => {
if(component.active) {
this.activateTheme(component);
} else {
this.deactivateTheme(component);
}
}});
}
get themes() {
return this.modelManager.itemsForContentType("SN|Theme");
}
/*
activeTheme: computed property that returns saved theme
currentTheme: stored variable that allows other classes to watch changes
*/
get activeTheme() {
var activeThemeId = this.storageManager.getItem("activeTheme");
if(!activeThemeId) {
return null;
}
var theme = _.find(this.themes, {uuid: activeThemeId});
return theme;
}
// get activeTheme() {
// var activeThemeId = this.storageManager.getItem("activeTheme");
// if(!activeThemeId) {
// return null;
// }
//
// var theme = _.find(this.themes, {uuid: activeThemeId});
// return theme;
// }
activateInitialTheme() {
var theme = this.activeTheme;
if(theme) {
this.activateTheme(theme);
}
// var theme = this.activeTheme;
// if(theme) {
// this.activateTheme(theme);
// }
}
submitTheme(url) {
var name = this.displayNameForThemeFile(this.fileNameFromPath(url));
var theme = this.modelManager.createItem({content_type: "SN|Theme", url: url, name: name});
this.modelManager.addItem(theme);
theme.setDirty(true);
this.syncManager.sync();
}
// submitTheme(url) {
// var name = this.displayNameForThemeFile(this.fileNameFromPath(url));
// var theme = this.modelManager.createItem({content_type: "SN|Theme", url: url, name: name});
// this.modelManager.addItem(theme);
// theme.setDirty(true);
// this.syncManager.sync();
// }
activateTheme(theme) {
var activeTheme = this.activeTheme;
if(activeTheme) {
this.deactivateTheme(activeTheme);
if(this.activeTheme && this.activeTheme !== theme) {
this.deactivateTheme(this.activeTheme);
}
var url = theme.computedUrl();
var link = document.createElement("link");
link.href = theme.url;
link.href = url;
link.type = "text/css";
link.rel = "stylesheet";
link.media = "screen,print";
@@ -72,10 +83,6 @@ class ThemeManager {
this.$rootScope.$broadcast("theme-changed");
}
isThemeActive(theme) {
return this.storageManager.getItem("activeTheme") === theme.uuid;
}
fileNameFromPath(filePath) {
return filePath.replace(/^.*[\\\/]/, '');
}

View File

@@ -68,7 +68,7 @@ $heading-height: 75px;
#note-tags-component-container {
height: 50px;
#note-tags-iframe {
iframe {
height: 50px;
width: 100%;
position: absolute;
@@ -116,8 +116,8 @@ $heading-height: 75px;
#editor-pane-component-stack {
width: 100%;
.component {
height: 50px;
.component-stack-item {
// height: 50px;
width: 100%;
position: relative;
&:not(:last-child) {
@@ -128,28 +128,6 @@ $heading-height: 75px;
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%;
}

View File

@@ -12,6 +12,10 @@
}
}
.panel {
background-color: white;
}
.modal {
position: fixed;
margin-left: auto;
@@ -44,14 +48,12 @@
}
}
.modal-iframe-container {
// Optionally use if .component-view container is not flex-based
.component-view-container {
flex-grow: 1;
display: flex;
iframe {
flex-grow: 1;
width: 100%;
}
height: 100%;
width: 100%;
}
.component-view {

View File

@@ -0,0 +1,7 @@
.panel {
color: black;
a {
color: $blue-color;
}
}

View File

@@ -11,5 +11,6 @@ $dark-gray: #2e2e2e;
@import "app/menus";
@import "app/modals";
@import "app/lock-screen";
@import "app/stylekit-sub";
@import "ionicons";

View File

@@ -1,7 +1,9 @@
.sn-component
.panel{"ng-attr-id" => "component-{{component.uuid}}"}
.header
%h1.title {{component.name}}
%a.close-button.info{"ng-click" => "dismiss()"} Close
.modal-iframe-container{"ng-attr-id" => "component-{{component.uuid}}"}
%iframe{"ng-src" => "{{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}}"}
.background{"ng-click" => "dismiss()"}
.content
.sn-component
.panel{"ng-attr-id" => "component-{{component.uuid}}"}
.header
%h1.title {{component.name}}
%a.close-button.info{"ng-click" => "dismiss()"} Close
%component-view.component-view{"component" => "component"}

View File

@@ -1,6 +1,6 @@
%iframe{"ng-if" => "component",
"ng-attr-id" => "component-{{component.uuid}}",
"ng-src" => "{{url() | trusted}}", "frameBorder" => "0",
"ng-src" => "{{component.computedUrl() | 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}}"}
Loading

View File

@@ -24,8 +24,8 @@
%h3
%input.bold{"ng-if" => "theme.rename", "ng-model" => "theme.tempName", "ng-keyup" => "$event.keyCode == 13 && submitExtensionRename(theme);", "mb-autofocus" => "true", "should-focus" => "true"}
%span{"ng-if" => "!theme.rename"} {{theme.name}}
%a{"ng-if" => "!themeManager.isThemeActive(theme)", "ng-click" => "themeManager.activateTheme(theme); $event.stopPropagation();"} Activate
%a{"ng-if" => "themeManager.isThemeActive(theme)", "ng-click" => "themeManager.deactivateTheme(theme); $event.stopPropagation();"} Deactivate
-# %a{"ng-if" => "!themeManager.isThemeActive(theme)", "ng-click" => "themeManager.activateTheme(theme); $event.stopPropagation();"} Activate
-# %a{"ng-if" => "themeManager.isThemeActive(theme)", "ng-click" => "themeManager.deactivateTheme(theme); $event.stopPropagation();"} Deactivate
.mt-3{"ng-if" => "theme.showDetails"}
.link-group
%a{"ng-click" => "renameExtension(theme); $event.stopPropagation();"} Rename

View File

@@ -5,7 +5,7 @@
.panel
.header
%h1.title Activate Extension
%a.close-button.info Cancel
%a.close-button.info{"ng-click" => "deny()"} Cancel
.content
.panel-section
.panel-row
@@ -14,7 +14,6 @@
would like to interact with your
%span{"ng-repeat" => "permission in formattedPermissions"}
{{permission}}.
-# %p.wrap URL: {{component.runningUrl}}
.panel-row
%p

View File

@@ -8,8 +8,8 @@
#save-status{"ng-class" => "{'red bold': ctrl.saveError, 'orange bold': ctrl.syncTakingTooLong}", "ng-bind-html" => "ctrl.noteStatus"}
.editor-tags
#note-tags-component-container{"ng-if" => "ctrl.tagsComponent && ctrl.tagsComponent.active"}
%iframe#note-tags-iframe{"ng-src" => "{{ctrl.componentManager.urlForComponent(ctrl.tagsComponent) | trusted}}", "frameBorder" => "0", "sandbox" => "allow-scripts", "data-component-id" => "{{ctrl.tagsComponent.uuid}}"}
#note-tags-component-container{"ng-if" => "ctrl.tagsComponent"}
%component-view.component-view{ "component" => "ctrl.tagsComponent"}
%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)"}
@@ -42,8 +42,7 @@
.editor-content#editor-content{"ng-if" => "ctrl.noteReady && !ctrl.note.errorDecrypting"}
%panel-resizer.left{"panel-id" => "'editor-content'", "on-resize-finish" => "ctrl.onPanelResizeFinish","control" => "ctrl.resizeControl", "min-width" => 300, "property" => "'left'", "hoverable" => "true"}
-# ng-show is required here (as opposed to ng-if) in order for the component-view to receive events such as nulling ctrl.selectorEditor
%component-view{"ng-show" => "ctrl.selectedEditor", "component" => "ctrl.selectedEditor", "class" => "component-view"}
%component-view.component-view{"ng-if" => "ctrl.selectedEditor", "component" => "ctrl.selectedEditor"}
%textarea.editable#note-text-editor{"ng-if" => "!ctrl.selectedEditor", "ng-model" => "ctrl.note.text",
"ng-change" => "ctrl.contentChanged()", "ng-click" => "ctrl.clickedTextArea()", "ng-focus" => "ctrl.onContentFocus()", "dir" => "auto"}
{{ctrl.onSystemEditorLoad()}}
@@ -53,6 +52,4 @@
%p.medium-padding{"style" => "padding-top: 0 !important;"} There was an error decrypting this item. Ensure you are running the latest version of this app, then sign out and sign back in to try again.
#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.disableComponentForCurrentItem(component, true)"} ×
%iframe{"ng-src" => "{{ctrl.componentManager.urlForComponent(component) | 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}}"}
%component-view.component-view.component-stack-item{"ng-repeat" => "component in ctrl.componentStack", "ng-if" => "component.active", "component" => "component"}

View File

@@ -22,7 +22,7 @@
.item{"ng-repeat" => "room in ctrl.rooms"}
.column{"ng-click" => "ctrl.selectRoom(room)"}
.label {{room.name}}
%component-modal{"ng-if" => "room.show", "component" => "room"}
%component-modal{"ng-if" => "room.showRoom", "component" => "room", "controller" => "room.directiveController"}
.right

View File

@@ -1,5 +1,6 @@
.section.tags#tags-column
%iframe#tags-list-iframe{"ng-if" => "ctrl.component && ctrl.component.active", "ng-src" => "{{ctrl.componentManager.urlForComponent(ctrl.component) | trusted}}", "frameBorder" => "0", "style" => "width: 100%; height: 100%;", "sandbox" => "allow-scripts"}
.component-view-container{"ng-if" => "ctrl.component"}
%component-view.component-view{"component" => "ctrl.component"}
#tags-content.content{"ng-if" => "!(ctrl.component && ctrl.component.active)"}
#tags-title-bar.section-title-bar
.section-title-bar-header

View File

@@ -6,9 +6,12 @@
.sn-component .panel {
box-shadow: 0px 2px 13px #C8C8C8;
border-radius: 0.7rem;
overflow: scroll;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sn-component .panel a:hover {
text-decoration: underline;
}
.sn-component .panel.static {
box-shadow: none;
@@ -28,15 +31,16 @@
.sn-component .panel .header .close-button {
font-weight: bold;
}
.sn-component .panel .footer {
.sn-component .panel .footer, .sn-component .panel .panel-footer {
padding: 1rem 2rem;
border-top: 1px solid #F1F1F1;
border-top: 1px solid #E1E1E1;
box-sizing: border-box;
}
.sn-component .panel .footer .left {
.sn-component .panel .footer .left, .sn-component .panel .panel-footer .left {
text-align: right;
display: block;
}
.sn-component .panel .footer .right {
.sn-component .panel .footer .right, .sn-component .panel .panel-footer .right {
text-align: right;
display: block;
}
@@ -44,75 +48,88 @@
padding: 1.6rem 2rem;
padding-bottom: 0;
flex-grow: 1;
overflow: scroll;
}
.sn-component .panel .content p {
color: #454545;
line-height: 1.3;
}
.sn-component .panel .content .label, .sn-component .panel .content .panel-section .subtitle {
.sn-component .panel .content .label, .sn-component .panel .content .panel-section .subtitle, .sn-component .panel-section .panel .content .subtitle {
font-weight: bold;
}
.sn-component .panel .content .panel-section {
.sn-component .panel-section {
padding-bottom: 1.6rem;
}
.sn-component .panel .content .panel-section .panel-row {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 0.4rem;
flex-direction: column;
}
.sn-component .panel .content .panel-section .panel-row.centered {
justify-content: center;
.sn-component .panel-section.no-bottom-pad {
padding-bottom: 0;
}
.sn-component .panel .content .panel-section .panel-row .panel-column {
width: 100%;
}
.sn-component .panel .content .panel-section .panel-row:not(:last-child) {
padding-bottom: 0.4rem;
}
.sn-component .panel .content .panel-section .panel-row:not(:last-child).condensed {
padding-top: 0.2rem;
padding-bottom: 0.2rem;
}
.sn-component .panel .content .panel-section .panel-row p {
margin: 0;
padding: 0;
}
.sn-component .panel .content .panel-section.hero {
.sn-component .panel-section.hero {
text-align: center;
}
.sn-component .panel .content .panel-section p:last-child {
.sn-component .panel-section p:last-child {
margin-bottom: 0;
}
.sn-component .panel .content .panel-section:not(:last-child) {
.sn-component .panel-section:not(:last-child) {
margin-bottom: 1.5rem;
border-bottom: 1px solid #DDDDDD;
}
.sn-component .panel .content .panel-section:last-child {
.sn-component .panel-section:not(:last-child).no-border {
border-bottom: none;
}
.sn-component .panel-section:last-child {
margin-bottom: 0.5rem;
}
.sn-component .panel .content .panel-section .outer-title {
.sn-component .panel-section .outer-title {
border-bottom: 1px solid #DDDDDD;
padding-bottom: 0.9rem;
margin-top: 2.1rem;
margin-bottom: 15px;
}
.sn-component .panel .content .panel-section .subtitle {
.sn-component .panel-section .subtitle {
margin-top: -0.5rem;
}
.sn-component .panel .content .panel-section .subtitle.subtle {
.sn-component .panel-section .subtitle.subtle {
font-weight: normal;
opacity: 0.6;
}
.sn-component .panel .content .panel-form {
.sn-component .panel-row {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 0.4rem;
}
.sn-component .panel-row.centered {
justify-content: center;
}
.sn-component .panel-row .panel-column {
width: 100%;
}
.sn-component .panel .content .panel-form.half {
.sn-component .panel-row.default-padding, .sn-component .panel-row:not(:last-child) {
padding-bottom: 0.4rem;
}
.sn-component .panel-row.default-padding.condensed, .sn-component .panel-row:not(:last-child).condensed {
padding-top: 0.2rem;
padding-bottom: 0.2rem;
}
.sn-component .panel-row p {
margin: 0;
padding: 0;
}
.sn-component .panel-form {
width: 100%;
}
.sn-component .panel-form.half {
width: 50%;
}
.sn-component .panel .content .panel-form .form-submit {
.sn-component .panel-form .form-submit {
margin-top: 0.15rem;
}
.sn-component .right-aligned {
justify-content: flex-end;
text-align: right;
}
.sn-component .menu-panel {
box-shadow: 0px 4px 4px #C8C8C8;
border-radius: 0.6rem;
@@ -156,14 +173,14 @@
.sn-component .menu-panel .row .column .left {
display: flex;
}
.sn-component .menu-panel .row .button .label, .sn-component .menu-panel .row .box .label, .sn-component .menu-panel .row .circle .label, .sn-component .menu-panel .row .button .panel .content .panel-section .subtitle, .sn-component .panel .content .panel-section .menu-panel .row .button .subtitle, .sn-component .menu-panel .row .box .panel .content .panel-section .subtitle, .sn-component .panel .content .panel-section .menu-panel .row .box .subtitle, .sn-component .menu-panel .row .circle .panel .content .panel-section .subtitle, .sn-component .panel .content .panel-section .menu-panel .row .circle .subtitle {
.sn-component .menu-panel .row .button .label, .sn-component .menu-panel .row .box .label, .sn-component .menu-panel .row .circle .label, .sn-component .menu-panel .row .button .panel-section .subtitle, .sn-component .panel-section .menu-panel .row .button .subtitle, .sn-component .menu-panel .row .box .panel-section .subtitle, .sn-component .panel-section .menu-panel .row .box .subtitle, .sn-component .menu-panel .row .circle .panel-section .subtitle, .sn-component .panel-section .menu-panel .row .circle .subtitle {
font-size: 0.8rem;
font-weight: normal;
}
.sn-component .menu-panel .row:hover {
background-color: #efefef;
}
.sn-component .menu-panel .row .label, .sn-component .menu-panel .row .panel .content .panel-section .subtitle, .sn-component .panel .content .panel-section .menu-panel .row .subtitle {
.sn-component .menu-panel .row .label, .sn-component .menu-panel .row .panel-section .subtitle, .sn-component .panel-section .menu-panel .row .subtitle {
font-size: 1rem;
font-weight: bold;
}
@@ -270,6 +287,9 @@
.sn-component .horizontal-group > *:not(:first-child), .sn-component .input-group > *:not(:first-child) {
margin-left: 0.9rem;
}
.sn-component .border-bottom {
border-bottom: 1px solid #DDDDDD;
}
.sn-component .checkbox-group {
padding-top: 0.5rem;
padding-bottom: 0.3rem;
@@ -322,7 +342,7 @@
text-align: center;
border: 1px solid;
}
.sn-component .button .label, .sn-component .box .label, .sn-component .circle .label, .sn-component .button .panel .content .panel-section .subtitle, .sn-component .panel .content .panel-section .button .subtitle, .sn-component .box .panel .content .panel-section .subtitle, .sn-component .panel .content .panel-section .box .subtitle, .sn-component .circle .panel .content .panel-section .subtitle, .sn-component .panel .content .panel-section .circle .subtitle {
.sn-component .button .label, .sn-component .box .label, .sn-component .circle .label, .sn-component .button .panel-section .subtitle, .sn-component .panel-section .button .subtitle, .sn-component .box .panel-section .subtitle, .sn-component .panel-section .box .subtitle, .sn-component .circle .panel-section .subtitle, .sn-component .panel-section .circle .subtitle {
font-weight: bold;
display: block;
text-align: center;
@@ -602,10 +622,10 @@
.sn-component .app-bar .item.no-pointer {
cursor: default;
}
.sn-component .app-bar .item:hover > .label:not(.subtle), .sn-component .app-bar .panel .content .panel-section .item:hover > .subtitle:not(.subtle), .sn-component .panel .content .panel-section .app-bar .item:hover > .subtitle:not(.subtle), .sn-component .app-bar .item:hover > .sublabel:not(.subtle), .sn-component .app-bar .item:hover > .column > .label:not(.subtle), .sn-component .app-bar .panel .content .panel-section .item:hover > .column > .subtitle:not(.subtle), .sn-component .panel .content .panel-section .app-bar .item:hover > .column > .subtitle:not(.subtle), .sn-component .app-bar .item:hover > .column > .sublabel:not(.subtle) {
.sn-component .app-bar .item:hover > .label:not(.subtle), .sn-component .app-bar .panel-section .item:hover > .subtitle:not(.subtle), .sn-component .panel-section .app-bar .item:hover > .subtitle:not(.subtle), .sn-component .app-bar .item:hover > .sublabel:not(.subtle), .sn-component .app-bar .item:hover > .column > .label:not(.subtle), .sn-component .app-bar .panel-section .item:hover > .column > .subtitle:not(.subtle), .sn-component .panel-section .app-bar .item:hover > .column > .subtitle:not(.subtle), .sn-component .app-bar .item:hover > .column > .sublabel:not(.subtle) {
color: #086DD6;
}
.sn-component .app-bar .item > .label, .sn-component .app-bar .panel .content .panel-section .item > .subtitle, .sn-component .panel .content .panel-section .app-bar .item > .subtitle, .sn-component .app-bar .item > .column > .label, .sn-component .app-bar .panel .content .panel-section .item > .column > .subtitle, .sn-component .panel .content .panel-section .app-bar .item > .column > .subtitle {
.sn-component .app-bar .item > .label, .sn-component .app-bar .panel-section .item > .subtitle, .sn-component .panel-section .app-bar .item > .subtitle, .sn-component .app-bar .item > .column > .label, .sn-component .app-bar .panel-section .item > .column > .subtitle, .sn-component .panel-section .app-bar .item > .column > .subtitle {
font-weight: bold;
font-size: 0.9rem;
white-space: nowrap;