UNPKG

apostrophe

Version:

The Apostrophe Content Management System.

1,160 lines (1,047 loc) 42.1 kB
// editor for an area. See enableAreas() method // in user.js for where this is invoked. // apos.define('apostrophe-areas-editor', { extend: 'apostrophe-context', afterConstruct: function(self) { self.init(); }, construct: function(self, options) { self.options = options; self.$el = self.options.$el; self.$body = $('body'); self.action = apos.areas.options.action; self.resetEl = function($el) { self.$el = $el; if (self.$selected && self.$selected.length) { self.$selected = self.$el.find('[data-apos-widget-id="' + self.$selected.data('apos-widget-id') + '"]', '[data-apos-area]'); } self.init(); }; self.init = function() { self.options.widgets = self.options.widgets || {}; // So we don't reinitialize it on every call to enableAll() self.$el.attr('data-initialized', 'true'); // So serialize can be invoked from the outside world self.$el.data('editor', self); // Receive options from the DOM too _.extend(self.options, JSON.parse(self.$el.attr('data-options'))); if (self.options.virtual) { // virtual area, in a new piece in a modal for instance. // Make it easy for child areas to detect this fact // and avoid trying to autosave on their own self.$el.attr('data-apos-area-virtual', '1'); } apos.areas.register(self.$el.attr('data-doc-id'), self.$el.attr('data-dot-path'), self); var canceled = false; self.$el.parents('[data-apos-area]').each(function() { if (canceled) { return; } var $parent = $(this); var pdi = $parent.attr('data-doc-id') || ''; var pdp = $parent.attr('data-dot-path') || ''; var di = self.$el.attr('data-doc-id') || ''; var dp = self.$el.attr('data-dot-path') || ''; if ((pdi === di) && ((pdp + '.') === dp.substr(0, pdp.length + 1))) { self.$el.removeAttr('data-autosave'); canceled = true; } }); self.$controls = self.$el.findSafe('[data-apos-area-controls]', '[data-apos-widget]'); self.addEmptyClass(); self.linkWidgetsToAreaEditor(); self.enhanceExistingWidgetControls(); self.registerClickHandlers(); self.registerEventHandlers(); self.enableHideControlsOnRichTextStart(); self.enableInterval(); self.previousData = self.serialize(); }; self.addEmptyClass = function() { if (self.$el.find('[data-apos-widgets]').html() === "") { self.$el.addClass('apos-empty'); } }; self.enhanceExistingWidgetControls = function() { var $widgets = self.getWidgets(); $widgets.each(function() { var $widget = $(this); self.enhanceWidgetControls($widget); self.addAreaControls($widget); }); self.respectLimit(); }; self.registerClickHandlers = function() { self.link('apos-add-item', self.startAutosavingHandler(self.addItem)); self.link('apos-edit-item', self.startAutosavingHandler(self.editItem)); self.link('apos-move-item', self.startAutosavingHandler(self.moveItem)); self.link('apos-trash-item', self.startAutosavingHandler(self.trashItem)); self.link('apos-clone-item', self.startAutosavingHandler(self.cloneItem)); self.$el.on('change', '[data-schema-widget-control-label]', self.startAutosavingHandler(self.changedSchemaWidgetControl)); }; self.registerEventHandlers = function() { self.$body.on('aposCloseMenus', function() { self.dismissContextContentMenu(); }); self.on('aposRichTextStarting', function() { // Stop any previously active editor self.stopEditingRichText(); }); self.$el.mouseover(function(e) { self.$el.addClass('apos-hover'); e.stopPropagation(); }); self.$el.mouseout(function(e) { self.$el.removeClass('apos-hover'); e.stopPropagation(); }); }; // Activate the autosave mechanism, if it is not already // operating. This method is invoked for you by // `startAutosaving` and `startAutosavingThen`, which // also obtain a session lock on the document first for // the current user. self.registerAutosave = function() { if (self.autosaving) { return; } if (self.$el.is('[data-autosave]')) { // Self-saving. Used for areas on regular pages. Areas in snippets // will be queried for their content at the time the snippet is saved if (!self.previousData) { self.previousData = self.serialize(); } self.autosaving = true; } }; // Add a new widget to the area. `$el` should be the widget wrapper // of the widget that should immediately precede it, or null if // we are adding at the top. `type` should be the widget type's name // property, such as `apostrophe-rich-text` (note no suffix). // `data` may be an object with existing properties, or null. // `callback`, if present, is invoked after the widget has been // added to the DOM. self.addItem = function($el, type, data, callback) { self.$el.removeClass('apos-empty'); // If this is not empty then we want to append the new item after this item. // If it is empty then we want to prepend it to the area (we used the content menu // at the top of the area, rather than one nested in an item). self.$selected = $el.parentsUntil('[data-apos-area]').filter('[data-apos-widget-wrapper]', self.$el).find('[data-apos-widget]').first(); // We may have come from a context content menu associated with a specific item; // if so dismiss it, but note we waited until after calling closest() to figure out // if the click came from such a menu self.dismissContextContentMenu($el); // If there are any initial content items, remove them on selecting // your first widget. Right now, we're parsing the data-options through // self.options and using self.options.initialContent or what we think is // the default from aposLocals. self.removeInitialContent(self.$el, true); return self.addWidget(type, data, callback); }; self.editItem = function($el) { var $widget = $el.closest('[data-apos-widget]'); if ($widget) { self.editWidget($widget); return false; } }; self.cloneItem = function($el) { var $widget = $el.closest('[data-apos-widget]'); if ($widget) { self.cloneWidget($widget); return false; } }; self.moveItem = function($el) { // We move the widget wrapper, with its associated area controls var $wrapper = $el.closest('[data-apos-widget-wrapper]'); var direction = $el.data('apos-move-item'); if (direction === 'up') { $wrapper.prev().before($wrapper); } else if (direction === 'down') { $wrapper.next().after($wrapper); } else if (direction === 'top') { $wrapper.parent().children(':first').before($wrapper); } else if (direction === 'bottom') { $wrapper.parent().children(':last').after($wrapper); } apos.areas.remapDotPaths(); apos.emit('widgetMoved', $wrapper.find('[data-apos-widget]')); }; self.trashItem = function($el) { var $widget = $el.closest('[data-apos-widget]'); self.stopEditingRichText(); self.removeAreaControls($widget); var $wrapper = $widget.parent('[data-apos-widget-wrapper]'); var $undoer = self.fromTemplate('[data-apos-undo-remove-widget]'); $undoer.find('[data-apos-widget-type-label]').text(apos.areas.getWidgetManager($widget.attr('data-apos-widget')).label); $wrapper.before($undoer).detach(); var undoerTimeout; $undoer.on('click', function() { $undoer.replaceWith($wrapper); self.checkEmptyAreas(); self.respectLimit(); apos.areas.remapDotPaths(); clearTimeout(undoerTimeout); return false; }); self.checkEmptyAreas(); self.respectLimit(); undoerTimeout = setTimeout(function() { $undoer.fadeOut(function() { $undoer.remove(); // It is unclear how to drop all jQuery data associated // with a detached (rather than removed) element, so // add it back hidden for a moment and use remove, which // definitely cleans up those things $wrapper.hide(); $('body').append($wrapper); $wrapper.remove(); }); }, 7000); apos.areas.remapDotPaths(); apos.emit('widgetTrashed', $widget); }; self.getWidgets = function() { return self.$el.findSafe('[data-apos-widget]', '[data-apos-area]'); }; // Disable area controls interactions while certain menus are open self.disableAreaControls = function() { $('body').find('[data-apos-widget-controls]').addClass('apos-area-widget-controls--disabled'); }; self.enableAreaControls = function() { $('body').find('[data-apos-widget-controls]').removeClass('apos-area-widget-controls--disabled'); }; self.dismissContextContentMenu = function() { $('body').unbind('click', self.dismissContextContentMenu); $(window).unbind('keyup', self.dismissContextContentMenuKeyEvent); self.$el.find('[data-apos-dropdown]').removeClass('apos-active'); self.$el.find('.apos-area-controls').removeClass('apos-active'); self.$el.removeClass('apos-context-content-menu-active'); // It is now OK to show this again without worrying about // menu interactions being suppressed due to stacking order issues. -Tom $('.apos-context-menu-container').removeClass('apos-content-menu-active'); self.enableAreaControls(); }; self.dismissContextContentMenuKeyEvent = function (e) { if (e.keyCode === 27) { self.dismissContextContentMenu(); } }; self.linkWidgetsToAreaEditor = function() { // Every item should know its area editor so we can talk // to other area editors after drag-and-drop var $widgets = self.getWidgets(); $widgets.each(function() { var $widget = $(this); $widget.data('areaEditor', self); }); }; self.removeInitialContent = function($el, entireItem) { if (entireItem) { // We added a real item to an area that only contains a // placeholder item which should be removed in its entirety $el.find('[data-apos-widget="apostrophe-rich-text"]:has([data-initial-content])').remove(); } else { // We started editing such an item. Don't trash it, // just remove the initial content <p> tag $el.find('[data-initial-content]').remove(); } }; self.stopEditingRichText = function() { if (!self.$activeRichText) { return; } apos.areas.getWidgetManager('apostrophe-rich-text').stopEditing(self.$activeRichText); self.$activeRichText = undefined; }; // Implementation detail of `addItem`, should not be called directly. // Adds the given widget wrapper to the DOM, respecting the limit. self.insertWidget = function($wrapper) { var $widget = $wrapper.children('[data-apos-widget]').first(); self.insertItem($wrapper); self.enhanceWidgetControls($widget); self.respectLimit(); apos.areas.remapDotPaths(); apos.emit('enhance', $widget); }; // Legacy, kept for bc, we now call remapDotPaths at a better time self.fixInsertedWidgetDotPaths = function($widget) {}; self.enhanceWidgetControls = function($widget) { var $controls = $widget.findSafe('[data-apos-widget-controls]', '[data-apos-area]'); if (self.options.limit === 1) { $controls.addClass('apos-limit-one'); } self.checkEmptyWidget($widget); $widget.parent('[data-apos-widget-wrapper]').draggable(self.draggableSettings); self.updateAllSchemaWidgetControlChoices($widget); }; self.addAreaControls = function($widget) { $widget.parent('[data-apos-widget-wrapper]').append(self.$controls.clone().removeAttr('data-apos-area-controls-original')); }; self.removeAreaControls = function($widget) { $widget.parent('[data-apos-widget-wrapper]').find('[data-apos-area-controls]').remove(); }; self.checkEmptyWidget = function($widget) { var type = $widget.attr('data-apos-widget'); if (apos.areas.getWidgetManager(type).isEmpty($widget)) { $widget.addClass('apos-empty'); } }; // Replace an existing widget, preserving any classes and // attributes specific to the area editor. Typically // called by the widget's editor on save, so it can change // attributes of the widget element itself self.replaceWidget = function($old, $wrapper) { var $widget = $wrapper.findSafe('[data-apos-widget]', '[data-apos-area]'); var data = apos.areas.getWidgetData($widget); apos.areas.setWidgetData($widget, data); $old.replaceWith($widget); self.enhanceWidgetControls($widget); $widget.parent('[data-apos-widget-wrapper]').attr('class', $wrapper.attr('class')); apos.areas.remapDotPaths(); $widget.data('areaEditor', self); apos.emit('enhance', $widget); }; // $item whould be a widget wrapper, not just the widget itself self.insertItem = function($item) { $item.find('[data-apos-widget]:first').data('areaEditor', self); if (self.$selected && self.$selected.length) { self.$selected.parent('[data-apos-widget-wrapper]').after($item); } else { self.$el.find('[data-apos-widgets]:first').prepend($item); } self.addAreaControls($item.findSafe('[data-apos-widget]', '[data-apos-area]')); }; // This method recreates separators throughout the entire page as appropriate // to the element being dragged. self.addSeparators = function($draggable) { var $areas = self.getDroppableAreas($draggable); $areas.each(function() { var $area = $(this); var $ancestor = $draggable.closest('[data-apos-area]'); var i; atTop(); after(); function atTop() { // Drop zone at the top of every area, unless we are dragging the top item // in that particular area if (($area[0] === $ancestor[0]) && (!$draggable.prev().length)) { return; } $area.find('[data-apos-widgets]:first').prepend(self.newSeparator()); } function after() { var $widgets = $area.findSafe('[data-apos-widget-wrapper]', '[data-apos-area]'); $(window).trigger('aposDragging', [$widgets]); // Counter so we can peek ahead i = 0; $widgets.each(function() { var $widget = $(this); if (!(($widgets[i] === $draggable[0]) || (((i + 1) < $widgets.length) && ($widgets[i + 1] === $draggable[0])))) { $widget.after(self.newSeparator()); } i++; }); } }); $('[data-apos-drag-item-separator]:not(.apos-template)').droppable(self.separatorDropSettings); setImmediate(function() { $areas.addClass('apos-dragging'); }); }; self.removeSeparators = function() { $(window).trigger('aposStopDragging'); $('[data-apos-area]').removeClass('apos-dragging'); $('[data-apos-refreshable] [data-apos-drag-item-separator]') .off('transitionend webkitTransitionEnd oTransitionEnd') .on('transitionend webkitTransitionEnd oTransitionEnd', function() { $(this).remove(); } ); }; self.getDroppableAreas = function($draggable) { var $widget = $draggable.find('[data-apos-widget]').first(); // Only the current area, and areas that are not full; also // rule out areas that do not allow the widget type in question return $('[data-apos-area]').filter(function() { var editor = $(this).data('editor'); if (editor && ((!editor.limitReached()) || ($widget.data('areaEditor') === editor))) { var movableOptionKey = [$widget.attr('data-apos-widget'), 'controls', 'movable']; var hasWidget = _.has(editor.options.widgets, $widget.attr('data-apos-widget')); if (hasWidget && (_.has(editor.options.widgets, movableOptionKey) ? _.get(editor.options.widgets, movableOptionKey) : true)) { return true; } } }); }; self.newSeparator = function() { var $separator = self.fromTemplate('[data-apos-drag-item-separator]'); return $separator; }; self.editWidget = function($widget) { self.stopEditingRichText(); self.$selected = $widget; var type = self.$selected.attr('data-apos-widget'); var data = apos.areas.getWidgetData($widget); data._errorPath = $widget.data('errorPath'); data._error = $widget.data('error'); var originalData = _.clone(data); var options = self.options.widgets[type] || {}; apos.areas.getWidgetManager(type).edit( data, options, function(data, callback) { $.jsonCall(self.action + '/render-widget', { dataType: 'html' }, { data: data, originalData: originalData, options: options, type: type }, function(html) { // This rather intense code works around // various situations in which jquery is // picky about HTML var $newWidget = $($.parseHTML($.trim(html), null, true)); self.replaceWidget(self.$selected, $newWidget); return callback(null, $newWidget.findSafe('[data-apos-widget]', '[data-apos-area]')); }); } ); }; self.cloneWidget = function($widget) { self.stopEditingRichText(); self.$selected = $widget; var type = self.$selected.attr('data-apos-widget'); var options = self.options.widgets[type] || {}; // Clone first so we're not just changing the id of the original $widget = $widget.clone(); // Recursively set new widget ids for this widget and its descendants // with the same docId newIds($widget); var data = apos.areas.getWidgetData($widget); $.jsonCall(self.action + '/render-widget', { dataType: 'html' }, { data: data, originalData: data, options: options, type: type }, function(html) { // This rather intense code works around // various situations in which jquery is // picky about HTML var $wrapper = $($.parseHTML($.trim(html), null, true)); self.insertWidget($wrapper); self.checkEmptyAreas(); } ); function newIds($widget) { var id = apos.utils.generateId(); $widget.attr('data-apos-widget-id', id); var data = apos.areas.getWidgetData($widget); data._id = id; var docId = data.__docId; apos.areas.setWidgetData($widget, data); var $descendants = $widget.find('[data-apos-widget]'); $descendants.each(function() { var $widget = $(this); var data = apos.areas.getWidgetData($widget); if (data.__docId === docId) { newIds($(this)); } }); } }; self.checkEmptyAreas = function() { $('[data-apos-area]').each(function() { var $el = $(this); if ($el.find('[data-apos-widget]').length === 0) { $el.addClass('apos-empty'); } else { $el.removeClass('apos-empty'); } }); return false; }; // This method is an implementation detail of `addItem` and should not be called directly. // // Insert a widget of the given type with the given initial data (may be null) // and, optionally, invoke a callback after adding to the DOM. self.addWidget = function(type, data, callback) { self.stopEditingRichText(); var options = self.options.widgets[type] || {}; var originalData = _.clone(data); apos.areas.getWidgetManager(type).edit( data || {}, options, function(data, saved) { $.jsonCall(self.action + '/render-widget', { dataType: 'html' }, { data: data, originalData: originalData, options: options, type: type }, function(html) { // This rather intense code works around // various situations in which jquery is // picky about HTML var $wrapper = $($.parseHTML($.trim(html), null, true)); self.insertWidget($wrapper); self.checkEmptyAreas(); saved(null, $wrapper.findSafe('[data-apos-widget]', '[data-apos-area]')); return callback && callback(null); } ); } ); }; self.draggableSettings = { handle: '[data-apos-drag-item]', revert: 'invalid', refreshPositions: true, cancel: false, tolerance: 'pointer', start: function(event, ui) { self.stopEditingRichText(); // If the limit has been reached, we can only accept // drags from the same area var $item = $(event.target); self.enableDroppables($item); }, stop: function(event, ui) { self.disableDroppables(); } }; self.enableDroppables = function($draggable) { self.addSeparators($draggable); }; self.disableDroppables = function($draggable) { self.removeSeparators(); }; self.separatorDropSettings = { accept: '[data-apos-widget-wrapper]', activeClass: 'apos-active', hoverClass: 'apos-hover', tolerance: 'pointer', drop: function(event, ui) { // TODO: after the drop we should re-render the dropped item to // reflect the options of its new parent area var $item = $(ui.draggable); // Get rid of the hardcoded position provided by jquery UI draggable, // but don't remove the position: relative without which we can't see the // element move when we try to drag it again later $item.css({ 'height': '', 'left': '', 'top': '', 'width': '' }); var $oldWidget = $item.find('[data-apos-widget]'); var oldEditor = self; var $newArea = $(event.target).closest('[data-apos-area]'); var newEditor = $newArea.data('editor'); apos.areas.remapDotPaths(); $oldWidget.data('areaEditor', newEditor); self.disableDroppables(); oldEditor.startAutosavingThen(function() { $(event.target).after($item); newEditor.startAutosavingThen(function() { newEditor.reRenderWidget($item, function(err) { if (err) { apos.notify('An error occurred.', { type: 'error' }); return; } newEditor.respectLimit(); apos.areas.remapDotPaths(); }); }); }); self.checkEmptyAreas(); } }; // Get the server to re-render a widget for us, applying the // options appropriate to its new area for instance. The callback // is optional. self.reRenderWidget = function($wrapper, callback) { self.stopEditingRichText(); var $widget = $wrapper.find('[data-apos-widget]'); var type = $widget.attr('data-apos-widget'); var data = apos.areas.getWidgetData($widget); var originalData = _.clone(data); var options = self.options.widgets[type] || {}; apos.ui.globalBusy(true); return $.jsonCall(self.action + '/render-widget', { dataType: 'html' }, { data: data, originalData: originalData, options: options, type: type }, function(html) { apos.ui.globalBusy(false); // This rather intense code works around // various situations in which jquery is // picky about HTML var $newWidget = $($.parseHTML($.trim(html), null, true)); self.replaceWidget($widget, $newWidget); return callback && callback(null); }, function(err) { apos.ui.globalBusy(false); return callback && callback(err); } ); }; // Serialize the editor to an array of items, exactly as expected for // storage in an area. self.serialize = function() { var items = []; self.getWidgets().each(function() { var $item = $(this); var item = apos.areas.getWidgetData($item); items.push(item); }); // use clonePermanent so we don't mistake differences in // dynamic, informational properties for real differences. // prevents false positives and extra versions. -Tom return apos.utils.clonePermanent(items); }; // Called every 5 seconds. Default version checks for empty areas // and autosaves if needed in appropriate cases. self.onInterval = function() { self.checkEmptyAreas(); self.saveIfNeeded(); }; // Returns a JSON-friendly object ready for // submission to the `save-area` route, if // the area is autosaving, has modifications // when compared to `self.previousData` and is present // in the DOM. In all other circumstances // this method returns `null`. Calling code should // set `self.previousData` to the `items` property // of the returned object, if and only if it succeeds // in actually saving the data. This ensures that // retries are made automatically in the event // of network errors. `self.previousData` is // updated as the basis of comparison next time, // unless `options.updatePreviousData` is explicitly `false`. // `options` may be entirely omitted. self.prepareAutosaveRequest = function(options) { options = options || {}; if (!self.autosaving) { // This area does not autosave // (it does not invoke the save-area route) return null; } // Die gracefully if the area has been removed from the DOM if (!self.$el.closest('body').length) { clearInterval(self.saveInterval); return null; } var items = self.serialize(); // Use _.isEqual because it ignores the order of properties. Differences // in property order in JSON were causing a false positive on every page load, // generating a proliferation of versions on the server side. if (_.isEqual(items, self.previousData)) { return null; } if (options.updatePreviousData !== false) { self.previousData = items; } return { docId: self.$el.attr('data-doc-id'), dotPath: self.$el.attr('data-dot-path'), options: apos.areas.getAreaOptions(self.$el), items: items }; }; // If the area editor believes its content has changed, send it to the // save-area route. If `sync` is true, make a synchronous AJAX call // (supported for bc only, we use a beforeUnload warning now). // // `callback` is optional and is invoked when the work is complete, // or immediately if there is no work to do. // // If the document cannot be saved because it has been locked // by another user, tab or window, a message is displayed to // the user and the page is refreshed to reflect the current // content and avoid a cascade of such messages. self.saveIfNeeded = function(sync, callback) { var request = self.prepareAutosaveRequest(); if (!request) { return callback && callback(null); } apos.areas.markUnsaved(); $.jsonCall( self.options.action + '/save-area', { async: !sync }, request, function(result) { apos.areas.markSavedIfReady(); if (result.status !== 'ok') { if ((result.status === 'locked') || (result.status === 'locked-by-me')) { // apos.notify unsuitable because this is quite modal. // TODO: implement an apos.alert that displays // modally, then invokes a callback alert(result.message); $(window).off('beforeunload'); window.location.reload(true); } else { if (result.status !== 'error') { // The server is telling us that validation constraints // are unmet. How is that possible? Because the browser // was not enforcing them at one point, or because // the constraints were added later. Open the most relevant // widget's modal via the provided dot path var path = result.status.split(/\./); var $el = self.$el; var $lastWidget; var failing = false; var unconsumed = []; // Last element is the error name, not part of the path. var errorName = path.pop(); _.each(path, function(c) { if (failing) { // Once we've found the widget that needs fixing, push the // rest of the path into `unconsumed`, // e.g., field name, array item number unconsumed.push(c); } else { if (c.match(/^\d+$/)) { $el = $el.findSafe('[data-apos-widget]', '[data-apos-widget]').eq(parseInt(c)); if ($el.length) { $lastWidget = $el; } else { unconsumed.push(c); failing = true; } } else { $el = $el.findSafe('[data-apos-area][data-dot-path$=".' + c + '"]', '[data-apos-widget]'); if (!$el[0]) { unconsumed.push(c); failing = true; } } } }); if ($lastWidget.length) { var $area = $lastWidget.closest('[data-apos-area]'); $lastWidget.data('errorPath', unconsumed); $lastWidget.data('error', errorName); $area.data('editor').editWidget($lastWidget); apos.notify('Incomplete or incorrect information was provided. Please review.'); } else { apos.notify('An error occurred saving the document.', { type: 'error' }); } } else { apos.notify('An error occurred saving the document.', { type: 'error' }); } return callback && callback(result.status); } return; } self.previousData = request.items; apos.emit('areaEdited', self.$el); return callback && callback(null); }, function(err) { return callback && callback(err); } ); }; // For bc only. Working version of this logic is inside // the drop handler. self.changeOwners = function($item) { }; self.respectLimit = function() { var $toggles = self.$el.find('[data-content-menu-toggle]'); if (self.limitReached()) { self.$el.addClass('apos-limit-reached'); $toggles.addClass('apos-disabled'); } else { self.$el.removeClass('apos-limit-reached'); $toggles.removeClass('apos-disabled'); } }; self.limitReached = function() { var count = self.getWidgets().length; return (self.options.limit && (count >= self.options.limit)); }; self.fromTemplate = function(sel) { return apos.areas.fromTemplate(sel); }; self.enableHideControlsOnRichTextStart = function() { self.$el.off('aposRichTextStarted'); self.on('aposRichTextStarted', function(e) { self.$activeRichText = $(e.target); self.dismissContextContentMenu(); $(e.target).find('[data-apos-area-item-buttons]:first').hide(); }); // self.$el.off('aposRichTextStopped'); // self.on('aposRichTextStopped', function(e) { // var $widget = $(e.target); // }); self.on('aposRichTextActive', function(state) { self.$el.addClass('apos-rich-text-active'); }); self.on('aposRichTextInactive', function() { self.$el.removeClass('apos-rich-text-active'); }); }; // Override default apostrophe-context functionality because we // need to use $.onSafe, for the sake of nested areas. self.link = function(action, callback) { var attribute; attribute = 'data-' + apos.utils.cssName(action); var attributeSel = '[' + attribute + ']'; self.on('click', attributeSel, function() { callback($(this), $(this).attr(attribute)); return false; }); }; // This is a wrapper for $.onSafe that avoids events that are actually // happening in nested areas. self.on = function(eventType, selector, fn) { if (arguments.length === 2) { fn = selector; selector = undefined; self.$el.onSafe(eventType, '[data-apos-area]', fn); } else { self.$el.onSafe(eventType, selector, '[data-apos-area]', fn); } }; // Given a method such as `self.addItem`, this method returns // a new function that will first ensure the user has a session lock // on the document, then initiate autosave for the area, and // finally invoke the callback. // // If necessary the user is given the option to shatter a lock belonging // to another user. // // If an error occurs, such as the user declining to steal // a session lock, `callback` is invoked with an error rather than null. self.startAutosaving = function(callback) { // Bug fix for working with areas inside piece modals: // if autosaving is not appropriate for this area, // we should not attempt to get a lock either. // (Autosaving only makes sense for existing documents // being edited in context on the page.) // // However, if our parent area is autosaving, // we should trigger `startAutosaving` on that instead. if (!self.$el.is('[data-autosave]')) { var $closest = self.$el.closest('[data-apos-area][data-autosave]'); if ($closest.length) { return $closest.data('editor').startAutosaving(callback); } return callback(null); } var _id = self.$el.attr('data-doc-id'); if ((!_id) || self.options.virtual) { return callback(null); } if (self.$el.closest('[data-apos-area-virtual]').length) { // Also look for virtual parent areas and stand down // if found; they will recursively save our contents return callback(null); } // Without this, race conditions on double click are easy // to encounter on a slower connection apos.ui.globalBusy(true); return apos.docs.lockAndWatch(_id, function(err) { apos.ui.globalBusy(false); if (err) { return callback(err); } return succeed(); }); function succeed() { self.registerAutosave(); return callback(null); } }; // Similar to `startAutosaving`, this method // obtains a context lock and starts autosaving of // the area, then invokes the given function, // invoking it with the given array of arguments. // Does not invoke `fn` at all if startAutosaving // fails. Part of the implementation of `startAutosavingHandler`. self.startAutosavingThen = function(fn, args) { return self.startAutosaving(function(err) { if (!err) { fn.apply(null, args); } else { apos.utils.error(err); } }); }; // Returns a function that invokes `startAutosavingThen` // with the given function and passes on the arguments // given to it. Useful as an event handler. self.startAutosavingHandler = function(fn) { return function() { return self.startAutosavingThen(fn, Array.prototype.slice.call(arguments)); }; }; // Establish the so-called `saveInterval`, which actually // also carries out the check for empty areas and can // be expanded to do more by extending `onInterval`. Note // that this interval is established for all areas the // user can edit, not just those that autosave. self.enableInterval = function() { self.saveInterval = setInterval(self.onInterval, 5000); }; self.changedSchemaWidgetControl = function(event) { var $el = $(event.target); var $widget = $el.closest('[data-apos-widget]'); var value = $el.val(); var name = $el.attr('name'); var data = apos.areas.getWidgetData($widget); if ($el.attr('data-type') === 'select') { data[name] = value; } else { var matches; var operator; if (value === '') { data[name] = []; } else if (value.match(/^[+-]/)) { matches = value.match(/^([+-]) (.*)$/); operator = matches[1]; value = matches[2]; if (operator === '+') { data[name] = (data[name] || []).concat([ value ]); } else if (operator === '-') { data[name] = _.without(data[name] || [], value); } } else { data[name] = [ value ]; } } apos.areas.setWidgetData($widget, data); self.reRenderWidget($widget.closest('[data-apos-widget-wrapper]')); return false; }; self.updateAllSchemaWidgetControlChoices = function($widget) { var data = apos.areas.getWidgetData($widget); $widget.find('.apos-area-widget-controls:first [data-schema-widget-control-label]').each(function() { var $control = $(this); var name = $control.attr('name'); self.updateSchemaWidgetControlChoices($widget, name, data[name] || []); }); }; // The dropdown acts as a multiple selector, biased toward // the more common use case where only one choice is made. // Until you make a choice it looks like a single-select situation. // The multiple-select capability can be seen when you pull it // down again. self.updateSchemaWidgetControlChoices = function($widget, name, selected) { var $controls = $widget.findSafe('[data-apos-widget-controls]', '[data-apos-area]'); var $select = $controls.find('[name="' + name + '"]:first'); var widgetType = $widget.attr('data-apos-widget'); var manager = apos.areas.getWidgetManager(widgetType); var field = _.find(manager.schema, { name: name }); return async.series([ getChoices ], function(err) { if (err) { apos.notify(err, { type: 'error' }); return; } return renderChoices(); }); function getChoices(callback) { if ($select.data('choices')) { return callback(null); } if ((typeof field.choices) !== 'string') { captureLabelsAndChoices(); return callback(null); } apos.ui.globalBusy(true); return $.jsonCall(apos.schemas.action + '/choices', { field: field }, function(result) { apos.ui.globalBusy(false); if (result.status !== 'ok') { return callback(result.status); } $select.data('choices', _.pluck(result.choices, 'value')); var labels = {}; _.each(result.choices, function(choice) { labels[choice.value] = choice.label; }); $select.data('labels', labels); return callback(null); }, callback); } function renderChoices() { var choices = $select.data('choices'); var labels = $select.data('labels'); $select.html(''); if (field.type === 'select') { addChoices(); $select.val(selected); } else { if (selected.length === 0) { add('', ''); addChoices(); } else { add('__', 'current', _.map(selected, function(value) { return labels[value]; }).join(', ')); _.each(choices, function(choice) { if (_.includes(selected, choice)) { add('- ', choice, '✓ ' + labels[choice]); } else { add('+ ', choice, labels[choice]); } }); add('', ''); } $select.selectedIndex = 0; } function addChoices() { _.each(choices, function(choice) { return add('', choice); }); } function add(prefix, value, label) { var $option = $('<option></option>'); $option.attr('value', prefix + value); if ((value === '') && (!(label || labels[value]))) { $option.text($select.attr('data-schema-widget-control-label')); } else { $option.text(label || labels[value]); } $select.append($option); } } function captureLabelsAndChoices() { var choices = []; var labels = {}; $select.find('option').each(function() { var value = $(this).attr('value'); labels[value] = $(this).text(); if (value) { choices.push(value); } }); $select.data('choices', choices); $select.data('labels', labels); } }; } });