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";