Changes for page InplaceEditing

From version 1.1
edited by N Pompei
on 08/06/2020 17:32
Change comment: Install extension [org.xwiki.platform:xwiki-platform-edit-ui/12.4]
To version 4.1
edited by Nazzareno Pompei
on 28/10/2022 08:43
Change comment: Install extension [org.xwiki.platform:xwiki-platform-edit-ui/14.9]

Summary

Details

Page properties
Author
... ... @@ -1,1 +1,1 @@
1 -XWiki.NPompei
1 +XWiki.NazzarenoPompei
Content
... ... @@ -9,11 +9,15 @@
9 9   #jsonResponse($editConfirmation)
10 10   #else
11 11   ## Lock the document for editing.
12 - #set ($discard = $response.sendRedirect($tdoc.getURL('lock', $escapetool.url({
12 + #set ($lockParams = {
13 13   'ajax': 1,
14 14   'action': $request.lockAction,
15 15   'language': $tdoc.realLocale
16 - }))))
16 + })
17 + #if ($request.force == 'true')
18 + #set ($lockParams.force = 1)
19 + #end
20 + #set ($discard = $response.sendRedirect($tdoc.getURL('lock', $escapetool.url($lockParams))))
17 17   #end
18 18  #end
19 19  {{/velocity}}
XWiki.JavaScriptExtension[0]
Code
... ... @@ -1,9 +1,11 @@
1 +(function(config) {
2 + "use strict";
3 +
4 +const paths = config.paths;
5 +const l10n = config.l10n;
6 +
1 1  require.config({
2 - paths: {
3 - 'actionButtons': $jsontool.serialize($xwiki.getSkinFile('js/xwiki/actionbuttons/actionButtons.js', true)),
4 - // Required in case the user needs to resolve merge conflicts on save.
5 - 'diff': $jsontool.serialize($xwiki.getSkinFile('uicomponents/viewers/diff.js'))
6 - }
8 + paths: paths.js
7 7  });
8 8  
9 9  define('xwiki-document-api', ['jquery'], function($) {
... ... @@ -14,13 +14,36 @@
14 14  
15 15   return {
16 16   /**
19 + * @return this document's plain title
20 + */
21 + getPlainTitle() {
22 + return $('<div/>').html(this.renderedTitle || this.title).text();
23 + },
24 +
25 + /**
17 17   * @return this document's real locale
18 18   */
19 19   getRealLocale: function() {
20 - return this.language || (this.translations && this.translations['default']) || $('html').attr('lang');
29 + var realLocale = this.language;
30 + if (typeof realLocale !== 'string' || realLocale === '') {
31 + realLocale = this.getDefaultLocale();
32 + }
33 + return realLocale;
21 21   },
22 22  
23 23   /**
37 + * @return this document's default locale
38 + */
39 + getDefaultLocale: function() {
40 + if (this.translations && typeof this.translations['default'] === 'string') {
41 + return this.translations['default'];
42 + } else {
43 + // The default locale is not specified. Use the UI locale.
44 + return $('html').attr('lang');
45 + }
46 + },
47 +
48 + /**
24 24   * @return the URL that can be used to perform the specified action on this document
25 25   */
26 26   getURL: function(action, queryString, fragment) {
... ... @@ -55,25 +55,31 @@
55 55   */
56 56   render: function(forView) {
57 57   var queryString = {
58 - xpage: 'rendercontent',
83 + xpage: 'get',
59 59   outputTitle: true,
60 - outputSyntax: forView ? null : 'annotatedxhtml',
61 61   language: this.getRealLocale(),
62 62   // Make sure the response is not retrieved from cache (IE11 doesn't obey the caching HTTP headers).
63 63   timestamp: new Date().getTime()
64 64   };
65 - var thisXWikiDocument = this;
66 - return $.get(this.getURL('view'), queryString).fail(function() {
67 - new XWiki.widgets.Notification(
68 - $jsontool.serialize($services.localization.render('edit.inplace.page.renderFailed')),
69 - 'error'
70 - );
71 - }).then(function(html) {
89 + if (!forView) {
90 + // We need the annotated HTML when editing in order to be able to protect the rendering transformations and to
91 + // be able to recreate the wiki syntax.
92 + queryString.outputSyntax = 'annotatedhtml';
93 + queryString.outputSyntaxVersion = '5.0'
94 + // Currently, only the macro transformations are protected and thus can be edited.
95 + // See XRENDERING-78: Add markers to modified XDOM by Transformations/Macros
96 + queryString.transformations = 'macro';
97 + }
98 + return Promise.resolve($.get(this.getURL('view'), queryString)).then(html => {
99 + // Render succeeded.
72 72   var container = $('<div/>').html(html);
73 - return $.extend(thisXWikiDocument, {
101 + return $.extend(this, {
74 74   renderedTitle: container.find('#document-title h1').html(),
75 75   renderedContent: container.find('#xwikicontent').html()
76 76   });
105 + }).catch(() => {
106 + new XWiki.widgets.Notification(l10n['edit.inplace.page.renderFailed'], 'error');
107 + return Promise.reject(this);
77 77   });
78 78   },
79 79  
... ... @@ -83,16 +83,19 @@
83 83   * @return a promise that resolves to this document instance if the reload request succeeds
84 84   */
85 85   reload: function() {
86 - var thisXWikiDocument = this;
87 - return $.getJSON(this.getRestURL(), {
117 + return Promise.resolve($.getJSON(this.getRestURL(), {
88 88   // Make sure the response is not retrieved from cache (IE11 doesn't obey the caching HTTP headers).
89 89   timestamp: new Date().getTime()
90 - }).then(function(newXWikiDocument) {
120 + })).then(newXWikiDocument => {
121 + // Reload succeeded.
91 91   // Resolve the document reference.
92 - thisXWikiDocument.documentReference = XWiki.Model.resolve(newXWikiDocument.id, XWiki.EntityType.DOCUMENT);
123 + this.documentReference = XWiki.Model.resolve(newXWikiDocument.id, XWiki.EntityType.DOCUMENT);
93 93   // We were able to load the document so it's not new.
94 - thisXWikiDocument.isNew = false;
95 - return $.extend(thisXWikiDocument, newXWikiDocument);
125 + this.isNew = false;
126 + return $.extend(this, newXWikiDocument);
127 + }).catch(() => {
128 + // Reload failed.
129 + return Promise.reject(this);
96 96   });
97 97   },
98 98  
... ... @@ -103,9 +103,8 @@
103 103   * @return a promise that resolves to this document instance if the lock request succeeds
104 104   */
105 105   lock: function(action, force) {
106 - var thisXWikiDocument = this;
107 107   action = action || 'edit';
108 - return $.getJSON(this.getURL('get'), {
141 + return Promise.resolve($.getJSON(this.getURL('get'), {
109 109   sheet: 'XWiki.InplaceEditing',
110 110   action: 'lock',
111 111   lockAction: action,
... ... @@ -114,9 +114,20 @@
114 114   outputSyntax: 'plain',
115 115   // Make sure the response is not retrieved from cache (IE11 doesn't obey the caching HTTP headers).
116 116   timestamp: new Date().getTime()
117 - }).then(function() {
118 - thisXWikiDocument.locked = action;
119 - return thisXWikiDocument;
150 + })).then(() => {
151 + // Lock succeeded.
152 + this.locked = action;
153 + return this;
154 + }).catch(response => {
155 + // Lock failed.
156 + delete this.locked;
157 + // Check if the user can force the lock.
158 + var lockConfirmation = response.responseJSON;
159 + if (response.status === 423 && lockConfirmation) {
160 + // The user can force the lock, but needs confirmation.
161 + this.lockConfirmation = lockConfirmation;
162 + }
163 + return Promise.reject(this);
120 120   });
121 121   },
122 122  
... ... @@ -139,6 +139,25 @@
139 139   // See https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/Synchronous_and_Asynchronous_Requests
140 140   $.ajax({type: 'GET', url: url, async: false});
141 141   }
186 + },
187 +
188 + /**
189 + * Makes sure this document matches the current UI locale.
190 + */
191 + translate: function() {
192 + const realLocale = this.getRealLocale();
193 + const uiLocale = $('html').attr('lang');
194 + if (realLocale && realLocale !== uiLocale) {
195 + this.language = uiLocale;
196 + // Set the original document locale.
197 + this.translations = this.translations || {};
198 + this.translations['default'] = realLocale;
199 + // Update the document fields that are not 'shared' with the original document.
200 + this.isNew = true;
201 + delete this.version;
202 + delete this.majorVersion;
203 + delete this.minorVersion;
204 + }
142 142   }
143 143   };
144 144  });
... ... @@ -155,9 +155,7 @@
155 155   'xwiki-events-bridge'
156 156  ], function($, xcontext, xwikiDocumentAPI) {
157 157   var preload = function() {
158 - loadCSS($jsontool.serialize($xwiki.getSkinFile('js/xwiki/actionbuttons/actionButtons.css', true)));
159 - // Required in case the user needs to resolve merge conflicts on save.
160 - loadCSS($jsontool.serialize($xwiki.getSkinFile('uicomponents/viewers/diff.css', true)));
221 + paths.css.forEach(loadCSS);
161 161   return initActionButtons();
162 162   };
163 163  
... ... @@ -177,6 +177,12 @@
177 177   });
178 178   };
179 179  
241 + var translatePage = function() {
242 + return editInPlace({
243 + afterEdit: createTranslation
244 + });
245 + };
246 +
180 180   var editSection = function(sectionId) {
181 181   return editInPlace({
182 182   lockFailed: function() {
... ... @@ -188,7 +188,7 @@
188 188   $('#xwikicontent').removeAttr('tabindex');
189 189   if (sectionId) {
190 190   // Select the heading of the specified section.
191 - $('#xwikicontent > #' + escapeSelector(sectionId)).each(function() {
258 + $('#xwikicontent > #' + $.escapeSelector(sectionId)).each(function() {
192 192   selectText(this);
193 193   });
194 194   }
... ... @@ -196,22 +196,6 @@
196 196   });
197 197   };
198 198  
199 - var escapeSelector = function(selector) {
200 - if (window.CSS && typeof CSS.escape === 'function') {
201 - // Not supported by Internet Explorer.
202 - return CSS.escape(selector);
203 - } else if (typeof $.escapeSelector === 'function') {
204 - // Added in jQuery 3.0
205 - return $.escapeSelector(selector);
206 - } else if (typeof selector === 'string') {
207 - // Simple implementation.
208 - // See https://learn.jquery.com/using-jquery-core/faq/how-do-i-select-an-element-by-an-id-that-has-characters-used-in-css-notation/
209 - return selector.replace(/(:|\.|\[|\]|,|=|@)/g, '\\$1');
210 - } else {
211 - return selector;
212 - }
213 - };
214 -
215 215   // We preserve the document data between edits in order to be able to know which document translation should be edited
216 216   // (e.g. when the document translation is missing and we create it, the next edit session should target the created
217 217   // translation).
... ... @@ -219,6 +219,11 @@
219 219   language: xcontext.locale
220 220   }, xwikiDocumentAPI);
221 221  
273 + var setCurrentXWikiDocument = function(xwikiDocument) {
274 + currentXWikiDocument = xwikiDocument;
275 + return Promise.resolve(xwikiDocument);
276 + };
277 +
222 222   var editInPlace = function(options) {
223 223   options = $.extend({
224 224   afterEdit: function() {},
... ... @@ -225,35 +225,57 @@
225 225   lockFailed: function() {}
226 226   }, options);
227 227   $('#xwikicontent').addClass('loading');
228 - return lock(currentXWikiDocument).fail(options.lockFailed)
229 - .then(load)
230 - .then(edit).done(options.afterEdit).always(function() {
284 + // Lock the document first.
285 + return lock(currentXWikiDocument)
286 + // Then load the document only if we managed to lock it.
287 + .then(load, xwikiDocument => {
288 + options.lockFailed(xwikiDocument);
289 + return Promise.reject(xwikiDocument);
290 + // Then load the editors only if we managed to load the document.
291 + }).then(edit).then(xwikiDocument => {
292 + options.afterEdit(xwikiDocument);
293 + return xwikiDocument;
294 + }).finally(() => {
231 231   $('#xwikicontent').removeClass('loading');
296 + // Then wait for an action (save, cancel, reload) only if the editors were loaded successfuly.
232 232   }).then(maybeSave)
233 - .then(unlock)
234 - .then(view);
298 + // Then unlock the document both when the edit ended with success and with a failure.
299 + .then(unlock, unlock)
300 + // Finally view the document both when the edit ended with success and with a failure.
301 + .then(view, view)
302 + // Update the current document for the next edit session.
303 + .then(setCurrentXWikiDocument, setCurrentXWikiDocument);
235 235   };
236 236  
237 237   var lock = function(xwikiDocument) {
238 - return xwikiDocument.lock().then(null, function(response) {
239 - var confirmation = response.responseJSON;
307 + return xwikiDocument.lock().catch(function(xwikiDocument) {
240 240   // If the document was already locked then we need to ask the user if they want to force the lock.
241 - if (response.status === 423 && confirmation) {
242 - return maybeForceLock(confirmation).then($.proxy(xwikiDocument, 'lock', 'edit', true));
309 + if (xwikiDocument.lockConfirmation) {
310 + var confirmation = xwikiDocument.lockConfirmation;
311 + delete xwikiDocument.lockConfirmation;
312 + return maybeForceLock(confirmation).then(xwikiDocument.lock.bind(xwikiDocument, 'edit', true), function() {
313 + // Cancel the edit action.
314 + return Promise.reject(xwikiDocument);
315 + });
243 243   } else {
244 - new XWiki.widgets.Notification(
245 - $jsontool.serialize($services.localization.render('edit.inplace.page.lockFailed')),
246 - 'error'
247 - );
317 + new XWiki.widgets.Notification(l10n['edit.inplace.page.lockFailed'], 'error');
318 + return Promise.reject(xwikiDocument);
248 248   }
249 249   });
250 250   };
251 251  
252 252   var maybeForceLock = function(confirmation) {
253 - var deferred = $.Deferred();
324 + var deferred, promise = new Promise((resolve, reject) => {
325 + deferred = {resolve, reject};
326 + });
327 + // We need the catch() to prevent the "Uncaught (in promise)" error log in the console.
328 + promise.catch(() => {}).finally(() => {
329 + // This flag is used by the Force Lock modal to know whether the promise is settled when the modal is closing.
330 + deferred.settled = true;
331 + });
254 254   // Reuse the confirmation modal once it is created.
255 255   var modal = $('.force-edit-lock-modal');
256 - if (modal.length === 0) {
334 + if (!modal.length) {
257 257   modal = createForceLockModal();
258 258   }
259 259   // Update the deferred that needs to be resolved or rejected.
... ... @@ -269,7 +269,7 @@
269 269   }
270 270   // Show the confirmation modal.
271 271   modal.modal('show');
272 - return deferred.promise();
350 + return promise;
273 273   };
274 274  
275 275   var createForceLockModal = function() {
... ... @@ -292,17 +292,17 @@
292 292   '</div>',
293 293   '</div>'
294 294   ].join(''));
295 - modal.find('.close').attr('aria-label', $jsontool.serialize($services.localization.render('edit.inplace.close')));
296 - modal.find('.modal-footer .btn-warning').click(function() {
373 + modal.find('.close').attr('aria-label', l10n['edit.inplace.close']);
374 + modal.find('.modal-footer .btn-warning').on('click', function() {
297 297   // The user has confirmed they want to force the lock.
298 298   modal.data('deferred').resolve();
299 299   modal.modal('hide');
300 300   });
301 301   modal.on('hide.bs.modal', function() {
302 - // If the lock promise is not yet resolved when the modal is closing then it means the modal was canceled,
380 + // If the lock promise is not yet settled when the modal is closing then it means the modal was canceled,
303 303   // i.e. the user doesn't want to force the lock.
304 304   var deferred = modal.data('deferred');
305 - if (deferred.state() === 'pending') {
383 + if (!deferred.settled) {
306 306   deferred.reject();
307 307   }
308 308   });
... ... @@ -310,18 +310,19 @@
310 310   };
311 311  
312 312   var load = function(xwikiDocument) {
313 - return xwikiDocument.reload().done(function(xwikiDocument) {
391 + return xwikiDocument.reload().then(xwikiDocument => {
314 314   // Clone the current document version and keep a reference to it in order to be able to restore it on cancel.
315 315   xwikiDocument.originalDocument = $.extend(true, {
316 316   renderedTitle: $('#document-title h1').html(),
317 317   renderedContent: $('#xwikicontent').html()
318 318   }, xwikiDocument);
319 - }).fail(function() {
320 - new XWiki.widgets.Notification($jsontool.serialize($services.localization.render('edit.inplace.page.loadFailed')),
321 - 'error');
397 + return xwikiDocument;
398 + }).catch(xwikiDocument => {
399 + new XWiki.widgets.Notification(l10n['edit.inplace.page.loadFailed'], 'error');
400 + return Promise.reject(xwikiDocument);
322 322   // Render the document for edit, in order to have the annotated content HTML. The annotations are used to protect
323 323   // the rendering transformations (e.g. macros) when editing the content.
324 - }).then($.proxy(render, null, false));
403 + }).then(render.bind(null, false));
325 325   };
326 326  
327 327   /**
... ... @@ -334,7 +334,7 @@
334 334   };
335 335  
336 336   var maybeSave = function(xwikiDocument) {
337 - return waitForAction(xwikiDocument).then(function(action) {
416 + return waitForAction(xwikiDocument).then(action => {
338 338   switch(action.name) {
339 339   case 'save': return save({
340 340   document: action.document,
... ... @@ -347,58 +347,76 @@
347 347   };
348 348  
349 349   var waitForAction = function(xwikiDocument) {
350 - var deferred = $.Deferred();
351 - // We wait for the first save, reload or cancel event, whichever is triggered first. Note that the event listeners
352 - // that are not executed first will remain registered but that doesn't cause any problems because the state of a
353 - // deferred object (promise) cannot change once it was resolved. So the first event that fires will resolve the
354 - // promise and the remaining events won't be able to change that. The remaining event listeners could be called
355 - // later but they won't have any effect on the deferred object.
356 - $(document).one([
357 - 'xwiki:actions:save',
358 - 'xwiki:actions:reload',
359 - 'xwiki:actions:cancel',
360 - ].join(' '), function(event, data) {
361 - deferred.resolve({
362 - name: event.type.substring('xwiki:actions:'.length),
363 - document: xwikiDocument,
364 - data: data
429 + return new Promise((resolve, reject) => {
430 + // We wait for the first save, reload or cancel event, whichever is triggered first. Note that the event listeners
431 + // that are not executed first will remain registered but that doesn't cause any problems because the state of a
432 + // deferred object (promise) cannot change once it was resolved. So the first event that fires will resolve the
433 + // promise and the remaining events won't be able to change that. The remaining event listeners could be called
434 + // later but they won't have any effect on the deferred object.
435 + $(document).one([
436 + 'xwiki:actions:save',
437 + 'xwiki:actions:reload',
438 + 'xwiki:actions:cancel',
439 + ].join(' '), '.xcontent.form', function(event, data) {
440 + resolve({
441 + name: event.type.substring('xwiki:actions:'.length),
442 + document: xwikiDocument,
443 + data: data
444 + });
365 365   });
366 366   });
367 - return deferred.promise();
368 368   };
369 369  
370 370   var save = function(data) {
371 - // Push the changes to the server then render the document for view. We need the view HTML both if we stop editing
372 - // now and if we continue but cancel the edit later.
373 - return push(data.document).then($.proxy(render, null, true)).then(function(xwikiDocument) {
450 + // Push the changes to the server.
451 + return push(data.document).then(xwikiDocument => {
374 374   // Save succeeded.
375 - if (data['continue']) {
376 - // Update the original version in order to be able to restore it on cancel.
377 - delete xwikiDocument.originalDocument;
378 - xwikiDocument.originalDocument = $.extend(true, {}, xwikiDocument);
453 + return shouldReload(xwikiDocument).then(
454 + // The document was saved with merge and thus if we want to continue eding we need to reload the editor (because
455 + // its content doesn't match the saved content).
456 + reload,
457 + // No need to reload the editor because either the action was Save & View or there was no merge on save.
458 + maybeContinueEditing.bind(null, data['continue'])
459 + );
460 + // Save failed. Continue editing because we may have unsaved content.
461 + }, maybeSave);
462 + };
463 +
464 + var push = function(xwikiDocument) {
465 + // Let actionButtons.js do the push. We just catch the result.
466 + return new Promise((resolve, reject) => {
467 + // We wait for the save request to either succeed or fail. Note that one of the event listeners will remain
468 + // registered but that doesn't cause any problems because the state of a deferred object (promise) cannot change
469 + // once it was resolved or rejected. So the first event that fires will resolve/reject the promise and the
470 + // remaining event won't be able to change that. The remaining event listener could be called later but it won't
471 + // have any effect.
472 + $(document).one('xwiki:document:saved', '.xcontent.form', resolve.bind(null, xwikiDocument));
473 + $(document).one('xwiki:document:saveFailed', '.xcontent.form', reject.bind(null, xwikiDocument));
474 + });
475 + };
476 +
477 + var maybeContinueEditing = function(continueEditing, xwikiDocument) {
478 + var afterReloadAndRender = function(success, xwikiDocument) {
479 + if (continueEditing) {
480 + if (success) {
481 + // Update the original version in order to be able to restore it on cancel.
482 + delete xwikiDocument.originalDocument;
483 + xwikiDocument.originalDocument = $.extend(true, {}, xwikiDocument);
484 + }
379 379   // Continue editing.
380 380   return maybeSave(xwikiDocument);
381 381   } else {
382 - // This is the final version.
488 + // This is the final version. We stop editing even if the reload / render failed.
383 383   return xwikiDocument;
384 384   }
385 - }, function(xwikiDocument) {
386 - // Save failed. Continue editing.
387 - return maybeSave(xwikiDocument);
388 - });
389 - };
491 + };
390 390  
391 - var push = function(xwikiDocument) {
392 - // Let actionButtons.js do the push. We just catch the result.
393 - var deferred = $.Deferred();
394 - // We wait for the save request to either succeed or fail. Note that one of the event listeners will remain
395 - // registered but that doesn't cause any problems because the state of a deferred object (promise) cannot change
396 - // once it was resolved or rejected. So the first event that fires will resolve/reject the promise and the remaining
397 - // event won't be able to change that. The remaining event listener could be called later but it won't have any
398 - // effect.
399 - $(document).one('xwiki:document:saved', $.proxy(deferred, 'resolve', xwikiDocument));
400 - $(document).one('xwiki:document:saveFailed', $.proxy(deferred, 'reject', xwikiDocument));
401 - return deferred.promise().then($.proxy(xwikiDocument, 'reload'));
493 + // Reload the document JSON data (to have the new version) and render the document for view. We need the view HTML
494 + // both if we stop editing now and if we continue but cancel the edit later.
495 + return xwikiDocument.reload().then(render.bind(null, true)).then(
496 + afterReloadAndRender.bind(null, /* success: */ true),
497 + afterReloadAndRender.bind(null, /* success: */ false)
498 + );
402 402   };
403 403  
404 404   var cancel = function(xwikiDocument) {
... ... @@ -416,8 +416,27 @@
416 416   };
417 417  
418 418   // Make sure we unlock the document when the user navigates to another page.
419 - $(window).on('unload pagehide', $.proxy(unlock, null, currentXWikiDocument));
516 + $(window).on('unload pagehide', unlock.bind(null, currentXWikiDocument));
420 420  
518 + var shouldReload = function(xwikiDocument) {
519 + var reloadEventFired = false;
520 + $(document).one('xwiki:actions:reload.maybe', '.xcontent.form', function() {
521 + reloadEventFired = true;
522 + });
523 + return new Promise((resolve, reject) => {
524 + // Wait a bit to see if the reload event is fired.
525 + setTimeout(function() {
526 + // Remove the listener in case the reload event wasn't fired.
527 + $(document).off('xwiki:actions:reload.maybe');
528 + if (reloadEventFired) {
529 + resolve(xwikiDocument);
530 + } else {
531 + reject(xwikiDocument);
532 + }
533 + }, 0);
534 + });
535 + };
536 +
421 421   var reload = function(xwikiDocument) {
422 422   // Leave the edit mode and then re-enter.
423 423   return view(xwikiDocument, true).then(editInPlace);
... ... @@ -424,21 +424,33 @@
424 424   };
425 425  
426 426   var view = function(xwikiDocument, reload) {
543 + var viewContent = $('#xwikicontent');
427 427   // Destroy the editors before returning to view.
428 - $(document).trigger('xwiki:actions:view', {document: xwikiDocument});
545 + viewContent.trigger('xwiki:actions:view', {document: xwikiDocument});
429 429   $('#document-title h1').html(xwikiDocument.renderedTitle);
430 - $('#xwikicontent').html(xwikiDocument.renderedContent);
547 + viewContent.html(xwikiDocument.renderedContent);
431 431   if (!reload) {
432 432   // If the user has canceled the edit then the restored page content may include the section edit links. Show them
433 433   // in case they were hidden.
434 - $('#xwikicontent').children(':header').children('.edit_section').removeClass('hidden');
551 + viewContent.children(':header').children('.edit_section').removeClass('hidden');
435 435   // Let others know that the DOM has been updated, in order to enhance it.
436 - $(document).trigger('xwiki:dom:updated', {'elements': $('#xwikicontent').toArray()});
553 + $(document).trigger('xwiki:dom:updated', {'elements': viewContent.toArray()});
437 437   }
438 - return $.Deferred().resolve(xwikiDocument).promise();
555 + // Remove the action events scope.
556 + viewContent.closest('.form').removeClass('form');
557 + // Update the URL.
558 + if (window.location.hash === '#edit' || window.location.hash === '#translate') {
559 + history.replaceState(null, null, '#');
560 + }
561 + return Promise.resolve(xwikiDocument);
439 439   };
440 440  
441 441   var edit = function(xwikiDocument) {
565 + // By adding the 'form' CSS class we set the scope of the action events (e.g. xwiki:actions:beforeSave or
566 + // xwiki:actions:cancel). We need this because in view mode we can have multiple forms active on the page (e.g. one
567 + // for editing the document content in place and one for editing the document syntax in-place) and we don't want
568 + // them to interfere (e.g. canceling one form shouldn't cancel the other forms).
569 + $('#xwikicontent').closest('.xcontent').addClass('form');
442 442   return initActionButtons(xwikiDocument).then(initTitleEditor).then(initContentEditor)
443 443   .then(startRealTimeEditingSession);
444 444   };
... ... @@ -445,7 +445,7 @@
445 445  
446 446   var initActionButtons = function(xwikiDocument) {
447 447   if (xwikiDocument) {
448 - maybeShowTranslateButton(xwikiDocument);
576 + initTranslateButton(xwikiDocument);
449 449   }
450 450   var editContent = $('#xwikicontent');
451 451   // We need the wrapper because #xwikicontent uses Bootstrap grid (col-xs-12) which is implemented with CSS float.
... ... @@ -455,63 +455,90 @@
455 455   var actionButtonsWrapper = editContent.nextAll('.sticky-buttons-wrapper');
456 456   if (actionButtonsWrapper.length === 0) {
457 457   actionButtonsWrapper = $('<div class="sticky-buttons-wrapper col-xs-12">' +
458 - '<div class="inplace-editing-buttons sticky-buttons"/></div>').insertAfter(editContent);
459 - var actionButtons = actionButtonsWrapper.children('.sticky-buttons').data('xwikiDocument', xwikiDocument)
460 - .toggle(!!xwikiDocument);
586 + '<div class="inplace-editing-buttons sticky-buttons"/></div>').insertAfter(editContent).toggle(!!xwikiDocument);
587 + var actionButtons = actionButtonsWrapper.children('.sticky-buttons')
588 + .data('xwikiDocument', xwikiDocument)
589 + // Expose the fake form if an extension needs to manipulate it.
590 + .data('fakeForm', fakeForm);
461 461   return loadActionButtons(actionButtons);
462 462   } else {
463 463   // If we're editing a page..
464 464   if (xwikiDocument) {
465 465   // ..then make sure the action buttons are displayed right away (don't wait for the user to scroll).
466 - actionButtonsWrapper.children('.sticky-buttons').data('xwikiDocument', xwikiDocument).show();
596 + actionButtonsWrapper.show().children('.sticky-buttons')
597 + .data('xwikiDocument', xwikiDocument)
598 + // Expose the fake form if an extension needs to manipulate it.
599 + .data('fakeForm', fakeForm)
600 + // but make sure the position of the action buttons is updated.
601 + .trigger('xwiki:dom:refresh');
467 467   // The action buttons are disabled on Save & View. We don't reload the page on Save & View and we reuse the
468 468   // action buttons so we need to re-enable them each time we enter the edit mode.
469 469   fakeForm.enable();
470 - $(document).trigger('xwiki:dom:refresh');
471 471   }
472 - return $.Deferred().resolve(xwikiDocument).promise();
606 + return Promise.resolve(xwikiDocument);
473 473   }
474 474   };
475 475  
476 - var maybeShowTranslateButton = function(xwikiDocument) {
477 - var xwikiDocumentLocale = xwikiDocument.getRealLocale();
478 - var uiLocale = $('html').attr('lang');
479 - if (xwikiDocumentLocale && xwikiDocumentLocale !== uiLocale) {
480 - $('#tmTranslate').off('click.translate').on('click.translate', function(event) {
481 - event.preventDefault();
482 - $(this).addClass('hidden');
483 - xwikiDocument.language = uiLocale;
484 - // Update the document translation fields that are not 'shared' with the original document.
485 - xwikiDocument.isNew = true;
486 - delete xwikiDocument.version;
487 - delete xwikiDocument.majorVersion;
488 - delete xwikiDocument.minorVersion;
489 - $('#document-title-input').focus().select();
490 - var message = $jsontool.serialize($services.localization.render('edit.inplace.page.translation',
491 - ['__locale__']));
492 - new XWiki.widgets.Notification(
493 - message.replace('__locale__', uiLocale),
494 - 'info'
495 - );
496 - }).removeClass('hidden');
497 - var message = $jsontool.serialize($services.localization.render('edit.inplace.page.original', ['__locale__']));
498 - new XWiki.widgets.Notification(
499 - message.replace('__locale__', xwikiDocumentLocale),
500 - 'info'
501 - );
502 - }
610 + var createTranslation = function(xwikiDocument) {
611 + xwikiDocument.translate();
612 + $('#document-title-input').focus().select();
613 + // Let the user know that they are now editing the translation of this page in the current locale.
614 + $('#document-title-input').popover({
615 + content: l10n['edit.inplace.page.translate.messageAfter'],
616 + placement: 'bottom',
617 + trigger: 'manual'
618 + }).popover('show').one('blur', function() {
619 + // Hide the popover when the title input loses the focus.
620 + $(this).popover('hide');
621 + });
503 503   };
504 504  
624 + var initTranslateButton = function(xwikiDocument) {
625 + // Initialize the translate button only if it's visible.
626 + const translateButton = $(config.translateButtonSelector).filter('[data-toggle="popover"]').filter(':visible');
627 + translateButton.off('click.translate').on('click.translate', function(event) {
628 + event.preventDefault();
629 + translateButton.parent().addClass('hidden');
630 + createTranslation(xwikiDocument);
631 + // Let the user know that they are editing the original version of the page and not the translation corresponding
632 + // to the current locale because there isn't one created yet.
633 + }).attr({
634 + // Backup the initial popover message to be able to restore it on view.
635 + 'data-content-view': translateButton.attr('data-content'),
636 + // Use a custom popover message dedicated to the edit action.
637 + 'data-content': l10n['edit.inplace.page.translate.messageBefore']
638 + }).popover('show')
639 + // Hide the popover on the next click. The user can still see the message by hovering the translate button.
640 + .closest('html').one('click', function() {
641 + translateButton.popover('hide');
642 + });
643 + };
644 +
505 505   var loadActionButtons = function(actionButtons) {
506 - $(document).on('xwiki:actions:view', function() {
507 - // Hide the action buttons and disable the shortcut keys (by disabling the buttons).
508 - actionButtons.hide().find(':input').prop('disabled', true);
509 - // Hide the translate button because it can be used only in edit mode for the moment.
510 - $('#tmTranslate').addClass('hidden');
646 + $(document).on('xwiki:actions:view', '.xcontent.form', function(event, data) {
647 + // Blur the action buttons first to re-enable the "disabled in inputs" shortcut keys (e.g. the page edit
648 + // shortcut), then disable the action buttons in order to disable their shortcut keys while we're not editing
649 + // in-place (e.g. prevent the Save shortcut while the user is only viewing the page). Finally hide the action
650 + // buttons to have them ready for the next editing session (the user can save or cancel and then edit again
651 + // without reloading the page).
652 + actionButtons.find(':input').blur().prop('disabled', true).end().parent().hide();
653 + // Restore the Translate button if the locale of the viewed document doesn't match the current user interface
654 + // locale (because the viewed document doesn't have a translation in the current locale).
655 + var xwikiDocumentLocale = data.document.getRealLocale();
656 + var uiLocale = $('html').attr('lang');
657 + if (xwikiDocumentLocale && xwikiDocumentLocale !== uiLocale) {
658 + const translateButton = $(config.translateButtonSelector).filter('[data-toggle="popover"]');
659 + // Restore the translation button behavior for view action.
660 + translateButton.off('click.translate')
661 + // Restore the popover text for view action.
662 + .attr('data-content', translateButton.attr('data-content-view') || translateButton.attr('data-content'))
663 + // Restore the visibility.
664 + .parent().removeClass('hidden');
665 + }
511 511   });
512 - return $.get(XWiki.currentDocument.getURL('get'), {
667 + return Promise.resolve($.get(XWiki.currentDocument.getURL('get'), {
513 513   xpage: 'editactions'
514 - }).then(function(html) {
669 + })).then(html => {
515 515   actionButtons.html(html);
516 516   // Fix the name of the Save & View action.
517 517   actionButtons.find('.btn-primary').first().attr('name', 'action_save');
... ... @@ -519,21 +519,24 @@
519 519   $('<input type="hidden" name="form_token" />').val(xcontext.form_token).appendTo(actionButtons);
520 520   // We need a place where actionButtons.js can add more hidden inputs.
521 521   actionButtons.append('<div class="hidden extra"/>');
522 - var deferred = $.Deferred();
523 - require(['actionButtons'], function() {
524 - overrideEditActions();
525 - overrideAjaxSaveAndContinue();
526 - var xwikiDocument = actionButtons.data('xwikiDocument');
527 - // Enable the action buttons (and their shortcut keys) only if we're editing a document.
528 - actionButtons.find(':input').prop('disabled', !xwikiDocument);
529 - deferred.resolve(xwikiDocument);
677 + // Let the others know that the DOM has been updated, in order to enhance it.
678 + $(document).trigger('xwiki:dom:updated', {'elements': actionButtons.toArray()});
679 + return new Promise((resolve, reject) => {
680 + require(['xwiki-actionButtons', 'xwiki-diff', 'xwiki-autoSave'], function() {
681 + overrideEditActions();
682 + overrideAjaxSaveAndContinue();
683 + // Activate the auto-save feature passing our fake edit form. Note that autosave.js also creates an instance of
684 + // AutoSave but it doesn't do anything because it doesn't find a real edit form in the page. This is why we have
685 + // to create our own instance of AutoSave passing the right (fake) form.
686 + new XWiki.editors.AutoSave({form: fakeForm});
687 + var xwikiDocument = actionButtons.data('xwikiDocument');
688 + // Enable the action buttons (and their shortcut keys) only if we're editing a document.
689 + actionButtons.find(':input').prop('disabled', !xwikiDocument);
690 + resolve(xwikiDocument);
691 + });
530 530   });
531 - return deferred.promise();
532 - }, function() {
533 - new XWiki.widgets.Notification(
534 - $jsontool.serialize($services.localization.render('edit.inplace.actionButtons.loadFailed')),
535 - 'error'
536 - );
693 + }).catch(() => {
694 + new XWiki.widgets.Notification(l10n['edit.inplace.actionButtons.loadFailed'], 'error');
537 537   });
538 538   };
539 539  
... ... @@ -559,6 +559,13 @@
559 559   insert: function(element) {
560 560   this._getActionButtons().find('.hidden.extra').append(element);
561 561   },
720 + // Note that this method only works with single argument.
721 + append: function(element) {
722 + this.insert(element);
723 + },
724 + down: function(selector) {
725 + return this._getActionButtons().find(selector)[0];
726 + },
562 562   serialize: function() {
563 563   var extra = this._getActionButtons().find(':input').serializeArray().reduce(function(extra, entry) {
564 564   var value = extra[entry.name] || [];
... ... @@ -566,22 +566,39 @@
566 566   extra[entry.name] = value;
567 567   return extra;
568 568   }, {});
734 + // retrieve all input fields listing the temporary uploaded files.
735 + var uploadedFiles = $('#xwikicontent').nextAll('input[name="uploadedFiles"]').serializeArray().reduce(function(extra, entry) {
736 + var value = extra[entry.name] || [];
737 + value.push(entry.value);
738 + extra[entry.name] = value;
739 + return extra;
740 + }, {});
569 569   var xwikiDocument = this._getActionButtons().data('xwikiDocument');
570 570   var formData = {
571 - title: xwikiDocument.title,
572 - content: xwikiDocument.renderedContent,
573 - RequiresHTMLConversion: 'content',
574 - content_syntax: xwikiDocument.syntax,
743 + title: xwikiDocument.rawTitle,
575 575   language: xwikiDocument.getRealLocale(),
576 576   isNew: xwikiDocument.isNew
577 577   };
747 + if (xwikiDocument.content != xwikiDocument.originalDocument.content) {
748 + // Submit the raw (source) content. No syntax conversion is needed in this case.
749 + formData.content = xwikiDocument.content;
750 + } else {
751 + // Submit the rendered content (HTML), but make sure it is converted to the document syntax on the server.
752 + $.extend(formData, {
753 + content: xwikiDocument.renderedContent,
754 + RequiresHTMLConversion: 'content',
755 + content_syntax: xwikiDocument.syntax
756 + });
757 + }
758 + // Add the temporary uploaded files to the form.
759 + $.extend(formData, uploadedFiles);
578 578   // Check for merge conflicts only if the document is not new and we know the current version.
579 579   if (!xwikiDocument.isNew && xwikiDocument.version) {
580 580   formData.previousVersion = xwikiDocument.version;
581 - // It would have been easier to send the timestamp but that's what the Save action expects.
582 - formData.editingVersionDate = new Date(xwikiDocument.modified).toISOString();
763 + formData.editingVersionDate = new Date(xwikiDocument.modified).getTime();
583 583   }
584 - return $.extend(formData, extra);
765 + // Ensure that formData information has priority over extra information.
766 + return $.extend({}, extra, formData);
585 585   }
586 586   };
587 587  
... ... @@ -603,17 +603,21 @@
603 603   var originalAjaxSaveAndContinue = $.extend({}, XWiki.actionButtons.AjaxSaveAndContinue.prototype);
604 604   $.extend(XWiki.actionButtons.AjaxSaveAndContinue.prototype, {
605 605   reloadEditor: function() {
606 - if ($('.inplace-editing-buttons').is(':visible')) {
607 - $(document).trigger('xwiki:actions:reload');
788 + var actionButtons = $('.inplace-editing-buttons');
789 + if (actionButtons.is(':visible')) {
790 + actionButtons.trigger('xwiki:actions:reload');
608 608   } else {
609 609   return originalAjaxSaveAndContinue.reloadEditor.apply(this, arguments);
610 610   }
611 611   },
612 - maybeRedirect: function() {
795 + maybeRedirect: function(continueEditing) {
613 613   if ($('.inplace-editing-buttons').is(':visible')) {
614 - // Never redirect when leaving the edit mode because we're already in view mode.
615 - return false;
797 + // Overwrite the default behavior so that we don't redirect when leaving the edit mode because we're already
798 + // in view mode. We still need to report a redirect (return true) if we don't continue editing, so that
799 + // actionButtons.js behaves as if a redirect was done.
800 + return !continueEditing;
616 616   } else {
802 + // Fallback on the default behavior if the in-place editing buttons are hidden.
617 617   return originalAjaxSaveAndContinue.maybeRedirect.apply(this, arguments);
618 618   }
619 619   }
... ... @@ -622,8 +622,8 @@
622 622  
623 623   var initTitleEditor = function(xwikiDocument) {
624 624   var label = $('<label for="document-title-input" class="sr-only"/>')
625 - .text($jsontool.serialize($services.localization.render('core.editors.content.titleField.label')));
626 - var input = $('<input type="text" id="document-title-input"/>').val(xwikiDocument.title);
811 + .text(l10n['core.editors.content.titleField.label']);
812 + var input = $('<input type="text" id="document-title-input"/>').val(xwikiDocument.rawTitle);
627 627   var placeholder = xwikiDocument.documentReference.name;
628 628   if (placeholder === 'WebHome') {
629 629   placeholder = xwikiDocument.documentReference.parent.name;
... ... @@ -630,13 +630,13 @@
630 630   }
631 631   input.attr('placeholder', placeholder);
632 632   $('#document-title h1').addClass('editable').empty().append([label, input]);
633 - $(document).on('xwiki:actions:beforeSave.titleEditor', function(event) {
634 - xwikiDocument.title = input.val();
819 + $(document).on('xwiki:actions:beforeSave.titleEditor', '.xcontent.form', function(event) {
820 + xwikiDocument.rawTitle = input.val();
635 635   });
636 - $(document).one('xwiki:actions:view', function(event, data) {
822 + $(document).one('xwiki:actions:view', '.xcontent.form', function(event, data) {
637 637   // Destroy the title editor.
638 638   $(document).off('xwiki:actions:beforeSave.titleEditor');
639 - $('#document-title h1').removeClass('editable').text(xwikiDocument.title);
825 + $('#document-title h1').removeClass('editable').text(xwikiDocument.rawTitle);
640 640   });
641 641   return xwikiDocument;
642 642   };
... ... @@ -653,24 +653,25 @@
653 653   // Keep the focus while the edit content is being prepared.
654 654   viewContent.focus();
655 655   }
656 - var data = {
657 - contentType: 'org.xwiki.rendering.syntax.SyntaxContent',
658 - editMode: 'wysiwyg',
842 + var data = $.extend({}, config, {
659 659   document: xwikiDocument,
660 660   // The content editor is loaded on demand, asynchronously.
661 661   deferred: $.Deferred()
662 - };
663 - var editContentPromise = data.deferred.promise();
664 - editContentPromise.done(function() {
846 + });
847 + editContent.trigger('xwiki:actions:edit', data);
848 + return data.deferred.promise().then(() => {
665 665   editContent.show();
666 666   viewContent.remove();
667 667   if (withFocus) {
668 - // Restore the focus when the edit content is ready but make sure we don't scroll the page.
669 - editContent[0].focus({preventScroll: true});
852 + // Restore the focus when the edit content is ready but make sure we don't scroll the page. We don't restore the
853 + // focus right away because we just made the content visible so it may not be editable yet (e.g. the WYSIWYG
854 + // editor can make the content editable only if it is visible).
855 + setTimeout(function() {
856 + editContent[0].focus({preventScroll: true});
857 + }, 0);
670 670   }
859 + return xwikiDocument;
671 671   });
672 - editContent.trigger('xwiki:actions:edit', data);
673 - return editContentPromise;
674 674   };
675 675  
676 676   var startRealTimeEditingSession = function(xwikiDocument) {
... ... @@ -689,16 +689,21 @@
689 689   };
690 690  
691 691   return {
692 - preload: preload,
693 - editPage: editPage,
694 - editSection: editSection
879 + preload,
880 + editPage,
881 + editSection,
882 + translatePage
695 695   };
696 696  });
697 697  
698 698  require(['jquery'], function($) {
699 - var inplaceEditingConfig = $('div[data-inplace-editing-config]').data('inplaceEditingConfig') || {};
700 - var wysiwygEditorModule = 'xwiki-' + inplaceEditingConfig.wysiwygEditor + '-inline';
887 + // We can edit in-place only if the #xwikicontent element is present.
888 + if (!$('#xwikicontent').length) {
889 + return;
890 + }
701 701  
892 + var wysiwygEditorModule = 'xwiki-' + config.wysiwygEditor + '-inline';
893 +
702 702   var preloadEditor = function() {
703 703   require(['editInPlace', wysiwygEditorModule], function(editInPlace) {
704 704   editInPlace.preload();
... ... @@ -715,44 +715,186 @@
715 715   });
716 716   }
717 717  
718 - var editButton = $('#tmEdit > a');
719 - editButton.on('click.inPlaceEditing', function(event) {
720 - event.preventDefault();
910 + var onInPlaceEditing = function(event) {
721 721   // Make sure the user doesn't try to re-activate the edit mode while we are in edit mode.
722 - editButton.addClass('disabled');
723 - // Load the code needed to edit in place only when the edit button is clicked.
724 - require(['editInPlace', wysiwygEditorModule], function(editInPlace) {
725 - editInPlace.editPage().always(function() {
726 - editButton.removeClass('disabled');
727 - });
728 - // Fallback on the standalone edit mode if we fail to load the required modules.
729 - }, $.proxy(disableInPlaceEditing, event.target));
730 - });
731 -
732 - // Section in-place editing.
733 - $('#xwikicontent').on('click.inPlaceEditing', '> :header > a.edit_section:not(.disabled)', function(event) {
912 + if (editButton.hasClass('disabled')) {
913 + return;
914 + }
915 + // Disable the edit buttons and hide the section edit links.
916 + editButton.add(translateButton).addClass('disabled');
917 + $('#xwikicontent').children(':header').children('.edit_section').addClass('hidden');
734 734   event.preventDefault();
735 - // Make sure the user doesn't try to re-activate the edit mode while we are in edit mode.
736 - editButton.addClass('disabled');
737 - // Hide the section editing links and focus the content right away. We could have replaced the section editing icon
738 - // with a loading animation / spinner but giving instant visual feedback about what is going to happen is perceived
739 - // better by the users (it feels faster).
740 - $('#xwikicontent').attr('tabindex', '0').focus().children(':header').children('.edit_section').addClass('hidden');
741 - var heading = $(event.target).closest(':header');
919 + const handler = event.data;
920 + const data = handler.beforeEdit?.(event);
742 742   // Load the code needed to edit in place only when the edit button is clicked.
743 743   require(['editInPlace', wysiwygEditorModule], function(editInPlace) {
744 - editInPlace.editSection(heading.attr('id')).always(function() {
923 + // Re-enable the translate button because it can be used while editing to create the missing translation.
924 + translateButton.removeClass('disabled');
925 + handler.edit(editInPlace, data).finally(function() {
926 + // Restore only the edit button at the end because:
927 + // * the translate button is restored (if needed) by the editInPlace module
928 + // * the section edit links are restored when the document is rendered for view
745 745   editButton.removeClass('disabled');
746 746   });
747 747   // Fallback on the standalone edit mode if we fail to load the required modules.
748 - }, $.proxy(disableInPlaceEditing, event.target));
749 - });
932 + }, disableInPlaceEditing.bind(event.target));
933 + };
750 750  
751 751   var disableInPlaceEditing = function() {
752 - editButton.off('click.inPlaceEditing').removeClass('disabled');
936 + editButton.add(translateButton).off('click.inPlaceEditing').removeClass('disabled');
753 753   $('#xwikicontent').off('click.inPlaceEditing').removeAttr('tabindex').children(':header').children('.edit_section')
754 754   .removeClass('hidden');
755 755   // Fallback on the standalone edit mode.
756 756   $(this).click();
757 757   };
942 +
943 + var editButton = $(config.editButtonSelector);
944 + editButton.on('click.inPlaceEditing', {
945 + beforeEdit: function() {
946 + history.replaceState(null, null, '#edit');
947 + },
948 + edit: function(editInPlace) {
949 + return editInPlace.editPage();
950 + }
951 + }, onInPlaceEditing).attr('data-editor', 'inplace');
952 +
953 + var translateButton = $(config.translateButtonSelector);
954 + translateButton.on('click.inPlaceEditing', {
955 + beforeEdit: function() {
956 + history.replaceState(null, null, '#translate');
957 + translateButton.parent().addClass('hidden');
958 + },
959 + edit: function(editInPlace) {
960 + return editInPlace.translatePage();
961 + }
962 + }, onInPlaceEditing);
963 +
964 + // Section in-place editing.
965 + $('#xwikicontent').on('click.inPlaceEditing', '> :header > a.edit_section:not(.disabled)', {
966 + beforeEdit: function(event) {
967 + // Focus the content right away to give the user instant visual feedback about what is going to happen.
968 + $('#xwikicontent').attr('tabindex', '0').focus();
969 + // Return the id of the edited section.
970 + return $(event.target).closest(':header').attr('id');
971 + },
972 + edit: function(editInPlace, sectionId) {
973 + return editInPlace.editSection(sectionId);
974 + }
975 + }, onInPlaceEditing);
976 +
977 + if (window.location.hash === '#edit') {
978 + editButton.click();
979 + } else if (window.location.hash === '#translate') {
980 + translateButton.click();
981 + }
758 758  });
983 +
984 +require(['jquery'], function($) {
985 + // Backup the document title before each editing session in order to catch changes.
986 + var previousPlainTitle;
987 + $('#xwikicontent').on('xwiki:actions:edit', function(event, data) {
988 + previousPlainTitle = data.document.getPlainTitle();
989 + });
990 +
991 + // Update the UI after each editing session.
992 + $(document).on('xwiki:actions:view', function(event, data) {
993 + var xwikiDocument = data.document;
994 + updateDocAuthorAndDate(xwikiDocument);
995 + updateDocExtraTabs(xwikiDocument);
996 + updateDrawer(xwikiDocument);
997 + updateContentMenu(xwikiDocument);
998 + if (xwikiDocument.getPlainTitle() !== previousPlainTitle) {
999 + updateDocTrees(xwikiDocument);
1000 + updateLinks(xwikiDocument);
1001 + }
1002 + });
1003 +
1004 + var updateDocAuthorAndDate = function(xwikiDocument) {
1005 + var urlWithSelector = xwikiDocument.getURL('get', 'xpage=contentheader') + ' .xdocLastModification';
1006 + $('.xdocLastModification').load(urlWithSelector, function() {
1007 + // load() replaces the content of the specified container but we want to replace the container itself. We can't do
1008 + // this from the selector, e.g. by using '.xdocLastModification > *' because we lose the text nodes.
1009 + $(this).children().unwrap();
1010 + });
1011 + };
1012 +
1013 + var updateDocExtraTabs = function(xwikiDocument) {
1014 + // Reload the selected tab and force the reload of the hidden tabs next time they are selected.
1015 + $('#docextrapanes').children().addClass('empty').empty();
1016 + var selectedTab = $('#docExtraTabs .active[data-template]');
1017 + if (selectedTab.length) {
1018 + var docExtraId = selectedTab.attr('id');
1019 + docExtraId = docExtraId.substring(0, docExtraId.length - 'tab'.length);
1020 + XWiki.displayDocExtra(docExtraId, selectedTab.data('template'), false);
1021 + }
1022 + };
1023 +
1024 + // Update the document trees (e.g. breadcrumb, navigation) if they have nodes that correspond to the edited document.
1025 + // Note that we want to update the internal tree data not just the link label. This is especially useful if we're
1026 + // going to implement refactoring operations (rename) in the document tree.
1027 + var updateDocTrees = function(xwikiDocument) {
1028 + var plainTitle = xwikiDocument.getPlainTitle();
1029 + $('.jstree-xwiki').each(function() {
1030 + $(this).jstree?.(true)?.set_text?.('document:' + xwikiDocument.id, plainTitle);
1031 + });
1032 + };
1033 +
1034 + // Update the links that target the edited document and whose label matches the document title. Note that this can
1035 + // update links whose label was not generated dynamically (e.g. with server side scripting) based on the document
1036 + // title. For instance there could be links with hard-coded labels or with labels generated using a translatin key
1037 + // (like in the Applications panel). For simplicity we assume that if the link matches the document URL and its
1038 + // previous title then it needs to be updated, but this happens only at the UI level.
1039 + var updateLinks = function(xwikiDocument) {
1040 + var docURL = xwikiDocument.getURL();
1041 + var newPlainTitle = xwikiDocument.getPlainTitle();
1042 + // Exclude the links from the document content.
1043 + // Update the links that contain only text (no child elements) otherwise we can lose UI elements (e.g. icons).
1044 + $('a').not('#xwikicontent a').not(':has(*)').filter(function() {
1045 + var linkURL = $(this).attr('href')?.split(/[?#]/, 1)[0];
1046 + return linkURL === docURL && $(this).text() === previousPlainTitle;
1047 + }).text(newPlainTitle);
1048 + };
1049 +
1050 + // Update the list of available document translations in the drawer menu. This is needed for instance when a new
1051 + // translation is created using the in-place editor.
1052 + var updateDrawer = function(xwikiDocument) {
1053 + var languageMenu = $('#tmLanguages_menu');
1054 + var locale = xwikiDocument.getRealLocale();
1055 + // Look for the language query string parameter, either inside or at the end.
1056 + var localeSelector = 'a[href*="language=' + locale + '&"], a[href$="language=' + locale + '"]';
1057 + // Check if the language menu is present (multilingual is on) and the document locale is not listed.
1058 + if (languageMenu.length && !languageMenu.find(localeSelector).length) {
1059 + // If we get here then it means a new document translation was created and it needs to be listed in the drawer.
1060 + $('<div/>').load(xwikiDocument.getURL('get', $.param({
1061 + 'xpage': 'xpart',
1062 + 'vm': 'drawer.vm',
1063 + 'useLayoutVars': true
1064 + // Pass the query string from the current URL so that it gets included in the translation URL.
1065 + // XWIKI-11314: Changing the current language from the UI does not preserve the query string of the current URL
1066 + })) + '&' + location.search.substring(1) + ' #tmLanguages_menu', function() {
1067 + $(this).find('a').each(function() {
1068 + // Clean the query string.
1069 + $(this).attr('href', $(this).attr('href').replace(/&?(xpage=xpart|vm=drawer\.vm|useLayoutVars=true)/g, '')
1070 + .replace('?&', '?'));
1071 + });
1072 + languageMenu.replaceWith($(this).children());
1073 + });
1074 + }
1075 + };
1076 +
1077 + // Update the links from the content menu to point to the real document locale. This is needed especially when a new
1078 + // document translation is created in-place.
1079 + var updateContentMenu = function(xwikiDocument) {
1080 + var realLocale = xwikiDocument.getRealLocale();
1081 + var defaultLocale = xwikiDocument.getDefaultLocale();
1082 + if (realLocale != defaultLocale) {
1083 + var defaultLocaleRegex = new RegExp('(\\blanguage=)' + defaultLocale + '($|&|#)');
1084 + $('#contentmenu a[href*="language=' + defaultLocale + '"]').each(function() {
1085 + $(this).attr('href', $(this).attr('href').replace(defaultLocaleRegex, '$1' + realLocale + '$2'));
1086 + });
1087 + }
1088 + };
1089 +});
1090 +
1091 +})(JSON.parse(document.querySelector('[data-inplace-editing-config]')?.getAttribute('data-inplace-editing-config')) ||
1092 + {});
Parse content
... ... @@ -1,1 +1,1 @@
1 -Yes
1 +No
XWiki.StyleSheetExtension[0]
Code
... ... @@ -6,6 +6,7 @@
6 6   margin-bottom: @line-height-computed / 4;
7 7  }
8 8  
9 +@document-title-input-padding-vertical: @line-height-computed / 4 - 1;
9 9  input#document-title-input {
10 10   /* Preserve the heading styles. */
11 11   border: 1px solid transparent;
... ... @@ -12,9 +12,10 @@
12 12   box-shadow: none;
13 13   color: inherit;
14 14   font-size: inherit;
15 - height: auto;
16 + /* It seems it's not enough to set the line height for the text input. We also need to set its height. */
17 + height: @font-size-document-title * @headings-line-height + 2 * (1 + @document-title-input-padding-vertical);
16 16   line-height: @headings-line-height;
17 - padding: (@line-height-computed / 4 - 1) (ceil(@grid-gutter-width / 2) - 1);
19 + padding: @document-title-input-padding-vertical (ceil(@grid-gutter-width / 2) - 1);
18 18   width: 100%;
19 19  }
20 20  
... ... @@ -47,3 +47,8 @@
47 47  #xwikicontent {
48 48   padding-top: @line-height-computed * 0.75;
49 49  }
52 +
53 +.sticky-buttons-wrapper {
54 + /* Leave some space for the bottom box shadow of the editing area. */
55 + margin-top: 7px;
56 +}
XWiki.UIExtensionClass[0]
Executed Content
... ... @@ -1,12 +1,66 @@
1 +{{velocity output="false"}}
2 +## TODO: Remove this when XWIKI-18511 (Add support for passing a query string when calling getSkinFile) is implemented.
3 +#macro (getSkinFileWithParams $file $params)
4 +#set ($url = $xwiki.getSkinFile($file, true))
5 +$url#if ($url.contains('?'))&#else?#end$escapetool.url($params)
6 +#end
7 +{{/velocity}}
8 +
1 1  {{velocity}}
2 2  {{html clean="false"}}
3 -#if ($xcontext.action == 'view' && !$doc.isNew())
11 +#if ($services.edit.document.inPlaceEditingEnabled() && $hasEdit && $xcontext.action == 'view' && !$doc.isNew())
4 4   ## We support in-place editing only for the WYSIWYG edit mode ATM.
5 5   #getDefaultDocumentEditor($defaultEditMode)
6 6   #if ($defaultEditMode == 'wysiwyg')
15 + #set ($l10nKeys = [
16 + 'edit.inplace.page.renderFailed',
17 + 'edit.inplace.page.lockFailed',
18 + 'edit.inplace.close',
19 + 'edit.inplace.page.loadFailed',
20 + 'edit.inplace.actionButtons.loadFailed',
21 + 'core.editors.content.titleField.label',
22 + ['edit.inplace.page.translate.messageBefore', $doc.realLocale.getDisplayName($xcontext.locale),
23 + $xcontext.locale.getDisplayName($xcontext.locale)],
24 + ['edit.inplace.page.translate.messageAfter', $xcontext.locale.getDisplayName($xcontext.locale)]
25 + ])
26 + #set ($l10n = {})
27 + #foreach ($key in $l10nKeys)
28 + #set ($params = $key.subList(1, $key.size()))
29 + #if ($params)
30 + #set ($discard = $l10n.put($key[0], $services.localization.render($key[0], $params)))
31 + #else
32 + #set ($discard = $l10n.put($key, $services.localization.render($key)))
33 + #end
34 + #end
35 + ## See stylesheets.vm
36 + #set ($cssParams = {
37 + 'skin': $xwiki.skin,
38 + 'colorTheme': $services.model.serialize($themeDoc.documentReference, 'default')
39 + })
40 + #set ($jsParams = {'language': $xcontext.locale})
41 + ## We have to explicitly enable the source mode for in-line edit because the latest version of the content editor
42 + ## could be installed on an older version of XWiki where the in-place editor didn't support the source mode (so the
43 + ## content editor cannot enable the source mode by default).
7 7   #set ($inplaceEditingConfig = {
45 + 'contentType': 'org.xwiki.rendering.syntax.SyntaxContent',
8 8   'editMode': $defaultEditMode,
9 - 'wysiwygEditor': $services.edit.syntaxContent.defaultWysiwygEditor.descriptor.id
47 + 'wysiwygEditor': $services.edit.syntaxContent.defaultWysiwygEditor.descriptor.id,
48 + 'editButtonSelector': '#tmEdit > a',
49 + 'translateButtonSelector': '#tmTranslate > a',
50 + 'enableSourceMode': true,
51 + 'paths': {
52 + 'js': {
53 + 'xwiki-actionButtons': "#getSkinFileWithParams('js/xwiki/actionbuttons/actionButtons.js' $jsParams)",
54 + 'xwiki-autoSave': "#getSkinFileWithParams('js/xwiki/editors/autosave.js' $jsParams)",
55 + 'xwiki-diff': $xwiki.getSkinFile('uicomponents/viewers/diff.js')
56 + },
57 + 'css': [
58 + "#getSkinFileWithParams('js/xwiki/actionbuttons/actionButtons.css' $cssParams)",
59 + "#getSkinFileWithParams('js/xwiki/editors/autosave.css' $cssParams)",
60 + "#getSkinFileWithParams('uicomponents/viewers/diff.css' $cssParams)"
61 + ]
62 + },
63 + 'l10n': $l10n
10 10   })
11 11   <div class="hidden" data-inplace-editing-config="$escapetool.xml($jsontool.serialize($inplaceEditingConfig))"></div>
12 12   ## We didn't move this to the file system because it uses LESS and we didn't want to include it in the skin.
XWiki.UIExtensionClass[1]
Cached
... ... @@ -1,1 +1,0 @@
1 -No
Asynchronous rendering
... ... @@ -1,1 +1,0 @@
1 -No
Executed Content
... ... @@ -1,22 +1,0 @@
1 -{{velocity}}
2 -{{html clean="false"}}
3 -## Output the translation button if all the following conditions are met:
4 -## * multilingual is on
5 -## * we're loading the original document version
6 -## * the original document version has a locale specified (it doesn't make sense to translate technical documents)
7 -## * the current UI locale doesn't match the original document locale
8 -#if ($xwiki.isMultiLingual() && $tdoc.realLocale == $doc.realLocale && "$!doc.realLocale" != ''
9 - && $doc.realLocale != $xcontext.locale)
10 - #set ($url = $doc.getURL('edit', $escapetool.url({'language': $xcontext.locale})))
11 - #set ($hint = $services.localization.render('edit.inplace.page.translate.hint',
12 - [$xcontext.locale.getDisplayName($xcontext.locale)]))
13 - ## We show the translate button only while editing in-place.
14 - <div class="btn-group hidden" id="tmTranslate">
15 - <a class="btn btn-default" href="$url" role="button" title="$escapetool.xml($hint)">
16 - $services.icon.renderHTML('translate')
17 - <span class="btn-label">$escapetool.xml($services.localization.render('edit.inplace.page.translate'))</span>
18 - </a>
19 - </div>
20 -#end
21 -{{/html}}
22 -{{/velocity}}
Extension Point ID
... ... @@ -1,1 +1,0 @@
1 -org.xwiki.plaftorm.menu.content
Extension ID
... ... @@ -1,1 +1,0 @@
1 -org.xwiki.plaftorm.menu.content.translate
Extension Parameters
... ... @@ -1,1 +1,0 @@
1 -order=5000
Extension Scope
... ... @@ -1,1 +1,0 @@
1 -wiki