X-Git-Url: http://git.inspyration.org/?a=blobdiff_plain;f=addons%2Fwebsite%2Fstatic%2Fsrc%2Fjs%2Fwebsite.editor.js;h=5915ee5b3591a5322052b5f01f8cd1c514b1ca3a;hb=854916ce54817dc23ed887e249586237882a94ae;hp=4bc6d2905101bc8c61d2f07eca31419f4b6d7855;hpb=75977b4e43026d21ce691f30c0df1043485d83fe;p=odoo%2Fodoo.git diff --git a/addons/website/static/src/js/website.editor.js b/addons/website/static/src/js/website.editor.js index 4bc6d29..5915ee5 100644 --- a/addons/website/static/src/js/website.editor.js +++ b/addons/website/static/src/js/website.editor.js @@ -2,57 +2,133 @@ 'use strict'; var website = openerp.website; + // $.fn.data automatically parses value, '0'|'1' -> 0|1 - website.templates.push('/website/static/src/xml/website.editor.xml'); + website.add_template_file('/website/static/src/xml/website.editor.xml'); website.dom_ready.done(function () { - // $.fn.data automatically parses value, '0'|'1' -> 0|1 - website.is_editable = $(document.documentElement).data('editable'); var is_smartphone = $(document.body)[0].clientWidth < 767; - if (website.is_editable && !is_smartphone) { + if (!is_smartphone) { website.ready().then(website.init_editor); } + + $(document).on('hide.bs.dropdown', '.dropdown', function (ev) { + // Prevent dropdown closing when a contenteditable children is focused + if (ev.originalEvent + && $(ev.target).has(ev.originalEvent.target).length + && $(ev.originalEvent.target).is('[contenteditable]')) { + ev.preventDefault(); + } + }); }); function link_dialog(editor) { - return new website.editor.LinkDialog(editor).appendTo(document.body); + return new website.editor.RTELinkDialog(editor).appendTo(document.body); } function image_dialog(editor) { - return new website.editor.ImageDialog(editor).appendTo(document.body); + return new website.editor.RTEImageDialog(editor).appendTo(document.body); } + // only enable editors manually + CKEDITOR.disableAutoInline = true; + // EDIT ALL THE THINGS + CKEDITOR.dtd.$editable = $.extend( + {}, CKEDITOR.dtd.$block, CKEDITOR.dtd.$inline); + // Disable removal of empty elements on CKEDITOR activation. Empty + // elements are used for e.g. support of FontAwesome icons + CKEDITOR.dtd.$removeEmpty = {}; + website.init_editor = function () { CKEDITOR.plugins.add('customdialogs', { - requires: 'link,image', +// requires: 'link,image', init: function (editor) { editor.on('doubleclick', function (evt) { - if (evt.data.dialog === 'link') { - delete evt.data.dialog; - link_dialog(editor); - } else if(evt.data.dialog === 'image') { - delete evt.data.dialog; + var element = evt.data.element; + if (element.is('img') + && !element.data('cke-realelement') + && !element.isReadOnly() + && (element.data('oe-model') !== 'ir.ui.view')) { image_dialog(editor); + return; } - // priority should be smaller than dialog (999) but bigger - // than link or image (default=10) + + element = get_selected_link(editor) || evt.data.element; + if (element.isReadOnly() + || !element.is('a') + || element.data('oe-model')) { + return; + } + + editor.getSelection().selectElement(element); + link_dialog(editor); }, null, null, 500); + var previousSelection; + editor.on('selectionChange', function (evt) { + var selected = evt.data.path.lastElement; + if (previousSelection) { + // cleanup previous selection + $(previousSelection).next().remove(); + previousSelection = null; + } + if (!selected.is('img') + || selected.data('cke-realelement') + || selected.isReadOnly() + || selected.data('oe-model') === 'ir.ui.view') { + return; + } + + // display button + var $el = $(previousSelection = selected.$); + var $btn = $('') + .insertAfter($el) + .click(function (e) { + e.preventDefault(); + e.stopPropagation(); + image_dialog(editor); + }); + + var position = $el.position(); + $btn.css({ + position: 'absolute', + top: $el.height() / 2 + position.top - $btn.outerHeight() / 2, + left: $el.width() / 2 + position.left - $btn.outerWidth() / 2, + }); + }); + + //noinspection JSValidateTypes editor.addCommand('link', { - exec: function (editor, data) { + exec: function (editor) { link_dialog(editor); return true; }, canUndo: false, editorFocus: true, }); + //noinspection JSValidateTypes editor.addCommand('image', { - exec: function (editor, data) { + exec: function (editor) { image_dialog(editor); return true; }, canUndo: false, editorFocus: true, }); + + editor.ui.addButton('Link', { + label: 'Link', + command: 'link', + toolbar: 'links,10', + icon: '/website/static/lib/ckeditor/plugins/link/icons/link.png', + }); + editor.ui.addButton('Image', { + label: 'Image', + command: 'image', + toolbar: 'insert,10', + icon: '/website/static/lib/ckeditor/plugins/image/icons/image.png', + }); + + editor.setKeystroke(CKEDITOR.CTRL + 76 /*L*/, 'link'); } }); CKEDITOR.plugins.add( 'tablebutton', { @@ -94,6 +170,7 @@ }).on('click', 'td', function (e) { var $e = $(e.target); + //noinspection JSPotentiallyInvalidConstructorUsage var table = new CKEDITOR.dom.element( $(openerp.qweb.render('website.editor.table', { rows: $e.closest('tr').index() + 1, @@ -102,6 +179,7 @@ editor.insertElement(table); setTimeout(function () { + //noinspection JSPotentiallyInvalidConstructorUsage var firstCell = new CKEDITOR.dom.element(table.$.rows[0].cells[0]); var range = editor.createRange(); range.moveToPosition(firstCell, CKEDITOR.POSITION_AFTER_START); @@ -116,10 +194,183 @@ } }); + CKEDITOR.plugins.add('oeref', { + requires: 'widget', + + init: function (editor) { + editor.widgets.add('oeref', { + editables: { text: '*' }, + + upcast: function (el) { + return el.attributes['data-oe-type'] + && el.attributes['data-oe-type'] !== 'monetary'; + }, + }); + editor.widgets.add('monetary', { + editables: { text: 'span.oe_currency_value' }, + + upcast: function (el) { + return el.attributes['data-oe-type'] === 'monetary'; + } + }); + } + }); + + CKEDITOR.plugins.add('bootstrapcombo', { + requires: 'richcombo', + + init: function (editor) { + var config = editor.config; + + editor.ui.addRichCombo('BootstrapLinkCombo', { + // default title + label: "Links", + // hover + title: "Link styling", + toolbar: 'styles,10', + allowedContent: ['a'], + + panel: { + css: [ + '/website/static/lib/bootstrap/css/bootstrap.css', + CKEDITOR.skin.getPath( 'editor' ) + ].concat( config.contentsCss ), + multiSelect: true, + }, + + types: { + 'basic': 'btn-default', + 'primary': 'btn-primary', + 'success': 'btn-success', + 'info': 'btn-info', + 'warning': 'btn-warning', + 'danger': 'btn-danger', + }, + + sizes: { + 'large': 'btn-lg', + 'default': '', + 'small': 'btn-sm', + 'extra small': 'btn-xs', + }, + + init: function () { + this.add('', 'Reset'); + this.startGroup("Types"); + for(var type in this.types) { + if (!this.types.hasOwnProperty(type)) { continue; } + var cls = this.types[type]; + var el = _.str.sprintf( + '%s', + cls, type); + this.add(type, el); + } + this.startGroup("Sizes"); + for (var size in this.sizes) { + if (!this.sizes.hasOwnProperty(size)) { continue; } + cls = this.sizes[size]; + + el = _.str.sprintf( + '%s', + cls, size); + this.add(size, el); + } + this.commit(); + }, + onRender: function () { + var self = this; + editor.on('selectionChange', function (e) { + var path = e.data.path, el; + + if (!(el = path.contains('a'))) { + self.element = null; + self.disable(); + return; + } + + self.enable(); + // This is crap, but getting the currently selected + // element from within onOpen absolutely does not + // work, so store the "current" element in the + // widget instead + self.element = el; + }); + setTimeout(function () { + // Because I can't find any normal hook where the + // bloody button's bloody element is available + self.disable(); + }, 0); + }, + onOpen: function () { + this.showAll(); + this.unmarkAll(); + + for(var val in this.types) { + if (!this.types.hasOwnProperty(val)) { continue; } + var cls = this.types[val]; + if (!this.element.hasClass(cls)) { continue; } + + this.mark(val); + break; + } + + var found; + for(val in this.sizes) { + if (!this.sizes.hasOwnProperty(val)) { continue; } + cls = this.sizes[val]; + if (!cls || !this.element.hasClass(cls)) { continue; } + + found = true; + this.mark(val); + break; + } + if (!found && this.element.hasClass('btn')) { + this.mark('default'); + } + }, + onClick: function (value) { + editor.focus(); + editor.fire('saveShapshot'); + + // basic btn setup + var el = this.element; + if (!el.hasClass('btn')) { + el.addClass('btn'); + el.addClass('btn-default'); + } + + if (!value) { + this.setClass(this.types); + this.setClass(this.sizes); + el.removeClass('btn'); + } else if (value in this.types) { + this.setClass(this.types, value); + } else if (value in this.sizes) { + this.setClass(this.sizes, value); + } + + editor.fire('saveShapshot'); + }, + setClass: function (classMap, value) { + var element = this.element; + _(classMap).each(function (cls) { + if (!cls) { return; } + element.removeClass(cls); + }.bind(this)); + + var cls = classMap[value]; + if (cls) { + element.addClass(cls); + } + } + }); + }, + }); + var editor = new website.EditorBar(); var $body = $(document.body); editor.prependTo($body).then(function () { - if (location.search.indexOf("unable_editor") >= 0) { + if (location.search.indexOf("enable_editor") >= 0) { editor.edit(); } }); @@ -151,8 +402,11 @@ } }); // Adding Static Menus - menu.append('
  • Change Theme
  • '); - menu.append('
  • Advanced view editor
  • '); + menu.append('
  • '); + menu.append('
  • HTML Editor
  • '); + menu.append('
  • Change Theme
  • '); + menu.append('
  • Install Apps
  • '); + self.trigger('rte:customize_menu_ready'); } ); }); @@ -160,7 +414,7 @@ var view_id = $(event.currentTarget).data('view-id'); openerp.jsonRpc('/website/customize_template_toggle', 'call', { 'view_id': view_id - }).then( function(result) { + }).then( function() { window.location.reload(); }); }); @@ -184,94 +438,102 @@ this.rte = new website.RTE(this); this.rte.on('change', this, this.proxy('rte_changed')); + this.rte.on('rte:ready', this, function () { + self.trigger('rte:ready'); + }); return $.when( this._super.apply(this, arguments), - this.rte.prependTo(this.$('#website-top-edit .nav.pull-right')) + this.rte.appendTo(this.$('#website-top-edit .nav.pull-right')) ); }, edit: function () { - var self = this; this.$buttons.edit.prop('disabled', true); this.$('#website-top-view').hide(); this.$('#website-top-edit').show(); $('.css_non_editable_mode_hidden').removeClass("css_non_editable_mode_hidden"); - var $editables = $('[data-oe-model]') - .not('link, script') - // FIXME: propagation should make "meta" blocks non-editable in the first place... - .not('.oe_snippets,.oe_snippet, .oe_snippet *') - .prop('contentEditable', true) - .addClass('oe_editable'); - var $rte_ables = $editables.not('[data-oe-type]'); - var $raw_editables = $editables.not($rte_ables); - - // temporary: on raw editables, links are still active so an - // editable link, containing a link or within a link becomes very - // hard to edit. Disable linking for these. - $raw_editables.parents('a') - .add($raw_editables.find('a')) - .on('click', function (e) { - e.preventDefault(); - }); - - this.rte.start_edition($rte_ables); - $raw_editables.each(function () { - observer.observe(this, OBSERVER_CONFIG); - }).one('content_changed', function () { - $(this).addClass('oe_dirty'); - self.rte_changed(); - }); + this.rte.start_edition(); }, rte_changed: function () { this.$buttons.save.prop('disabled', false); }, save: function () { var self = this; - var defs = []; + observer.disconnect(); - $('.oe_dirty').each(function (i, v) { - var $el = $(this); - // TODO: Add a queue with concurrency limit in webclient - // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js - var def = self.saving_mutex.exec(function () { - return self.saveElement($el).then(function () { - $el.removeClass('oe_dirty'); - }).fail(function () { - var data = $el.data(); - console.error(_.str.sprintf('Could not save %s#%d#%s', data.oeModel, data.oeId, data.oeField)); + var editor = this.rte.editor; + var root = editor.element.$; + editor.destroy(); + // FIXME: select editables then filter by dirty? + var defs = this.rte.fetch_editables(root) + .filter('.oe_dirty') + .removeAttr('contentEditable') + .removeClass('oe_dirty oe_editable cke_focus oe_carlos_danger') + .map(function () { + var $el = $(this); + // TODO: Add a queue with concurrency limit in webclient + // https://github.com/medikoo/deferred/blob/master/lib/ext/function/gate.js + return self.saving_mutex.exec(function () { + return self.saveElement($el) + .then(undefined, function (thing, response) { + // because ckeditor regenerates all the dom, + // we can't just setup the popover here as + // everything will be destroyed by the DOM + // regeneration. Add markings instead, and + // returns a new rejection with all relevant + // info + var id = _.uniqueId('carlos_danger_'); + $el.addClass('oe_dirty oe_carlos_danger'); + $el.addClass(id); + return $.Deferred().reject({ + id: id, + error: response.data, + }); + }); }); - }); - defs.push(def); - }); + }).get(); return $.when.apply(null, defs).then(function () { - window.location.href = window.location.href.replace(/unable_editor(=[^&]*)?/, ''); + website.reload(); + }, function (failed) { + // If there were errors, re-enable edition + self.rte.start_edition(true).then(function () { + // jquery's deferred being a pain in the ass + if (!_.isArray(failed)) { failed = [failed]; } + + _(failed).each(function (failure) { + $(root).find('.' + failure.id) + .removeClass(failure.id) + .popover({ + trigger: 'hover', + content: failure.error.message, + placement: 'auto top', + }) + // Force-show popovers so users will notice them. + .popover('show'); + }) + }); }); }, + /** + * Saves an RTE content, which always corresponds to a view section (?). + */ saveElement: function ($el) { - var data = $el.data(); - var html = $el.html(); - var xpath = data.oeXpath; - if (xpath) { - var $w = $el.clone(); - $w.removeClass('oe_dirty'); - _.each(['model', 'id', 'field', 'xpath'], function(d) {$w.removeAttr('data-oe-' + d);}); - $w - .removeClass('oe_editable') - .prop('contentEditable', false); - html = $w.wrap('
    ').parent().html(); - } + var markup = $el.prop('outerHTML'); return openerp.jsonRpc('/web/dataset/call', 'call', { model: 'ir.ui.view', method: 'save', - args: [data.oeModel, data.oeId, data.oeField, html, xpath, website.get_context()] + args: [$el.data('oe-id'), markup, + $el.data('oe-xpath') || null, + website.get_context()], }); }, cancel: function () { - window.location.href = window.location.href.replace(/unable_editor(=[^&]*)?/, ''); + website.reload(); }, }); + var blocks_selector = _.keys(CKEDITOR.dtd.$block).join(','); /* ----- RICH TEXT EDITOR ---- */ website.RTE = openerp.Widget.extend({ tagName: 'li', @@ -285,18 +547,162 @@ this._super.apply(this, arguments); }, - start_edition: function ($elements) { + /** + * In Webkit-based browsers, triple-click will select a paragraph up to + * the start of the next "paragraph" including any empty space + * inbetween. When said paragraph is removed or altered, it nukes + * the empty space and brings part of the content of the next + * "paragraph" (which may well be e.g. an image) into the current one, + * completely fucking up layouts and breaking snippets. + * + * Try to fuck around with selections on triple-click to attempt to + * fix this garbage behavior. + * + * Note: for consistent behavior we may actually want to take over + * triple-clicks, in all browsers in order to ensure consistent cross- + * platform behavior instead of being at the mercy of rendering engines + * & platform selection quirks? + */ + webkitSelectionFixer: function (root) { + root.addEventListener('click', function (e) { + // only webkit seems to have a fucked up behavior, ignore others + // FIXME: $.browser goes away in jquery 1.9... + if (!$.browser.webkit) { return; } + // http://www.w3.org/TR/DOM-Level-2-Events/events.html#Events-eventgroupings-mouseevents + // The detail attribute indicates the number of times a mouse button has been pressed + // we just want the triple click + if (e.detail !== 3) { return; } + e.preventDefault(); + + // Get closest block-level element to the triple-clicked + // element (using ckeditor's block list because why not) + var $closest_block = $(e.target).closest(blocks_selector); + + // manually set selection range to the content of the + // triple-clicked block-level element, to avoid crossing over + // between block-level elements + document.getSelection().selectAllChildren($closest_block[0]); + }); + }, + tableNavigation: function (root) { + var self = this; + $(root).on('keydown', function (e) { + // ignore non-TAB + if (e.which !== 9) { return; } + + if (self.handleTab(e)) { + e.preventDefault(); + } + }); + }, + /** + * Performs whatever operation is necessary on a [TAB] hit, returns + * ``true`` if the event's default should be cancelled (if the TAB was + * handled by the function) + */ + handleTab: function (event) { + var forward = !event.shiftKey; + + var root = window.getSelection().getRangeAt(0).commonAncestorContainer; + var $cell = $(root).closest('td,th'); + + if (!$cell.length) { return false; } + + var cell = $cell[0]; + + // find cell in same row + var row = cell.parentNode; + var sibling = row.cells[cell.cellIndex + (forward ? 1 : -1)]; + if (sibling) { + document.getSelection().selectAllChildren(sibling); + return true; + } + + // find cell in previous/next row + var table = row.parentNode; + var sibling_row = table.rows[row.rowIndex + (forward ? 1 : -1)]; + if (sibling_row) { + var new_cell = sibling_row.cells[forward ? 0 : sibling_row.cells.length - 1]; + document.getSelection().selectAllChildren(new_cell); + return true; + } + + // at edge cells, copy word/openoffice behavior: if going backwards + // from first cell do nothing, if going forwards from last cell add + // a row + if (forward) { + var row_size = row.cells.length; + var new_row = document.createElement('tr'); + while(row_size--) { + var newcell = document.createElement('td'); + // zero-width space + newcell.textContent = '\u200B'; + new_row.appendChild(newcell); + } + table.appendChild(new_row); + document.getSelection().selectAllChildren(new_row.cells[0]); + } + + return true; + }, + /** + * Makes the page editable + * + * @param {Boolean} [restart=false] in case the edition was already set + * up once and is being re-enabled. + * @returns {$.Deferred} deferred indicating when the RTE is ready + */ + start_edition: function (restart) { + var self = this; + // create a single editor for the whole page + var root = document.getElementById('wrapwrap'); + if (!restart) { + $(root).on('dragstart', 'img', function (e) { + e.preventDefault(); + }); + this.webkitSelectionFixer(root); + this.tableNavigation(root); + } + var def = $.Deferred(); + var editor = this.editor = CKEDITOR.inline(root, self._config()); + editor.on('instanceReady', function () { + editor.setReadOnly(false); + // ckeditor set root to editable, disable it (only inner + // sections are editable) + // FIXME: are there cases where the whole editor is editable? + editor.editable().setReadOnly(true); + + self.setup_editables(root); + + // disable firefox's broken table resizing thing + document.execCommand("enableObjectResizing", false, "false"); + document.execCommand("enableInlineTableEditing", false, "false"); + + self.trigger('rte:ready'); + def.resolve(); + }); + return def; + }, + + setup_editables: function (root) { + // selection of editable sub-items was previously in + // EditorBar#edit, but for some unknown reason the elements were + // apparently removed and recreated (?) at editor initalization, + // and observer setup was lost. var self = this; - $elements - .not('span, [data-oe-type]') + // setup dirty-marking for each editable element + this.fetch_editables(root) + .addClass('oe_editable') .each(function () { var node = this; var $node = $(node); - var editor = CKEDITOR.inline(this, self._config()); - editor.on('instanceReady', function () { - self.trigger('instanceReady'); - observer.observe(node, OBSERVER_CONFIG); - }); + // only explicitly set contenteditable on view sections, + // cke widgets system will do the widgets themselves + if ($node.data('oe-model') === 'ir.ui.view') { + node.contentEditable = true; + } + + observer.observe(node, OBSERVER_CONFIG); $node.one('content_changed', function () { $node.addClass('oe_dirty'); self.trigger('change'); @@ -304,31 +710,58 @@ }); }, + fetch_editables: function (root) { + return $(root).find('[data-oe-model]') + .not('link, script') + .not('.oe_snippet_editor') + .filter(function () { + var $this = $(this); + // keep view sections and fields which are *not* in + // view sections for top-level editables + return $this.data('oe-model') === 'ir.ui.view' + || !$this.closest('[data-oe-model = "ir.ui.view"]').length; + }); + }, + _current_editor: function () { return CKEDITOR.currentInstance; }, _config: function () { - var removed_plugins = [ - // remove custom context menu - 'contextmenu,tabletools,liststyle', - // magicline captures mousein/mouseout => draggable does not work - 'magicline' + // base plugins minus + // - magicline (captures mousein/mouseout -> breaks draggable) + // - contextmenu & tabletools (disable contextual menu) + // - bunch of unused plugins + var plugins = [ + 'a11yhelp', 'basicstyles', 'blockquote', + 'clipboard', 'colorbutton', 'colordialog', 'dialogadvtab', + 'elementspath', 'enterkey', 'entities', 'filebrowser', + 'find', 'floatingspace','format', 'htmlwriter', 'iframe', + 'indentblock', 'indentlist', 'justify', + 'list', 'pastefromword', 'pastetext', 'preview', + 'removeformat', 'resize', 'save', 'selectall', 'stylescombo', + 'table', 'templates', 'toolbar', 'undo', 'wysiwygarea' ]; return { + // FIXME + language: 'en', // Disable auto-generated titles // FIXME: accessibility, need to generate user-sensible title, used for @title and @aria-label title: false, - removePlugins: removed_plugins.join(','), + plugins: plugins.join(','), uiColor: '', + // FIXME: currently breaks RTE? // Ensure no config file is loaded customConfig: '', // Disable ACF allowedContent: true, // Don't insert paragraphs around content in e.g.
  • autoParagraph: false, + // Don't automatically add   or
    in empty block-level + // elements when edition starts + fillEmptyBlocks: false, filebrowserImageUploadUrl: "/website/attach", // Support for sharedSpaces in 4.x - extraPlugins: 'sharedspace,customdialogs,tablebutton', + extraPlugins: 'sharedspace,customdialogs,tablebutton,oeref,bootstrapcombo', // Place toolbar in controlled location sharedSpaces: { top: 'oe_rte_toolbar' }, toolbar: [{ @@ -340,7 +773,7 @@ "Superscript", "TextColor", "BGColor", "RemoveFormat" ]},{ name: 'span', items: [ - "Link", "Unlink", "Blockquote", "BulletedList", + "Link", "Blockquote", "BulletedList", "NumberedList", "Indent", "Outdent" ]},{ name: 'justify', items: [ @@ -350,7 +783,7 @@ "Image", "TableButton" ]},{ name: 'styles', items: [ - "Styles" + "Styles", "BootstrapLinkCombo" ]} ], // styles dropdown in toolbar @@ -363,14 +796,7 @@ {name: "Heading 5", element: 'h5'}, {name: "Heading 6", element: 'h6'}, {name: "Formatted", element: 'pre'}, - {name: "Address", element: 'address'}, - // emphasis - {name: "Muted", element: 'span', attributes: {'class': 'text-muted'}}, - {name: "Primary", element: 'span', attributes: {'class': 'text-primary'}}, - {name: "Warning", element: 'span', attributes: {'class': 'text-warning'}}, - {name: "Danger", element: 'span', attributes: {'class': 'text-danger'}}, - {name: "Success", element: 'span', attributes: {'class': 'text-success'}}, - {name: "Info", element: 'span', attributes: {'class': 'text-info'}} + {name: "Address", element: 'address'} ], }; }, @@ -388,10 +814,13 @@ }, start: function () { var sup = this._super(); - this.$el.modal(); + this.$el.modal({backdrop: 'static'}); return sup; }, save: function () { + this.close(); + }, + close: function () { this.$el.modal('hide'); }, }); @@ -399,23 +828,155 @@ website.editor.LinkDialog = website.editor.Dialog.extend({ template: 'website.editor.dialog.link', events: _.extend({}, website.editor.Dialog.prototype.events, { + 'change .url-source': function (e) { this.changed($(e.target)); }, + 'mousedown': function (e) { + var $target = $(e.target).closest('.list-group-item'); + if (!$target.length || $target.hasClass('active')) { + // clicked outside groups, or clicked in active groups + return; + } + + this.changed($target.find('.url-source')); + }, + 'click button.remove': 'remove_link', + 'change input#link-text': function (e) { + this.text = $(e.target).val() + }, }), init: function (editor) { this._super(editor); // url -> name mapping for existing pages this.pages = Object.create(null); + this.text = null; }, start: function () { + var self = this; + return $.when( + this.fetch_pages().done(this.proxy('fill_pages')), + this._super() + ).done(function () { + self.bind_data(); + }); + }, + save: function () { + var self = this, _super = this._super.bind(this); + var $e = this.$('.list-group-item.active .url-source'); + var val = $e.val(); + if (!val || !$e[0].checkValidity()) { + // FIXME: error message + $e.closest('.form-group').addClass('has-error'); + $e.focus(); + return; + } + + var done = $.when(); + if ($e.hasClass('email-address')) { + this.make_link('mailto:' + val, false, val); + } else if ($e.hasClass('existing')) { + self.make_link(val, false, this.pages[val]); + } else if ($e.hasClass('pages')) { + // Create the page, get the URL back + done = $.get(_.str.sprintf( + '/pagenew/%s?noredirect', encodeURI(val))) + .then(function (response) { + self.make_link(response, false, val); + }); + } else { + this.make_link(val, this.$('input.window-new').prop('checked')); + } + done.then(_super); + }, + make_link: function (url, new_window, label) { + }, + bind_data: function (text, href, new_window) { + href = href || this.element && (this.element.data( 'cke-saved-href') + || this.element.getAttribute('href')); + if (!href) { return; } + + if (new_window === undefined) { + new_window = this.element.getAttribute('target') === '_blank'; + } + if (text === undefined) { + text = this.element.getText(); + } + + var match, $control; + if ((match = /mailto:(.+)/.exec(href))) { + $control = this.$('input.email-address').val(match[1]); + } else if (href in this.pages) { + $control = this.$('select.existing').val(href); + } else if ((match = /\/page\/(.+)/.exec(href))) { + var actual_href = '/page/website.' + match[1]; + if (actual_href in this.pages) { + $control = this.$('select.existing').val(actual_href); + } + } + if (!$control) { + $control = this.$('input.url').val(href); + } + + this.changed($control); + + this.$('input#link-text').val(text); + this.$('input.window-new').prop('checked', new_window); + }, + changed: function ($e) { + this.$('.url-source').not($e).val(''); + $e.closest('.list-group-item') + .addClass('active') + .siblings().removeClass('active') + .addBack().removeClass('has-error'); + }, + fetch_pages: function () { + return openerp.jsonRpc('/web/dataset/call_kw', 'call', { + model: 'website', + method: 'list_pages', + args: [null], + kwargs: { + context: website.get_context() + }, + }); + }, + fill_pages: function (results) { + var self = this; + var pages = this.$('select.existing')[0]; + _(results).each(function (result) { + self.pages[result.url] = result.name; + + pages.options[pages.options.length] = + new Option(result.name, result.url); + }); + }, + }); + website.editor.RTELinkDialog = website.editor.LinkDialog.extend({ + start: function () { var element; if ((element = this.get_selected_link()) && element.hasAttribute('href')) { this.editor.getSelection().selectElement(element); } this.element = element; + if (element) { + this.add_removal_button(); + } - return $.when( - this.fetch_pages().done(this.proxy('fill_pages')), - this._super() - ).done(this.proxy('bind_data')); + return this._super(); + }, + add_removal_button: function () { + this.$('.modal-footer').prepend( + openerp.qweb.render( + 'website.editor.dialog.link.footer-button')); + }, + remove_link: function () { + var editor = this.editor; + // same issue as in make_link + setTimeout(function () { + editor.removeStyle(new CKEDITOR.style({ + element: 'a', + type: CKEDITOR.STYLE_INLINE, + alwaysRemoveElement: true, + })); + }, 0); + this.close(); }, /** * Greatly simplified version of CKEDITOR's @@ -437,16 +998,20 @@ if (this.element) { this.element.setAttributes(attributes); this.element.removeAttributes(to_remove); + if (this.text) { this.element.setText(this.text); } } else { var selection = this.editor.getSelection(); var range = selection.getRanges(true)[0]; if (range.collapsed) { - var text = new CKEDITOR.dom.text(label || url); + //noinspection JSPotentiallyInvalidConstructorUsage + var text = new CKEDITOR.dom.text( + this.text || label || url); range.insertNode(text); range.selectNodeContents(text); } + //noinspection JSPotentiallyInvalidConstructorUsage new CKEDITOR.style({ type: CKEDITOR.STYLE_INLINE, element: 'a', @@ -460,144 +1025,69 @@ }, 0); } }, - save: function () { - var self = this, _super = this._super.bind(this); - var $active_tab = this.$('.tab-pane.active'); - - var $e = $active_tab.find('.url-source'); - - var val = $e.val(), done = $.when(); - if ($active_tab.is('#link-email')) { - this.make_link('mailto:' + val, false, val); - } else if ($active_tab.is('#link-existing')) { - self.make_link(val, false, this.pages[val]); - } else if ($active_tab.is('#link-new')) { - // Create the page, get the URL back - done = $.get(_.str.sprintf( - '/pagenew/%s?noredirect', encodeURIComponent(val))) - .then(function (response) { - val = response; - }); - done.then(function () { - self.make_link(val, false); - }); - } else { - this.make_link(val, this.$('input.window-new').prop('checked')); - } - done.then(_super); - }, - bind_data: function () { - var href = this.element && (this.element.data( 'cke-saved-href') - || this.element.getAttribute('href')); - if (!href) { return; } - - var match, $control; - if (match = /(mailto):(.+)/.exec(href)) { - $control = this.$('#link-email input').val(match[2]); - } else if(href in this.pages) { - $control = this.$('#link-existing select').val(href); - } else { - $control = this.$('#link-external input:first').val(href); - } - - var tab_name = $control.closest('.tab-pane').attr('id'); - this.$('.nav a[href="#' + tab_name + '"]').tab('show'); - - this.$('input.window-new').prop( - 'checked', this.element.getAttribute('target') === '_blank'); - }, /** * CKEDITOR.plugins.link.getSelectedLink ignores the editor's root, * if the editor is set directly on a link it will thus not work. */ get_selected_link: function () { - var sel = this.editor.getSelection(), - el = sel.getSelectedElement(); - if (el && el.is('a')) { return el; } - - var range = sel.getRanges(true)[0]; - if (!range) { return null; } - - range.shrink(CKEDITOR.SHRINK_TEXT); - return this.editor.elementPath(range.getCommonAncestor()) - .contains('a'); - - }, - fetch_pages: function () { - return openerp.jsonRpc('/web/dataset/call_kw', 'call', { - model: 'website', - method: 'list_pages', - args: [], - kwargs: { - context: website.get_context() - }, - }); - }, - fill_pages: function (results) { - var self = this; - var pages = this.$('#link-existing-select')[0]; - _(results).each(function (result) { - self.pages[result.url] = result.name; - - pages.options[pages.options.length] = - new Option(result.name, result.url); - }); + return get_selected_link(this.editor); }, }); + + /** + * ImageDialog widget. Lets users change an image, including uploading a + * new image in OpenERP or selecting the image style (if supported by + * the caller). + * + * Initialized as usual, but the caller can hook into two events: + * + * @event start({url, style}) called during dialog initialization and + * opening, the handler can *set* the ``url`` + * and ``style`` properties on its parameter + * to provide these as default values to the + * dialog + * @event save({url, style}) called during dialog finalization, the handler + * is provided with the image url and style + * selected by the users (or possibly the ones + * originally passed in) + */ website.editor.ImageDialog = website.editor.Dialog.extend({ template: 'website.editor.dialog.image', events: _.extend({}, website.editor.Dialog.prototype.events, { + 'change .url-source': function (e) { this.changed($(e.target)); }, 'click button.filepicker': function () { this.$('input[type=file]').click(); }, 'change input[type=file]': 'file_selection', 'change input.url': 'preview_image', + 'click a[href=#existing]': 'browse_existing', 'change select.image-style': 'preview_image', - 'click .existing-attachments a': 'select_existing', }), - start: function () { - var selection = this.editor.getSelection(); - var el = selection && selection.getSelectedElement(); - this.element = null; - var $select = this.$('.image-style'); - var $options = $select.children(); + start: function () { + this.$('.modal-footer [disabled]').text("Uploading…"); + var $options = this.$('.image-style').children(); this.image_styles = $options.map(function () { return this.value; }).get(); - if (el && el.is('img')) { - this.element = el; - _(this.image_styles).each(function (style) { - if (el.hasClass(style)) { - $select.val(style); - } - }); - // set_image should follow setup of image style - this.set_image(el.getAttribute('src')); + var o = { url: null, style: null, }; + // avoid typos, prevent addition of new properties to the object + Object.preventExtensions(o); + this.trigger('start', o); + + if (o.url) { + if (o.style) { + this.$('.image-style').val(o.style); + } + this.set_image(o.url); } - return $.when( - this._super(), - this.fetch_existing().then(this.proxy('fetched_existing'))); + return this._super(); }, save: function () { - var url = this.$('input.url').val(); - var style = this.$('.image-style').val(); - var element, editor = this.editor; - if (!(element = this.element)) { - element = editor.document.createElement('img'); - // focus event handler interactions between bootstrap (modal) - // and ckeditor (RTE) lead to blowing the stack in Safari and - // Chrome (but not FF) when this is done synchronously => - // defer insertion so modal has been hidden & destroyed before - // it happens - setTimeout(function () { - editor.insertElement(element); - }, 0); - } - element.setAttribute('src', url); - $(element.$).removeClass(this.image_styles.join(' ')); - if (style) { element.addClass(style); } - + this.trigger('save', { + url: this.$('input.url').val(), + style: this.$('.image-style').val(), + }); return this._super(); }, @@ -610,7 +1100,8 @@ this.preview_image(); }, - file_selection: function (e) { + file_selection: function () { + this.$el.addClass('nosave'); this.$('button.filepicker').removeClass('btn-danger btn-success'); var self = this; @@ -633,6 +1124,7 @@ this.set_image(url); }, preview_image: function () { + this.$el.removeClass('nosave'); var image = this.$('input.url').val(); if (!image) { return; } @@ -641,15 +1133,92 @@ .removeClass(this.image_styles.join(' ')) .addClass(this.$('select.image-style').val()); }, + browse_existing: function (e) { + e.preventDefault(); + new website.editor.ExistingImageDialog(this).appendTo(document.body); + }, + }); + website.editor.RTEImageDialog = website.editor.ImageDialog.extend({ + init: function () { + this._super.apply(this, arguments); + + this.on('start', this, this.proxy('started')); + this.on('save', this, this.proxy('saved')); + }, + started: function (holder) { + var selection = this.editor.getSelection(); + var el = selection && selection.getSelectedElement(); + this.element = null; + + if (el && el.is('img')) { + this.element = el; + _(this.image_styles).each(function (style) { + if (el.hasClass(style)) { + holder.style = style; + } + }); + holder.url = el.getAttribute('src'); + } + }, + saved: function (data) { + var element, editor = this.editor; + if (!(element = this.element)) { + element = editor.document.createElement('img'); + element.addClass('img'); + // focus event handler interactions between bootstrap (modal) + // and ckeditor (RTE) lead to blowing the stack in Safari and + // Chrome (but not FF) when this is done synchronously => + // defer insertion so modal has been hidden & destroyed before + // it happens + setTimeout(function () { + editor.insertElement(element); + }, 0); + } + + var style = data.style; + element.setAttribute('src', data.url); + element.removeAttribute('data-cke-saved-src'); + $(element.$).removeClass(this.image_styles.join(' ')); + if (style) { element.addClass(style); } + }, + }); + + var IMAGES_PER_ROW = 6; + var IMAGES_ROWS = 4; + website.editor.ExistingImageDialog = website.editor.Dialog.extend({ + template: 'website.editor.dialog.image.existing', + events: _.extend({}, website.editor.Dialog.prototype.events, { + 'click .existing-attachments img': 'select_existing', + 'click .pager > li': function (e) { + e.preventDefault(); + var $target = $(e.currentTarget); + if ($target.hasClass('disabled')) { + return; + } + this.page += $target.hasClass('previous') ? -1 : 1; + this.display_attachments(); + }, + }), + init: function (parent) { + this.image = null; + this.page = 0; + this.parent = parent; + this._super(parent.editor); + }, + + start: function () { + return $.when( + this._super(), + this.fetch_existing().then(this.proxy('fetched_existing'))); + }, fetch_existing: function () { - // FIXME: lazy load attachments? return openerp.jsonRpc('/web/dataset/call_kw', 'call', { model: 'ir.attachment', method: 'search_read', args: [], kwargs: { - fields: ['name'], + fields: ['name', 'website_url'], domain: [['res_model', '=', 'ir.ui.view']], order: 'name', context: website.get_context(), @@ -657,22 +1226,60 @@ }); }, fetched_existing: function (records) { + this.records = records; + this.display_attachments(); + }, + display_attachments: function () { + var per_screen = IMAGES_PER_ROW * IMAGES_ROWS; + + var from = this.page * per_screen; + var records = this.records; + // Create rows of 3 records var rows = _(records).chain() - .groupBy(function (_, index) { return Math.floor(index / 3); }) + .slice(from, from + per_screen) + .groupBy(function (_, index) { return Math.floor(index / IMAGES_PER_ROW); }) .values() .value(); + this.$('.existing-attachments').replaceWith( - openerp.qweb.render('website.editor.dialog.image.existing', {rows: rows})); + openerp.qweb.render( + 'website.editor.dialog.image.existing.content', {rows: rows})); + this.$('.pager') + .find('li.previous').toggleClass('disabled', (from === 0)).end() + .find('li.next').toggleClass('disabled', (from + per_screen >= records.length)); + }, select_existing: function (e) { - e.preventDefault(); - this.set_image(e.currentTarget.getAttribute('href')); + var link = $(e.currentTarget).attr('src'); + if (link) { + this.parent.set_image(link); + } + this.close() }, }); + function get_selected_link(editor) { + var sel = editor.getSelection(), + el = sel.getSelectedElement(); + if (el && el.is('a')) { return el; } + + var range = sel.getRanges(true)[0]; + if (!range) { return null; } + + range.shrink(CKEDITOR.SHRINK_TEXT); + var commonAncestor = range.getCommonAncestor(); + var viewRoot = editor.elementPath(commonAncestor).contains(function (element) { + return element.data('oe-model') === 'ir.ui.view' + }); + if (!viewRoot) { return null; } + // if viewRoot is the first link, don't edit it. + return new CKEDITOR.dom.elementPath(commonAncestor, viewRoot) + .contains('a', true); + } + - var Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver; + website.Observer = window.MutationObserver || window.WebkitMutationObserver || window.JsMutationObserver; var OBSERVER_CONFIG = { childList: true, attributes: true, @@ -680,20 +1287,20 @@ subtree: true, attributeOldValue: true, }; - var observer = new Observer(function (mutations) { + var observer = new website.Observer(function (mutations) { // NOTE: Webkit does not fire DOMAttrModified => webkit browsers // relying on JsMutationObserver shim (Chrome < 18, Safari < 6) // will not mark dirty on attribute changes (@class, img/@src, // a/@href, ...) _(mutations).chain() - .filter(function (m) { + .filter(function (m) { switch(m.type) { case 'attributes': // ignore .cke_focus being added or removed // if attribute is not a class, can't be .cke_focus change if (m.attributeName !== 'class') { return true; } // find out what classes were added or removed - var oldClasses = m.oldValue.split(/\s+/); + var oldClasses = (m.oldValue || '').split(/\s+/); var newClasses = m.target.className.split(/\s+/); var change = _.union(_.difference(oldClasses, newClasses), _.difference(newClasses, oldClasses)); @@ -711,6 +1318,7 @@ while (node && !$(node).hasClass('oe_editable')) { node = node.parentNode; } + $(m.target).trigger('node_changed'); return node; }) .compact()