Merge pull request 'feature/recent-pins' (#542) from feature/recent-pins into develop

Reviewed-on: https://git.onlyoffice.com/ONLYOFFICE/desktop-apps/pulls/542
This commit is contained in:
Maxim Kadushkin
2025-12-07 20:38:17 +00:00
7 changed files with 268 additions and 80 deletions

View File

@ -386,9 +386,9 @@ li.menu-item {
align-items: center;
justify-content: space-between;
padding-left: 16px;
padding-right: 56px;
padding-right: 88px;
.rtl & {
padding-left: 56px;
padding-left: 88px;
padding-right: 16px;
}
}
@ -706,7 +706,7 @@ li.menu-item {
justify-content: flex-end;
}
.col-more {
.col-more, .col-pin {
position: absolute;
top: 0;
bottom: 0;
@ -752,6 +752,19 @@ li.menu-item {
background-color: @background-normal-element-light;
}
&.unavail {
.col-more, .col-pin {
display: none;
}
}
&:hover:not(.unavail) {
.col-more, .col-pin {
opacity: 1;
}
}
&:hover {
.col-more {
opacity: 1;
@ -766,6 +779,22 @@ li.menu-item {
background-color: @highlight-button-pressed;
}
.col-pin {
right: 44px;
.rtl & {
right: unset;
left: 44px;
}
}
&.pinned .col-pin {
opacity: 1;
.icon {
opacity: 1;
}
}
&.unavail {
> :not(.col-more) {
opacity: 0.5;
@ -1018,6 +1047,13 @@ li.menu-item {
gap: 8px;
.file-list-body {
overflow-x: hidden;
.col-pin {
right: 16px;
.rtl & {
right: auto;
left: 16px;
}
}
}
.win_xp & {
@ -1116,7 +1152,7 @@ li.menu-item {
#box-recovery {
.file-list-body, .file-list-head {
.col-location, .col-date {
.col-location, .col-date, .col-pin {
display: none;
}
}

View File

@ -56,6 +56,8 @@ l10n.en = {
menuRemoveModel: 'Remove from list',
menuClear: 'Clear',
menuLogout: 'Logout',
menuFilePin: 'Pin',
menuFileUnpin: 'Unpin',
textMyComputer: 'My Computer',
textThrough: 'through',
linkForgotPass: 'Forgot password?',

View File

@ -204,6 +204,16 @@ Model.prototype.set = function(key, value, opts) {
this.events.changed.notify(args);
};
Model.prototype.setMany = function(args, opts) {
for (const [key, value] of Object.entries(args)) {
this[key] = value;
}
if (!opts || opts.silent !== true) {
this.events.changed.notify(args);
}
};
Model.prototype.get = function(key) {
return this[key];
};

View File

@ -83,6 +83,11 @@
utils.fn.extend(ControllerFolders.prototype, (function() {
var _on_update = function(params) {
var _dirs = utils.fn.parseRecent(params, 'folders'), $item;
_dirs.sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return 0;
});
const $listRecentDirs = this.view.$panel.find('.file-list-body');
@ -92,12 +97,26 @@
if (!utils.getUrlProtocol(dir.full)) {
$item = $(app.controller.recent.view.listitemtemplate(dir));
$item.click({path: dir.full}, e=>{
if (dir.pinned) {
$item.addClass('pinned');
}
$item.find('.col-pin .btn-quick').click(function (e) {
const folderPath = dir.full;
const newPinState = utils.fn.pinnedFolders(folderPath, 'toggle');
$item.toggleClass('pinned', newPinState);
_on_update.call(this, params);
}.bind(this));
$item.click({path: dir.full}, e=>{
openFile(OPEN_FILE_FOLDER, e.data.path);
e.preventDefault();
return false;
});
});
$listRecentDirs.append($item);
}

View File

@ -36,24 +36,26 @@
* panel 'recent'
*/
+function(){ 'use strict'
var ControllerRecent = function(args={}) {
+function () {
'use strict'
var ControllerRecent = function (args = {}) {
args.caption = 'Recent files';
args.action =
this.action = "recents";
this.action = "recents";
this.view = new ViewRecent(args);
};
ControllerRecent.prototype = Object.create(baseController.prototype);
ControllerRecent.prototype.constructor = ControllerRecent;
const isSvgIcons = window.devicePixelRatio >= 2 || window.devicePixelRatio === 1;
var ViewRecent = function(args) {
var ViewRecent = function (args) {
var _lang = utils.Lang;
// args.id&&(args.id=`"id=${args.id}"`)||(args.id='');
// localStorage.removeItem('welcome');
//language=HTML
const helpLink = `<a l10n class="link" href="https://helpcenter.onlyoffice.com/" target="popup">${_lang.textHelpCenter}</a>`;
const welcomeBannerTemplate = !localStorage.getItem('welcome') ? `
@ -70,11 +72,11 @@
<div class="search-bar hidden">
<h1 l10n>${_lang.welWelcome}</h1>
</div>
<section id="area-document-creation-grid"></section>
${welcomeBannerTemplate}
<section id="area-dnd-file"></section>
<div id="box-container">
<div id="box-recovery">
<div class="file-list-title">
@ -114,7 +116,7 @@
ViewRecent.prototype = Object.create(baseView.prototype);
ViewRecent.prototype.constructor = ViewRecent;
utils.fn.extend(ViewRecent.prototype, {
render: function() {
render: function () {
baseView.prototype.render.apply(this, arguments);
if (!localStorage.getItem('welcome')) {
@ -125,7 +127,7 @@
this.$boxRecent = this.$panel.find('#box-recent');
this.$panelContainer = this.$panel.find('.recent-panel-container');
},
listitemtemplate: function(info) {
listitemtemplate: function (info) {
let id = !!info.uid ? (` id="${info.uid}"`) : '';
info.crypted === undefined && (info.crypted = false);
const dotIndex = info.name.lastIndexOf('.');
@ -144,7 +146,8 @@
<div ${id} class="row text-normal">
<div class="col-name" title="${fullName}">
<div class="icon">
<svg class="icon" data-iconname="${info.type === 'folder' ? 'folder' : `${info.format}`}" data-precls="tool-icon">
<svg class="icon" data-iconname="${info.type === 'folder' ? 'folder' : `${info.format}`}"
data-precls="tool-icon">
<use xlink:href="#${info.type === 'folder' ? 'folder-small' : info.format}"></use>
</svg>
${info.crypted ? `<svg class="icon shield" data-iconname="shield" data-precls="tool-icon">
@ -158,29 +161,47 @@
</p>
</div>
<div class="col-location" title="${info.descr}">
<!-- todo: icon here -->
<!-- todo: icon here -->
${info.descr}
</div>
`;
//language=HTML
_tpl += `
<div class="col-pin">
<button id="${info.uid}-pin-btn" class="btn-quick">
<svg class="icon">
<use xlink:href="#pin"/>
</svg>
${!isSvgIcons ? '<i class="icon tool-icon pin"></i>' : ''}
</button>
</div>`;
if (info.type !== 'folder') {
_tpl += `<div class="col-date"><p>${info.date}</p></div>`;
_tpl += `<div class="col-more">
<button id="${info.uid}-more-btn" class="btn-quick more">
<svg class="icon"><use xlink:href="#more"/></svg>
${!isSvgIcons ? '<i class="icon tool-icon more"></i>' : ''}
</button>
</div>`;
//language=HTML
_tpl += `
<div class="col-date"><p>${info.date}</p></div>`;
//language=HTML
_tpl += `
<div class="col-more">
<button id="${info.uid}-more-btn" class="btn-quick more">
<svg class="icon">
<use xlink:href="#more"/>
</svg>
${!isSvgIcons ? '<i class="icon tool-icon more"></i>' : ''}
</button>
</div>`;
}
return _tpl + '</div>';
},
onscale: function (pasteSvg) {
let elm,icoName, parent,
let elm, icoName, parent,
emptylist = $('[class*="text-emptylist"]', '#box-recent');
emptylist.toggleClass('text-emptylist text-emptylist-svg');
if(pasteSvg && !emptylist.find('svg').length)
if (pasteSvg && !emptylist.find('svg').length)
emptylist.prepend($('<svg class = "icon"><use xlink:href="#folder-big"></use></svg>'));
// todo: rewrite cicon rescale
@ -252,26 +273,26 @@
window.ControllerRecent = ControllerRecent;
String.prototype.hashCode = function() {
String.prototype.hashCode = function () {
var hash = 0, i, chr;
if (this.length === 0) return hash;
for (i = this.length; !(--i < 0);) {
chr = this.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash = hash & hash; // Convert to 32bit integer
chr = this.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash = hash & hash; // Convert to 32bit integer
}
return hash;
};
utils.fn.extend(ControllerRecent.prototype, (function() {
utils.fn.extend(ControllerRecent.prototype, (function () {
let collectionRecents, collectionRecovers;
let ppmenu;
const ITEMS_LOAD_RANGE = 40;
const _add_recent_block = function () {
if ( !this.rawRecents || !Object.keys(this.rawRecents).length ) return;
if (!this.rawRecents || !Object.keys(this.rawRecents).length) return;
const _raw_block = this.rawRecents.slice(this.recentIndex, this.recentIndex + ITEMS_LOAD_RANGE);
const _files = utils.fn.parseRecent(_raw_block);
@ -282,22 +303,22 @@
var model = new FileModel(item);
model.set('hash', item.path.hashCode());
if ( !!this.rawRecents ) {
if (!!this.rawRecents) {
collectionRecents.add(model);
_check_block[model.get('hash')] = item.path;
} else return;
}
const _new_items_count = Object.keys(_check_block).length;
if ( _new_items_count ) {
if ( this.appready ) {
if (_new_items_count) {
if (this.appready) {
sdk.execCommand('files:check', JSON.stringify(_check_block));
}
Object.assign(this.check_list, _check_block);
}
if ( _new_items_count == ITEMS_LOAD_RANGE ) {
if (_new_items_count === ITEMS_LOAD_RANGE) {
setTimeout(e => {
this.recentIndex += ITEMS_LOAD_RANGE;
_add_recent_block.call(this);
@ -307,7 +328,6 @@
}
// this.view.$boxRecent.css('display', collectionRecents.size() > 0 ? 'flex' : 'none');
// requestAnimationFrame(() => this.view.updateListSize());
if (collectionRecents.size() > 0 || collectionRecovers.size() > 0) {
this.dndZone.hide();
@ -315,7 +335,7 @@
}
};
var _on_recents = function(params) {
var _on_recents = function (params) {
this.rawRecents = undefined;
setTimeout(e => {
@ -348,6 +368,12 @@
};
function addContextMenuEventListener(collection, model, view, actionList) {
$(`#${model.uid}-pin-btn`, view).click((e) => {
e.stopPropagation();
const pinned = !model.pinned;
model.setMany({ pinned: pinned, pinid: pinned ? -model.fileid : model.fileid });
})
$(`#${model.uid}-more-btn`, view).click((e) => {
e.stopPropagation();
@ -356,6 +382,18 @@
if (m.uid != model.uid)
Menu.closeAll();
}
ppmenu.actionlist = actionList;
if (actionList === 'recovery') {
ppmenu.hideItem('files:explore', true);
ppmenu.hideItem('files:pin', true);
ppmenu.hideItem('files:unpin', true);
} else {
ppmenu.hideItem('files:explore', (!model.islocal && !model.dir) || !model.exist);
ppmenu.hideItem(model.pinned ? 'files:pin' : 'files:unpin', true);
ppmenu.hideItem(model.pinned ? 'files:unpin' : 'files:pin', false);
}
ppmenu.showUnderElem(e.currentTarget, model, $('body').hasClass('rtl') ? 'left' : 'right');
if (!Menu.opened) {
ppmenu.actionlist = actionList;
@ -366,6 +404,24 @@
})
}
function handlePin(collection, model) {
let $el = $('#' + model.uid, collection.list);
if ($el.length) {
const f = collection.items.find((elem) => {
return model.pinid <= 0 ? elem.pinid < model.pinid : elem.pinid > model.pinid
});
if (f) {
const $item = $('#' + f.uid, collection.list);
$el.insertBefore($item);
} else if (!f && model.pinid > 0) {
$el.appendTo(collection.list);
} else {
$el.prependTo(collection.list);
}
}
}
function _init_collections() {
let _cl_rcbox = this.view.$boxRecent,
_cl_rvbox = this.view.$boxRecovery;
@ -380,9 +436,20 @@
});
collectionRecents.events.inserted.attach((collection, model) => {
let $item = this.view.listitemtemplate(model);
let $item = $(this.view.listitemtemplate(model));
collection.list.append($item);
if (model.pinned) {
const $pinned = collection.list.children('.row.pinned');
if ($pinned.length) {
$item.insertAfter($pinned.last());
} else {
$item.prependTo(collection.list);
}
} else {
collection.list.append($item);
}
$item[model.pinned ? 'addClass' : 'removeClass']('pinned');
addContextMenuEventListener(collection, model, this.view.$panel, 'recent');
@ -392,21 +459,33 @@
collectionRecents.events.click.attach((collection, model) => {
// var _portal = model.descr;
// if ( !model.islocal && !app.controller.portals.isConnected(_portal) ) {
// app.controller.portals.authorizeOn(_portal, {type: 'fileid', id: model.fileid});
// app.controller.portals.authorizeOn(_portal, {type: 'fileid', id: model.fileid});
// } else {
openFile(OPEN_FILE_RECENT, model);
openFile(OPEN_FILE_RECENT, model);
// }
});
collectionRecents.events.contextmenu.attach(function(collection, model, e){
collectionRecents.events.contextmenu.attach(function (collection, model, e) {
ppmenu.actionlist = 'recent';
ppmenu.hideItem('files:explore', (!model.islocal && !model.dir) || !model.exist);
ppmenu.hideItem(model.pinned ? 'files:pin' : 'files:unpin', true);
ppmenu.hideItem(model.pinned ? 'files:unpin' : 'files:pin', false);
ppmenu.show({left: e.clientX, top: e.clientY}, model);
});
collectionRecents.events.changed.attach(function(collection, model){
collectionRecents.events.changed.attach(function (collection, model, property) {
let $el = collection.list.find('#' + model.uid);
if ( $el ) $el[model.exist ? 'removeClass' : 'addClass']('unavail');
if ($el) {
$el[model.exist ? 'removeClass' : 'addClass']('unavail');
if (property['pinned'] !== undefined) {
sdk.setRecentFilePinned(model.get('fileid'), property['pinned']);
$el[model.pinned ? 'addClass' : 'removeClass']('pinned');
}
if (property.pinid != undefined) {
handlePin(collection, model);
}
}
});
collectionRecents.empty();
@ -417,16 +496,18 @@
view: _cl_rvbox,
list: _cl_rvbox.find('.file-list-body')
});
collectionRecovers.events.inserted.attach((collection, model)=>{
collection.list.append( this.view.listitemtemplate(model) );
collectionRecovers.events.inserted.attach((collection, model) => {
collection.list.append(this.view.listitemtemplate(model));
addContextMenuEventListener(collection, model, this.view.$panel, 'recovery');
});
collectionRecovers.events.click.attach((collection, model)=>{
collectionRecovers.events.click.attach((collection, model) => {
openFile(OPEN_FILE_RECOVERY, model);
});
collectionRecovers.events.contextmenu.attach((collection, model, e)=>{
collectionRecovers.events.contextmenu.attach((collection, model, e) => {
ppmenu.actionlist = 'recovery';
ppmenu.hideItem('files:explore', true);
ppmenu.hideItem('files:pin', true);
ppmenu.hideItem('files:unpin', true);
ppmenu.show({left: e.clientX, top: e.clientY}, model);
});
};
@ -437,11 +518,13 @@
className: 'with-icons',
bottomlimitoffset: 10,
items: [
{ caption: utils.Lang.menuFileOpen, action: 'files:open' , icon: '#folder'},
{ caption: utils.Lang.menuFileExplore, action: 'files:explore', icon: '#gofolder' },
{ caption: utils.Lang.menuRemoveModel, action: 'files:forget', icon: '#remove' },
{ caption: '--' },
{ caption: utils.Lang.menuClear, action: 'files:clear', variant: 'negative' }
{caption: utils.Lang.menuFileOpen, action: 'files:open', icon: '#folder'},
{caption: utils.Lang.menuFilePin, action: 'files:pin', icon: '#pin20'},
{caption: utils.Lang.menuFileUnpin, action: 'files:unpin', icon: '#unpin20'},
{caption: utils.Lang.menuFileExplore, action: 'files:explore', icon: '#gofolder'},
{caption: utils.Lang.menuRemoveModel, action: 'files:forget', icon: '#remove'},
{caption: '--'},
{caption: utils.Lang.menuClear, action: 'files:clear', variant: 'negative'}
]
});
@ -454,6 +537,16 @@
menu.actionlist == 'recent' ?
openFile(OPEN_FILE_RECENT, data) :
openFile(OPEN_FILE_RECOVERY, data);
} else if (/\:pin/.test(action)) {
const targetModel = collectionRecents.find('uid', data.uid);
if (targetModel) {
targetModel.setMany({ pinned: true, pinid: -targetModel.fileid });
}
} else if (/\:unpin/.test(action)) {
const targetModel = collectionRecents.find('uid', data.uid);
if (targetModel) {
targetModel.setMany({ pinned: false, pinid: targetModel.fileid });
}
} else if (/\:clear/.test(action)) {
if (menu.actionlist === 'recent') {
window.sdk.LocalFileRemoveAllRecents();
@ -466,8 +559,7 @@
this.dndZone.show();
}
}
} else
if (/\:forget/.test(action)) {
} else if (/\:forget/.test(action)) {
$('#' + data.uid, this.view.$panel).addClass('lost');
const count = collectionRecovers.size() + collectionRecents.size();
@ -480,10 +572,13 @@
if ( !(count > 1) ) {
this.dndZone.show();
}
} else
if (/\:explore/.test(action)) {
} else if (/\:explore/.test(action)) {
if (menu.actionlist == 'recent') {
sdk.execCommand('files:explore', JSON.stringify({path: data.path, id: data.fileid, hash: data.hash}));
sdk.execCommand('files:explore', JSON.stringify({
path: data.path,
id: data.fileid,
hash: data.hash
}));
}
}
};
@ -493,7 +588,7 @@
console.log('on recents filter', e.target.value)
const _filter = e.target.value;
if ( !_filter.length ) {
if (!_filter.length) {
$('.table-files tr.hidden', this.view.$panel).removeClass('hidden')
collectionRecents.items.forEach(model => model.set('hidden', false));
@ -501,11 +596,10 @@
const re = new RegExp(_filter, "gi");
collectionRecents.items.forEach(model => {
const _path = model.get('path');
if ( !re.test(_path) ) {
if (!re.test(_path)) {
$('#' + model.uid, this.view.$panel).addClass('hidden');
model.set('hidden', true);
} else
if ( model.get('hidden') ) {
} else if (model.get('hidden')) {
$('#' + model.uid, this.view.$panel).removeClass('hidden');
model.set('hidden', false);
}
@ -515,7 +609,7 @@
return {
init: function() {
init: function () {
baseController.prototype.init.apply(this, arguments);
this.view.render();
@ -526,25 +620,23 @@
window.sdk.on('onupdaterecents', _on_recents.bind(this));
window.sdk.on('onupdaterecovers', _on_recovers.bind(this));
window.sdk.on('on_native_message', (cmd, param)=>{
window.sdk.on('on_native_message', (cmd, param) => {
if (/files:checked/.test(cmd)) {
let fobjs = JSON.parse(param);
if ( fobjs ) {
if (fobjs) {
for (let obj in fobjs) {
let value = JSON.parse(fobjs[obj]);
let model = collectionRecents.find('hash', parseInt(obj));
if ( model ) {
if (model) {
model.get('exist') != value && model.set('exist', value);
}
}
}
} else
if (/file\:skip/.test(cmd)) {
} else if (/file\:skip/.test(cmd)) {
sdk.LocalFileRemoveRecent(parseInt(param));
} else
if (/app\:ready/.test(cmd)) {
if ( Object.keys(this.check_list).length ) {
setTimeout(()=>{
} else if (/app\:ready/.test(cmd)) {
if (Object.keys(this.check_list).length) {
setTimeout(() => {
sdk.execCommand('files:check', JSON.stringify(this.check_list));
}, 100);
}
@ -553,11 +645,9 @@
}
});
// $(window).resize(() => requestAnimationFrame(() => this.view.updateListSize()));
CommonEvents.on("icons:svg", this.view.onscale);
CommonEvents.on('portal:authorized', (data)=>{
if ( data.type == 'fileid' ) {
CommonEvents.on('portal:authorized', (data) => {
if (data.type == 'fileid') {
let fileid = data.id;
// openFile(OPEN_FILE_RECENT, fileid);
}
@ -632,10 +722,10 @@
return this;
},
getRecents: function() {
getRecents: function () {
return collectionRecents;
},
getRecovers: function() {
getRecovers: function () {
return collectionRecovers;
}
};

View File

@ -194,12 +194,14 @@ $(document).ready(function() {
function onActionClick(e) {
var $el = $(this);
var action = $el.attr('action');
const pinned = JSON.parse(localStorage.getItem('pinnedFolders') || '[]');
if (/^custom/.test(action)) return;
if (action == 'open' &&
!app.controller.recent.getRecents().size() &&
!app.controller.recent.getRecovers().size())
!app.controller.recent.getRecovers().size() &&
!pinned.length)
{
openFile(OPEN_FILE_FOLDER, '');
} else {

View File

@ -385,6 +385,20 @@ utils.fn.extend = function(dest, src) {
return dest;
};
utils.fn.pinnedFolders = function(path, action) {
const key = 'pinnedFolders';
let pinned = JSON.parse(localStorage.getItem(key) || '[]');
if (action === 'check') return pinned.includes(path);
const i = pinned.indexOf(path);
if (action === 'toggle') {
i === -1 ? pinned.push(path) : pinned.splice(i, 1);
}
localStorage.setItem(key, JSON.stringify(pinned));
};
utils.fn.parseRecent = function(arr, out = 'files') {
var _files_arr = [], _dirs_arr = [];
@ -401,6 +415,7 @@ utils.fn.parseRecent = function(arr, out = 'files') {
_files_arr.push({
fileid: _f_.id,
pinid: !_f_.pin ? _f_.id : -_f_.id,
type: _f_.type,
format: utils.parseFileFormat(_f_.type),
name: name,
@ -408,6 +423,7 @@ utils.fn.parseRecent = function(arr, out = 'files') {
date: _f_.modifyed,
path: $('<div>').html(fn).text(),
cloud: _f_.cloud,
pinned: _f_.pin,
});
_dirs_arr.indexOf(path) < 0 && _dirs_arr.push(path);
@ -416,6 +432,13 @@ utils.fn.parseRecent = function(arr, out = 'files') {
if (out == 'files') return _files_arr;
const pinned = JSON.parse(localStorage.getItem('pinnedFolders') || '[]');
for (const pinnedPath of pinned) {
if (!_dirs_arr.includes(pinnedPath)) {
_dirs_arr.push(pinnedPath);
}
}
var out_dirs_arr = [];
for (let _d_ of _dirs_arr) {
let name = (!_is_win ? /([^/]+)$/ : /([^\\/]+)$/).exec(_d_)[1],
@ -426,11 +449,17 @@ utils.fn.parseRecent = function(arr, out = 'files') {
} else
parent = _d_.slice(0, _d_.length - name.length - 1);
let pinned = utils.fn.pinnedFolders(_d_, 'check');
let id = _d_.hashCode();
out_dirs_arr.push({
type: 'folder',
full: _d_,
name: name,
descr: parent
descr: parent,
pinid: !pinned ? id : -id,
pinned: pinned,
uid: `folder-${id}`
});
}