diff --git a/browser/css/toolbar.css b/browser/css/toolbar.css
index c3326103b7ea..7529fc97f3b2 100644
--- a/browser/css/toolbar.css
+++ b/browser/css/toolbar.css
@@ -1806,3 +1806,12 @@ menu-entry-with-icon.padding-left + menu-entry-icon.width */
font-size: var(--default-font-size);
color: var(--color-main-text);
}
+
+#saved-status-label {
+ background-image: url("data:image/svg+xml,%3Csvg id='Icons' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:%235cb82a;%7D.cls-2%7Bfill:%23fff;%7D%3C/style%3E%3C/defs%3E%3Ctitle%3E_%3C/title%3E%3Cg id='Funktions-Icons_24' data-name='Funktions-Icons 24'%3E%3Ccircle class='cls-1' cx='10' cy='10' r='10'/%3E%3Crect class='cls-2' x='3.36' y='10.52' width='6.75' height='2.5' rx='0.5' ry='0.5' transform='translate(10.29 -1.31) rotate(45)'/%3E%3Crect class='cls-2' x='5.63' y='8.75' width='11.75' height='2.5' rx='0.5' ry='0.5' transform='translate(-3.7 11.06) rotate(-45)'/%3E%3C/g%3E%3C/svg%3E");
+ background-position: left center;
+ background-repeat: no-repeat;
+ padding-left: 18px;
+ margin-left: 15px;
+ white-space: nowrap;
+}
diff --git a/browser/html/cool.html.m4 b/browser/html/cool.html.m4
index 3298fcf5ac0f..00f88b8e15cb 100644
--- a/browser/html/cool.html.m4
+++ b/browser/html/cool.html.m4
@@ -247,6 +247,7 @@ m4_ifelse(MOBILEAPP, [true],
data-enable-accessibility = "%ENABLE_ACCESSIBILITY%"
data-out-of-focus-timeout-secs = "%OUT_OF_FOCUS_TIMEOUT_SECS%"
data-idle-timeout-secs = "%IDLE_TIMEOUT_SECS%"
+ data-min-saved-message-timeout-secs = %MIN_SAVED_MESSAGE_TIMEOUT_SECS%;
data-protocol-debug = "%PROTOCOL_DEBUG%"
data-enable-debug = "%ENABLE_DEBUG%"
data-frame-ancestors = "%FRAME_ANCESTORS%"
diff --git a/browser/src/control/Control.Menubar.js b/browser/src/control/Control.Menubar.js
index 5bdae2e5523f..734297a09714 100644
--- a/browser/src/control/Control.Menubar.js
+++ b/browser/src/control/Control.Menubar.js
@@ -1377,6 +1377,8 @@ L.Control.Menubar = L.Control.extend({
map.on('addmenu', this._addMenu, this);
map.on('languagesupdated', this._onInitLanguagesMenu, this);
map.on('updatetoolbarcommandvalues', this._onStyleMenu, this);
+ map.on('initmodificationindicator', this._onInitModificationIndicator, this);
+ map.on('updatemodificationindicator', this._onUpdateModificationIndicator, this);
},
onRemove: function() {
@@ -1385,6 +1387,8 @@ L.Control.Menubar = L.Control.extend({
this._map.off('addmenu', this._addMenu, this);
this._map.off('languagesupdated', this._onInitLanguagesMenu, this);
this._map.off('updatetoolbarcommandvalues', this._onStyleMenu, this);
+ this._map.off('initmodificationindicator', this._onInitModificationIndicator, this);
+ this._map.off('updatemodificationindicator', this._onUpdateModificationIndicator, this);
this._menubarCont.remove();
this._menubarCont = null;
@@ -2532,6 +2536,39 @@ L.Control.Menubar = L.Control.extend({
}
return null;
},
+
+ _onInitModificationIndicator: function(lastmodtime) {
+ var lastModButton = L.DomUtil.get('menu-last-mod');
+ if (lastModButton !== null && lastModButton !== undefined
+ && lastModButton.firstChild.innerHTML !== null
+ && lastModButton.firstChild.childElementCount == 0) {
+ if (lastmodtime == null) {
+ // No modification time -> hide the indicator
+ L.DomUtil.setStyle(lastModButton, 'display', 'none');
+ return;
+ }
+ var mainSpan = document.createElement('span');
+ this.lastModIndicator = document.createElement('span');
+ mainSpan.appendChild(this.lastModIndicator);
+
+ //this._map.updateModificationIndicator(this._lastmodtime);
+
+ // Replace menu button body with new content
+ lastModButton.firstChild.replaceChildren();
+ lastModButton.firstChild.appendChild(mainSpan);
+
+ if (L.Params.revHistoryEnabled) {
+ L.DomUtil.setStyle(lastModButton, 'cursor', 'pointer');
+ }
+
+ this._map.fire('modificationindicatorinitialized');
+ }
+ },
+ _onUpdateModificationIndicator: function(e) {
+ if (this.lastModIndicator !== null && this.lastModIndicator !== undefined) {
+ this.lastModIndicator.innerHTML = e.lastSaved;
+ }
+ }
});
L.control.menubar = function (options) {
diff --git a/browser/src/control/Control.StatusBar.js b/browser/src/control/Control.StatusBar.js
index 451200539925..21242cbb43e0 100644
--- a/browser/src/control/Control.StatusBar.js
+++ b/browser/src/control/Control.StatusBar.js
@@ -25,6 +25,8 @@ class StatusBar extends JSDialog.Toolbar {
map.on('updatestatepagenumber', this.onPageChange, this);
map.on('search', this.onSearch, this);
map.on('zoomend', this.onZoomEnd, this);
+ map.on('initmodificationindicator', this.onInitModificationIndicator, this);
+ map.on('updatemodificationindicator', this.onUpdateModificationIndicator, this);
}
localizeStateTableCell(text) {
@@ -242,6 +244,7 @@ class StatusBar extends JSDialog.Toolbar {
this._generateHtmlItem('permissionmode'), // spreadsheet, text, presentation
{type: 'toolitem', id: 'signstatus', command: '.uno:Signature', w2icon: '', text: _UNO('.uno:Signature'), visible: false},
{type: 'spacer', id: 'permissionspacer'},
+ this._generateHtmlItem('documentstatus'), // spreadsheet, text, presentation, drawing
{type: 'customtoolitem', id: 'prev', command: 'prev', text: _UNO('.uno:PageUp', 'text'), pressAndHold: true},
{type: 'customtoolitem', id: 'next', command: 'next', text: _UNO('.uno:PageDown', 'text'), pressAndHold: true},
{type: 'separator', id: 'prevnextbreak', orientation: 'vertical'},
@@ -292,6 +295,7 @@ class StatusBar extends JSDialog.Toolbar {
this.showItem('StateTableCellMenu', !app.map.isReadOnlyMode());
this.showItem('statetablebreak', !app.map.isReadOnlyMode());
this.showItem('permissionmode-container', true);
+ this.showItem('documentstatus-container', true);
}
break;
@@ -304,6 +308,7 @@ class StatusBar extends JSDialog.Toolbar {
this.showItem('languagestatus', !app.map.isReadOnlyMode());
this.showItem('languagestatusbreak', !app.map.isReadOnlyMode());
this.showItem('permissionmode-container', true);
+ this.showItem('documentstatus-container', true);
}
break;
@@ -313,6 +318,7 @@ class StatusBar extends JSDialog.Toolbar {
this.showItem('languagestatus', !app.map.isReadOnlyMode());
this.showItem('languagestatusbreak', !app.map.isReadOnlyMode());
this.showItem('permissionmode-container', true);
+ this.showItem('documentstatus-container', true);
}
break;
case 'drawing':
@@ -321,6 +327,7 @@ class StatusBar extends JSDialog.Toolbar {
this.showItem('languagestatus', !app.map.isReadOnlyMode());
this.showItem('languagestatusbreak', !app.map.isReadOnlyMode());
this.showItem('permissionmode-container', true);
+ this.showItem('documentstatus-container', true);
}
break;
}
@@ -505,6 +512,33 @@ class StatusBar extends JSDialog.Toolbar {
JSDialog.MenuDefinitions.set('LanguageStatusMenu', menuEntries);
}
+
+ onInitModificationIndicator(lastmodtime) {
+ const docstatcontainer = document.getElementById('documentstatus-container');
+ if (lastmodtime == null) {
+ if (docstatcontainer !== null && docstatcontainer !== undefined) {
+ docstatcontainer.classList.add('hidden');
+ }
+ return;
+ }
+ docstatcontainer.classList.remove('hidden');
+
+ this.map.fire('modificationindicatorinitialized');
+ }
+
+ // status can be '', 'SAVING', 'MODIFIED' or 'SAVED'
+ onUpdateModificationIndicator(e) {
+ if (this._lastModstatus !== e.status) {
+ this.updateHtmlItem('DocumentStatus', e.status);
+ this._lastModStatus = e.status;
+ }
+ if (e.lastSaved !== null && e.lastSaved !== undefined) {
+ const lastSaved = document.getElementById('last-saved');
+ if (lastSaved !== null && lastSaved !== undefined) {
+ lastSaved.textContent = e.lastSaved;
+ }
+ }
+ }
}
JSDialog.StatusBar = function (map) {
diff --git a/browser/src/control/Toolbar.js b/browser/src/control/Toolbar.js
index 13cfc5cf8724..f5b5c48f4c29 100644
--- a/browser/src/control/Toolbar.js
+++ b/browser/src/control/Toolbar.js
@@ -327,6 +327,8 @@ L.Map.include({
},
save: function(dontTerminateEdit, dontSaveIfUnmodified, extendedData) {
+ this.fire('updatemodificationindicator', { status: 'SAVING' });
+
var msg = 'save' +
' dontTerminateEdit=' + (dontTerminateEdit ? 1 : 0) +
' dontSaveIfUnmodified=' + (dontSaveIfUnmodified ? 1 : 0);
diff --git a/browser/src/control/jsdialog/Widget.HTMLContent.ts b/browser/src/control/jsdialog/Widget.HTMLContent.ts
index 195cd4f813e8..a11e42e14e5f 100644
--- a/browser/src/control/jsdialog/Widget.HTMLContent.ts
+++ b/browser/src/control/jsdialog/Widget.HTMLContent.ts
@@ -118,6 +118,30 @@ function getPageStatusElements(text: string) {
return getStatusbarItemElements('PageStatus', _('Number of Pages'), text);
}
+function getDocumentStatusElements(text: string) {
+ const docstat = getStatusbarItemElements(
+ 'DocumentStatus',
+ _('Your changes have been saved'),
+ '',
+ );
+
+ if (text === 'SAVING') docstat.textContent = _('Saving...');
+ else if (text === 'SAVED') {
+ const lastSaved = document.createElement('span');
+ lastSaved.id = 'last-saved';
+ lastSaved.title = _('Your changes have been saved') + '.';
+ lastSaved.textContent = '';
+ docstat.appendChild(lastSaved);
+
+ const savedStatus = document.createElement('span');
+ savedStatus.id = 'saved-status-label';
+ savedStatus.textContent = _('Document saved');
+ docstat.appendChild(savedStatus);
+ }
+
+ return docstat;
+}
+
var getElementsFromId = function (
id: string,
closeCallback: EventListenerOrEventListenerObject,
@@ -159,6 +183,7 @@ var getElementsFromId = function (
else if (id === 'statetablecell') return getStateTableCellElements(data.text);
else if (id === 'slidestatus') return getSlideStatusElements(data.text);
else if (id === 'pagestatus') return getPageStatusElements(data.text);
+ else if (id === 'documentstatus') return getDocumentStatusElements(data.text);
};
function htmlContent(
diff --git a/browser/src/map/Map.js b/browser/src/map/Map.js
index 1dcee2186afc..43953a3d74fc 100644
--- a/browser/src/map/Map.js
+++ b/browser/src/map/Map.js
@@ -148,6 +148,10 @@ L.Map = L.Evented.extend({
}
});
+ this.on('modificationindicatorinitialized', function() {
+ this._modIndicatorInitialized = true;
+ });
+
// When all these conditions are met, fire statusindicator:initializationcomplete
this.initConditions = {
'doclayerinit': false,
@@ -245,6 +249,10 @@ L.Map = L.Evented.extend({
// Fire an event to let the client know whether the document needs saving or not.
this.fire('postMessage', {msgId: 'Doc_ModifiedStatus', args: { Modified: e.state === 'true' }});
+
+ if (this._everModified) {
+ this.fire('updatemodificationindicator', { status: e.state === 'true' ? 'MODIFIED' : 'SAVED' });
+ }
}
}, this);
@@ -424,29 +432,8 @@ L.Map = L.Evented.extend({
},
initializeModificationIndicator: function() {
- var lastModButton = L.DomUtil.get('menu-last-mod');
- if (lastModButton !== null && lastModButton !== undefined
- && lastModButton.firstChild.innerHTML !== null
- && lastModButton.firstChild.childElementCount == 0) {
- if (this._lastmodtime == null) {
- // No modification time -> hide the indicator
- L.DomUtil.setStyle(lastModButton, 'display', 'none');
- return;
- }
- var mainSpan = document.createElement('span');
- this.lastModIndicator = document.createElement('span');
- mainSpan.appendChild(this.lastModIndicator);
-
- this.updateModificationIndicator(this._lastmodtime);
-
- // Replace menu button body with new content
- lastModButton.firstChild.replaceChildren();
- lastModButton.firstChild.appendChild(mainSpan);
-
- if (L.Params.revHistoryEnabled) {
- L.DomUtil.setStyle(lastModButton, 'cursor', 'pointer');
- }
- }
+ this.fire('initmodificationindicator', this._lastmodtime);
+ this.updateModificationIndicator(this._lastmodtime);
},
updateModificationIndicator: function(newModificationTime) {
@@ -458,13 +445,16 @@ L.Map = L.Evented.extend({
clearTimeout(this._modTimeout);
- if (this.lastModIndicator !== null && this.lastModIndicator !== undefined) {
+ if (this._modIndicatorInitialized) {
var dateTime = new Date(this._lastmodtime.replace(/,.*/, 'Z'));
var dateValue;
var elapsed = Date.now() - dateTime;
var rtf1 = new Intl.RelativeTimeFormat(String.locale, { style: 'narrow' });
- if (elapsed < 60000) {
+ if (('minSavedMessageTimeoutSecs' in window) && (elapsed < (window.minSavedMessageTimeoutSecs * 1000))) {
+ timeout = window.minSavedMessageTimeoutSecs * 1000;
+ dateValue = '';
+ } else if (elapsed < 60000) {
dateValue = _('Last saved:') + ' ' + rtf1.format(-Math.round(elapsed / 1000), 'second');
timeout = 6000;
} else if (elapsed < 3600000) {
@@ -479,7 +469,7 @@ L.Map = L.Evented.extend({
timeout = 60000;
}
- this.lastModIndicator.innerHTML = dateValue;
+ this.fire('updatemodificationindicator', {lastSaved: dateValue});
if (timeout) {
this._modTimeout = setTimeout(L.bind(this.updateModificationIndicator, this, -1), timeout);
diff --git a/coolwsd.xml.in b/coolwsd.xml.in
index 9c5d59fc31eb..f90a81c80a7e 100644
--- a/coolwsd.xml.in
+++ b/coolwsd.xml.in
@@ -83,6 +83,7 @@
300
900
+ 6
diff --git a/wsd/COOLWSD.cpp b/wsd/COOLWSD.cpp
index c985cfe383ae..cd2de62b2a28 100644
--- a/wsd/COOLWSD.cpp
+++ b/wsd/COOLWSD.cpp
@@ -2115,6 +2115,7 @@ void COOLWSD::innerInitialize(Application& self)
{ "per_view.idle_timeout_secs", "900" },
{ "per_view.out_of_focus_timeout_secs", "120" },
{ "per_view.custom_os_info", "" },
+ { "per_view.min_saved_message_timeout_secs", "0"},
{ "security.capabilities", "true" },
{ "security.seccomp", "true" },
{ "security.jwt_expiry_secs", "1800" },
diff --git a/wsd/FileServer.cpp b/wsd/FileServer.cpp
index 26b371b25ffe..95250695463b 100644
--- a/wsd/FileServer.cpp
+++ b/wsd/FileServer.cpp
@@ -1310,6 +1310,8 @@ FileServerRequestHandler::ResourceAccessDetails FileServerRequestHandler::prepro
Poco::replaceInPlace(preprocess, std::string("%OUT_OF_FOCUS_TIMEOUT_SECS%"), std::to_string(outOfFocusTimeoutSecs));
const unsigned int idleTimeoutSecs = config.getUInt("per_view.idle_timeout_secs", 900);
Poco::replaceInPlace(preprocess, std::string("%IDLE_TIMEOUT_SECS%"), std::to_string(idleTimeoutSecs));
+ const unsigned int minSavedMessTimeoutSecs = config.getUInt("per_view.min_saved_message_timeout_secs", 0);
+ Poco::replaceInPlace(preprocess, std::string("%MIN_SAVED_MESSAGE_TIMEOUT_SECS%"), std::to_string(minSavedMessTimeoutSecs));
#if ENABLE_WELCOME_MESSAGE
std::string enableWelcomeMessage = "true";