From d08658e19ff52be27e57a8665bee326f3a866c19 Mon Sep 17 00:00:00 2001 From: Bas Date: Fri, 29 Nov 2024 12:52:01 +0000 Subject: [PATCH 01/30] feat(message-list): Implement virtual scroll table - Improves on the less accessible canvastable. - Introduces multi select using ctrl and shift. - Reduces memory footprint by a good margin. --- e2e/cypress/component/message_list.ts | 37 + e2e/cypress/integration/canvastable.ts | 44 +- e2e/cypress/integration/compose.ts | 5 +- e2e/cypress/integration/folder-switching.ts | 11 +- e2e/cypress/integration/folders.ts | 16 +- e2e/cypress/integration/mailviewer.ts | 43 +- e2e/cypress/integration/message-caching.ts | 38 - src/app/app.component.html | 291 +++- src/app/app.component.scss | 181 +++ src/app/app.component.ts | 328 +++- src/app/app.module.ts | 27 +- src/app/canvastable/canvastable.spec.ts | 76 - src/app/canvastable/canvastable.ts | 1354 ++++++++--------- src/app/common/human-bytes.ts | 31 + src/app/common/messagedisplay.ts | 1 + src/app/common/messagelist.ts | 19 + .../directives/resize-observer.directive.ts | 62 + .../follows-mouse.component.html | 3 + .../follows-mouse.component.scss | 8 + .../follows-mouse/follows-mouse.component.ts | 42 + src/app/human-bytes.pipe.ts | 29 + .../mailviewer/singlemailviewer.component.ts | 2 +- src/app/messagetable/messagetablerow.ts | 14 +- src/app/models/bindable-selection-model.ts | 44 + .../filter-selection-model.ts} | 40 +- .../resizable-button.component.html | 11 + .../resizable-button.component.scss | 25 + .../resizable-button.component.ts | 148 ++ src/app/rmmapi/messagelist.service.ts | 9 +- src/app/sort-button/sort-button.component.ts | 135 ++ .../virtual-scroll-table.component.html | 14 + .../virtual-scroll-table.component.scss | 22 + .../virtual-scroll-table.component.ts | 140 ++ .../websocketsearchmaillist.ts | 11 + src/app/xapian/searchmessagedisplay.ts | 46 +- src/app/xapian/searchservice.ts | 50 +- src/assets/Avenir-Next-LT-Pro-Demi.otf | Bin 0 -> 69172 bytes src/styles.scss | 168 +- 38 files changed, 2428 insertions(+), 1097 deletions(-) create mode 100644 e2e/cypress/component/message_list.ts delete mode 100644 e2e/cypress/integration/message-caching.ts delete mode 100644 src/app/canvastable/canvastable.spec.ts create mode 100644 src/app/common/human-bytes.ts create mode 100644 src/app/directives/resize-observer.directive.ts create mode 100644 src/app/follows-mouse/follows-mouse.component.html create mode 100644 src/app/follows-mouse/follows-mouse.component.scss create mode 100644 src/app/follows-mouse/follows-mouse.component.ts create mode 100644 src/app/human-bytes.pipe.ts create mode 100644 src/app/models/bindable-selection-model.ts rename src/app/{help/help.component.spec.ts => models/filter-selection-model.ts} (53%) create mode 100644 src/app/resizable-button/resizable-button.component.html create mode 100644 src/app/resizable-button/resizable-button.component.scss create mode 100644 src/app/resizable-button/resizable-button.component.ts create mode 100644 src/app/sort-button/sort-button.component.ts create mode 100644 src/app/virtual-scroll-table/virtual-scroll-table.component.html create mode 100644 src/app/virtual-scroll-table/virtual-scroll-table.component.scss create mode 100644 src/app/virtual-scroll-table/virtual-scroll-table.component.ts create mode 100644 src/assets/Avenir-Next-LT-Pro-Demi.otf diff --git a/e2e/cypress/component/message_list.ts b/e2e/cypress/component/message_list.ts new file mode 100644 index 000000000..e6c7b80d1 --- /dev/null +++ b/e2e/cypress/component/message_list.ts @@ -0,0 +1,37 @@ +const pick = (functions) => { + const randomIndex = Math.floor(Math.random() * functions.length); + return functions[randomIndex]; // Returns a randomly selected function +}; + +export function rangeCheckMessages(from, to) { + checkMessage(from) + cy.get('body').type('{shift}', { release: false }); // Press Shift + checkMessage(to) + cy.get('body').type('{shift}'); // Release Shift +} + +function table() { + return cy.get('app-virtual-scroll-table'); +} + +export function firstMessage() { + return nthMessage(0) +} + +export function nthMessage(n) { + return table().find('tbody').eq(n); +} + +export function checkMessage(n) { + const checkboxClick = () => nthMessage(n).find('mat-checkbox').click() + const ctrlMessageClick = () => nthMessage(n).click({ ctrlKey: true }) + + return pick([ + checkboxClick, + ctrlMessageClick, + ])() +} + +export function checkedMessages() { + return cy.get('tbody .mat-checkbox-checked') +} diff --git a/e2e/cypress/integration/canvastable.ts b/e2e/cypress/integration/canvastable.ts index e69d91e30..10c3eed94 100644 --- a/e2e/cypress/integration/canvastable.ts +++ b/e2e/cypress/integration/canvastable.ts @@ -1,23 +1,21 @@ /// -describe('Selecting rows in canvastable', () => { - function canvas() { - return cy.get('canvastable canvas:first-of-type'); - } +import { checkMessage, checkedMessages, rangeCheckMessages } from '../component/message_list.ts'; - function moveButton() { - return cy.get('button[mattooltip*="Move"]'); - } +function moveButton() { + return cy.get('button[mattooltip*="Move"]'); +} - it('should select one row', () => { +describe('Selecting rows in canvastable', () => { + it('should select and deselect one row', () => { cy.viewport('iphone-6'); cy.visit('/'); - // select - canvas().click({ x: 15, y: 40 }); + moveButton().should('not.exist'); + checkMessage(0) moveButton().should('be.visible'); - // unselect - canvas().click({ x: 21, y: 41, force: true }); + checkedMessages().should('have.length', 1) + checkMessage(0) moveButton().should('not.exist'); }) @@ -25,14 +23,22 @@ describe('Selecting rows in canvastable', () => { cy.viewport('iphone-6'); cy.visit('/'); - canvas().trigger('mousedown', { x: 15, y: 10 }); - for (let ndx = 0; ndx <= 5; ndx++) { - canvas().trigger('mousemove', { x: 20, y: 36 * ndx + 11 }); - } + rangeCheckMessages(0, 5) + + + // Verify multiple checkboxes are checked + checkedMessages().should('have.length', 6); + moveButton().should('be.visible'); - // unselect by moving mouse back up - canvas().trigger('mousemove', { x: 21, y: 12 }); + checkMessage(0) + + // Verify count decreases + checkedMessages().should('have.length', 5) + + rangeCheckMessages(1, 5) + checkedMessages().should('have.length', 0) moveButton().should('not.exist'); - }) + }); + }) diff --git a/e2e/cypress/integration/compose.ts b/e2e/cypress/integration/compose.ts index bc87851a2..f6cf3114a 100644 --- a/e2e/cypress/integration/compose.ts +++ b/e2e/cypress/integration/compose.ts @@ -1,5 +1,7 @@ /// +import { firstMessage } from '../component/message_list.ts'; + describe('Composing emails', () => { beforeEach(async () => { localStorage.setItem('221:localSearchPromptDisplayed', JSON.stringify('true')); @@ -104,10 +106,7 @@ describe('Composing emails', () => { }); it('closing a new reply should return to inbox', () => { - cy.visit('/'); - cy.wait(1000); cy.visit('/#Inbox:1'); - cy.get('canvastable canvas:first-of-type').click({ x: 300, y: 10 }); cy.get('single-mail-viewer').should('exist'); cy.get('button[mattooltip="Reply"]').click(); cy.get('button[mattooltip="Close draft"').click(); diff --git a/e2e/cypress/integration/folder-switching.ts b/e2e/cypress/integration/folder-switching.ts index eb91b41ca..2b35da7b8 100644 --- a/e2e/cypress/integration/folder-switching.ts +++ b/e2e/cypress/integration/folder-switching.ts @@ -1,19 +1,18 @@ /// describe('Switching between folders (and not-folders)', () => { - function goToInbox() { cy.get('rmm-folderlist mat-tree-node:contains(Inbox)', {'timeout':10000}).click(); cy.url().should('match', /\/(#Inbox)?$/); cy.get('rmm-folderlist mat-tree-node:contains(Inbox)').should('have.class', 'selectedFolder'); } - it('can switch from welcome to inbox', () => { - localStorage.setItem('221:localSearchPromptDisplayed', JSON.stringify('true')); - localStorage.setItem('221:localSearchPromptDisplayed', JSON.stringify('true')); + it('can switch from welcome to inbox', () => { + localStorage.setItem('221:localSearchPromptDisplayed', JSON.stringify('true')); + localStorage.setItem('221:localSearchPromptDisplayed', JSON.stringify('true')); - // start of on /welcome, like a fresh new user - cy.visit('/welcome'); + // start of on /welcome, like a fresh new user + cy.visit('/welcome'); // should be able to switch to inbox... goToInbox(); diff --git a/e2e/cypress/integration/folders.ts b/e2e/cypress/integration/folders.ts index 269270e01..00c0bd231 100644 --- a/e2e/cypress/integration/folders.ts +++ b/e2e/cypress/integration/folders.ts @@ -1,13 +1,13 @@ /// -describe('Folder management', () => { - function canvas() { - return cy.get('canvastable canvas:first-of-type'); - } +import { firstMessage } from '../component/message_list.ts' +describe('Folder management', () => { it('should create folder at root level', () => { + cy.intercept('GET', '/rest/v1/email_folder/list').as('getEmailFolders'); cy.visit('/'); - + cy.wait('@getEmailFolders'); + cy.wait(5000); cy.get('#createFolderButton').click(); cy.get('.mat-dialog-title').should('contain', 'Add new folder'); cy.get('mat-dialog-container mat-dialog-content').should('contain', 'root level'); @@ -32,9 +32,9 @@ describe('Folder management', () => { }); it('should create new draft on templates folder message click', () => { - cy.visit('/') - cy.contains('mat-tree-node', 'Templates').click() - canvas().click({ x: 55, y: 40 }); + cy.visit('/') + cy.contains('mat-tree-node', 'Templates').click() + firstMessage().click(); cy.location().should((loc) => { expect(loc.pathname).to.eq('/compose'); }); diff --git a/e2e/cypress/integration/mailviewer.ts b/e2e/cypress/integration/mailviewer.ts index 7abe6d3fc..84732a919 100644 --- a/e2e/cypress/integration/mailviewer.ts +++ b/e2e/cypress/integration/mailviewer.ts @@ -1,10 +1,8 @@ /// -describe('Interacting with mailviewer', () => { - function canvas() { - return cy.get('canvastable canvas:first-of-type'); - } +import { nthMessage } from '../component/message_list.ts' +describe('Interacting with mailviewer', () => { beforeEach(async () => { localStorage.setItem('221:localSearchPromptDisplayed', JSON.stringify('true')); localStorage.setItem('221:Global:messageSubjectDragTipShown', JSON.stringify('true')); @@ -26,30 +24,20 @@ describe('Interacting with mailviewer', () => { // }); it('can open an email and go back and forth in browser history', () => { - cy.intercept('/rest/v1/email/download/*').as('get11'); cy.visit('/#Inbox:11'); - - cy.wait('@get11', {'timeout':10000}); - // canvas().click(400, 300); - cy.hash().should('equal', '#Inbox:11'); - /* TODO: apparently forward broke at some point - * in headless mode. Works normally in a proper browser + nthMessage(1).click(); + cy.hash().should('equal', '#Inbox:2'); cy.go('back'); - cy.hash().should('not.contain', 'Inbox:11'); - cy.go('forward'); cy.hash().should('equal', '#Inbox:11'); + cy.go('forward'); + cy.hash().should('equal', '#Inbox:2'); cy.get('button[mattooltip="Close"]').click(); cy.hash().should('equal', '#Inbox'); - */ }); it('can reply to an email with no "To"', () => { - cy.intercept('/rest/v1/email/download/*').as('get11'); cy.visit('/#Inbox:11') - cy.wait('@get11', {'timeout':10000}); - // cy.get('#messageContents'); - cy.get('button[mattooltip="Reply"]').click(); cy.location().should((loc) => { expect(loc.pathname).to.eq('/compose'); @@ -59,11 +47,8 @@ describe('Interacting with mailviewer', () => { }); it('can forward an email with no "To"', () => { - cy.intercept('/rest/v1/email/download/*').as('get11'); cy.visit('/'); - cy.wait('@get11', {'timeout':10000}); cy.visit('/#Inbox:11'); - // cy.get('#messageContents'); cy.get('button[mattooltip="Forward"]').click(); cy.location().should((loc) => { @@ -74,11 +59,8 @@ describe('Interacting with mailviewer', () => { }); it('can reply to an email with no "To" or "Subject"', () => { - cy.intercept('/rest/v1/email/download/*').as('get13'); cy.visit('/'); - cy.wait('@get13', {'timeout':10000}); cy.visit('/#Inbox:13'); - // cy.get('#messageContents'); cy.get('button[mattooltip="Reply"]').click(); cy.location().should((loc) => { @@ -89,11 +71,8 @@ describe('Interacting with mailviewer', () => { }); it('can forward an email with no "To" or "Subject"', () => { - cy.intercept('/rest/v1/email/download/*').as('get13'); cy.visit('/'); - cy.wait('@get13', {'timeout':10000}); cy.visit('/#Inbox:13'); - // cy.get('#messageContents'); cy.get('button[mattooltip="Forward"]').click(); cy.location().should((loc) => { @@ -104,9 +83,7 @@ describe('Interacting with mailviewer', () => { }); it('Vertical to horizontal mode exposes full height button', () => { - cy.intercept('/rest/v1/email/download/*').as('get11'); cy.visit('/'); - cy.wait('@get11', {'timeout':10000}); cy.visit('/#Inbox:11'); // Make sure we're in vertical mode @@ -116,9 +93,7 @@ describe('Interacting with mailviewer', () => { }); it('Changing viewpane height is stored', () => { - cy.intercept('/rest/v1/email/download/*').as('get11'); cy.visit('/'); - cy.wait('@get11', {'timeout':10000}); cy.visit('/#Inbox:11'); // cy.hash().should('equal', '#Inbox:11'); @@ -133,9 +108,7 @@ describe('Interacting with mailviewer', () => { }); it('Half height reduces stored pane height', () => { - cy.intercept('/rest/v1/email/download/*').as('get11'); cy.visit('/'); - cy.wait('@get11', {'timeout':10000}); cy.visit('/#Inbox:11'); // cy.hash().should('equal', '#Inbox:11'); @@ -161,9 +134,7 @@ describe('Interacting with mailviewer', () => { }); it('Revisit open email in horizontal mode loads it', () => { - cy.intercept('/rest/v1/email/download/*').as('get11'); cy.visit('/'); - cy.wait('@get11', {'timeout':10000}); cy.visit('/#Inbox:11'); // cy.hash().should('equal', '#Inbox:11'); @@ -177,9 +148,7 @@ describe('Interacting with mailviewer', () => { }); it('Can go out of mailviewer and back and still see our email', () => { - cy.intercept('/rest/v1/email/download/*').as('get12'); cy.visit('/'); - cy.wait('@get12',{'timeout':10000}); cy.visit('/#Inbox:12'); // cy.hash().should('equal', '#Inbox:12'); diff --git a/e2e/cypress/integration/message-caching.ts b/e2e/cypress/integration/message-caching.ts deleted file mode 100644 index 658217809..000000000 --- a/e2e/cypress/integration/message-caching.ts +++ /dev/null @@ -1,38 +0,0 @@ -/// - -describe('Message caching', () => { - beforeEach(async () => { - localStorage.setItem('221:localSearchPromptDisplayed', 'true'); - (await indexedDB.databases()) - .filter(db => db.name && /messageCache/.test(db.name)) - .forEach(db => indexedDB.deleteDatabase(db.name!)); - - }); - - it('should fetch all messages on first time page load', () => { - cy.intercept('/rest/v1/email/download/*').as('message12requested'); - - cy.visit('/'); - cy.wait('@message12requested', {'timeout':10000}); - cy.wait(1000); // hopefully this is enough time for all the iDB writes to actually finish - }); - - it('should not re-request messages after a page reload', () => { - cy.intercept('/rest/v1/email/download/*').as('message12requested'); - - cy.visit('/'); - cy.wait('@message12requested', {'timeout':10000}); - // This should have fetched/cached the message - - // Now don't fetch it again: - cy.visit('/#Inbox:12'); - let called = false; - cy.intercept('/rest/v1/email/download/*', (_req) => { - called = true; - }); - - cy.get('div#messageHeaderSubject').contains('Default from fix test').then(() => { - assert.equal(called, false); - }); - }); -}); diff --git a/src/app/app.component.html b/src/app/app.component.html index b7f173af2..48886f75e 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -6,7 +6,7 @@ fixedTopGap="0" id="sideMenu" appResizable> - + @@ -189,7 +189,7 @@

No Message Selected

- +

@@ -239,12 +239,12 @@

No Message Selected

- + - +
- +
- + @@ -368,7 +368,7 @@

No Message Selected

Unread only
- + No Message Selected
-
- -
+ +
+ + + + + + + + + + + + + + Date + + + + + + {{ + selectedFolder !== messagelistservice.sentFolderName ? "From" : "To" + }} + + + + + + + Subject + + + + + + Count + + + + + + Size + + + + + + + Folder + + + + + Attachments + + + Answered + + + Flagged + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{item.messageDate}} + + + + {{item.from}} + + + + {{item.subject}} + + +
+ {{item.count}} +
+ + + {{item.size | humanBytes}} + + + {{item.folder}} + + + + + + + + + + + + + + + + + + + + + + + {{item.plaintext | async}} + + + + + + + + + +
+
+
No Message Selected
- +
+ + + + mail Moving {{rowsSelectionModel.selected.length}} + + +
+ + diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 1150ca33e..8af57bd97 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -2,6 +2,10 @@ border-right: 1px solid darkgrey !important; } +canvastablecontainer { + opacity: 0; +} + #rightPane { border-left: 1px solid darkgrey !important; } @@ -68,3 +72,180 @@ width: 150px; } +.resizable { + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + + +#canvasTableContainerArea { + container-type: inline-size; // or use `container: inline-size;` + + .messages-table { + --cell-y-spacing-top: 0.3lh; + --cell-y-spacing-top-first: 0.15lh; + --cell-y-spacing-bottom: 0.2lh; + --row-y-spacing: 0.3lh; + + th { + text-align: left; + position: relative; + background-color: white; + z-index: 1; + } + + td { + padding-top: var(--cell-y-spacing-top); + padding-bottom: var(--cell-y-spacing-bottom); + + &.count { + text-align: center; + } + } + + tbody tr td:first-child { + padding-top: var(--cell-y-spacing-top-first); + } + + td, + th { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + td.count > div { + display: inline-block; + font-size: 0.75rem; + padding: 0.2em 0.4em; + line-height: 1.2; + } + + tbody { + cursor: pointer; + } + + .checkbox-cell { + width: 3ch; + text-align: center; + } + + + .time-cell { + width: 11%; + } + + .from-cell { + width: 22%; + } + + .subject-cell { + width: 33%; + } + + .conversations-cell { + width: 11%; + } + + .size-cell { + width: 6%; + } + + .folder-cell { + width: 11%; + } + + .attachments-cell { + width: 2%; + } + + .answered-cell { + width: 2%; + } + + .flagged-cell { + width: 2%; + } + + .preview { + display: block; + height: 1lh; + } + + // Container query replaces media query + @container (max-width: 25rem) { + display: block; + + thead { + display: none; + } + + tbody { + position: relative; + display: block; + padding-top: var(--row-y-spacing); + padding-bottom: var(--row-y-spacing); + } + + td, tbody tr:first-child td { + padding: 0; + display: inline-block; + margin-top: auto; + margin-bottom: auto; + margin-left: 2rem; + + &.sm-hidden { + display: none; + } + + &.count > div { + position: absolute; + top: var(--row-y-spacing); + right: 0; + margin-right: 8px; + } + + &.subject { + width: 100%; + } + + &.checkbox-cell { + position: absolute; + margin-left: 4px; + } + } + + tr { + padding-top: unset; + padding-bottom: unset; + margin-left: 8px; + display: flex; + flex-flow: row wrap; + } + } + } +} + +::ng-deep table { + font-family: "Avenir Next Pro Regular", "Helvetica Neue", sans-serif; + font-size: 14px; +} + +tbody mat-icon { + /* Prevent icon from increasing tbody height */ + max-height: 1.3em; + margin: -0.3em; +} + +@keyframes skeleton-loading { + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -200% 0; + } +} + diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 2634b24de..60622f153 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -17,14 +17,13 @@ // along with Runbox 7. If not, see . // ---------- END RUNBOX LICENSE ---------- -import { AfterViewInit, Component, DoCheck, NgZone, OnInit, ViewChild, Renderer2, ChangeDetectorRef, ElementRef } from '@angular/core'; +import { AfterViewInit, Component, DoCheck, NgZone, OnInit, ViewChild, Renderer2, ChangeDetectorRef, ElementRef, HostListener } from '@angular/core'; import { CanvasTableSelectListener, CanvasTableComponent, CanvasTableContainerComponent } from './canvastable/canvastable'; import { SingleMailViewerComponent } from './mailviewer/singlemailviewer.component'; import { SearchService } from './xapian/searchservice'; -import { PostMessageAction } from './xapian/messageactions'; import { MatLegacyDialogRef as MatDialogRef, MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog'; import { MatIconRegistry } from '@angular/material/icon'; @@ -43,11 +42,12 @@ import { DraftDeskService } from './compose/draftdesk.service'; import { RMM7MessageActions } from './mailviewer/rmm7messageactions'; import { FolderListComponent, CreateFolderEvent, RenameFolderEvent, MoveFolderEvent } from './folder/folder.module'; import { SimpleInputDialog, SimpleInputDialogParams } from './dialog/dialog.module'; -import { map, take, skip, mergeMap, filter, tap, throttleTime, debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { map, take, skip, mergeMap, filter, tap, debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { WebSocketSearchService } from './websocketsearch/websocketsearch.service'; import { WebSocketSearchMailList } from './websocketsearch/websocketsearchmaillist'; -import { from, Observable } from 'rxjs'; +import { BUILD_TIMESTAMP } from './buildtimestamp'; +import { from, Observable, BehaviorSubject, firstValueFrom } from 'rxjs'; import { xapianLoadedSubject } from './xapian/xapianwebloader'; import { SwPush } from '@angular/service-worker'; import { exportKeysFromJWK } from './webpush/vapid.tools'; @@ -66,6 +66,9 @@ import { UsageReportsService } from './common/usage-reports.service'; import { objectEqualWithKeys } from './common/util'; import { UpdateAlertService } from './updatealert/updatealert.service'; import { UpdateAlertComponent } from './updatealert/updatealert.component'; +import { FilterSelectionModel } from './models/filter-selection-model'; +import { BindableSelectionModel } from './models/bindable-selection-model'; +import { Direction } from './sort-button/sort-button.component'; const LOCAL_STORAGE_SETTING_MAILVIEWER_ON_RIGHT_SIDE_IF_MOBILE = 'mailViewerOnRightSideIfMobile'; const LOCAL_STORAGE_SETTING_MAILVIEWER_ON_RIGHT_SIDE = 'mailViewerOnRightSide'; @@ -81,12 +84,39 @@ const TOOLBAR_LIST_BUTTON_WIDTH = 30; // eslint-disable-next-line @angular-eslint/component-selector selector: 'app', styleUrls: ['app.component.scss'], - templateUrl: 'app.component.html' + templateUrl: 'app.component.html', }) export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectListener, DoCheck { - showSelectOperations: boolean; showSelectMarkOpMenu: boolean; + + rows = []; + + private rowsSubject= new BehaviorSubject(this.rows); + debouncedRows$ = this.rowsSubject.asObservable().pipe(debounceTime(300)); + + lastCheckedIndex: number = -1; + scrollToIndex: number = 0; + rowSelectionModel = new FilterSelectionModel( + false, + [], + false, + messagesEqual, + hasId + ); + rowsSelectionModel = new FilterSelectionModel( + true, + [], + false, + messagesEqual, + hasId + ); + orderSelectionModel = new BindableSelectionModel( + false, + [], + true, + ) + lastSearchText = ''; searchText = ''; dataReady = false; @@ -103,6 +133,8 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis localSearchIndexPrompted = false; offerInitialLocalIndex = false; + dragEvent: DragEvent | null = null + indexDocCount = 0; entireHistoryInProgress = false; @@ -153,6 +185,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis messageActionsHandler: RMM7MessageActions = new RMM7MessageActions(); + dynamicSearchFieldPlaceHolder: string; numHistoryChunksProcessed = 0; @@ -164,6 +197,9 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis xapianLoaded = xapianLoadedSubject; morelistbuttonindex = 7; + renderedRange = {start: 0, end: 0}; // First ten messages. + + widths = {}; constructor( public searchService: SearchService, @@ -193,21 +229,20 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis private usage: UsageReportsService, public updateService: UpdateAlertService, ) { - this.hotkeysService.add( - new Hotkey(['j', 'k'], - (event: KeyboardEvent, combo: string): ExtendedKeyboardEvent => { - if (combo === 'k') { - this.canvastable.scrollUp(); - combo = null; - } - if (combo === 'j') { - this.canvastable.scrollDown(); - } - const e: ExtendedKeyboardEvent = event; - e.returnValue = false; - return e; - }) - ); + this.orderSelectionModel.selectionModel.changed.subscribe(() => { + const {data: column, direction} = this.orderSelectionModel.selected + + if (direction === Direction.None) { + this.canvastablecontainer.sortColumn = 2; + this.canvastablecontainer.sortDescending = true; + } else { + this.canvastablecontainer.sortColumn = column; + this.canvastablecontainer.sortDescending = Direction.Descending === direction; + } + + this.updateSearch(true) + }) + this.hotkeysService.add( new Hotkey( 'up up down down left right left right b a', @@ -291,7 +326,6 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.mailViewerRightSideWidth = '100%'; this.mailViewerOnRightSide = this.preferences.get(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SETTING_MAILVIEWER_ON_RIGHT_SIDE_IF_MOBILE}`); } - console.log(this.mailViewerOnRightSide); }); @@ -344,6 +378,10 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis : 0; } + get showSelectOperations() { + return !this.rowsSelectionModel.isEmpty() || !this.rowSelectionModel.isEmpty() + } + ngDoCheck(): void { if (!this.usewebsocketsearch && this.searchService.api && this.xapianDocCount) { this.dynamicSearchFieldPlaceHolder = 'Start typing to search ' + @@ -359,7 +397,9 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.calculateWidthDependentElements(); } - ngOnInit(): void { + async ngOnInit() { + await firstValueFrom(this.xapianLoaded); + this.canvastable = this.canvastablecontainer.canvastable; if (this.preferences.has(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SHOWCONTENTPREVIEW}`)) { this.canvastable.showContentTextPreview = this.preferences.get(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SHOWCONTENTPREVIEW}`) === 'true'; @@ -367,8 +407,10 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis if (this.preferences.has(`${this.preferenceService.prefGroup}:canvasNamedColumnWidthsBySet`)) { this.canvastable.columnWidths = this.preferences.get(`${this.preferenceService.prefGroup}:canvasNamedColumnWidthsBySet`) || {}; } - this.canvastablecontainer.sortColumn = 2; - this.canvastablecontainer.sortDescending = true; + this.orderSelectionModel.selected = { + data: 2, + direction: Direction.Descending + } this.resetColumns(); this.messagelistservice.messagesInViewSubject.subscribe(res => { @@ -415,8 +457,6 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.route.fragment.subscribe( fragment => { if (!fragment) { - // This also runs when we load '/compose' .. but doesnt need to - this.switchToFolder('Inbox'); if (this.singlemailviewer) { this.singlemailviewer.close(); } @@ -446,6 +486,10 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis }); } }); + + if (!this.selectedFolder && this.router.url === '/') { + this.switchToFolder('Inbox'); + } } ngAfterViewInit() { @@ -468,35 +512,12 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.overviewSelected = this.router.url === '/overview'; }); - // Download visible messages in the background - this.canvastable.repaintDoneSubject.pipe( - filter(() => !this.canvastable.isScrollInProgress()), - throttleTime(1000) - ).subscribe(() => { - const rowIndexes = this.canvastable.getVisibleRowIndexes(); - const messageIds = rowIndexes.filter( - idx => idx < this.canvastable.rows.rowCount() - ).map(idx => this.canvastable.rows.getRowMessageId(idx)); - // FIXME: promise errors? - this.rmmapi.downloadMessages(messageIds).then( - (messages) => { - const updateWorker = new Map(); - for (const msg of messages) { - this.searchService.updateMessageText(msg['mid']); - updateWorker.set(msg['mid'], msg.text.text); - } - // Send to the messageCache in the worker, so we can add the text to the index: - if(updateWorker.size > 0) { - this.searchService.indexWorker.postMessage({'action': PostMessageAction.messageCache, 'updates': updateWorker }); - this.canvastable.hasChanges = true; - } - }); - }); - if ('serviceWorker' in navigator) { try { Notification.requestPermission(); - } catch (e) {} + } catch (e) { + console.error(e) + } } this.subscribeToNotifications(); @@ -513,9 +534,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis const [folder, msgId] = fragmentTarget; this.switchToFolder(folder); if (msgId === null) { - if (this.singlemailviewer) { - this.singlemailviewer.close(); - } + this.singlemailviewer?.close(); } if (msgId != null && this.singlemailviewer && this.singlemailviewer.messageId !== msgId) { this.selectRowByMessageId(msgId); @@ -628,7 +647,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis true, spamFolderName ).toPromise(); - const messageIds = messageLists.map(msg => msg.id); + const messageIds = messageLists.map(idValue); this.messageActionsHandler.updateMessages({ messageIds: messageIds, updateLocal: (msgIds: number[]) => this.messagelistservice.moveMessages(msgIds, this.messagelistservice.trashFolderName), @@ -687,7 +706,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis public trainSpam(params) { const msg = params.is_spam ? 'Reporting spam' : 'Reporting not spam'; this.snackBar.open( msg, 'Dismiss' ); - const unfilteredMessageIds = this.canvastable.rows.selectedMessageIds(); + const unfilteredMessageIds = this.selectedMessageIds; // ensure valid IDs const messageIds = unfilteredMessageIds.filter(id => Number.isInteger(id)); @@ -757,7 +776,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis public setReadStatus(status: boolean) { this.snackBar.open('Toggling read status...'); - const messageIds = this.canvastable.rows.selectedMessageIds(); + const messageIds = this.selectedMessageIds; this.messageActionsHandler.updateMessages({ messageIds: messageIds, @@ -768,7 +787,6 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis new MessageFlagChange(id, status, null) ); }); - this.clearSelection(); if (this.singlemailviewer && messageIds.find((id) => id === this.singlemailviewer.messageId)) { this.singlemailviewer.mailObj.seen_flag = status ? 1 : 0; } @@ -786,7 +804,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis public setFlaggedStatus(status: boolean) { this.snackBar.open('Toggling flags...'); - const messageIds = this.canvastable.rows.selectedMessageIds(); + const messageIds = this.selectedMessageIds; this.messageActionsHandler.updateMessages({ messageIds: messageIds, @@ -797,7 +815,6 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis new MessageFlagChange(id, null, status) ); }); - this.clearSelection(); if (this.singlemailviewer && messageIds.find((id) => id === this.singlemailviewer.messageId)) { this.singlemailviewer.mailObj.flagged_flag = status ? 1 : 0; } @@ -816,7 +833,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis // Delete selected messages in current canvastable view // If looking at Trash, this will be "delete permanently" public deleteMessages() { - const messageIds = this.canvastable.rows.selectedMessageIds(); + const messageIds = this.selectedMessageIds; this.messageActionsHandler.updateMessages({ messageIds: messageIds, @@ -893,6 +910,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.selectMessageFromFragment(this.fragment); } } + this.filterMessageDisplay(); // FIXME: looks weird, should probably rename "rows" to "messagedisplay" @@ -904,6 +922,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis // NB this triggers hasChanged for us and forces a redraw this.canvastable.columns = this.canvastable.rows.getCanvasTableColumns(this); + this.updateRows() } public filterMessageDisplay() { @@ -917,25 +936,27 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis } public clearSelection() { - if (this.canvastable.rows) { - this.canvastable.rows.clearSelection(); - } - this.canvastable.hasChanges = true; - this.showSelectOperations = false; - this.showSelectMarkOpMenu = false; + this.rowsSelectionModel.clear() } public selectRowByMessageId(messageId: number) { const matchingRowIndex = this.canvastable.rows.findRowByMessageId(messageId); if (matchingRowIndex > -1) { + this.rowSelectionModel.select({id: messageId}); this.rowSelected(matchingRowIndex, 1, false); - } else { - this.singlemailviewer.close(); - } + } } public rowSelected(rowIndex: number, columnIndex: number, multiSelect?: boolean) { const isSelect = (columnIndex === 0) || multiSelect + const shouldScroll = this.scrollToIndex === 0 || !this.singlemailviewer.messageId + + this.rowSelectionModel.select(this.rows[rowIndex]) + this.lastCheckedIndex = rowIndex + + if (shouldScroll) { + this.scrollToIndex = rowIndex - 1 + } if ((this.selectedFolder === this.messagelistservice.templateFolderName) && !isSelect) { this.draftDeskService.newTemplateDraft( @@ -947,7 +968,6 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis } this.canvastable.rows.rowSelected(rowIndex, columnIndex, multiSelect); - this.showSelectOperations = this.canvastable.rows.anySelected(); if (this.canvastable.rows.hasChanges) { this.updateUrlFragment(this.canvastable.rows.getRowMessageId(rowIndex)); @@ -958,6 +978,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.preferenceService.set(DefaultPrefGroups.Global, 'messageSubjectDragTipShown', 'true'); } // FIXME: [2] is searchservice specific! + if (this.viewmode === 'conversations' && this.canvastable.rows.getCurrentRow()[2] !== '1') { this.viewmode = 'singleconversation'; this.resetColumns(); @@ -1066,7 +1087,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.offerInitialLocalIndex = false; } - singleMailViewerClosed(action: string): void { + singleMailViewerClosed(): void { this.canvastable.rows.clearOpenedRow(); this.updateUrlFragment(); } @@ -1095,8 +1116,21 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis } } + onMessagesDragStart(event: DragEvent, row) { + + // If no messages are selected we'll select the current message + if (this.rowsSelectionModel.isEmpty()) { + this.rowsSelectionModel.select(row) + } + + // Remove the default image + event.dataTransfer?.setDragImage(new Image(), 0, 0); // Set an empty image + + this.dragEvent = event + } + dropToFolder(folderId): void { - const messageIds = this.canvastable.rows.selectedMessageIds(); + const messageIds = this.selectedMessageIds this.messageActionsHandler.updateMessages({ messageIds: messageIds, @@ -1128,9 +1162,8 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis public moveToFolder() { const dialogRef: MatDialogRef = this.dialog.open(MoveMessageDialogComponent); // dialogRef.componentInstance.messageActionsHandler = this.messageActionsHandler; - const messageIds = this.canvastable.rows.selectedMessageIds(); + const messageIds = this.selectedMessageIds; - console.log('selected messages', messageIds); // dialogRef.componentInstance.selectedMessageIds = messageIds; dialogRef.afterClosed().subscribe(folder => { if (folder) { @@ -1371,7 +1404,9 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.canvastable.scrollTop(); } } - } catch (e) { } + } catch (e) { + console.error(e) + } } } @@ -1437,11 +1472,140 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis fragment += `:${messageId}`; } - // navigating to the same page does not fire off our fragment.subscribe - if (fragment !== this.fragment) { - this.fragment = fragment; - this.router.navigate(['/'], { fragment }); + this.router.navigate(['/'], { fragment }); + } + + get selectedMessageIds() { + return this.rowsSelectionModel.isEmpty() + ? this.rowSelectionModel.selected.map(idValue) + : this.rowsSelectionModel.selected.map(idValue) + } + + updateRows() { + this.rows = this.canvastable?.rows?.rows ? [...this.canvastable.rows.rows] : [] + + return this.enrichRows() + } + + async enrichRows() { + if (!this.canvastable.rows) return; + + const { start, end } = this.renderedRange; + + for (let index = start; index < end; index++) { + if (index >= this.rows.length) break + + this.rows[index] = this.canvastable.rows.getRowData(index, this) + this.rows[index].plaintext = this.searchService.messageText(this.rows[index].id) + this.rows[index].loaded = true + } + + this.rows = Object.create(this.rows) + + this.rowsSubject.next(this.rows) + } + + rangeSelectFrom(from: number, to: number, check: boolean) { + const left = Math.min(from, to) + const right = Math.max(from, to) + + for (let i = left; i <= right; i++) { + if (check) { + this.rowsSelectionModel.select(this.rows[i]) + } else { + this.rowsSelectionModel.deselect(this.rows[i]) + } + } + + this.lastCheckedIndex = to + + } + + onCheckboxClick(event, row, index) { + this.onRowClick(event, row, index, true) + event.stopPropagation() + event.preventDefault() + } + + rangeSelect(to: number, check: boolean) { + let from = this.lastCheckedIndex; + + // When nothing is selected yet. + if (from === -1) return this.oneSelect(to, check) + + return this.rangeSelectFrom(from, to, check) + } + + oneSelect(index, check) { + this.rangeSelectFrom(index, index, check) + } + + onRowClick(event, row, index, checkbox = false) { + const shiftKey = event.getModifierState("Shift") + const check = !this.rowsSelectionModel.isSelected(this.rows[index]) + + if (shiftKey) { + return this.rangeSelect(index, check) + } + + const ctrlKey = event.getModifierState("Control") + const metaKey = event.getModifierState("Meta") + + if (ctrlKey || metaKey) { + return this.oneSelect(index, check) + } + + if (!checkbox) { + // Deselect an email when clicking on a selected email. + if (this.rowSelectionModel.isSelected(this.rows[index])) { + this.singlemailviewer.messageId = null; + this.rowSelectionModel.clear() + return this.singleMailViewerClosed() + } + + return this.rowSelected(index, 3, false); + } + + this.oneSelect(index, check) + } + + onRowKeydown(event, row, index) { + // Only work on Enter and space. + if (event.key !== 'Enter') return; + + return this.onRowClick(event, row, index) + } + + onAllCheckboxChange() { + if (this.rowsSelectionModel.isEmpty()) { + this.rowsSelectionModel.select(...this.rows); + } else { + this.rowsSelectionModel.deselect(...this.rows); } } + + get allItemsSelected() { + return this.rowsSelectionModel.selected.length === this.rows.length + } + + @HostListener('document:dragend', ['$event']) + onDragEnded() { + delete this.dragEvent; + } + + onTableResize() { + this.widths = {}; + } + + // TODO: The this.rows can change after a onRenderedRangeChange is called. + // This will drop the resolved values. + onRenderedRangeChange(event) { + this.renderedRange = event; + this.enrichRows() + } + } +const idValue = (x: any) => x.id +const messagesEqual = (a: any, b: any) => a?.id === b?.id +const hasId = (x: any) => Boolean(x?.id) diff --git a/src/app/app.module.ts b/src/app/app.module.ts index bced66edb..6d88ad959 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -34,6 +34,7 @@ import { ContactsService } from './contacts-app/contacts.service'; import { StorageService } from './storage.service'; import { RouterModule, Routes } from '@angular/router'; import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button'; +import { MatBadgeModule } from '@angular/material/badge'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card'; import { MatLegacyCheckboxModule as MatCheckboxModule } from '@angular/material/legacy-checkbox'; @@ -52,6 +53,7 @@ import { MatLegacySnackBarModule as MatSnackBarModule } from '@angular/material/ import { MatToolbarModule } from '@angular/material/toolbar'; import { MatLegacyTooltipModule as MatTooltipModule } from '@angular/material/legacy-tooltip'; import { CanvasTableModule } from './canvastable/canvastable'; +import { VirtualScrollTableComponent } from './virtual-scroll-table/virtual-scroll-table.component' import { MoveMessageDialogComponent } from './actions/movemessage.action'; import { RunboxWebmailAPI } from './rmmapi/rbwebmail'; import { RMMOfflineService } from './rmmapi/rmmoffline.service'; @@ -88,7 +90,12 @@ import { SavedSearchesService } from './saved-searches/saved-searches.service'; import { HelpComponent } from './help/help.component'; import { HelpModule } from './help/help.module'; import { DomainRegisterRedirectComponent } from './domainregister/domreg-redirect.component'; - +import { HumanBytesPipe } from './human-bytes.pipe'; +import { FollowsMouseComponent } from './follows-mouse/follows-mouse.component'; +import { DatePipe } from '@angular/common'; +import { ResizableButtonComponent } from './resizable-button/resizable-button.component'; +import { SortButtonComponent } from './sort-button/sort-button.component'; +import { ResizeObserverDirective } from './directives/resize-observer.directive'; window.addEventListener('dragover', (event) => event.preventDefault()); window.addEventListener('drop', (event) => event.preventDefault()); @@ -140,8 +147,16 @@ const routes: Routes = [ ]; @NgModule({ - imports: [BrowserModule, FormsModule, + imports: [ + BrowserModule, + FormsModule, + ResizeObserverDirective, + DatePipe, + ResizableButtonComponent, + SortButtonComponent, + MatBadgeModule, HttpClientModule, + VirtualScrollTableComponent, HttpClientJsonpModule, CanvasTableModule, ComposeModule, @@ -180,10 +195,14 @@ const routes: Routes = [ RunboxCommonModule, RouterModule.forRoot(routes), ServiceWorkerModule.register('/app/ngsw-worker.js', { enabled: environment.production }), - HotkeyModule.forRoot() + HotkeyModule.forRoot(), + HumanBytesPipe, + FollowsMouseComponent ], exports: [], - declarations: [MainContainerComponent, AppComponent, + declarations: [ + MainContainerComponent, + AppComponent, MoveMessageDialogComponent, PopularRecipientsComponent, SavedSearchesComponent, diff --git a/src/app/canvastable/canvastable.spec.ts b/src/app/canvastable/canvastable.spec.ts deleted file mode 100644 index ef51405f1..000000000 --- a/src/app/canvastable/canvastable.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -// --------- BEGIN RUNBOX LICENSE --------- -// Copyright (C) 2016-2022 Runbox Solutions AS (runbox.com). -// -// This file is part of Runbox 7. -// -// Runbox 7 is free software: You can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// Runbox 7 is distributed in the hope that it will be useful, but -// WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -// General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Runbox 7. If not, see . -// ---------- END RUNBOX LICENSE ---------- - -import { TestBed } from '@angular/core/testing'; -import { CanvasTableModule, CanvasTableContainerComponent } from './canvastable'; -import { MessageList } from '../common/messagelist'; - -describe('canvastable', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - CanvasTableModule - ] - }); - }); - - it('should activate draggable column overlay on mouseover', async () => { - const fixture = TestBed.createComponent(CanvasTableContainerComponent); - fixture.componentInstance.canvastableselectlistener = { - rowSelected: (rowIndex: number, colIndex: number, rowContent: any, multiSelect?: boolean): void => { - - }, - saveColumnWidthsPreference: (widths: any): void => { - } - }; - fixture.componentInstance.canvastable.columns = [ - { - name: 'Column1', - cacheKey: 'col1', - sortColumn: null, - getValue: (row) => row.col1, - width: 200 - }, - { - name: 'Column2', - cacheKey: 'col2', - sortColumn: null, - getValue: (row) => row.col2, - width: 200, - draggable: true - }, - ]; - fixture.componentInstance.canvastable.rows = new MessageList([ - { col1: 'subject1', col2: 'fld' }, - { col1: 'test', col2: 'hello' } - ]); - fixture.componentInstance.canvastable.rowWrapMode = false; - fixture.detectChanges(); - - fixture.componentInstance.canvastable.canvRef.nativeElement.dispatchEvent(new MouseEvent('mousemove', { - clientX: 270, - clientY: 50 - })); - - await new Promise(resolve => setTimeout(resolve, 500)); - fixture.detectChanges(); - expect(fixture.componentInstance.canvastable.floatingTooltip).toBeTruthy(); - expect(fixture.componentInstance.canvastable.columnOverlay).toBeTruthy(); - }); -}); diff --git a/src/app/canvastable/canvastable.ts b/src/app/canvastable/canvastable.ts index 30dbe0895..a0ac1b04a 100644 --- a/src/app/canvastable/canvastable.ts +++ b/src/app/canvastable/canvastable.ts @@ -24,9 +24,9 @@ import { NgModule, Component, AfterViewInit, - Input, Output, Renderer2, + Input, Output, ElementRef, - DoCheck, NgZone, EventEmitter, OnInit, ViewChild + EventEmitter, OnInit, ViewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button'; @@ -82,7 +82,7 @@ export namespace CanvasTable { selector: 'canvastable', templateUrl: 'canvastable.component.html' }) -export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { +export class CanvasTableComponent implements AfterViewInit, OnInit { static incrementalId = 1; public elementId: string; private _topindex = 0.0; @@ -109,14 +109,14 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { private canv: HTMLCanvasElement; - private ctx: CanvasRenderingContext2D; - private wantedCanvasWidth = 300; - private wantedCanvasHeight = 300; + // private ctx: CanvasRenderingContext2D; + // private wantedCanvasWidth = 300; + // private wantedCanvasHeight = 300; private _rowheight = 28; - private fontheight = 14; - private fontheightSmall = 13; - private fontheightSmaller = 12; + // private fontheight = 14; + // private fontheightSmall = 13; + // private fontheightSmaller = 12; private scrollbarwidth = 12; @@ -132,8 +132,6 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { columnResizeInProgress = false; private scrollbarArea = false; - private jumpToMessage = false; - visibleColumnSeparatorAlpha = 0; visibleColumnSeparatorIndex = 0; lastClientY: number; @@ -194,8 +192,6 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { } } - private dragSelectionDirectionIsDown: boolean = null; - // Auto row wrap mode (width based on iphone 5) - set to 0 to disable row wrap mode public autoRowWrapModeWidth = 540; @@ -207,7 +203,7 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { public hasChanges: boolean; - private formattedValueCache: { [key: string]: string; } = {}; + // private formattedValueCache: { [key: string]: string; } = {}; public scrollLimitHit: BehaviorSubject = new BehaviorSubject(0); @@ -221,21 +217,22 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { // Are we selecting all rows, or just the visible ones? public selectWhichRows = CanvasTable.RowSelect.Visible; - constructor(elementRef: ElementRef, private renderer: Renderer2, private _ngZone: NgZone) { + constructor(elementRef: ElementRef) { } - ngDoCheck() { - if (this.canv) { + // No need to track changes. + // ngDoCheck() { + // if (this.canv) { - const devicePixelRatio = window.devicePixelRatio ? window.devicePixelRatio : 1; - this.wantedCanvasWidth = this.canv.parentElement.parentElement.clientWidth * devicePixelRatio; - this.wantedCanvasHeight = this.canv.parentElement.parentElement.clientHeight * devicePixelRatio; + // const devicePixelRatio = window.devicePixelRatio ? window.devicePixelRatio : 1; + // this.wantedCanvasWidth = this.canv.parentElement.parentElement.clientWidth * devicePixelRatio; + // this.wantedCanvasHeight = this.canv.parentElement.parentElement.clientHeight * devicePixelRatio; - if (this.canv.width !== this.wantedCanvasWidth || this.canv.height !== this.wantedCanvasHeight) { - this.hasChanges = true; - } - } - } + // if (this.canv.width !== this.wantedCanvasWidth || this.canv.height !== this.wantedCanvasHeight) { + // this.hasChanges = true; + // } + // } + // } private calculateColumnWidths(columns: CanvasTableColumn[]) { const colWidthSet = columns.map((col) => col.name).filter((cname) => cname.length > 0).join(','); @@ -253,7 +250,7 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { ngAfterViewInit() { this.canv = this.canvRef.nativeElement; - this.ctx = this.canv.getContext('2d'); + // this.ctx = this.canv.getContext('2d'); this.canv.onwheel = (event: WheelEvent) => { event.preventDefault(); @@ -272,7 +269,7 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { break; } - this.enforceScrollLimit(); + // this.enforceScrollLimit(); }; /** @@ -329,7 +326,7 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { } // Reset drag select direction - this.dragSelectionDirectionIsDown = null; + // this.dragSelectionDirectionIsDown = null; }; let previousTouchY: number; @@ -377,7 +374,7 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { previousTouchY = newTouchY; previousTouchX = newTouchX; } - this.enforceScrollLimit(); + // this.enforceScrollLimit(); this.touchscroll.emit(this.horizScroll); } @@ -397,116 +394,117 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { } }); - this.renderer.listen('window', 'mousemove', (event: MouseEvent) => { - if (this.scrollbarDragInProgress === true) { - event.preventDefault(); - this.doScrollBarDrag(event.clientY); - } - }); - - this.canv.onmousemove = (event: MouseEvent) => { - if (this.scrollbarDragInProgress === true || this.columnResizeInProgress === true) { - event.preventDefault(); - return; - } - - const canvrect = this.canv.getBoundingClientRect(); - const clientX = event.clientX - canvrect.left; - - let newHoverRowIndex = this.getRowIndexByClientY(event.clientY); - if (this.scrollbarDragInProgress || checkIfScrollbarArea(event.clientX, event.clientY, true)) { - newHoverRowIndex = null; - } - - if (this.hoverRowIndex !== newHoverRowIndex) { - // check if mouse is down - if (this.lastMouseDownEvent) { - // set drag select direction to true if down, or false if up - const newDragSelectionDirectionIsDown = newHoverRowIndex > this.hoverRowIndex ? true : false; - - if (this.dragSelectionDirectionIsDown !== newDragSelectionDirectionIsDown) { - // select previous row on drag select direction change - this.selectRowByIndex(this.lastMouseDownEvent.clientX, this.hoverRowIndex); - this.dragSelectionDirectionIsDown = newDragSelectionDirectionIsDown; - } - let rowIndex = this.hoverRowIndex; - // Select all rows between the previous and current hover row index - while ( - (newDragSelectionDirectionIsDown === true && rowIndex < newHoverRowIndex) || - (newDragSelectionDirectionIsDown === false && rowIndex > newHoverRowIndex) - ) { - if (newDragSelectionDirectionIsDown === true) { - rowIndex ++; - } else { - rowIndex --; - } - this.selectRowByIndex(this.lastMouseDownEvent.clientX, rowIndex); - } - } - this.hoverRowIndex = newHoverRowIndex; - } - - if (this.dragSelectionDirectionIsDown === null) { - // Check for column resize - if (this.lastMouseDownEvent && this.visibleColumnSeparatorIndex > 0) { - this.columnresize.emit(this.visibleColumnSeparatorIndex); - } else { - this.updateVisibleColumnSeparatorIndex(clientX); - } - - if (this.visibleColumnSeparatorIndex > 0) { - this.lastClientY = event.clientY - canvrect.top; - this.hasChanges = true; - return; - } - } - - if (this.dragSelectionDirectionIsDown === null && this.hoverRowIndex !== null) { - const colIndex = this.getColIndexByClientX(clientX); - let colStartX = this.columns.reduce((prev, curr, ndx) => ndx < colIndex ? prev + curr.width : prev, 0); - - let tooltipText: string | ((rowIndex: any) => string) = - this.columns[colIndex] && this.columns[colIndex].tooltipText; - - // FIXME: message display class - if (typeof tooltipText === 'function' && this.rows.rowExists(this.hoverRowIndex)) { - tooltipText = tooltipText(this.hoverRowIndex); - } - - if (!event.shiftKey && !this.lastMouseDownEvent && - (tooltipText || (this.columns[colIndex] && this.columns[colIndex].draggable)) - ) { - if (this.rowWrapMode && - colIndex >= this.rowWrapModeWrapColumn) { - // Subtract first row width if in row wrap mode - colStartX -= this.columns.reduce((prev, curr, ndx) => - ndx < this.rowWrapModeWrapColumn ? prev + curr.width : prev, 0); - } - - this.floatingTooltip = new FloatingTooltip( - (this.hoverRowIndex - this.topindex) * this.rowheight, - colStartX - this.horizScroll + this.colpaddingleft, - this.columns[colIndex].width - this.colpaddingright - this.colpaddingleft, - this.rowheight, tooltipText as string); - - if (this.rowWrapMode) { - this.floatingTooltip.top += - + (colIndex >= this.rowWrapModeWrapColumn ? this.rowheight / 2 : 0); - this.floatingTooltip.height = this.rowheight / 2; - } - - setTimeout(() => { - if (this.columnOverlay) { - this.columnOverlay.show(300); - } - }, 0); - } else { - this.floatingTooltip = null; - } - } else { - this.floatingTooltip = null; - } - }; + // this.renderer.listen('window', 'mousemove', (event: MouseEvent) => { + // if (this.scrollbarDragInProgress === true) { + // event.preventDefault(); + // this.doScrollBarDrag(event.clientY); + // } + // }); + + // this.canv.onmousemove = (event: MouseEvent) => { + // if (this.scrollbarDragInProgress === true || this.columnResizeInProgress === true) { + // event.preventDefault(); + // return; + // } + + // const canvrect = this.canv.getBoundingClientRect(); + // const clientX = event.clientX - canvrect.left; + + // let newHoverRowIndex = this.getRowIndexByClientY(event.clientY); + // if (this.scrollbarDragInProgress || checkIfScrollbarArea(event.clientX, event.clientY, true)) { + // newHoverRowIndex = null; + // } + + // if (this.hoverRowIndex !== newHoverRowIndex) { + // // check if mouse is down + // if (this.lastMouseDownEvent) { + // // set drag select direction to true if down, or false if up + // const newDragSelectionDirectionIsDown = newHoverRowIndex > this.hoverRowIndex ? true : false; + + // if (this.dragSelectionDirectionIsDown !== newDragSelectionDirectionIsDown) { + // // select previous row on drag select direction change + // this.selectRowByIndex(this.lastMouseDownEvent.clientX, this.hoverRowIndex); + // this.dragSelectionDirectionIsDown = newDragSelectionDirectionIsDown; + // } + // let rowIndex = this.hoverRowIndex; + // // Select all rows between the previous and current hover row index + // while ( + // (newDragSelectionDirectionIsDown === true && rowIndex < newHoverRowIndex) || + // (newDragSelectionDirectionIsDown === false && rowIndex > newHoverRowIndex) + // ) { + // if (newDragSelectionDirectionIsDown === true) { + // rowIndex ++; + // } else { + // rowIndex --; + // } + // this.selectRowByIndex(this.lastMouseDownEvent.clientX, rowIndex); + // } + // } + // this.hoverRowIndex = newHoverRowIndex; + // this.updateDragImage(newHoverRowIndex); + // } + + // if (this.dragSelectionDirectionIsDown === null) { + // // Check for column resize + // if (this.lastMouseDownEvent && this.visibleColumnSeparatorIndex > 0) { + // this.columnresize.emit(this.visibleColumnSeparatorIndex); + // } else { + // this.updateVisibleColumnSeparatorIndex(clientX); + // } + + // if (this.visibleColumnSeparatorIndex > 0) { + // this.lastClientY = event.clientY - canvrect.top; + // this.hasChanges = true; + // return; + // } + // } + + // if (this.dragSelectionDirectionIsDown === null && this.hoverRowIndex !== null) { + // const colIndex = this.getColIndexByClientX(clientX); + // let colStartX = this.columns.reduce((prev, curr, ndx) => ndx < colIndex ? prev + curr.width : prev, 0); + + // let tooltipText: string | ((rowIndex: any) => string) = + // this.columns[colIndex] && this.columns[colIndex].tooltipText; + + // // FIXME: message display class + // if (typeof tooltipText === 'function' && this.rows.rowExists(this.hoverRowIndex)) { + // tooltipText = tooltipText(this.hoverRowIndex); + // } + + // if (!event.shiftKey && !this.lastMouseDownEvent && + // (tooltipText || (this.columns[colIndex] && this.columns[colIndex].draggable)) + // ) { + // if (this.rowWrapMode && + // colIndex >= this.rowWrapModeWrapColumn) { + // // Subtract first row width if in row wrap mode + // colStartX -= this.columns.reduce((prev, curr, ndx) => + // ndx < this.rowWrapModeWrapColumn ? prev + curr.width : prev, 0); + // } + + // this.floatingTooltip = new FloatingTooltip( + // (this.hoverRowIndex - this.topindex) * this.rowheight, + // colStartX - this.horizScroll + this.colpaddingleft, + // this.columns[colIndex].width - this.colpaddingright - this.colpaddingleft, + // this.rowheight, tooltipText as string); + + // if (this.rowWrapMode) { + // this.floatingTooltip.top += + // + (colIndex >= this.rowWrapModeWrapColumn ? this.rowheight / 2 : 0); + // this.floatingTooltip.height = this.rowheight / 2; + // } + + // setTimeout(() => { + // if (this.columnOverlay) { + // this.columnOverlay.show(300); + // } + // }, 0); + // } else { + // this.floatingTooltip = null; + // } + // } else { + // this.floatingTooltip = null; + // } + // }; this.canv.onmouseout = (event: MouseEvent) => { const newHoverRowIndex = null; @@ -515,13 +513,13 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { } }; - this.renderer.listen('window', 'mouseup', (event: MouseEvent) => { - this.lastMouseDownEvent = undefined; - if (this.scrollbarDragInProgress) { - this.scrollbarDragInProgress = false; - this.hasChanges = true; - } - }); + // this.renderer.listen('window', 'mouseup', (event: MouseEvent) => { + // this.lastMouseDownEvent = undefined; + // if (this.scrollbarDragInProgress) { + // this.scrollbarDragInProgress = false; + // this.hasChanges = true; + // } + // }); this.canv.onmouseup = (event: MouseEvent) => { event.preventDefault(); @@ -538,56 +536,56 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { } this.lastMouseDownEvent = null; - this.dragSelectionDirectionIsDown = null; + // this.dragSelectionDirectionIsDown = null; }; - this.renderer.listen('window', 'resize', () => true); - - const paintLoop = () => { - if (this.hasChanges) { - if (Math.abs(this.touchScrollSpeedY) > 0) { - // Scroll if speed - this.topindex -= this.touchScrollSpeedY / this.rowheight; - - // ---- Enforce scroll limit - if (this.topindex < 0) { - this.topindex = 0; - } else if (this.rows.rowCount() < this.maxVisibleRows) { - this.topindex = 0; - } else if (this.topindex + this.maxVisibleRows > this.rows.rowCount()) { - this.topindex = this.rows.rowCount() - this.maxVisibleRows; - } - // --------- - - // Slow down - this.touchScrollSpeedY *= 0.9; - if (Math.abs(this.touchScrollSpeedY) < 0.4) { - this.touchScrollSpeedY = 0; - } - } - try { - this.dopaint(); - if (this.rows) { - this.repaintDoneSubject.next(undefined); - } - } catch (e) { - console.log(e); - } - - if (Math.abs(this.touchScrollSpeedY) > 0) { - // Continue scrolling while we have scroll speed - this.hasChanges = true; - } else { - this.hasChanges = false; - } - } - window.requestAnimationFrame(() => paintLoop()); - }; - - this._ngZone.runOutsideAngular(() => - window.requestAnimationFrame(() => paintLoop()) - ); + // this.renderer.listen('window', 'resize', () => true); + + // const paintLoop = () => { + // if (this.hasChanges) { + // if (Math.abs(this.touchScrollSpeedY) > 0) { + // // Scroll if speed + // this.topindex -= this.touchScrollSpeedY / this.rowheight; + + // // ---- Enforce scroll limit + // if (this.topindex < 0) { + // this.topindex = 0; + // } else if (this.rows.rowCount() < this.maxVisibleRows) { + // this.topindex = 0; + // } else if (this.topindex + this.maxVisibleRows > this.rows.rowCount()) { + // this.topindex = this.rows.rowCount() - this.maxVisibleRows; + // } + // // --------- + + // // Slow down + // this.touchScrollSpeedY *= 0.9; + // if (Math.abs(this.touchScrollSpeedY) < 0.4) { + // this.touchScrollSpeedY = 0; + // } + // } + // try { + // this.dopaint(); + // if (this.rows) { + // this.repaintDoneSubject.next(undefined); + // } + // } catch (e) { + // console.log(e); + // } + + // if (Math.abs(this.touchScrollSpeedY) > 0) { + // // Continue scrolling while we have scroll speed + // this.hasChanges = true; + // } else { + // this.hasChanges = false; + // } + // } + // // window.requestAnimationFrame(() => paintLoop()); + // }; + + // this._ngZone.runOutsideAngular(() => + // window.requestAnimationFrame(() => paintLoop()) + // ); } private updateDragImage(selectedRowIndex: number) :HTMLCanvasElement { @@ -662,7 +660,7 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { const canvrect = this.canv.getBoundingClientRect(); this.topindex = this.rows.rowCount() * ((clientY - canvrect.top) / this.canv.scrollHeight); - this.enforceScrollLimit(); + // this.enforceScrollLimit(); } private getRowIndexByClientY(clientY: number) { @@ -776,11 +774,14 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { } public autoAdjustColumnWidths(minwidth: number, tryFitScreenWidth = false) { + // Make innert + return + if (!this.canv || this._columns.length === 0) { return; } - const canvasWidth = Math.floor(this.wantedCanvasWidth / window.devicePixelRatio) - this.scrollbarwidth - 2; + const canvasWidth = Math.floor(window.devicePixelRatio) - this.scrollbarwidth - 2; const columnsTotalWidth = () => this.columns.reduce((prev, curr) => prev + curr.width, 0); @@ -825,13 +826,13 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { public scrollUp() { this.topindex--; - this.enforceScrollLimit(); + // this.enforceScrollLimit(); this.hasChanges = true; } public scrollDown() { this.topindex++; - this.enforceScrollLimit(); + // this.enforceScrollLimit(); this.hasChanges = true; } @@ -849,7 +850,7 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { public updateRows(newList) { this.rows.setRows(newList); - this.enforceScrollLimit(); + // this.enforceScrollLimit(); this.hasChanges = true; } @@ -864,33 +865,37 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { // When loading a url with a fragment containing a msg id - scroll to there public jumpToOpenMessage() { - this.jumpToMessage = true; - } - - private enforceScrollLimit() { - if (this.topindex < 0) { - this.topindex = 0; - } else if (this.rows && this.rows.rowCount() < this.maxVisibleRows) { - this.topindex = 0; - } else if (this.rows && this.topindex + this.maxVisibleRows > this.rows.rowCount()) { - this.topindex = this.rows.rowCount() - this.maxVisibleRows; - // send max rows hit events (use to fetch more data) - this.scrollLimitHit.next(this.rows.rowCount()); - } - - - const columnsTotalWidth = this.columns.reduce((width, col) => - col.width + width, 0); - - if (this.horizScroll < 0) { - this.horizScroll = 0; - } else if ( - this.canv.scrollWidth < columnsTotalWidth && - this.horizScroll + this.canv.scrollWidth > columnsTotalWidth) { - this.horizScroll = columnsTotalWidth - this.canv.scrollWidth; + // currently selected row in the centre: + if (this.rows.rowCount() > 0 && this.rows.openedRowIndex) { + this.topindex = this.rows.openedRowIndex - Math.round(this.maxVisibleRows / 2); + // this.enforceScrollLimit(); } } + // private enforceScrollLimit() { + // if (this.topindex < 0) { + // this.topindex = 0; + // } else if (this.rows && this.rows.rowCount() < this.maxVisibleRows) { + // this.topindex = 0; + // } else if (this.rows && this.topindex + this.maxVisibleRows > this.rows.rowCount()) { + // this.topindex = this.rows.rowCount() - this.maxVisibleRows; + // // send max rows hit events (use to fetch more data) + // this.scrollLimitHit.next(this.rows.rowCount()); + // } + + + // const columnsTotalWidth = this.columns.reduce((width, col) => + // col.width + width, 0); + + // if (this.horizScroll < 0) { + // this.horizScroll = 0; + // } else if ( + // this.canv.scrollWidth < columnsTotalWidth && + // this.horizScroll + this.canv.scrollWidth > columnsTotalWidth) { + // this.horizScroll = columnsTotalWidth - this.canv.scrollWidth; + // } + // } + /** * Draws a rounded rectangle using the current state of the canvas. * If you omit the last three params, it will draw a rectangle @@ -909,40 +914,40 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { * @param {Boolean} [fill = false] Whether to fill the rectangle. * @param {Boolean} [stroke = true] Whether to stroke the rectangle. */ - private roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, - width: number, height: number, - radius?: any, fill?: boolean, stroke?: boolean) { - if (typeof stroke === 'undefined') { - stroke = true; - } - if (typeof radius === 'undefined') { - radius = 5; - } - if (typeof radius === 'number') { - radius = { tl: radius, tr: radius, br: radius, bl: radius }; - } else { - const defaultRadius = { tl: 0, tr: 0, br: 0, bl: 0 }; - Object.keys(defaultRadius).forEach(side => - radius[side] = radius[side] || defaultRadius[side]); - } - ctx.beginPath(); - ctx.moveTo(x + radius.tl, y); - ctx.lineTo(x + width - radius.tr, y); - ctx.quadraticCurveTo(x + width, y, x + width, y + radius.tr); - ctx.lineTo(x + width, y + height - radius.br); - ctx.quadraticCurveTo(x + width, y + height, x + width - radius.br, y + height); - ctx.lineTo(x + radius.bl, y + height); - ctx.quadraticCurveTo(x, y + height, x, y + height - radius.bl); - ctx.lineTo(x, y + radius.tl); - ctx.quadraticCurveTo(x, y, x + radius.tl, y); - ctx.closePath(); - if (fill) { - ctx.fill(); - } - if (stroke) { - ctx.stroke(); - } - } + // private roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, + // width: number, height: number, + // radius?: any, fill?: boolean, stroke?: boolean) { + // if (typeof stroke === 'undefined') { + // stroke = true; + // } + // if (typeof radius === 'undefined') { + // radius = 5; + // } + // if (typeof radius === 'number') { + // radius = { tl: radius, tr: radius, br: radius, bl: radius }; + // } else { + // const defaultRadius = { tl: 0, tr: 0, br: 0, bl: 0 }; + // Object.keys(defaultRadius).forEach(side => + // radius[side] = radius[side] || defaultRadius[side]); + // } + // ctx.beginPath(); + // ctx.moveTo(x + radius.tl, y); + // ctx.lineTo(x + width - radius.tr, y); + // ctx.quadraticCurveTo(x + width, y, x + width, y + radius.tr); + // ctx.lineTo(x + width, y + height - radius.br); + // ctx.quadraticCurveTo(x + width, y + height, x + width - radius.br, y + height); + // ctx.lineTo(x + radius.bl, y + height); + // ctx.quadraticCurveTo(x, y + height, x, y + height - radius.bl); + // ctx.lineTo(x, y + radius.tl); + // ctx.quadraticCurveTo(x, y, x + radius.tl, y); + // ctx.closePath(); + // if (fill) { + // ctx.fill(); + // } + // if (stroke) { + // ctx.stroke(); + // } + // } // Height of message list rows public get rowheight(): number { @@ -957,410 +962,403 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { } } - private dopaint() { - const devicePixelRatio = window.devicePixelRatio; - if (this.canv.width !== this.wantedCanvasWidth || - this.canv.height !== this.wantedCanvasHeight) { - - const widthChanged = this.canv.width !== this.wantedCanvasWidth; - /* Only resize on detection of width change - * otherwise reducing column widths so that the scrollbar - * disappears indicates a change of height and triggers resize - */ - - this.canv.style.width = (this.wantedCanvasWidth / devicePixelRatio) + 'px'; - this.canv.style.height = (this.wantedCanvasHeight / devicePixelRatio) + 'px'; - - this.canv.width = this.wantedCanvasWidth; - this.canv.height = this.wantedCanvasHeight; - - this.maxVisibleRows = this.canv.scrollHeight / this.rowheight; - if(this.jumpToMessage) { - // currently selected row in the centre: - if (this.rows.rowCount() > 0 && this.rows.openedRowIndex) { - this.topindex = this.rows.openedRowIndex - Math.round(this.maxVisibleRows / 2); - } - this.jumpToMessage = false; - } - this.enforceScrollLimit(); - this.hasChanges = true; - if (this.canv.clientWidth < this.autoRowWrapModeWidth) { - this.rowWrapMode = true; - } else { - this.rowWrapMode = false; - } - - this.canvasResizedSubject.next(widthChanged); - } - - if (devicePixelRatio !== 1) { - // This is not scale() as that would keep multiplying - // Moved out of above if() statement as something (!?) - // was resetting transform, still not sure what - this.ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); - } - - this.ctx.textBaseline = 'middle'; - this.ctx.font = this.fontheight + 'px ' + this.fontFamily; - - const canvwidth: number = this.canv.scrollWidth; - const canvheight: number = this.canv.scrollHeight; - - let colx = 0 - this.horizScroll; - // Columns - for (let colindex = 0; colindex < this.columns.length; colindex++) { - const col: CanvasTableColumn = this.columns[colindex]; - if (colx + col.width > 0 && colx < canvwidth) { - this.ctx.fillStyle = col.backgroundColor ? col.backgroundColor : '#fff'; - this.ctx.fillRect(colx, - 0, - colindex === this.columns.length - 1 ? - canvwidth - colx : - col.width, - canvheight - ); - } - colx += col.width; - } - - if (!this.rows || this.rows.rowCount() < 1) { - return; - } - - // Rows - for (let n = this.topindex; n < this.rows.rowCount(); n += 1.0) { - const rowIndex = Math.floor(n); - - if (rowIndex > this.rows.rowCount()) { - break; - } - -// const rowobj = this.rows[rowIndex]; - - const halfrowheight = (this.rowheight / 2); - const rowy = (rowIndex - this.topindex) * this.rowheight; - if (this.rows.rowExists(rowIndex)) { - // Clear row area - // Alternating row colors: - // let rowBgColor : string = (rowIndex%2===0 ? "#e8e8e8" : "rgba(255,255,255,0.7)"); - // Single row color: - let rowBgColor = '#fff'; - - const isBoldRow = this.rows.isBoldRow(rowIndex); - const isSelectedRow = this.rows.isSelectedRow(rowIndex); - const isOpenedRow = this.rows.isOpenedRow(rowIndex); - if (this.hoverRowIndex === rowIndex) { - rowBgColor = this.hoverRowColor; - } - if (isSelectedRow) { - rowBgColor = this.selectedRowColor; - } - if (isOpenedRow) { - rowBgColor = this.openedRowColor; - } - - this.ctx.fillStyle = rowBgColor; - this.ctx.fillRect(0, rowy, canvwidth, this.rowheight); - - // Row borders separating each row - this.ctx.strokeStyle = '#eee'; - this.ctx.beginPath(); - this.ctx.moveTo(0, rowy); - this.ctx.lineTo(canvwidth, rowy); - this.ctx.stroke(); - - let x = 0; - for (let colindex = 0; colindex < this.columns.length; colindex++) { - const col: CanvasTableColumn = this.columns[colindex]; - let val: any = col.getValue(rowIndex); - if (val === 'RETRY') { - // retry later if value is null - setTimeout(() => this.hasChanges = true, 2); - val = ''; - } - let formattedVal: string; - const formattedValueCacheKey: string = col.cacheKey + ':' + val; - if (this.formattedValueCache[formattedValueCacheKey]) { - formattedVal = this.formattedValueCache[formattedValueCacheKey]; - } else if (('' + val).length > 0 && col.getFormattedValue) { - formattedVal = col.getFormattedValue(val); - this.formattedValueCache[formattedValueCacheKey] = formattedVal; - } else { - formattedVal = '' + val; - this.formattedValueCache[formattedValueCacheKey] = formattedVal; - } - if (this.rowWrapMode && col.rowWrapModeHidden) { - continue; - } else if (this.rowWrapMode && col.rowWrapModeChipCounter && parseInt(val, 10) > 1) { - this.ctx.save(); - - this.ctx.strokeStyle = ''; - - this.roundRect(this.ctx, - canvwidth - 50, - rowy + 9, - 28, - 15, 10, false); - this.ctx.font = '10px ' + this.fontFamily; - - this.ctx.strokeStyle = '#000'; - if (isSelectedRow) { - this.ctx.fillStyle = this.textColor; - } else { - this.ctx.fillStyle = this.textColor; - } - this.ctx.textAlign = 'center'; - this.ctx.fillText(formattedVal + '', canvwidth - 36, rowy + halfrowheight - 15); - - this.ctx.restore(); - - continue; - } else if (this.rowWrapMode && col.rowWrapModeChipCounter) { - continue; - } - if (this.rowWrapMode && colindex === this.rowWrapModeWrapColumn) { - x = 0; - } - - x += this.colpaddingleft; - - if ((x - this.horizScroll + col.width) >= 0 && formattedVal.length > 0) { - this.ctx.fillStyle = this.textColor; // Text color of unselected row - if (isSelectedRow) { - this.ctx.fillStyle = this.textColor; // Text color of selected row - } - - if (this.rowWrapMode) { - // Wrap rows if in row wrap mode (for e.g. mobile portrait view) - - // Check box - const texty: number = rowy + halfrowheight; - const textx: number = x - this.horizScroll; - - const width = col.width - this.colpaddingright - this.colpaddingleft; - - this.ctx.save(); - this.ctx.beginPath(); - this.ctx.moveTo(textx, rowy); - this.ctx.lineTo(textx + width, rowy); - this.ctx.lineTo(textx + width, rowy + this.rowheight); - this.ctx.lineTo(textx, rowy + this.rowheight); - this.ctx.closePath(); - - if (col.checkbox) { - const checkboxWidthHeight = 12; - const checkboxCheckedPadding = 3; - const checkboxLeftPadding = 4; - this.ctx.strokeStyle = this.textColor; - this.ctx.beginPath(); - this.ctx.rect(checkboxLeftPadding + textx, texty - checkboxWidthHeight / 2, checkboxWidthHeight, checkboxWidthHeight); - this.ctx.stroke(); - if (val) { - this.ctx.beginPath(); - this.ctx.rect(checkboxLeftPadding + textx + checkboxCheckedPadding, - checkboxCheckedPadding + texty - checkboxWidthHeight / 2, - checkboxWidthHeight - checkboxCheckedPadding * 2, - checkboxWidthHeight - checkboxCheckedPadding * 2); - this.ctx.fill(); - } - } else { - - // Other columns - if (colindex >= this.rowWrapModeWrapColumn) { - // Subject - x += 30; // Increase padding before Subject - this.ctx.save(); - if (isBoldRow) { - this.ctx.save(); - this.ctx.font = 'bold ' + this.fontheight + 'px ' + this.fontFamilyBold; - this.ctx.fillStyle = this.textColorLink; - } else { - this.ctx.save(); - this.ctx.font = this.fontheight + 'px ' + this.fontFamily; - this.ctx.fillStyle = this.textColorLink; - } - this.ctx.fillText(formattedVal, x, rowy + halfrowheight + 12 - - (this.showContentTextPreview ? 12 : 0) - ); - this.ctx.restore(); - } else if (col.rowWrapModeMuted) { - // Date/time - x = 42; // sufficiently away from the checkbox - this.ctx.save(); - this.ctx.font = this.fontheightSmaller + 'px ' + this.fontFamily; - this.ctx.fillStyle = this.textColor; - this.ctx.fillText(formattedVal, x, rowy + halfrowheight - 10 - - (this.showContentTextPreview ? 8 : 0) - ); - this.ctx.restore(); - } else { - x = 128; // far enough to make the date above fit nicely - this.ctx.font = this.fontheightSmall + 'px ' + this.fontFamily; - this.ctx.fillText(formattedVal, x, rowy + halfrowheight - 10 - - (this.showContentTextPreview ? 8 : 0)); - this.ctx.fillStyle = this.textColorLink; - } - } - this.ctx.restore(); - } else if (x - this.horizScroll < canvwidth) { - // Normal no-wrap mode - - // Check box - const texty: number = rowy + halfrowheight - (this.showContentTextPreview ? 10 : 0); - let textx: number = x - this.horizScroll; - - const width = col.width - this.colpaddingright - this.colpaddingleft; - - this.ctx.save(); - this.ctx.beginPath(); - this.ctx.moveTo(textx, rowy); - this.ctx.lineTo(textx + width, rowy); - this.ctx.lineTo(textx + width, rowy + this.rowheight); - this.ctx.lineTo(textx, rowy + this.rowheight); - this.ctx.closePath(); - - this.ctx.clip(); - - if (col.checkbox) { - const checkboxWidthHeight = 12; - const checkboxCheckedPadding = 3; - const checkboxLeftPadding = 4; - this.ctx.strokeStyle = this.textColor; - this.ctx.beginPath(); - this.ctx.rect(checkboxLeftPadding + textx, texty - checkboxWidthHeight / 2, checkboxWidthHeight, checkboxWidthHeight); - this.ctx.stroke(); - if (val) { - this.ctx.beginPath(); - this.ctx.rect(checkboxLeftPadding + textx + checkboxCheckedPadding, - checkboxCheckedPadding + texty - checkboxWidthHeight / 2, - checkboxWidthHeight - checkboxCheckedPadding * 2, - checkboxWidthHeight - checkboxCheckedPadding * 2); - this.ctx.fill(); - } - } else { - // Other columns - if (col.textAlign === 1) { - textx += width; - this.ctx.textAlign = 'end'; - } - - if (col.font) { - this.ctx.font = col.font; - } - if (colindex === 2 || colindex === 3) { - // Column 2 is From, 3 is Subject - this.ctx.fillStyle = this.textColorLink; - if (isBoldRow) { - this.ctx.font = 'bold ' + this.fontheight + 'px ' + this.fontFamilyBold; - } - } - this.ctx.fillText(formattedVal, textx, texty); - } - this.ctx.restore(); - } - } - - x += (Math.round(col.width * (this.rowWrapMode && col.rowWrapModeMuted ? - (10 / this.fontheight) : 1)) - this.colpaddingleft); // We've already added colpaddingleft above - } - } else { - // skipping rows we've removed while canvas was updating.... - console.log('Skipped repainting a row as its data is missing, continuing anyway'); - } - if (this.showContentTextPreview) { - const contentTextPreviewColumn = this.columns - .find(col => col.getContentPreviewText ? true : false); - if (contentTextPreviewColumn) { - const contentPreviewText = contentTextPreviewColumn.getContentPreviewText(rowIndex); - if (contentPreviewText) { - this.ctx.save(); - this.ctx.fillStyle = this.textColor; - this.ctx.font = this.fontheightSmaller + 'px ' + this.fontFamily; - const contentTextPreviewColumnPadding = this.rowWrapMode ? 2 : 10; // Increase left padding of content preview - this.ctx.fillText(contentPreviewText, this.columns[0]. width + contentTextPreviewColumnPadding, - rowy + halfrowheight + (this.rowWrapMode ? 18 : 15)); - this.ctx.restore(); - } - } - } - - if (rowy > canvheight) { - break; - } - this.ctx.fillStyle = this.textColor; - - } - - // Column separators - - if (!this.rowWrapMode) { - // No column separators in row wrap mode - this.ctx.fillStyle = `rgba(166,166,166,${this.visibleColumnSeparatorAlpha})`; - this.ctx.strokeStyle = `rgba(176,176,176,${this.visibleColumnSeparatorAlpha})`; - - if (this.visibleColumnSeparatorAlpha < 1) { - this.visibleColumnSeparatorAlpha += 0.01; - setTimeout(() => this.hasChanges = true, 0); - } - - let x = 0; - for (let colindex = 0; colindex < this.columns.length; colindex++) { - if (colindex > 0 && this.visibleColumnSeparatorIndex === colindex) { - // Only draw column separator near the mouse pointer - this.ctx.beginPath(); - this.ctx.moveTo(x - this.horizScroll, 0); - this.ctx.lineTo(x - this.horizScroll, canvheight); - this.ctx.stroke(); - - this.ctx.fillRect(x - this.horizScroll - 5, this.lastClientY - 10, 10, 20); - } - x += this.columns[colindex].width; - } - } - - // Scrollbar - let scrollbarheight = (this.maxVisibleRows / this.rows.rowCount()) * canvheight; - if (scrollbarheight < 20) { - scrollbarheight = 20; - } - const scrollbarpos = - (this.topindex / (this.rows.rowCount() - this.maxVisibleRows)) * (canvheight - scrollbarheight); - - if (scrollbarheight < canvheight) { - const scrollbarverticalpadding = 4; - - const scrollbarx = canvwidth - this.scrollbarwidth; - this.ctx.fillStyle = '#aaa'; - this.ctx.fillRect(scrollbarx, 0, this.scrollbarwidth, canvheight); - this.ctx.fillStyle = '#fff'; - this.scrollBarRect = { - x: scrollbarx + 1, - y: scrollbarpos + scrollbarverticalpadding / 2, - width: this.scrollbarwidth - 2, - height: scrollbarheight - scrollbarverticalpadding - }; - - if (this.scrollbarDragInProgress) { - this.ctx.fillStyle = 'rgba(200,200,255,0.5)'; - this.roundRect(this.ctx, - this.scrollBarRect.x - 4, - this.scrollBarRect.y - 4, - this.scrollBarRect.width + 8, - this.scrollBarRect.height + 8, 5, true); - - this.ctx.fillStyle = '#fff'; - this.ctx.fillRect(this.scrollBarRect.x, - this.scrollBarRect.y, - this.scrollBarRect.width, - this.scrollBarRect.height); - } else { - this.ctx.fillStyle = '#fff'; - this.ctx.fillRect(this.scrollBarRect.x, this.scrollBarRect.y, this.scrollBarRect.width, this.scrollBarRect.height); - } - - } - - } +// private dopaint() { +// const devicePixelRatio = window.devicePixelRatio; +// if (this.canv.width !== this.wantedCanvasWidth || +// this.canv.height !== this.wantedCanvasHeight) { + +// const widthChanged = this.canv.width !== this.wantedCanvasWidth; +// /* Only resize on detection of width change +// * otherwise reducing column widths so that the scrollbar +// * disappears indicates a change of height and triggers resize +// */ + +// this.canv.style.width = (this.wantedCanvasWidth / devicePixelRatio) + 'px'; +// this.canv.style.height = (this.wantedCanvasHeight / devicePixelRatio) + 'px'; + +// this.canv.width = this.wantedCanvasWidth; +// this.canv.height = this.wantedCanvasHeight; + +// this.maxVisibleRows = this.canv.scrollHeight / this.rowheight; +// this.enforceScrollLimit(); +// this.hasChanges = true; +// if (this.canv.clientWidth < this.autoRowWrapModeWidth) { +// this.rowWrapMode = true; +// } else { +// this.rowWrapMode = false; +// } + +// this.canvasResizedSubject.next(widthChanged); +// } + +// if (devicePixelRatio !== 1) { +// // This is not scale() as that would keep multiplying +// // Moved out of above if() statement as something (!?) +// // was resetting transform, still not sure what +// this.ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); +// } + +// this.ctx.textBaseline = 'middle'; +// this.ctx.font = this.fontheight + 'px ' + this.fontFamily; + +// const canvwidth: number = this.canv.scrollWidth; +// const canvheight: number = this.canv.scrollHeight; + +// let colx = 0 - this.horizScroll; +// // Columns +// for (let colindex = 0; colindex < this.columns.length; colindex++) { +// const col: CanvasTableColumn = this.columns[colindex]; +// if (colx + col.width > 0 && colx < canvwidth) { +// this.ctx.fillStyle = col.backgroundColor ? col.backgroundColor : '#fff'; +// this.ctx.fillRect(colx, +// 0, +// colindex === this.columns.length - 1 ? +// canvwidth - colx : +// col.width, +// canvheight +// ); +// } +// colx += col.width; +// } + +// if (!this.rows || this.rows.rowCount() < 1) { +// return; +// } + +// // Rows +// for (let n = this.topindex; n < this.rows.rowCount(); n += 1.0) { +// const rowIndex = Math.floor(n); + +// if (rowIndex > this.rows.rowCount()) { +// break; +// } + +// // const rowobj = this.rows[rowIndex]; + +// const halfrowheight = (this.rowheight / 2); +// const rowy = (rowIndex - this.topindex) * this.rowheight; +// if (this.rows.rowExists(rowIndex)) { +// // Clear row area +// // Alternating row colors: +// // let rowBgColor : string = (rowIndex%2===0 ? "#e8e8e8" : "rgba(255,255,255,0.7)"); +// // Single row color: +// let rowBgColor = '#fff'; + +// const isBoldRow = this.rows.isBoldRow(rowIndex); +// const isSelectedRow = this.rows.isSelectedRow(rowIndex); +// const isOpenedRow = this.rows.isOpenedRow(rowIndex); +// if (this.hoverRowIndex === rowIndex) { +// rowBgColor = this.hoverRowColor; +// } +// if (isSelectedRow) { +// rowBgColor = this.selectedRowColor; +// } +// if (isOpenedRow) { +// rowBgColor = this.openedRowColor; +// } + +// this.ctx.fillStyle = rowBgColor; +// this.ctx.fillRect(0, rowy, canvwidth, this.rowheight); + +// // Row borders separating each row +// this.ctx.strokeStyle = '#eee'; +// this.ctx.beginPath(); +// this.ctx.moveTo(0, rowy); +// this.ctx.lineTo(canvwidth, rowy); +// this.ctx.stroke(); + +// let x = 0; +// for (let colindex = 0; colindex < this.columns.length; colindex++) { +// const col: CanvasTableColumn = this.columns[colindex]; +// let val: any = col.getValue(rowIndex); +// if (val === 'RETRY') { +// // retry later if value is null +// setTimeout(() => this.hasChanges = true, 2); +// val = ''; +// } +// let formattedVal: string; +// const formattedValueCacheKey: string = col.cacheKey + ':' + val; +// if (this.formattedValueCache[formattedValueCacheKey]) { +// formattedVal = this.formattedValueCache[formattedValueCacheKey]; +// } else if (('' + val).length > 0 && col.getFormattedValue) { +// formattedVal = col.getFormattedValue(val); +// this.formattedValueCache[formattedValueCacheKey] = formattedVal; +// } else { +// formattedVal = '' + val; +// this.formattedValueCache[formattedValueCacheKey] = formattedVal; +// } +// if (this.rowWrapMode && col.rowWrapModeHidden) { +// continue; +// } else if (this.rowWrapMode && col.rowWrapModeChipCounter && parseInt(val, 10) > 1) { +// this.ctx.save(); + +// this.ctx.strokeStyle = ''; + +// this.roundRect(this.ctx, +// canvwidth - 50, +// rowy + 9, +// 28, +// 15, 10, false); +// this.ctx.font = '10px ' + this.fontFamily; + +// this.ctx.strokeStyle = '#000'; +// if (isSelectedRow) { +// this.ctx.fillStyle = this.textColor; +// } else { +// this.ctx.fillStyle = this.textColor; +// } +// this.ctx.textAlign = 'center'; +// this.ctx.fillText(formattedVal + '', canvwidth - 36, rowy + halfrowheight - 15); + +// this.ctx.restore(); + +// continue; +// } else if (this.rowWrapMode && col.rowWrapModeChipCounter) { +// continue; +// } +// if (this.rowWrapMode && colindex === this.rowWrapModeWrapColumn) { +// x = 0; +// } + +// x += this.colpaddingleft; + +// if ((x - this.horizScroll + col.width) >= 0 && formattedVal.length > 0) { +// this.ctx.fillStyle = this.textColor; // Text color of unselected row +// if (isSelectedRow) { +// this.ctx.fillStyle = this.textColor; // Text color of selected row +// } + +// if (this.rowWrapMode) { +// // Wrap rows if in row wrap mode (for e.g. mobile portrait view) + +// // Check box +// const texty: number = rowy + halfrowheight; +// const textx: number = x - this.horizScroll; + +// const width = col.width - this.colpaddingright - this.colpaddingleft; + +// this.ctx.save(); +// this.ctx.beginPath(); +// this.ctx.moveTo(textx, rowy); +// this.ctx.lineTo(textx + width, rowy); +// this.ctx.lineTo(textx + width, rowy + this.rowheight); +// this.ctx.lineTo(textx, rowy + this.rowheight); +// this.ctx.closePath(); + +// if (col.checkbox) { +// const checkboxWidthHeight = 12; +// const checkboxCheckedPadding = 3; +// const checkboxLeftPadding = 4; +// this.ctx.strokeStyle = this.textColor; +// this.ctx.beginPath(); +// this.ctx.rect(checkboxLeftPadding + textx, texty - checkboxWidthHeight / 2, checkboxWidthHeight, checkboxWidthHeight); +// this.ctx.stroke(); +// if (val) { +// this.ctx.beginPath(); +// this.ctx.rect(checkboxLeftPadding + textx + checkboxCheckedPadding, +// checkboxCheckedPadding + texty - checkboxWidthHeight / 2, +// checkboxWidthHeight - checkboxCheckedPadding * 2, +// checkboxWidthHeight - checkboxCheckedPadding * 2); +// this.ctx.fill(); +// } +// } else { + +// // Other columns +// if (colindex >= this.rowWrapModeWrapColumn) { +// // Subject +// x += 30; // Increase padding before Subject +// this.ctx.save(); +// if (isBoldRow) { +// this.ctx.save(); +// this.ctx.font = 'bold ' + this.fontheight + 'px ' + this.fontFamilyBold; +// this.ctx.fillStyle = this.textColorLink; +// } else { +// this.ctx.save(); +// this.ctx.font = this.fontheight + 'px ' + this.fontFamily; +// this.ctx.fillStyle = this.textColorLink; +// } +// this.ctx.fillText(formattedVal, x, rowy + halfrowheight + 12 +// - (this.showContentTextPreview ? 12 : 0) +// ); +// this.ctx.restore(); +// } else if (col.rowWrapModeMuted) { +// // Date/time +// x = 42; // sufficiently away from the checkbox +// this.ctx.save(); +// this.ctx.font = this.fontheightSmaller + 'px ' + this.fontFamily; +// this.ctx.fillStyle = this.textColor; +// this.ctx.fillText(formattedVal, x, rowy + halfrowheight - 10 +// - (this.showContentTextPreview ? 8 : 0) +// ); +// this.ctx.restore(); +// } else { +// x = 128; // far enough to make the date above fit nicely +// this.ctx.font = this.fontheightSmall + 'px ' + this.fontFamily; +// this.ctx.fillText(formattedVal, x, rowy + halfrowheight - 10 +// - (this.showContentTextPreview ? 8 : 0)); +// this.ctx.fillStyle = this.textColorLink; +// } +// } +// this.ctx.restore(); +// } else if (x - this.horizScroll < canvwidth) { +// // Normal no-wrap mode + +// // Check box +// const texty: number = rowy + halfrowheight - (this.showContentTextPreview ? 10 : 0); +// let textx: number = x - this.horizScroll; + +// const width = col.width - this.colpaddingright - this.colpaddingleft; + +// this.ctx.save(); +// this.ctx.beginPath(); +// this.ctx.moveTo(textx, rowy); +// this.ctx.lineTo(textx + width, rowy); +// this.ctx.lineTo(textx + width, rowy + this.rowheight); +// this.ctx.lineTo(textx, rowy + this.rowheight); +// this.ctx.closePath(); + +// this.ctx.clip(); + +// if (col.checkbox) { +// const checkboxWidthHeight = 12; +// const checkboxCheckedPadding = 3; +// const checkboxLeftPadding = 4; +// this.ctx.strokeStyle = this.textColor; +// this.ctx.beginPath(); +// this.ctx.rect(checkboxLeftPadding + textx, texty - checkboxWidthHeight / 2, checkboxWidthHeight, checkboxWidthHeight); +// this.ctx.stroke(); +// if (val) { +// this.ctx.beginPath(); +// this.ctx.rect(checkboxLeftPadding + textx + checkboxCheckedPadding, +// checkboxCheckedPadding + texty - checkboxWidthHeight / 2, +// checkboxWidthHeight - checkboxCheckedPadding * 2, +// checkboxWidthHeight - checkboxCheckedPadding * 2); +// this.ctx.fill(); +// } +// } else { +// // Other columns +// if (col.textAlign === 1) { +// textx += width; +// this.ctx.textAlign = 'end'; +// } + +// if (col.font) { +// this.ctx.font = col.font; +// } +// if (colindex === 2 || colindex === 3) { +// // Column 2 is From, 3 is Subject +// this.ctx.fillStyle = this.textColorLink; +// if (isBoldRow) { +// this.ctx.font = 'bold ' + this.fontheight + 'px ' + this.fontFamilyBold; +// } +// } +// this.ctx.fillText(formattedVal, textx, texty); +// } +// this.ctx.restore(); +// } +// } + +// x += (Math.round(col.width * (this.rowWrapMode && col.rowWrapModeMuted ? +// (10 / this.fontheight) : 1)) - this.colpaddingleft); // We've already added colpaddingleft above +// } +// } else { +// // skipping rows we've removed while canvas was updating.... +// console.log('Skipped repainting a row as its data is missing, continuing anyway'); +// } +// if (this.showContentTextPreview) { +// const contentTextPreviewColumn = this.columns +// .find(col => col.getContentPreviewText ? true : false); +// if (contentTextPreviewColumn) { +// const contentPreviewText = contentTextPreviewColumn.getContentPreviewText(rowIndex); +// if (contentPreviewText) { +// this.ctx.save(); +// this.ctx.fillStyle = this.textColor; +// this.ctx.font = this.fontheightSmaller + 'px ' + this.fontFamily; +// const contentTextPreviewColumnPadding = this.rowWrapMode ? 2 : 10; // Increase left padding of content preview +// this.ctx.fillText(contentPreviewText, this.columns[0]. width + contentTextPreviewColumnPadding, +// rowy + halfrowheight + (this.rowWrapMode ? 18 : 15)); +// this.ctx.restore(); +// } +// } +// } + +// if (rowy > canvheight) { +// break; +// } +// this.ctx.fillStyle = this.textColor; + +// } + +// // Column separators + +// if (!this.rowWrapMode) { +// // No column separators in row wrap mode +// this.ctx.fillStyle = `rgba(166,166,166,${this.visibleColumnSeparatorAlpha})`; +// this.ctx.strokeStyle = `rgba(176,176,176,${this.visibleColumnSeparatorAlpha})`; + +// if (this.visibleColumnSeparatorAlpha < 1) { +// this.visibleColumnSeparatorAlpha += 0.01; +// setTimeout(() => this.hasChanges = true, 0); +// } + +// let x = 0; +// for (let colindex = 0; colindex < this.columns.length; colindex++) { +// if (colindex > 0 && this.visibleColumnSeparatorIndex === colindex) { +// // Only draw column separator near the mouse pointer +// this.ctx.beginPath(); +// this.ctx.moveTo(x - this.horizScroll, 0); +// this.ctx.lineTo(x - this.horizScroll, canvheight); +// this.ctx.stroke(); + +// this.ctx.fillRect(x - this.horizScroll - 5, this.lastClientY - 10, 10, 20); +// } +// x += this.columns[colindex].width; +// } +// } + +// // Scrollbar +// let scrollbarheight = (this.maxVisibleRows / this.rows.rowCount()) * canvheight; +// if (scrollbarheight < 20) { +// scrollbarheight = 20; +// } +// const scrollbarpos = +// (this.topindex / (this.rows.rowCount() - this.maxVisibleRows)) * (canvheight - scrollbarheight); + +// if (scrollbarheight < canvheight) { +// const scrollbarverticalpadding = 4; + +// const scrollbarx = canvwidth - this.scrollbarwidth; +// this.ctx.fillStyle = '#aaa'; +// this.ctx.fillRect(scrollbarx, 0, this.scrollbarwidth, canvheight); +// this.ctx.fillStyle = '#fff'; +// this.scrollBarRect = { +// x: scrollbarx + 1, +// y: scrollbarpos + scrollbarverticalpadding / 2, +// width: this.scrollbarwidth - 2, +// height: scrollbarheight - scrollbarverticalpadding +// }; + +// if (this.scrollbarDragInProgress) { +// this.ctx.fillStyle = 'rgba(200,200,255,0.5)'; +// this.roundRect(this.ctx, +// this.scrollBarRect.x - 4, +// this.scrollBarRect.y - 4, +// this.scrollBarRect.width + 8, +// this.scrollBarRect.height + 8, 5, true); + +// this.ctx.fillStyle = '#fff'; +// this.ctx.fillRect(this.scrollBarRect.x, +// this.scrollBarRect.y, +// this.scrollBarRect.width, +// this.scrollBarRect.height); +// } else { +// this.ctx.fillStyle = '#fff'; +// this.ctx.fillRect(this.scrollBarRect.x, this.scrollBarRect.y, this.scrollBarRect.width, this.scrollBarRect.height); +// } + +// } + +// } } @Component({ @@ -1369,7 +1367,7 @@ export class CanvasTableComponent implements AfterViewInit, DoCheck, OnInit { templateUrl: 'canvastablecontainer.component.html', styleUrls: ['canvastablecontainer.component.scss'] }) -export class CanvasTableContainerComponent implements OnInit { +export class CanvasTableContainerComponent { colResizeInitialClientX: number; colResizeColumnIndex: number; colResizePreviousWidth: number; @@ -1395,22 +1393,6 @@ export class CanvasTableContainerComponent implements OnInit { RowSelect = CanvasTable.RowSelect; private selectAllTimeout; - constructor(private renderer: Renderer2) { - // const oldSavedColumnWidths = localStorage.getItem('canvasNamedColumnWidths'); - // if (oldSavedColumnWidths) { - // const colWidthSet = Object.keys(JSON.parse(oldSavedColumnWidths)).filter((col) => col.length > 0).join(','); - // const newColWidths = {}; - // newColWidths[colWidthSet] = JSON.parse(oldSavedColumnWidths); - // localStorage.setItem('canvasNamedColumnWidthsBySet', JSON.stringify(newColWidths)); - // localStorage.removeItem('canvasNamedColumnWidths'); - // } - - // const savedColumnWidths = localStorage.getItem('canvasNamedColumnWidthsBySet'); - // if (savedColumnWidths) { - // this.columnWidths = JSON.parse(savedColumnWidths); - // } - } - saveColumnWidths() { const newColWidths = {}; const colWidthSet = this.canvastable.columns.map((col) => col.name).filter((cname) => cname.length > 0).join(','); @@ -1422,24 +1404,6 @@ export class CanvasTableContainerComponent implements OnInit { // localStorage.setItem('canvasNamedColumnWidthsBySet', JSON.stringify(this.columnWidths)); } - ngOnInit() { - this.renderer.listen('window', 'mousemove', (event: MouseEvent) => { - if (this.colResizeInitialClientX) { - event.preventDefault(); - event.stopPropagation(); - this.colresize(event.clientX); - } - }); - - this.renderer.listen('window', 'mouseup', (event: MouseEvent) => { - if (this.colResizeInitialClientX) { - event.preventDefault(); - event.stopPropagation(); - this.colresizeend(); - } - }); - } - colresizestart(clientX: number, colIndex: number) { if (colIndex > 0) { this.colResizeInitialClientX = clientX; diff --git a/src/app/common/human-bytes.ts b/src/app/common/human-bytes.ts new file mode 100644 index 000000000..5eabde644 --- /dev/null +++ b/src/app/common/human-bytes.ts @@ -0,0 +1,31 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +export default function humanBytes(value: number, decimalPlaces: number = 0): string { + if (value === 0) { + return '0 B'; + } + + const base = 1000; + const suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + const exponent = Math.floor(Math.log(value) / Math.log(base)); + + const result = (value / Math.pow(base, exponent)).toFixed(decimalPlaces); + return `${parseFloat(result)} ${suffixes[exponent]}`; +} diff --git a/src/app/common/messagedisplay.ts b/src/app/common/messagedisplay.ts index 57b7947f6..daf1dedea 100644 --- a/src/app/common/messagedisplay.ts +++ b/src/app/common/messagedisplay.ts @@ -203,4 +203,5 @@ export abstract class MessageDisplay { // columns abstract getCanvasTableColumns(app: any): CanvasTableColumn[]; + abstract getRowData(index: number, app: any): any; } diff --git a/src/app/common/messagelist.ts b/src/app/common/messagelist.ts index dd7e50249..b55fae1c5 100644 --- a/src/app/common/messagelist.ts +++ b/src/app/common/messagelist.ts @@ -157,4 +157,23 @@ export class MessageList extends MessageDisplay { return columns; } + + getRowData(rowIndex, app) { + const row = this.rows[rowIndex] + + return { + id: row.id, + seen: row.seenFlag, + messageDate: MessageTableRowTool.formatTimestamp(row.messageDate.toJSON()), + from: app.selectedFolder === 'Sent' + ? this.getToColumnValueForRow(rowIndex) + : this.getFromColumnValueForRow(rowIndex), + subject: row.subject, + size: row.size, + attachment: row.attachment , + answered: row.answeredFlag , + flagged: row.flaggedFlag , + plaintext: row.plaintext?.trim(), + }; + } } diff --git a/src/app/directives/resize-observer.directive.ts b/src/app/directives/resize-observer.directive.ts new file mode 100644 index 000000000..446f9a491 --- /dev/null +++ b/src/app/directives/resize-observer.directive.ts @@ -0,0 +1,62 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { Directive, ElementRef, EventEmitter, OnDestroy, Output } from '@angular/core'; + +@Directive({ + selector: '[appResizeObserver]', + standalone: true, +}) +export class ResizeObserverDirective implements OnDestroy { + @Output() resize = new EventEmitter(); + @Output() horizontalResize = new EventEmitter(); + @Output() verticalResize = new EventEmitter(); + + private observer: ResizeObserver; + private lastSize: { width: number; height: number } | null = null; + + constructor(private elementRef: ElementRef) { + this.observer = new ResizeObserver((entries) => { + const entry = entries[0]; + + if (!entry) return; + + const { width, height } = entry.contentRect; + + if (this.lastSize) { + if (this.lastSize.width !== width) { + this.horizontalResize.emit(entry); + } + + if (this.lastSize.height !== height) { + this.verticalResize.emit(entry); + } + } + + this.resize.emit(entry); + this.lastSize = { width, height }; + }); + + this.observer.observe(this.elementRef.nativeElement); + } + + ngOnDestroy(): void { + this.observer.disconnect(); + } +} diff --git a/src/app/follows-mouse/follows-mouse.component.html b/src/app/follows-mouse/follows-mouse.component.html new file mode 100644 index 000000000..e85571510 --- /dev/null +++ b/src/app/follows-mouse/follows-mouse.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/follows-mouse/follows-mouse.component.scss b/src/app/follows-mouse/follows-mouse.component.scss new file mode 100644 index 000000000..77645beaf --- /dev/null +++ b/src/app/follows-mouse/follows-mouse.component.scss @@ -0,0 +1,8 @@ +.follows-mouse { + /* Ensure the mouse can interact with underlying elements */ + pointer-events: none; + transition: transform 0.1s ease; + z-index: 10000; + display: inline-block; + white-space: nowrap; +} diff --git a/src/app/follows-mouse/follows-mouse.component.ts b/src/app/follows-mouse/follows-mouse.component.ts new file mode 100644 index 000000000..1a83b4507 --- /dev/null +++ b/src/app/follows-mouse/follows-mouse.component.ts @@ -0,0 +1,42 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { Component, ElementRef, HostListener } from '@angular/core'; + +@Component({ + selector: 'app-follows-mouse', + standalone: true, + templateUrl: './follows-mouse.component.html', + styleUrls: ['./follows-mouse.component.scss'], +}) +export class FollowsMouseComponent { + + constructor(private el: ElementRef) { + this.el.nativeElement.style.display = 'inline-block'; + this.el.nativeElement.style.position = 'fixed'; + this.el.nativeElement.style['z-index'] = '1000'; + } + + @HostListener('document:mousemove', ['$event']) + @HostListener('document:drag', ['$event']) + onMouseMove(event: MouseEvent) { + this.el.nativeElement.style.left = `${event.clientX + 4}px`; + this.el.nativeElement.style.top = `${event.clientY + 4}px`; + } +} diff --git a/src/app/human-bytes.pipe.ts b/src/app/human-bytes.pipe.ts new file mode 100644 index 000000000..0f8c9b853 --- /dev/null +++ b/src/app/human-bytes.pipe.ts @@ -0,0 +1,29 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { Pipe, PipeTransform } from '@angular/core'; +import humanBytes from './common/human-bytes' + +@Pipe({ + name: 'humanBytes', + standalone: true +}) +export class HumanBytesPipe implements PipeTransform { + public transform = humanBytes +} diff --git a/src/app/mailviewer/singlemailviewer.component.ts b/src/app/mailviewer/singlemailviewer.component.ts index b52f888d5..e3f8d3ed6 100644 --- a/src/app/mailviewer/singlemailviewer.component.ts +++ b/src/app/mailviewer/singlemailviewer.component.ts @@ -70,7 +70,7 @@ type Mail = any; templateUrl: 'singlemailviewer.component.html', styleUrls: ['singlemailviewer.component.scss'] }) -export class SingleMailViewerComponent implements OnInit, DoCheck, AfterViewInit { +export class SingleMailViewerComponent implements OnInit, AfterViewInit, DoCheck { _messageId = null; // Message id or filename diff --git a/src/app/messagetable/messagetablerow.ts b/src/app/messagetable/messagetablerow.ts index cb238da2a..9161f21c0 100644 --- a/src/app/messagetable/messagetablerow.ts +++ b/src/app/messagetable/messagetablerow.ts @@ -18,6 +18,7 @@ // ---------- END RUNBOX LICENSE ---------- const datelen: number = 'yyyy-MM-dd'.length; +import humanBytes from '../common/human-bytes' export class MessageTableRowTool { @@ -75,18 +76,7 @@ export class MessageTableRowTool { )); } - public static formatBytes(a, b?): string { - if (0 === a) { - return'0 B'; - } - - const c = 1e3, - d = b || 0, - e = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'], - f = Math.floor(Math.log(a) / Math.log(c)); - - return parseFloat((a / Math.pow(c, f)).toFixed(d)) + ' ' + e[f]; - } + public static formatBytes = humanBytes } export interface MessageTableRow { diff --git a/src/app/models/bindable-selection-model.ts b/src/app/models/bindable-selection-model.ts new file mode 100644 index 000000000..c7fe3a0ea --- /dev/null +++ b/src/app/models/bindable-selection-model.ts @@ -0,0 +1,44 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { SelectionModel } from '@angular/cdk/collections'; + +export class BindableSelectionModel { + selectionModel: SelectionModel; + + constructor( + multiple: boolean, + initialValues: T[] = [], + emitChanges: boolean = true, + compareWith: (a: T, b: T) => boolean = (a, b) => a === b, + ) { + this.selectionModel = new SelectionModel(multiple, initialValues, emitChanges, compareWith); + } + + // Getter for `selected` + get selected(): T | T[] { + return this.selectionModel.isMultipleSelection() ? this.selectionModel.selected : this.selectionModel.selected[0]; + } + + // Setter for `selected` + set selected(items: T | T[]) { + const selection = (this.selectionModel.isMultipleSelection() ? items : [items]) as T[]; + this.selectionModel.setSelection(...selection) + } +} diff --git a/src/app/help/help.component.spec.ts b/src/app/models/filter-selection-model.ts similarity index 53% rename from src/app/help/help.component.spec.ts rename to src/app/models/filter-selection-model.ts index 78805b537..f771112b6 100644 --- a/src/app/help/help.component.spec.ts +++ b/src/app/models/filter-selection-model.ts @@ -1,5 +1,5 @@ // --------- BEGIN RUNBOX LICENSE --------- -// Copyright (C) 2016-2021 Runbox Solutions AS (runbox.com). +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). // // This file is part of Runbox 7. // @@ -17,28 +17,22 @@ // along with Runbox 7. If not, see . // ---------- END RUNBOX LICENSE ---------- -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SelectionModel } from '@angular/cdk/collections'; -import { HelpComponent } from './help.component'; +export class FilterSelectionModel extends SelectionModel { + constructor(multiple: boolean, initialValues: T[], emitChanges: boolean, compareWith: (a: T, b: T) => boolean, predicate: (a) => boolean) { + super(multiple, initialValues, emitChanges, compareWith); -describe('HelpComponent', () => { - let component: HelpComponent; - let fixture: ComponentFixture; + return new Proxy(this, { + get(target, prop) { + if (prop === 'select') { + return (...items: T[]) => { + return target.select(...items.filter(predicate)); + }; + } - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [ HelpComponent ] - }) - .compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(HelpComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); + return target[prop]; + } + }); + } +} diff --git a/src/app/resizable-button/resizable-button.component.html b/src/app/resizable-button/resizable-button.component.html new file mode 100644 index 000000000..74a099e92 --- /dev/null +++ b/src/app/resizable-button/resizable-button.component.html @@ -0,0 +1,11 @@ + diff --git a/src/app/resizable-button/resizable-button.component.scss b/src/app/resizable-button/resizable-button.component.scss new file mode 100644 index 000000000..c88828c67 --- /dev/null +++ b/src/app/resizable-button/resizable-button.component.scss @@ -0,0 +1,25 @@ +button { + --fg: #333; + border-left: 1px solid var(--fg); + border-right: 1px solid var(--fg); + border-top: none; + border-bottom: none; + position: absolute; + top: 0; + bottom: 0; + cursor: col-resize; + right: 0; + width: 0.5rem; + border-radius: unset; + padding: 0; + margin: 0; + display: block; + transition: opacity 0.1s ease; + opacity: 0; + background: none; +} + +button:hover, button:focus, button.resizing { + opacity: 1; +} + diff --git a/src/app/resizable-button/resizable-button.component.ts b/src/app/resizable-button/resizable-button.component.ts new file mode 100644 index 000000000..184835223 --- /dev/null +++ b/src/app/resizable-button/resizable-button.component.ts @@ -0,0 +1,148 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { Component, ElementRef, EventEmitter, Output, Input, HostListener, OnChanges } from '@angular/core'; +import { Subject } from 'rxjs'; +import { take } from 'rxjs/operators'; + +const userResize = new Subject() + +@Component({ + selector: 'app-resizable-button', + templateUrl: './resizable-button.component.html', + styleUrls: ['./resizable-button.component.scss'], + standalone: true, +}) +export class ResizableButtonComponent implements OnChanges { + + @Input() width: number; + @Output() widthChange = new EventEmitter(); + + isResizing = false; + private startX: number = 0; + private startWidth: number = 0; + + // Hold the reference to the event listeners + private onMouseMoveListener: (event: MouseEvent) => void; + private onMouseUpListener: () => void; + + constructor(private elementRef: ElementRef) { + // Only set absolute value when the user does a resize. + userResize.pipe(take(1)).subscribe(() => { + this.setAbsoluteWidth(); + }); + } + + ngOnChanges(changes) { + if (changes.width?.currentValue == null) { + this.resetWidth(); + } + } + + get parentElement() { + return this.elementRef.nativeElement.parentElement; + } + + setAbsoluteWidth() { + setTimeout(() => { + if (!this.parentElement) return + + this.changeWidth(this.parentElement.offsetWidth); + }, 0) + } + + resetWidth() { + this.parentElement.style.removeProperty('width'); + this.setAbsoluteWidth() + } + + onMouseDown(event: MouseEvent): void { + this.isResizing = true; + this.startX = event.clientX; + const parentElement = this.parentElement; + if (parentElement) { + this.startWidth = parentElement.offsetWidth; + } + + // Define the mouse move and up handlers + this.onMouseMoveListener = this.onMouseMove.bind(this); + this.onMouseUpListener = this.onMouseUp.bind(this); + + // Add the mousemove and mouseup event listeners to the document + document.addEventListener('mousemove', this.onMouseMoveListener); + document.addEventListener('mouseup', this.onMouseUpListener); + + // Prevent text selection during resizing + event.preventDefault(); + } + + private onMouseMove(event: MouseEvent): void { + if (!this.isResizing) return; + + const parentElement = this.parentElement; + if (parentElement) { + const diff = event.clientX - this.startX; + const newWidth = this.startWidth + diff; + this.changeWidth(newWidth); + } + } + + private onMouseUp(): void { + this.isResizing = false; + this.removeMouseListeners(); + } + + @HostListener('document:keydown', ['$event']) + onKeyDown(event: KeyboardEvent): void { + if (!this.elementRef.nativeElement.contains(document.activeElement)) { + return; + } + + const parentElement = this.elementRef.nativeElement.parentElement; + if (!parentElement) return; + + const step = 10; // Resize step for each key press + const currentWidth = parentElement.offsetWidth; + + if (event.key === 'ArrowRight') { + this.changeWidth(currentWidth + step); + } else if (event.key === 'ArrowLeft') { + this.changeWidth(currentWidth - step); + } + } + + changeWidth(pixels: number) { + this.widthChange.emit(pixels) + userResize.next(pixels) + } + + @HostListener('window:blur') + @HostListener('window:focus') + onWindowFocus(): void { + if (this.isResizing) { + this.isResizing = false; // Stop resizing immediately + this.removeMouseListeners(); // Remove listeners if resizing was interrupted + } + } + + private removeMouseListeners(): void { + document.removeEventListener('mousemove', this.onMouseMoveListener); + document.removeEventListener('mouseup', this.onMouseUpListener); + } +} diff --git a/src/app/rmmapi/messagelist.service.ts b/src/app/rmmapi/messagelist.service.ts index ce8d08da4..a76eae003 100644 --- a/src/app/rmmapi/messagelist.service.ts +++ b/src/app/rmmapi/messagelist.service.ts @@ -1,18 +1,18 @@ // --------- BEGIN RUNBOX LICENSE --------- // Copyright (C) 2016-2018 Runbox Solutions AS (runbox.com). -// +// // This file is part of Runbox 7. -// +// // Runbox 7 is free software: You can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the // Free Software Foundation, either version 3 of the License, or (at your // option) any later version. -// +// // Runbox 7 is distributed in the hope that it will be useful, but // WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU // General Public License for more details. -// +// // You should have received a copy of the GNU General Public License // along with Runbox 7. If not, see . // ---------- END RUNBOX LICENSE ---------- @@ -61,6 +61,7 @@ export class MessageListService { staleFolders: { [name: string]: boolean } = {}; trashFolderName = 'Trash'; + sentFolderName = 'Sent'; spamFolderName = 'Spam'; unindexedFolders = ['Trash', 'Spam', 'Templates']; templateFolderName = 'Templates'; diff --git a/src/app/sort-button/sort-button.component.ts b/src/app/sort-button/sort-button.component.ts new file mode 100644 index 000000000..63478a547 --- /dev/null +++ b/src/app/sort-button/sort-button.component.ts @@ -0,0 +1,135 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; + +export enum Direction { + Ascending = 'ASC', + Descending = 'DESC', + None = 'NONE' +} + +export interface OrderEvent { + data: any; + direction: Direction; +} + +@Component({ + standalone: true, + imports: [CommonModule, MatIconModule], + selector: 'app-sort-button', + template: ` + + `, + styles: [ + ` + .sort-button { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + background: none; + border: none; + font-weight: inherit; + padding-left: 0; + } + + .sort-button[disabled] { + color: black; + } + + .sort-button:hover { + text-decoration: underline; + } + + .sort-button[disabled]:hover { + cursor: not-allowed; + text-decoration: none; + } + `, + ], +}) +export class SortButtonComponent { + @Input() order: OrderEvent = { data: Symbol('init'), direction: Direction.None }; + @Input() data: any; + @Input() disabled?:any; + + @Output() orderChange = new EventEmitter(); + + readonly Direction = Direction; + + private readonly directionCycle = new Map([ + [Direction.Ascending, Direction.Descending], + [Direction.Descending, Direction.Ascending], + ]); + + private readonly hrDirectionTr = new Map([ + [Direction.Ascending, 'ascending'], + [Direction.Descending, 'descending'], + [Direction.None, 'no particular'], + ]) + + private readonly directionIconMap = new Map([ + [Direction.Ascending, 'arrow_downward'], + [Direction.Descending, 'arrow_upward'], + [Direction.None, 'empty'], + ]); + + // Optional helper getter if you want cleaner template usage + get isDisabled(): boolean { + return this.disabled !== undefined && this.disabled !== false; + } + + get directionIcon() { + return (this.data === this.order?.data) + ? this.directionIconMap.get(this.order?.direction) + : this.directionIconMap.get(Direction.None); + } + + get hrDirection() { + return this.hrDirectionTr.get(this.order?.direction) + } + + onClick(): void { + // Set direction to Ascending when switching columns. + const direction = (this.order?.data !== this.data) + ? Direction.Descending + : this.directionCycle.get(this.order?.direction) ?? Direction.Ascending + + this.orderChange.emit({ + data: this.data, + direction, + }); + } +} diff --git a/src/app/virtual-scroll-table/virtual-scroll-table.component.html b/src/app/virtual-scroll-table/virtual-scroll-table.component.html new file mode 100644 index 000000000..3a1e3b5bc --- /dev/null +++ b/src/app/virtual-scroll-table/virtual-scroll-table.component.html @@ -0,0 +1,14 @@ + + +
+ + diff --git a/src/app/virtual-scroll-table/virtual-scroll-table.component.scss b/src/app/virtual-scroll-table/virtual-scroll-table.component.scss new file mode 100644 index 000000000..a7591a11d --- /dev/null +++ b/src/app/virtual-scroll-table/virtual-scroll-table.component.scss @@ -0,0 +1,22 @@ +table { + border-collapse: collapse; /* Removes space between table cells */ + border-spacing: 0; /* Ensures no extra spacing between cells */ + max-width: 100%; + table-layout: fixed; + user-select: none; + width: 100%; + box-shadow: unset; +} + +cdk-virtual-scroll-viewport { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + + ::ng-deep & table thead { + visibilty: hidden; + opacity: 0; + } +} diff --git a/src/app/virtual-scroll-table/virtual-scroll-table.component.ts b/src/app/virtual-scroll-table/virtual-scroll-table.component.ts new file mode 100644 index 000000000..a7e22a8f4 --- /dev/null +++ b/src/app/virtual-scroll-table/virtual-scroll-table.component.ts @@ -0,0 +1,140 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2025 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { + OnDestroy, + ChangeDetectionStrategy, + AfterViewInit, + Component, + ContentChild, + ElementRef, + EventEmitter, + Input, + Output, + TemplateRef, + ViewChild, +} from '@angular/core'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { CommonModule } from '@angular/common'; +import { ScrollingModule, CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; +import { ListRange } from '@angular/cdk/collections'; +import { Subject, Subscription } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; + +@Component({ + selector: 'app-virtual-scroll-table', + standalone: true, + imports: [ScrollingModule, CommonModule, MatCheckboxModule], + templateUrl: './virtual-scroll-table.component.html', + styleUrls: ['./virtual-scroll-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class VirtualScrollTableComponent implements OnDestroy, AfterViewInit { + @ContentChild('tbody', { read: TemplateRef }) tbodyTemplate!: TemplateRef | null; + @ContentChild('thead', { read: TemplateRef }) theadTemplate!: TemplateRef | null; + + @ViewChild(CdkVirtualScrollViewport) viewport: CdkVirtualScrollViewport; + + @Output() renderedRangeChange = new EventEmitter(); + @Input() items: any[] = []; + + + @Input() + set scrollToIndex(index: number) { + this.pendingScrollToIndex = index; + this.inputChanges$.next() + } + + firstRowHeight: number = 50; + maxBufferPx: number; + + private renderedRangeSub!: Subscription; + private inputChangesSub!: Subscription; + private inputChanges$ = new Subject(); + + private mutationObserver?: MutationObserver; + private pendingScrollToIndex: number | null = null; + + constructor(private elementRef: ElementRef) {} + + ngAfterViewInit() { + this.renderedRangeSub = this.viewport.renderedRangeStream + .pipe(debounceTime(50)) + .subscribe(range => { + this.renderedRangeChange.emit(range); + }); + + this.inputChangesSub = this.inputChanges$ + .pipe(debounceTime(500)) + .subscribe(() => { + this.updateFirstRowHeight(); + + this.doScrollToIndex(this.pendingScrollToIndex); + }); + + const elem = this.elementRef.nativeElement; + + this.mutationObserver = new MutationObserver((mutations) => { + this.inputChanges$.next(); + }); + + this.mutationObserver.observe(elem, { + childList: true, + subtree: true, + attributes: false + }); + } + + ngOnDestroy(): void { + this.renderedRangeSub.unsubscribe(); + this.inputChangesSub.unsubscribe(); + this.mutationObserver.disconnect(); + } + + trackBy(index: number) { + return index; + } + + doScrollToIndex(index: number, retries: number = 5, delayMs: number = 500): void { + if (!this.viewport || index == null) return; + if (this.pendingScrollToIndex == null) return + + const scrollPosBefore = this.viewport.measureScrollOffset(); + + this.viewport.scrollToIndex(index, 'smooth'); + + setTimeout(() => { + const scrollPosAfter = this.viewport.measureScrollOffset(); + + const isStable = Math.abs(scrollPosAfter - scrollPosBefore) < 1; + + if (!isStable && retries > 0) { + this.doScrollToIndex(index, retries - 1, delayMs); + } else { + this.pendingScrollToIndex = null; + } + }, delayMs); + } + + private updateFirstRowHeight(): void { + const elem = this.elementRef.nativeElement.querySelector('tbody'); + + this.firstRowHeight = elem?.offsetHeight || this.firstRowHeight; + } +} diff --git a/src/app/websocketsearch/websocketsearchmaillist.ts b/src/app/websocketsearch/websocketsearchmaillist.ts index 61ae6e6d5..3802a4e3e 100644 --- a/src/app/websocketsearch/websocketsearchmaillist.ts +++ b/src/app/websocketsearch/websocketsearchmaillist.ts @@ -98,4 +98,15 @@ export class WebSocketSearchMailList extends MessageDisplay { return columns; } + getRowData(rowIndex, app) { + return { + id: this.getRowMessageId(rowIndex), + selectbox: this.isSelectedRow(rowIndex), + messageDate: this.getRow(rowIndex).dateTime, + from: this.getRow(rowIndex).fromName, + subject: this.getRow(rowIndex).subject, + size: this.getRow(rowIndex).size, + }; + } + } diff --git a/src/app/xapian/searchmessagedisplay.ts b/src/app/xapian/searchmessagedisplay.ts index 8bd686cb7..3e571bbe2 100644 --- a/src/app/xapian/searchmessagedisplay.ts +++ b/src/app/xapian/searchmessagedisplay.ts @@ -32,7 +32,7 @@ export class SearchMessageDisplay extends MessageDisplay { } getRowSeen(index: number): boolean { - return this.searchService.getDocData(this.rows[index][0]).seen ? false : true; + return this.searchService.getDocData(this.getRowId(index)).seen; } getRowId(index: number): number { @@ -42,11 +42,11 @@ export class SearchMessageDisplay extends MessageDisplay { getRowMessageId(index: number): number { let msgId = 0; try { - msgId = this.searchService.getMessageIdFromDocId(this.rows[index][0]); + msgId = this.searchService.getMessageIdFromDocId(this.getRowId(index)); } catch (e) { // This shouldnt happen, it means something changed the stored // data without updating the messagedisplay rows. - console.log('Tried to lookup ' + index + ' in searchIndex, isnt there! ' + e); + console.error('Tried to lookup ' + index + ' in searchIndex, isnt there! ', e); } return msgId; } @@ -215,4 +215,44 @@ export class SearchMessageDisplay extends MessageDisplay { } return columns; } + + public getRowData(index: number, app: any) { + const rowData: any = { + id: this.getRowMessageId(index), + messageDate: MessageTableRowTool.formatTimestampFromStringWithoutSeparators(this.searchService.api.getStringValue(this.getRowId(index), 2)), + from: app.selectedFolder.indexOf('Sent') === 0 && !app.displayFolderColumn + ? this.searchService.getDocData(this.getRowId(index)).recipients.join(', ') + : this.searchService.getDocData(this.getRowId(index)).from, + subject: this.searchService.getDocData(this.getRowId(index)).subject, + plaintext: this.searchService.getDocData(this.getRowId(index)).textcontent?.trim(), + size: this.searchService.api.getNumericValue(this.getRowId(index), 3), + attachment: this.searchService.getDocData(this.getRowId(index)).attachment ? true : false, + answered: this.searchService.getDocData(this.getRowId(index)).answered ? true : false, + flagged: this.searchService.getDocData(this.getRowId(index)).flagged ? true : false, + folder: this.searchService.getDocData(this.getRowId(index)).folder, + seen: this.searchService.getDocData(this.getRowId(index)).seen, + }; + + if (app.viewmode === 'conversations') { + const rowObj = this.getRow(index); + + const conversationId = this.searchService.api.getStringValue(rowObj[0], 1); + this.searchService.api.setStringValueRange(1, 'conversation:'); + const conversationSearchText = `conversation:${conversationId}..${conversationId}`; + const results = this.searchService.api.sortedXapianQuery( + conversationSearchText, + 1, 0, 0, 1000, 1 + ); + this.searchService.api.clearValueRange(); + + if (results[0]?.[1]) { + rowObj[2] = `${results[0][1] + 1}`; + rowData.count = rowObj[2]; + } else { + rowData.count = 1 + } + } + + return rowData; + } } diff --git a/src/app/xapian/searchservice.ts b/src/app/xapian/searchservice.ts index efe5c9420..7b3fa310c 100644 --- a/src/app/xapian/searchservice.ts +++ b/src/app/xapian/searchservice.ts @@ -22,7 +22,7 @@ import { HttpClient, HttpRequest, HttpResponse, HttpEventType } from '@angular/c import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog'; import { MatLegacySnackBar as MatSnackBar, MatLegacySnackBarRef as MatSnackBarRef } from '@angular/material/legacy-snack-bar'; -import { Observable, AsyncSubject, Subject, of, from } from 'rxjs'; +import { Observable, AsyncSubject, Subject, of, from, firstValueFrom } from 'rxjs'; import { mergeMap, map, filter, catchError, tap, take, bufferCount, distinctUntilChanged } from 'rxjs/operators'; import { XapianAPI } from '@runboxcom/runbox-searchindex'; @@ -862,7 +862,6 @@ export class SearchService { if (this.messageTextCache.has(rmmMessageId)) { this.currentDocData.textcontent = this.messageTextCache.get(rmmMessageId); } - this.updateMessageText(rmmMessageId); try { this.api.documentXTermList(docid); @@ -911,31 +910,28 @@ export class SearchService { // fetch message contents, we actually only want the "text.text" part here // then we can use it for previews and search, both with/without local index // skip haschanges/updates if we already saw this one .. - public updateMessageText(messageId: number): boolean { - if (!this.messageTextCache.has(messageId)) { - this.rmmapi.getMessageContents(messageId).subscribe((content) => { - if (content['status'] === 'success') { - this.messageTextCache.set(messageId, content.text.text); - if (this.messagelistservice.messagesById[messageId]) { - this.messagelistservice.messagesById[messageId].plaintext = content.text.text; - } - } else { - if (content.hasOwnProperty('errors')) { - // this is an error restapi generated - console.error(`DataError in updateMessageText ${messageId}`, content['errors']); - } - // even if we dont know where it came from, still dont retry - // it this session - this.messageTextCache.set(messageId, ''); - } - }, - (err) => { - console.error(`HTTPError in updateMessageText ${messageId}`, err); - // stop repeatedly looking up broken ones - this.messageTextCache.set(messageId, ''); - }); - return true; + async messageText(messageId: number): Promise { + const cached = this.messageTextCache.get(messageId); + if (cached !== undefined) return cached; + + let text = ''; + + try { + const content = await firstValueFrom(this.rmmapi.getMessageContents(messageId)); + if (content['status'] === 'success') { + text = content.text.text; + } else if ('errors' in content) { + console.error(`DataError in messageText ${messageId}`, content.errors); + } + } catch (err) { + console.error(`HTTPError in messageText ${messageId}`, err); } - return false; + + this.messageTextCache.set(messageId, text); + + const message = this.messagelistservice.messagesById[messageId]; + if (message) message.plaintext = text; + + return text; } } diff --git a/src/assets/Avenir-Next-LT-Pro-Demi.otf b/src/assets/Avenir-Next-LT-Pro-Demi.otf new file mode 100644 index 0000000000000000000000000000000000000000..0edab0f57dc3d1b701a5f74ba9ffbaf95b736791 GIT binary patch literal 69172 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%0DQ@m@+TjhnVvDWid*P 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 literal 0 HcmV?d00001 diff --git a/src/styles.scss b/src/styles.scss index 6e6d63eb2..7b520382a 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -32,7 +32,7 @@ $rmm-darker-background: #01001c; $rmm-gray: #dddddd; $rmm-gray-light: #eeeeee; $rmm-gray-lighter: #f3f3f3; - + $rmm-default-theme: mat.define-light-theme($rmm-default-primary, $rmm-default-accent, $rmm-default-warn); $rmm-default-lighter-gray: #eeeeee; @@ -60,12 +60,22 @@ $rmm-default-black: #444444; font-weight: normal; } +@font-face { + font-family: "Avenir Next Pro Demi"; + src: url("assets/Avenir-Next-LT-Pro-Demi.otf"); + font-style: normal; + font-weight: normal; +} + // GTA 13.06.2018: Override default fonts as per https://material.angular.io/guide/typography $custom-typography: mat.define-legacy-typography-config( $font-family: '"Avenir Next Pro Regular", "Helvetica Neue", sans-serif' ); +$font-family: "Avenir Next Pro Regular", "Helvetica Neue", sans-serif; +$font-family-bold: "Avenir Next Pro Demi", "Helvetica Neue", sans-serif; + // TODO(v15): As of v15 mat.legacy-core no longer includes default typography styles. // The following line adds: // 1. Default typography styles for all components @@ -87,7 +97,7 @@ html { body { margin: 0; height: 100%; - font-family: "Avenir Next Pro Regular", "Helvetica Neue", sans-serif; + font-family: $font-family; overscroll-behavior: contain; } @@ -159,7 +169,7 @@ a[mat-list-item] .mat-list-item-content { min-height: 24px !important; } -.mat-list[dense] .mat-list-item .mat-list-text, +.mat-list[dense] .mat-list-item .mat-list-text, .mat-nav-list[dense] .mat-list-item .mat-list-text>*, mat-list-item .mat-list-text, a[mat-list-item] .mat-list-text { @@ -313,21 +323,21 @@ mat-grid-tile.tableTitle { height: 16px; width: 42px; } - + .mat-slide-toggle.mat-checked .mat-slide-toggle-thumb-container { top: -5px; transform: translate3d(20px, 0, 0); } - + .mat-slide-toggle.mat-checked .mat-slide-toggle-thumb { height: 24px; width: 24px; } - + .mat-slide-toggle-label { font-size: 16px; } - + .mat-slide-toggle-content { margin-left: 2px; } @@ -337,6 +347,10 @@ mat-grid-tile.tableTitle { font-size: 12px; } +.text-primary { + color: mat.get-color-from-palette($rmm-default-primary); +} + .warning { color: mat.get-color-from-palette($rmm-default-warn); font-weight: bold; @@ -384,7 +398,7 @@ mat-grid-tile.tableTitle { color: #949494 !important; } .themePaletteBlack { - color: #444444 !important; + color: $rmm-default-black !important; } /*** App-specific styles ***/ @@ -394,12 +408,12 @@ mat-grid-tile.tableTitle { /*** Main ***/ #main { - position: fixed; - top: 0px; - bottom: 0px; - left: 0px; - right: 0px; - width: 100%; + position: fixed; + top: 0px; + bottom: 0px; + left: 0px; + right: 0px; + width: 100%; height: 100%; min-height: 100%; display: flex; @@ -439,7 +453,7 @@ mat-grid-tile.tableTitle { display: none; margin: 0; } - + #logo { margin: 0; width: 300px; @@ -518,7 +532,7 @@ div.loginScreen { mat-form-field { width: 200px; } - } + } #loginOptions { display: flex; margin: 0.5em; @@ -724,8 +738,8 @@ rmm-headertoolbar { /* Sidenav pane */ mat-sidenav-container { - position: absolute !important; - bottom: 0px !important; + position: absolute !important; + bottom: 0px !important; left: 0px !important; right: 0px !important; width: 100% !important; @@ -782,7 +796,7 @@ mat-sidenav-container { .mat-mini-fab .mat-button-wrapper { line-height: 18px; } - + a { width: 30%; } @@ -914,12 +928,12 @@ rmm-folderlist { } .folderName { - color: #444; + color: $rmm-default-black; } .folderNameUnread { - font-family: "Avenir Next Pro Medium"; - color: #444; + font-family: "Avenir Next Pro Demi", "Helvetica Neue", sans-serif; + color: $rmm-default-black; font-weight: bold !important; } @@ -936,7 +950,7 @@ rmm-folderlist { } .foldersidebarcount { - font-size: 10px; + font-size: 10px; } .draftsFolder { @@ -982,7 +996,7 @@ app-saved-searches .mat-list-base[dense] .mat-list-item { flex-grow: 1; overflow: hidden; } - + .messageListActionButtonsRight button { width: 30px; // Remember to also update TOOLBAR_LIST_BUTTON_WIDTH in app.component.ts in order to show the correct number of menu items } @@ -1008,7 +1022,7 @@ app-saved-searches .mat-list-base[dense] .mat-list-item { button { margin-right: 10px; - + @media(max-width: 540px) { margin-right: 2px; height: 30px; @@ -1022,7 +1036,7 @@ app-saved-searches .mat-list-base[dense] .mat-list-item { #offerLocalIndex .mat-list-item-content { padding: 0 5px; -} +} #searchField { flex-grow: 10; @@ -1097,7 +1111,7 @@ app-saved-searches .mat-list-base[dense] .mat-list-item { .mat-icon, .mat-icon-button { color: mat.get-color-from-palette($rmm-default-primary); } - + @media (max-width: 540px) { #threadedCheckbox { display: none; @@ -1112,7 +1126,7 @@ app-saved-searches .mat-list-base[dense] .mat-list-item { margin-top: 20px; flex-grow: 1; text-align: center; - font-family: "Avenir Next Pro Regular ", "Helvetica Neue", sans-serif; + font-family: $font-family; font-weight: 400; font-size: 13px; color: #949494; @@ -1125,6 +1139,25 @@ app-saved-searches .mat-list-base[dense] .mat-list-item { right: 0px; } +#canvasTableContainerArea { + + tbody { + border-bottom: 1px solid $rmm-gray-light; + } + tbody:hover { + background-color: $rmm-gray; + } + tbody.selected { + background-color: $rmm-gray-light; + } + tbody.checked { + background-color: mat.get-color-from-palette($rmm-default-highlight); + } + .mat-checkbox-checked.mat-accent .mat-checkbox-background { + background-color: mat.get-color-from-palette($rmm-default-primary); + } +} + .mat-fab.mat-accent { color: mat.get-color-from-palette($rmm-default-primary); } @@ -1172,7 +1205,7 @@ app-saved-searches .mat-list-base[dense] .mat-list-item { .mat-radio-label-content { padding: 0 !important; } - + button, .mat-radio-button, .mat-checkbox { margin-left: 5px; } @@ -1195,7 +1228,7 @@ app-saved-searches .mat-list-base[dense] .mat-list-item { .mat-icon { margin: 0 3px !important; } - + mat-flat-button, .mat-flat-button, mat-raised-button, .mat-raised-button { min-width: 30px !important; width: 30px !important; @@ -1307,7 +1340,7 @@ compose #fieldFrom .mat-form-field-wrapper, compose .fieldRecipient .mat-form-fi .recipientSuggestionContainer { max-height: 90px; overflow: auto; - + span { font-size: 12.5px; } @@ -1347,7 +1380,7 @@ compose #fieldFrom .mat-form-field-wrapper, compose .fieldRecipient .mat-form-fi .mat-nav-list[dense], .mat-list-item, .mat-list-text, .mat-form-field { height: 48px !important; } -} +} .contactList .mat-form-field-infix { font-size: 16px; @@ -1370,7 +1403,7 @@ compose #fieldFrom .mat-form-field-wrapper, compose .fieldRecipient .mat-form-fi .mat-form-field-appearance-legacy .mat-form-field-infix { padding-top: 0 !important; } - + .mat-form-field-appearance-legacy .mat-form-field-label { top: 0.75em; } @@ -1541,7 +1574,7 @@ app-calendar-event-editor-dialog p { .productGrid mat-card.recommended { border: 1px solid mat.get-color-from-palette($rmm-default-primary); -} +} #pricePlans td { /* border-right: 1px solid $rmm-dark-background !important; */ @@ -1591,7 +1624,7 @@ app-calendar-event-editor-dialog p { color: #0F0; } -.dev.runbox-components .nice_green_timer .timeunit { +.dev.runbox-components .nice_green_timer .timeunit { border: 1px solid #0F0; width: 40px; height: 40px; @@ -1606,22 +1639,22 @@ app-calendar-event-editor-dialog p { .dev.runbox-components .nice_green_timer .timeunit.hours { color: #0f0; } -.dev.runbox-components .nice_green_timer .timeunit.years::after { +.dev.runbox-components .nice_green_timer .timeunit.years::after { content: "y"; } -.dev.runbox-components .nice_green_timer .timeunit.months::after { +.dev.runbox-components .nice_green_timer .timeunit.months::after { content: "m"; } -.dev.runbox-components .nice_green_timer .timeunit.days::after { +.dev.runbox-components .nice_green_timer .timeunit.days::after { content: "d"; } -.dev.runbox-components .nice_green_timer .timeunit.hours::after { +.dev.runbox-components .nice_green_timer .timeunit.hours::after { content: "h"; } -.dev.runbox-components .nice_green_timer .timeunit.minutes::after { +.dev.runbox-components .nice_green_timer .timeunit.minutes::after { content: "m"; } -.dev.runbox-components .nice_green_timer .timeunit.seconds::after { +.dev.runbox-components .nice_green_timer .timeunit.seconds::after { content: "s"; } @@ -1637,22 +1670,22 @@ app-calendar-event-editor-dialog p { align-items: center; } -.dev.runbox-components .nice_blue_timer .timeunit.years::after { +.dev.runbox-components .nice_blue_timer .timeunit.years::after { content: " years"; } -.dev.runbox-components .nice_blue_timer .timeunit.months::after { +.dev.runbox-components .nice_blue_timer .timeunit.months::after { content: " months"; } -.dev.runbox-components .nice_blue_timer .timeunit.days::after { +.dev.runbox-components .nice_blue_timer .timeunit.days::after { content: " days"; } -.dev.runbox-components .nice_blue_timer .timeunit.hours::after { +.dev.runbox-components .nice_blue_timer .timeunit.hours::after { content: " hours"; } -.dev.runbox-components .nice_blue_timer .timeunit.minutes::after { +.dev.runbox-components .nice_blue_timer .timeunit.minutes::after { content: " mins"; } -.dev.runbox-components .nice_blue_timer .timeunit.seconds::after { +.dev.runbox-components .nice_blue_timer .timeunit.seconds::after { content: " secs"; } @@ -1740,7 +1773,7 @@ td.mat-cell.cdk-column-renewal_name.mat-column-renewal_name { table.renewalsTable td, table.paymentsTable td { padding: 5px 10px 0px 10px !important; -} +} table.detailsTable { width: 100%; @@ -1750,3 +1783,42 @@ table.detailsTable tr td:nth-of-type(2) { display: flex; justify-content: flex-end; } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + clip: rect(0, 0, 0, 0); + overflow: hidden; +} + +.bold { + font-family: $font-family-bold; + font-weight: bold; +} + +.text-center { + text-align: center; +} + +/* Transition causes column width calculations to glitch. */ +.mat-drawer-transition .mat-drawer-content { + transition: none !important; +} + +.skeleton-bone { + content: ' '; + display: inline-block; + width: 100%; + /* Adjust based on the required placeholder size */ + height: 1em; + /* Adjust for height */ + background: linear-gradient(90deg, #e0e0e0 25%, #f8f8f8 50%, #e0e0e0 75%); + background-size: 200% 100%; + animation: skeleton-loading 1.5s infinite; + border-radius: 4px; + /* Optional for rounded edges */ +} From 9fec25e4a01b299e2e7e4cbd96ce91cb6de1d8a7 Mon Sep 17 00:00:00 2001 From: shadowbas Date: Wed, 18 Jun 2025 14:57:32 +0300 Subject: [PATCH 02/30] fixup! feat(message-list): Implement virtual scroll table --- src/app/canvastable/canvastable.ts | 678 ----------------------------- 1 file changed, 678 deletions(-) diff --git a/src/app/canvastable/canvastable.ts b/src/app/canvastable/canvastable.ts index a0ac1b04a..0c5d7f21b 100644 --- a/src/app/canvastable/canvastable.ts +++ b/src/app/canvastable/canvastable.ts @@ -109,14 +109,7 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { private canv: HTMLCanvasElement; - // private ctx: CanvasRenderingContext2D; - // private wantedCanvasWidth = 300; - // private wantedCanvasHeight = 300; - private _rowheight = 28; - // private fontheight = 14; - // private fontheightSmall = 13; - // private fontheightSmaller = 12; private scrollbarwidth = 12; @@ -203,8 +196,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { public hasChanges: boolean; - // private formattedValueCache: { [key: string]: string; } = {}; - public scrollLimitHit: BehaviorSubject = new BehaviorSubject(0); public floatingTooltip: FloatingTooltip; @@ -220,20 +211,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { constructor(elementRef: ElementRef) { } - // No need to track changes. - // ngDoCheck() { - // if (this.canv) { - - // const devicePixelRatio = window.devicePixelRatio ? window.devicePixelRatio : 1; - // this.wantedCanvasWidth = this.canv.parentElement.parentElement.clientWidth * devicePixelRatio; - // this.wantedCanvasHeight = this.canv.parentElement.parentElement.clientHeight * devicePixelRatio; - - // if (this.canv.width !== this.wantedCanvasWidth || this.canv.height !== this.wantedCanvasHeight) { - // this.hasChanges = true; - // } - // } - // } - private calculateColumnWidths(columns: CanvasTableColumn[]) { const colWidthSet = columns.map((col) => col.name).filter((cname) => cname.length > 0).join(','); for (const c of columns) { @@ -250,7 +227,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { ngAfterViewInit() { this.canv = this.canvRef.nativeElement; - // this.ctx = this.canv.getContext('2d'); this.canv.onwheel = (event: WheelEvent) => { event.preventDefault(); @@ -269,7 +245,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { break; } - // this.enforceScrollLimit(); }; /** @@ -324,9 +299,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { if (this.visibleColumnSeparatorIndex > 0) { this.columnresizestart.emit({ colindex: this.visibleColumnSeparatorIndex, clientx: event.clientX }); } - - // Reset drag select direction - // this.dragSelectionDirectionIsDown = null; }; let previousTouchY: number; @@ -374,7 +346,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { previousTouchY = newTouchY; previousTouchX = newTouchX; } - // this.enforceScrollLimit(); this.touchscroll.emit(this.horizScroll); } @@ -394,118 +365,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { } }); - // this.renderer.listen('window', 'mousemove', (event: MouseEvent) => { - // if (this.scrollbarDragInProgress === true) { - // event.preventDefault(); - // this.doScrollBarDrag(event.clientY); - // } - // }); - - // this.canv.onmousemove = (event: MouseEvent) => { - // if (this.scrollbarDragInProgress === true || this.columnResizeInProgress === true) { - // event.preventDefault(); - // return; - // } - - // const canvrect = this.canv.getBoundingClientRect(); - // const clientX = event.clientX - canvrect.left; - - // let newHoverRowIndex = this.getRowIndexByClientY(event.clientY); - // if (this.scrollbarDragInProgress || checkIfScrollbarArea(event.clientX, event.clientY, true)) { - // newHoverRowIndex = null; - // } - - // if (this.hoverRowIndex !== newHoverRowIndex) { - // // check if mouse is down - // if (this.lastMouseDownEvent) { - // // set drag select direction to true if down, or false if up - // const newDragSelectionDirectionIsDown = newHoverRowIndex > this.hoverRowIndex ? true : false; - - // if (this.dragSelectionDirectionIsDown !== newDragSelectionDirectionIsDown) { - // // select previous row on drag select direction change - // this.selectRowByIndex(this.lastMouseDownEvent.clientX, this.hoverRowIndex); - // this.dragSelectionDirectionIsDown = newDragSelectionDirectionIsDown; - // } - // let rowIndex = this.hoverRowIndex; - // // Select all rows between the previous and current hover row index - // while ( - // (newDragSelectionDirectionIsDown === true && rowIndex < newHoverRowIndex) || - // (newDragSelectionDirectionIsDown === false && rowIndex > newHoverRowIndex) - // ) { - // if (newDragSelectionDirectionIsDown === true) { - // rowIndex ++; - // } else { - // rowIndex --; - // } - // this.selectRowByIndex(this.lastMouseDownEvent.clientX, rowIndex); - // } - // } - // this.hoverRowIndex = newHoverRowIndex; - // this.updateDragImage(newHoverRowIndex); - // } - - // if (this.dragSelectionDirectionIsDown === null) { - // // Check for column resize - // if (this.lastMouseDownEvent && this.visibleColumnSeparatorIndex > 0) { - // this.columnresize.emit(this.visibleColumnSeparatorIndex); - // } else { - // this.updateVisibleColumnSeparatorIndex(clientX); - // } - - // if (this.visibleColumnSeparatorIndex > 0) { - // this.lastClientY = event.clientY - canvrect.top; - // this.hasChanges = true; - // return; - // } - // } - - // if (this.dragSelectionDirectionIsDown === null && this.hoverRowIndex !== null) { - // const colIndex = this.getColIndexByClientX(clientX); - // let colStartX = this.columns.reduce((prev, curr, ndx) => ndx < colIndex ? prev + curr.width : prev, 0); - - // let tooltipText: string | ((rowIndex: any) => string) = - // this.columns[colIndex] && this.columns[colIndex].tooltipText; - - // // FIXME: message display class - // if (typeof tooltipText === 'function' && this.rows.rowExists(this.hoverRowIndex)) { - // tooltipText = tooltipText(this.hoverRowIndex); - // } - - // if (!event.shiftKey && !this.lastMouseDownEvent && - // (tooltipText || (this.columns[colIndex] && this.columns[colIndex].draggable)) - // ) { - // if (this.rowWrapMode && - // colIndex >= this.rowWrapModeWrapColumn) { - // // Subtract first row width if in row wrap mode - // colStartX -= this.columns.reduce((prev, curr, ndx) => - // ndx < this.rowWrapModeWrapColumn ? prev + curr.width : prev, 0); - // } - - // this.floatingTooltip = new FloatingTooltip( - // (this.hoverRowIndex - this.topindex) * this.rowheight, - // colStartX - this.horizScroll + this.colpaddingleft, - // this.columns[colIndex].width - this.colpaddingright - this.colpaddingleft, - // this.rowheight, tooltipText as string); - - // if (this.rowWrapMode) { - // this.floatingTooltip.top += - // + (colIndex >= this.rowWrapModeWrapColumn ? this.rowheight / 2 : 0); - // this.floatingTooltip.height = this.rowheight / 2; - // } - - // setTimeout(() => { - // if (this.columnOverlay) { - // this.columnOverlay.show(300); - // } - // }, 0); - // } else { - // this.floatingTooltip = null; - // } - // } else { - // this.floatingTooltip = null; - // } - // }; - this.canv.onmouseout = (event: MouseEvent) => { const newHoverRowIndex = null; if (this.hoverRowIndex !== newHoverRowIndex) { @@ -513,14 +372,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { } }; - // this.renderer.listen('window', 'mouseup', (event: MouseEvent) => { - // this.lastMouseDownEvent = undefined; - // if (this.scrollbarDragInProgress) { - // this.scrollbarDragInProgress = false; - // this.hasChanges = true; - // } - // }); - this.canv.onmouseup = (event: MouseEvent) => { event.preventDefault(); if (this.visibleColumnSeparatorIndex > 0) { @@ -536,56 +387,8 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { } this.lastMouseDownEvent = null; - // this.dragSelectionDirectionIsDown = null; }; - - // this.renderer.listen('window', 'resize', () => true); - - // const paintLoop = () => { - // if (this.hasChanges) { - // if (Math.abs(this.touchScrollSpeedY) > 0) { - // // Scroll if speed - // this.topindex -= this.touchScrollSpeedY / this.rowheight; - - // // ---- Enforce scroll limit - // if (this.topindex < 0) { - // this.topindex = 0; - // } else if (this.rows.rowCount() < this.maxVisibleRows) { - // this.topindex = 0; - // } else if (this.topindex + this.maxVisibleRows > this.rows.rowCount()) { - // this.topindex = this.rows.rowCount() - this.maxVisibleRows; - // } - // // --------- - - // // Slow down - // this.touchScrollSpeedY *= 0.9; - // if (Math.abs(this.touchScrollSpeedY) < 0.4) { - // this.touchScrollSpeedY = 0; - // } - // } - // try { - // this.dopaint(); - // if (this.rows) { - // this.repaintDoneSubject.next(undefined); - // } - // } catch (e) { - // console.log(e); - // } - - // if (Math.abs(this.touchScrollSpeedY) > 0) { - // // Continue scrolling while we have scroll speed - // this.hasChanges = true; - // } else { - // this.hasChanges = false; - // } - // } - // // window.requestAnimationFrame(() => paintLoop()); - // }; - - // this._ngZone.runOutsideAngular(() => - // window.requestAnimationFrame(() => paintLoop()) - // ); } private updateDragImage(selectedRowIndex: number) :HTMLCanvasElement { @@ -659,8 +462,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { public doScrollBarDrag(clientY: number) { const canvrect = this.canv.getBoundingClientRect(); this.topindex = this.rows.rowCount() * ((clientY - canvrect.top) / this.canv.scrollHeight); - - // this.enforceScrollLimit(); } private getRowIndexByClientY(clientY: number) { @@ -826,13 +627,11 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { public scrollUp() { this.topindex--; - // this.enforceScrollLimit(); this.hasChanges = true; } public scrollDown() { this.topindex++; - // this.enforceScrollLimit(); this.hasChanges = true; } @@ -850,7 +649,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { public updateRows(newList) { this.rows.setRows(newList); - // this.enforceScrollLimit(); this.hasChanges = true; } @@ -868,87 +666,9 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { // currently selected row in the centre: if (this.rows.rowCount() > 0 && this.rows.openedRowIndex) { this.topindex = this.rows.openedRowIndex - Math.round(this.maxVisibleRows / 2); - // this.enforceScrollLimit(); } } - // private enforceScrollLimit() { - // if (this.topindex < 0) { - // this.topindex = 0; - // } else if (this.rows && this.rows.rowCount() < this.maxVisibleRows) { - // this.topindex = 0; - // } else if (this.rows && this.topindex + this.maxVisibleRows > this.rows.rowCount()) { - // this.topindex = this.rows.rowCount() - this.maxVisibleRows; - // // send max rows hit events (use to fetch more data) - // this.scrollLimitHit.next(this.rows.rowCount()); - // } - - - // const columnsTotalWidth = this.columns.reduce((width, col) => - // col.width + width, 0); - - // if (this.horizScroll < 0) { - // this.horizScroll = 0; - // } else if ( - // this.canv.scrollWidth < columnsTotalWidth && - // this.horizScroll + this.canv.scrollWidth > columnsTotalWidth) { - // this.horizScroll = columnsTotalWidth - this.canv.scrollWidth; - // } - // } - - /** - * Draws a rounded rectangle using the current state of the canvas. - * If you omit the last three params, it will draw a rectangle - * outline with a 5 pixel border radius - * @param {CanvasRenderingContext2D} ctx - * @param {Number} x The top left x coordinate - * @param {Number} y The top left y coordinate - * @param {Number} width The width of the rectangle - * @param {Number} height The height of the rectangle - * @param {Number} [radius = 5] The corner radius; It can also be an object - * to specify different radii for corners - * @param {Number} [radius.tl = 0] Top left - * @param {Number} [radius.tr = 0] Top right - * @param {Number} [radius.br = 0] Bottom right - * @param {Number} [radius.bl = 0] Bottom left - * @param {Boolean} [fill = false] Whether to fill the rectangle. - * @param {Boolean} [stroke = true] Whether to stroke the rectangle. - */ - // private roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, - // width: number, height: number, - // radius?: any, fill?: boolean, stroke?: boolean) { - // if (typeof stroke === 'undefined') { - // stroke = true; - // } - // if (typeof radius === 'undefined') { - // radius = 5; - // } - // if (typeof radius === 'number') { - // radius = { tl: radius, tr: radius, br: radius, bl: radius }; - // } else { - // const defaultRadius = { tl: 0, tr: 0, br: 0, bl: 0 }; - // Object.keys(defaultRadius).forEach(side => - // radius[side] = radius[side] || defaultRadius[side]); - // } - // ctx.beginPath(); - // ctx.moveTo(x + radius.tl, y); - // ctx.lineTo(x + width - radius.tr, y); - // ctx.quadraticCurveTo(x + width, y, x + width, y + radius.tr); - // ctx.lineTo(x + width, y + height - radius.br); - // ctx.quadraticCurveTo(x + width, y + height, x + width - radius.br, y + height); - // ctx.lineTo(x + radius.bl, y + height); - // ctx.quadraticCurveTo(x, y + height, x, y + height - radius.bl); - // ctx.lineTo(x, y + radius.tl); - // ctx.quadraticCurveTo(x, y, x + radius.tl, y); - // ctx.closePath(); - // if (fill) { - // ctx.fill(); - // } - // if (stroke) { - // ctx.stroke(); - // } - // } - // Height of message list rows public get rowheight(): number { return (this.rowWrapMode || this.showContentTextPreview ) ? @@ -962,403 +682,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { } } -// private dopaint() { -// const devicePixelRatio = window.devicePixelRatio; -// if (this.canv.width !== this.wantedCanvasWidth || -// this.canv.height !== this.wantedCanvasHeight) { - -// const widthChanged = this.canv.width !== this.wantedCanvasWidth; -// /* Only resize on detection of width change -// * otherwise reducing column widths so that the scrollbar -// * disappears indicates a change of height and triggers resize -// */ - -// this.canv.style.width = (this.wantedCanvasWidth / devicePixelRatio) + 'px'; -// this.canv.style.height = (this.wantedCanvasHeight / devicePixelRatio) + 'px'; - -// this.canv.width = this.wantedCanvasWidth; -// this.canv.height = this.wantedCanvasHeight; - -// this.maxVisibleRows = this.canv.scrollHeight / this.rowheight; -// this.enforceScrollLimit(); -// this.hasChanges = true; -// if (this.canv.clientWidth < this.autoRowWrapModeWidth) { -// this.rowWrapMode = true; -// } else { -// this.rowWrapMode = false; -// } - -// this.canvasResizedSubject.next(widthChanged); -// } - -// if (devicePixelRatio !== 1) { -// // This is not scale() as that would keep multiplying -// // Moved out of above if() statement as something (!?) -// // was resetting transform, still not sure what -// this.ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); -// } - -// this.ctx.textBaseline = 'middle'; -// this.ctx.font = this.fontheight + 'px ' + this.fontFamily; - -// const canvwidth: number = this.canv.scrollWidth; -// const canvheight: number = this.canv.scrollHeight; - -// let colx = 0 - this.horizScroll; -// // Columns -// for (let colindex = 0; colindex < this.columns.length; colindex++) { -// const col: CanvasTableColumn = this.columns[colindex]; -// if (colx + col.width > 0 && colx < canvwidth) { -// this.ctx.fillStyle = col.backgroundColor ? col.backgroundColor : '#fff'; -// this.ctx.fillRect(colx, -// 0, -// colindex === this.columns.length - 1 ? -// canvwidth - colx : -// col.width, -// canvheight -// ); -// } -// colx += col.width; -// } - -// if (!this.rows || this.rows.rowCount() < 1) { -// return; -// } - -// // Rows -// for (let n = this.topindex; n < this.rows.rowCount(); n += 1.0) { -// const rowIndex = Math.floor(n); - -// if (rowIndex > this.rows.rowCount()) { -// break; -// } - -// // const rowobj = this.rows[rowIndex]; - -// const halfrowheight = (this.rowheight / 2); -// const rowy = (rowIndex - this.topindex) * this.rowheight; -// if (this.rows.rowExists(rowIndex)) { -// // Clear row area -// // Alternating row colors: -// // let rowBgColor : string = (rowIndex%2===0 ? "#e8e8e8" : "rgba(255,255,255,0.7)"); -// // Single row color: -// let rowBgColor = '#fff'; - -// const isBoldRow = this.rows.isBoldRow(rowIndex); -// const isSelectedRow = this.rows.isSelectedRow(rowIndex); -// const isOpenedRow = this.rows.isOpenedRow(rowIndex); -// if (this.hoverRowIndex === rowIndex) { -// rowBgColor = this.hoverRowColor; -// } -// if (isSelectedRow) { -// rowBgColor = this.selectedRowColor; -// } -// if (isOpenedRow) { -// rowBgColor = this.openedRowColor; -// } - -// this.ctx.fillStyle = rowBgColor; -// this.ctx.fillRect(0, rowy, canvwidth, this.rowheight); - -// // Row borders separating each row -// this.ctx.strokeStyle = '#eee'; -// this.ctx.beginPath(); -// this.ctx.moveTo(0, rowy); -// this.ctx.lineTo(canvwidth, rowy); -// this.ctx.stroke(); - -// let x = 0; -// for (let colindex = 0; colindex < this.columns.length; colindex++) { -// const col: CanvasTableColumn = this.columns[colindex]; -// let val: any = col.getValue(rowIndex); -// if (val === 'RETRY') { -// // retry later if value is null -// setTimeout(() => this.hasChanges = true, 2); -// val = ''; -// } -// let formattedVal: string; -// const formattedValueCacheKey: string = col.cacheKey + ':' + val; -// if (this.formattedValueCache[formattedValueCacheKey]) { -// formattedVal = this.formattedValueCache[formattedValueCacheKey]; -// } else if (('' + val).length > 0 && col.getFormattedValue) { -// formattedVal = col.getFormattedValue(val); -// this.formattedValueCache[formattedValueCacheKey] = formattedVal; -// } else { -// formattedVal = '' + val; -// this.formattedValueCache[formattedValueCacheKey] = formattedVal; -// } -// if (this.rowWrapMode && col.rowWrapModeHidden) { -// continue; -// } else if (this.rowWrapMode && col.rowWrapModeChipCounter && parseInt(val, 10) > 1) { -// this.ctx.save(); - -// this.ctx.strokeStyle = ''; - -// this.roundRect(this.ctx, -// canvwidth - 50, -// rowy + 9, -// 28, -// 15, 10, false); -// this.ctx.font = '10px ' + this.fontFamily; - -// this.ctx.strokeStyle = '#000'; -// if (isSelectedRow) { -// this.ctx.fillStyle = this.textColor; -// } else { -// this.ctx.fillStyle = this.textColor; -// } -// this.ctx.textAlign = 'center'; -// this.ctx.fillText(formattedVal + '', canvwidth - 36, rowy + halfrowheight - 15); - -// this.ctx.restore(); - -// continue; -// } else if (this.rowWrapMode && col.rowWrapModeChipCounter) { -// continue; -// } -// if (this.rowWrapMode && colindex === this.rowWrapModeWrapColumn) { -// x = 0; -// } - -// x += this.colpaddingleft; - -// if ((x - this.horizScroll + col.width) >= 0 && formattedVal.length > 0) { -// this.ctx.fillStyle = this.textColor; // Text color of unselected row -// if (isSelectedRow) { -// this.ctx.fillStyle = this.textColor; // Text color of selected row -// } - -// if (this.rowWrapMode) { -// // Wrap rows if in row wrap mode (for e.g. mobile portrait view) - -// // Check box -// const texty: number = rowy + halfrowheight; -// const textx: number = x - this.horizScroll; - -// const width = col.width - this.colpaddingright - this.colpaddingleft; - -// this.ctx.save(); -// this.ctx.beginPath(); -// this.ctx.moveTo(textx, rowy); -// this.ctx.lineTo(textx + width, rowy); -// this.ctx.lineTo(textx + width, rowy + this.rowheight); -// this.ctx.lineTo(textx, rowy + this.rowheight); -// this.ctx.closePath(); - -// if (col.checkbox) { -// const checkboxWidthHeight = 12; -// const checkboxCheckedPadding = 3; -// const checkboxLeftPadding = 4; -// this.ctx.strokeStyle = this.textColor; -// this.ctx.beginPath(); -// this.ctx.rect(checkboxLeftPadding + textx, texty - checkboxWidthHeight / 2, checkboxWidthHeight, checkboxWidthHeight); -// this.ctx.stroke(); -// if (val) { -// this.ctx.beginPath(); -// this.ctx.rect(checkboxLeftPadding + textx + checkboxCheckedPadding, -// checkboxCheckedPadding + texty - checkboxWidthHeight / 2, -// checkboxWidthHeight - checkboxCheckedPadding * 2, -// checkboxWidthHeight - checkboxCheckedPadding * 2); -// this.ctx.fill(); -// } -// } else { - -// // Other columns -// if (colindex >= this.rowWrapModeWrapColumn) { -// // Subject -// x += 30; // Increase padding before Subject -// this.ctx.save(); -// if (isBoldRow) { -// this.ctx.save(); -// this.ctx.font = 'bold ' + this.fontheight + 'px ' + this.fontFamilyBold; -// this.ctx.fillStyle = this.textColorLink; -// } else { -// this.ctx.save(); -// this.ctx.font = this.fontheight + 'px ' + this.fontFamily; -// this.ctx.fillStyle = this.textColorLink; -// } -// this.ctx.fillText(formattedVal, x, rowy + halfrowheight + 12 -// - (this.showContentTextPreview ? 12 : 0) -// ); -// this.ctx.restore(); -// } else if (col.rowWrapModeMuted) { -// // Date/time -// x = 42; // sufficiently away from the checkbox -// this.ctx.save(); -// this.ctx.font = this.fontheightSmaller + 'px ' + this.fontFamily; -// this.ctx.fillStyle = this.textColor; -// this.ctx.fillText(formattedVal, x, rowy + halfrowheight - 10 -// - (this.showContentTextPreview ? 8 : 0) -// ); -// this.ctx.restore(); -// } else { -// x = 128; // far enough to make the date above fit nicely -// this.ctx.font = this.fontheightSmall + 'px ' + this.fontFamily; -// this.ctx.fillText(formattedVal, x, rowy + halfrowheight - 10 -// - (this.showContentTextPreview ? 8 : 0)); -// this.ctx.fillStyle = this.textColorLink; -// } -// } -// this.ctx.restore(); -// } else if (x - this.horizScroll < canvwidth) { -// // Normal no-wrap mode - -// // Check box -// const texty: number = rowy + halfrowheight - (this.showContentTextPreview ? 10 : 0); -// let textx: number = x - this.horizScroll; - -// const width = col.width - this.colpaddingright - this.colpaddingleft; - -// this.ctx.save(); -// this.ctx.beginPath(); -// this.ctx.moveTo(textx, rowy); -// this.ctx.lineTo(textx + width, rowy); -// this.ctx.lineTo(textx + width, rowy + this.rowheight); -// this.ctx.lineTo(textx, rowy + this.rowheight); -// this.ctx.closePath(); - -// this.ctx.clip(); - -// if (col.checkbox) { -// const checkboxWidthHeight = 12; -// const checkboxCheckedPadding = 3; -// const checkboxLeftPadding = 4; -// this.ctx.strokeStyle = this.textColor; -// this.ctx.beginPath(); -// this.ctx.rect(checkboxLeftPadding + textx, texty - checkboxWidthHeight / 2, checkboxWidthHeight, checkboxWidthHeight); -// this.ctx.stroke(); -// if (val) { -// this.ctx.beginPath(); -// this.ctx.rect(checkboxLeftPadding + textx + checkboxCheckedPadding, -// checkboxCheckedPadding + texty - checkboxWidthHeight / 2, -// checkboxWidthHeight - checkboxCheckedPadding * 2, -// checkboxWidthHeight - checkboxCheckedPadding * 2); -// this.ctx.fill(); -// } -// } else { -// // Other columns -// if (col.textAlign === 1) { -// textx += width; -// this.ctx.textAlign = 'end'; -// } - -// if (col.font) { -// this.ctx.font = col.font; -// } -// if (colindex === 2 || colindex === 3) { -// // Column 2 is From, 3 is Subject -// this.ctx.fillStyle = this.textColorLink; -// if (isBoldRow) { -// this.ctx.font = 'bold ' + this.fontheight + 'px ' + this.fontFamilyBold; -// } -// } -// this.ctx.fillText(formattedVal, textx, texty); -// } -// this.ctx.restore(); -// } -// } - -// x += (Math.round(col.width * (this.rowWrapMode && col.rowWrapModeMuted ? -// (10 / this.fontheight) : 1)) - this.colpaddingleft); // We've already added colpaddingleft above -// } -// } else { -// // skipping rows we've removed while canvas was updating.... -// console.log('Skipped repainting a row as its data is missing, continuing anyway'); -// } -// if (this.showContentTextPreview) { -// const contentTextPreviewColumn = this.columns -// .find(col => col.getContentPreviewText ? true : false); -// if (contentTextPreviewColumn) { -// const contentPreviewText = contentTextPreviewColumn.getContentPreviewText(rowIndex); -// if (contentPreviewText) { -// this.ctx.save(); -// this.ctx.fillStyle = this.textColor; -// this.ctx.font = this.fontheightSmaller + 'px ' + this.fontFamily; -// const contentTextPreviewColumnPadding = this.rowWrapMode ? 2 : 10; // Increase left padding of content preview -// this.ctx.fillText(contentPreviewText, this.columns[0]. width + contentTextPreviewColumnPadding, -// rowy + halfrowheight + (this.rowWrapMode ? 18 : 15)); -// this.ctx.restore(); -// } -// } -// } - -// if (rowy > canvheight) { -// break; -// } -// this.ctx.fillStyle = this.textColor; - -// } - -// // Column separators - -// if (!this.rowWrapMode) { -// // No column separators in row wrap mode -// this.ctx.fillStyle = `rgba(166,166,166,${this.visibleColumnSeparatorAlpha})`; -// this.ctx.strokeStyle = `rgba(176,176,176,${this.visibleColumnSeparatorAlpha})`; - -// if (this.visibleColumnSeparatorAlpha < 1) { -// this.visibleColumnSeparatorAlpha += 0.01; -// setTimeout(() => this.hasChanges = true, 0); -// } - -// let x = 0; -// for (let colindex = 0; colindex < this.columns.length; colindex++) { -// if (colindex > 0 && this.visibleColumnSeparatorIndex === colindex) { -// // Only draw column separator near the mouse pointer -// this.ctx.beginPath(); -// this.ctx.moveTo(x - this.horizScroll, 0); -// this.ctx.lineTo(x - this.horizScroll, canvheight); -// this.ctx.stroke(); - -// this.ctx.fillRect(x - this.horizScroll - 5, this.lastClientY - 10, 10, 20); -// } -// x += this.columns[colindex].width; -// } -// } - -// // Scrollbar -// let scrollbarheight = (this.maxVisibleRows / this.rows.rowCount()) * canvheight; -// if (scrollbarheight < 20) { -// scrollbarheight = 20; -// } -// const scrollbarpos = -// (this.topindex / (this.rows.rowCount() - this.maxVisibleRows)) * (canvheight - scrollbarheight); - -// if (scrollbarheight < canvheight) { -// const scrollbarverticalpadding = 4; - -// const scrollbarx = canvwidth - this.scrollbarwidth; -// this.ctx.fillStyle = '#aaa'; -// this.ctx.fillRect(scrollbarx, 0, this.scrollbarwidth, canvheight); -// this.ctx.fillStyle = '#fff'; -// this.scrollBarRect = { -// x: scrollbarx + 1, -// y: scrollbarpos + scrollbarverticalpadding / 2, -// width: this.scrollbarwidth - 2, -// height: scrollbarheight - scrollbarverticalpadding -// }; - -// if (this.scrollbarDragInProgress) { -// this.ctx.fillStyle = 'rgba(200,200,255,0.5)'; -// this.roundRect(this.ctx, -// this.scrollBarRect.x - 4, -// this.scrollBarRect.y - 4, -// this.scrollBarRect.width + 8, -// this.scrollBarRect.height + 8, 5, true); - -// this.ctx.fillStyle = '#fff'; -// this.ctx.fillRect(this.scrollBarRect.x, -// this.scrollBarRect.y, -// this.scrollBarRect.width, -// this.scrollBarRect.height); -// } else { -// this.ctx.fillStyle = '#fff'; -// this.ctx.fillRect(this.scrollBarRect.x, this.scrollBarRect.y, this.scrollBarRect.width, this.scrollBarRect.height); -// } - -// } - -// } } @Component({ @@ -1401,7 +724,6 @@ export class CanvasTableContainerComponent { } this.columnWidths[colWidthSet] = newColWidths; this.canvastableselectlistener.saveColumnWidthsPreference(this.columnWidths); - // localStorage.setItem('canvasNamedColumnWidthsBySet', JSON.stringify(this.columnWidths)); } colresizestart(clientX: number, colIndex: number) { From 6f7d128290c7249149882ce8243d773911b8848e Mon Sep 17 00:00:00 2001 From: shadowbas Date: Wed, 18 Jun 2025 15:14:13 +0300 Subject: [PATCH 03/30] fixup! fixup! fixup! feat(message-list): Implement virtual scroll table --- .../virtual-scroll-table/virtual-scroll-table.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/virtual-scroll-table/virtual-scroll-table.component.ts b/src/app/virtual-scroll-table/virtual-scroll-table.component.ts index a7e22a8f4..2f030d213 100644 --- a/src/app/virtual-scroll-table/virtual-scroll-table.component.ts +++ b/src/app/virtual-scroll-table/virtual-scroll-table.component.ts @@ -61,7 +61,7 @@ export class VirtualScrollTableComponent implements OnDestroy, AfterViewInit { this.inputChanges$.next() } - firstRowHeight: number = 50; + firstRowHeight: number = 24; maxBufferPx: number; private renderedRangeSub!: Subscription; @@ -81,7 +81,7 @@ export class VirtualScrollTableComponent implements OnDestroy, AfterViewInit { }); this.inputChangesSub = this.inputChanges$ - .pipe(debounceTime(500)) + .pipe(debounceTime(50)) .subscribe(() => { this.updateFirstRowHeight(); From 05c5d024a7be6f2e74dfcbf1621a9e4b92f4965c Mon Sep 17 00:00:00 2001 From: shadowbas Date: Wed, 18 Jun 2025 15:17:26 +0300 Subject: [PATCH 04/30] fixup! fixup! fixup! fixup! feat(message-list): Implement virtual scroll table --- src/app/websocketsearch/websocketsearchmaillist.ts | 1 + src/app/xapian/searchmessagedisplay.ts | 2 +- src/app/xapian/searchservice.ts | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/websocketsearch/websocketsearchmaillist.ts b/src/app/websocketsearch/websocketsearchmaillist.ts index 3802a4e3e..ee014182b 100644 --- a/src/app/websocketsearch/websocketsearchmaillist.ts +++ b/src/app/websocketsearch/websocketsearchmaillist.ts @@ -106,6 +106,7 @@ export class WebSocketSearchMailList extends MessageDisplay { from: this.getRow(rowIndex).fromName, subject: this.getRow(rowIndex).subject, size: this.getRow(rowIndex).size, + seen: this.getRowSeen(rowIndex) }; } diff --git a/src/app/xapian/searchmessagedisplay.ts b/src/app/xapian/searchmessagedisplay.ts index 3e571bbe2..1c67d1df8 100644 --- a/src/app/xapian/searchmessagedisplay.ts +++ b/src/app/xapian/searchmessagedisplay.ts @@ -32,7 +32,7 @@ export class SearchMessageDisplay extends MessageDisplay { } getRowSeen(index: number): boolean { - return this.searchService.getDocData(this.getRowId(index)).seen; + return this.searchService.getDocData(this.rows[index][0]).seen ? false : true; } getRowId(index: number): number { diff --git a/src/app/xapian/searchservice.ts b/src/app/xapian/searchservice.ts index 7b3fa310c..c49cf77c7 100644 --- a/src/app/xapian/searchservice.ts +++ b/src/app/xapian/searchservice.ts @@ -258,10 +258,10 @@ export class SearchService { FS.syncfs(true, () => { // console.log('Main: Syncd files:'); // console.log(FS.stat(XAPIAN_GLASS_WR)); - // FS.readdir(this.partitionsdir).forEach((f) => { + FS.readdir(this.partitionsdir).forEach((f) => { // console.log(`${f}`); // console.log(FS.stat(`${this.partitionsdir}/${f}`)); - // }); + }); this.api.reloadXapianDatabase(); this.indexReloadedSubject.next(undefined); }); From 883304701f6c8e32d2d9e28250d4c907e810de37 Mon Sep 17 00:00:00 2001 From: shadowbas Date: Wed, 18 Jun 2025 16:17:55 +0300 Subject: [PATCH 05/30] fixup! fixup! fixup! fixup! fixup! feat(message-list): Implement virtual scroll table --- src/app/canvastable/canvastable.ts | 84 ------------------- .../canvastablecontainer.component.html | 4 - 2 files changed, 88 deletions(-) diff --git a/src/app/canvastable/canvastable.ts b/src/app/canvastable/canvastable.ts index 0c5d7f21b..ddd2dd76b 100644 --- a/src/app/canvastable/canvastable.ts +++ b/src/app/canvastable/canvastable.ts @@ -305,90 +305,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { let previousTouchX: number; let touchMoved = false; - this.canv.addEventListener('touchstart', (event: TouchEvent) => { - this.isTouchZoom = false; - - previousTouchX = event.targetTouches[0].clientX; - previousTouchY = event.targetTouches[0].clientY; - checkScrollbarDrag(event.targetTouches[0].clientX, event.targetTouches[0].clientY); - if (this.scrollbarDragInProgress) { - event.preventDefault(); - } - - touchMoved = false; - }); - - - this.canv.addEventListener('touchmove', (event: TouchEvent) => { - if (event.targetTouches.length > 1) { - this.isTouchZoom = true; - return; - } - event.preventDefault(); - touchMoved = true; - - if (event.targetTouches.length === 1) { - const newTouchY = event.targetTouches[0].clientY; - const newTouchX = event.targetTouches[0].clientX; - if (this.scrollbarDragInProgress === true) { - this.doScrollBarDrag(newTouchY); - } else { - - this.touchScrollSpeedY = (newTouchY - previousTouchY); - if (Math.abs(this.touchScrollSpeedY) > 0) { - this.hasChanges = true; - } - - if (!this.rowWrapMode) { - this.horizScroll -= (newTouchX - previousTouchX); - } - - previousTouchY = newTouchY; - previousTouchX = newTouchX; - } - this.touchscroll.emit(this.horizScroll); - } - - }, false); - - this.canv.addEventListener('touchend', (event: TouchEvent) => { - if (this.isTouchZoom) { - return; - } - event.preventDefault(); - if (!this.scrollbarArea && !touchMoved) { - this.selectRow(event.changedTouches[0].clientX, event.changedTouches[0].clientY); - } - if (this.scrollbarDragInProgress) { - this.scrollbarDragInProgress = false; - this.hasChanges = true; - } - }); - - this.canv.onmouseout = (event: MouseEvent) => { - const newHoverRowIndex = null; - if (this.hoverRowIndex !== newHoverRowIndex) { - this.hoverRowIndex = newHoverRowIndex; - } - }; - - this.canv.onmouseup = (event: MouseEvent) => { - event.preventDefault(); - if (this.visibleColumnSeparatorIndex > 0) { - this.columnresizeend.emit(); - } else if (!this.scrollbarArea && this.lastMouseDownEvent) { - const lastcol = this.getColIndexByClientX(this.lastMouseDownEvent.clientX); - const thiscol = this.getColIndexByClientX(event.clientX); - const lastrow = this.getRowIndexByClientY(this.lastMouseDownEvent.clientY); - const thisrow = this.getRowIndexByClientY(event.clientY); - if (lastcol === thiscol && lastrow === thisrow) { - this.selectRow(event.clientX, event.clientY); - } - } - - this.lastMouseDownEvent = null; - }; - } private updateDragImage(selectedRowIndex: number) :HTMLCanvasElement { diff --git a/src/app/canvastable/canvastablecontainer.component.html b/src/app/canvastable/canvastablecontainer.component.html index 0c43569ff..02f52e067 100644 --- a/src/app/canvastable/canvastablecontainer.component.html +++ b/src/app/canvastable/canvastablecontainer.component.html @@ -51,10 +51,6 @@
@@ -52,7 +50,7 @@
Date: Wed, 18 Jun 2025 16:31:28 +0300 Subject: [PATCH 07/30] fixup! fixup! fixup! fixup! fixup! fixup! fixup! feat(message-list): Implement virtual scroll table --- src/app/canvastable/canvastable.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/app/canvastable/canvastable.ts b/src/app/canvastable/canvastable.ts index ddd2dd76b..b235c7ac7 100644 --- a/src/app/canvastable/canvastable.ts +++ b/src/app/canvastable/canvastable.ts @@ -120,10 +120,8 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { private scrollBarRect: any; - private isTouchZoom = false; private scrollbarDragInProgress = false; columnResizeInProgress = false; - private scrollbarArea = false; visibleColumnSeparatorAlpha = 0; visibleColumnSeparatorIndex = 0; @@ -175,16 +173,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { public colpaddingright = 10; public seprectextraverticalpadding = 4; // Extra padding above/below for separator rectangles - private lastMouseDownEvent: MouseEvent; - private _hoverRowIndex: number; - private get hoverRowIndex(): number { return this._hoverRowIndex; } - private set hoverRowIndex(hoverRowIndex: number) { - if (this._hoverRowIndex !== hoverRowIndex) { - this._hoverRowIndex = hoverRowIndex; - this.hasChanges = true; - } - } - // Auto row wrap mode (width based on iphone 5) - set to 0 to disable row wrap mode public autoRowWrapModeWidth = 540; @@ -301,10 +289,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { } }; - let previousTouchY: number; - let previousTouchX: number; - let touchMoved = false; - } private updateDragImage(selectedRowIndex: number) :HTMLCanvasElement { From b5f36327d3c1f163235a2092a97c1c450465e248 Mon Sep 17 00:00:00 2001 From: shadowbas Date: Wed, 18 Jun 2025 16:40:07 +0300 Subject: [PATCH 08/30] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! feat(message-list): Implement virtual scroll table --- src/app/canvastable/canvastable.ts | 7 ------- .../virtual-scroll-table.component.scss | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/app/canvastable/canvastable.ts b/src/app/canvastable/canvastable.ts index b235c7ac7..6c9176a82 100644 --- a/src/app/canvastable/canvastable.ts +++ b/src/app/canvastable/canvastable.ts @@ -261,7 +261,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { const canvrect = this.canv.getBoundingClientRect(); if (checkIfScrollbarArea(clientX, clientY)) { this.scrollbarDragInProgress = true; - this.scrollbarArea = true; } else if (checkIfScrollbarArea(clientX, clientY, true)) { // Check if click is above or below scrollbar slider @@ -273,16 +272,12 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { // below this.topindex += this.canv.scrollHeight / this.rowheight; } - this.scrollbarArea = true; - } else { - this.scrollbarArea = false; } }; this.canv.onmousedown = (event: MouseEvent) => { event.preventDefault(); checkScrollbarDrag(event.clientX, event.clientY); - this.lastMouseDownEvent = event; if (this.visibleColumnSeparatorIndex > 0) { this.columnresizestart.emit({ colindex: this.visibleColumnSeparatorIndex, clientx: event.clientX }); @@ -348,14 +343,12 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { event.dataTransfer.setData('text/plain', 'rowIndex:' + selectedRowIndex); } else { event.preventDefault(); - this.lastMouseDownEvent = event; } this.hasChanges = true; } public columnOverlayClicked(event: MouseEvent) { - this.lastMouseDownEvent = null; this.selectRow(event.clientX, event.clientY); } diff --git a/src/app/virtual-scroll-table/virtual-scroll-table.component.scss b/src/app/virtual-scroll-table/virtual-scroll-table.component.scss index a7591a11d..c58eaed75 100644 --- a/src/app/virtual-scroll-table/virtual-scroll-table.component.scss +++ b/src/app/virtual-scroll-table/virtual-scroll-table.component.scss @@ -16,7 +16,7 @@ cdk-virtual-scroll-viewport { right: 0; ::ng-deep & table thead { - visibilty: hidden; + visibility: hidden; opacity: 0; } } From 390069d40c0b58056efdc76c67c99299f7739775 Mon Sep 17 00:00:00 2001 From: shadowbas Date: Wed, 18 Jun 2025 17:28:35 +0300 Subject: [PATCH 09/30] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! feat(message-list): Implement virtual scroll table --- src/app/app.component.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 60622f153..f5d50ae86 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1217,6 +1217,8 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.searchFor(''); this.switchToFolder(folder); this.updateUrlFragment(); + // Little hack to trigger scroll to top. Alternative is using rxjs. + this.scrollToIndex = this.scrollToIndex === 0 ? -1 : 0; } private switchToFolder(folder: string): void { From 9112bbf9d4bbd684acb89024179303a6dcba09e8 Mon Sep 17 00:00:00 2001 From: shadowbas Date: Thu, 19 Jun 2025 13:05:19 +0300 Subject: [PATCH 10/30] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! feat(message-list): Implement virtual scroll table --- src/app/app.component.html | 4 ++-- src/app/app.component.ts | 15 +++++++------ .../virtual-scroll-table.component.ts | 21 ++++++++++++------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/app/app.component.html b/src/app/app.component.html index 48886f75e..f5e164bcf 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -107,7 +107,7 @@ [folders]="displayedFolders" [folderMessageCounts]="messagelistservice.folderMessageCountSubject" [selectedFolder]="selectedFolder" - (folderSelected)="selectFolder($event)" + (folderSelected)="onFolderSelect($event)" (droppedToFolder)="dropToFolder($event)" (emptyTrash)="emptyTrash($event)" (emptySpam)="emptySpam($event)" @@ -420,7 +420,7 @@

No Message Selected

(0); rowSelectionModel = new FilterSelectionModel( false, [], @@ -949,13 +949,13 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis public rowSelected(rowIndex: number, columnIndex: number, multiSelect?: boolean) { const isSelect = (columnIndex === 0) || multiSelect - const shouldScroll = this.scrollToIndex === 0 || !this.singlemailviewer.messageId + const shouldScroll = !this.singlemailviewer.messageId this.rowSelectionModel.select(this.rows[rowIndex]) this.lastCheckedIndex = rowIndex if (shouldScroll) { - this.scrollToIndex = rowIndex - 1 + this.scrollToIndex$.next(rowIndex - 1); } if ((this.selectedFolder === this.messagelistservice.templateFolderName) && !isSelect) { @@ -1209,6 +1209,11 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis } } + onFolderSelect(folder: string) { + this.scrollToIndex$.next(0); + this.selectFolder(folder) + } + selectFolder(folder: string): void { if (this.mobileQuery.matches && this.sidemenu.opened) { this.sidemenu.close(); @@ -1217,8 +1222,6 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.searchFor(''); this.switchToFolder(folder); this.updateUrlFragment(); - // Little hack to trigger scroll to top. Alternative is using rxjs. - this.scrollToIndex = this.scrollToIndex === 0 ? -1 : 0; } private switchToFolder(folder: string): void { diff --git a/src/app/virtual-scroll-table/virtual-scroll-table.component.ts b/src/app/virtual-scroll-table/virtual-scroll-table.component.ts index 2f030d213..71bf33a1c 100644 --- a/src/app/virtual-scroll-table/virtual-scroll-table.component.ts +++ b/src/app/virtual-scroll-table/virtual-scroll-table.component.ts @@ -19,6 +19,7 @@ import { OnDestroy, + OnInit, ChangeDetectionStrategy, AfterViewInit, Component, @@ -34,7 +35,7 @@ import { MatCheckboxModule } from '@angular/material/checkbox'; import { CommonModule } from '@angular/common'; import { ScrollingModule, CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; import { ListRange } from '@angular/cdk/collections'; -import { Subject, Subscription } from 'rxjs'; +import { Subject, Subscription, BehaviorSubject } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; @Component({ @@ -45,7 +46,7 @@ import { debounceTime } from 'rxjs/operators'; styleUrls: ['./virtual-scroll-table.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class VirtualScrollTableComponent implements OnDestroy, AfterViewInit { +export class VirtualScrollTableComponent implements OnInit, OnDestroy, AfterViewInit { @ContentChild('tbody', { read: TemplateRef }) tbodyTemplate!: TemplateRef | null; @ContentChild('thead', { read: TemplateRef }) theadTemplate!: TemplateRef | null; @@ -54,18 +55,14 @@ export class VirtualScrollTableComponent implements OnDestroy, AfterViewInit { @Output() renderedRangeChange = new EventEmitter(); @Input() items: any[] = []; - - @Input() - set scrollToIndex(index: number) { - this.pendingScrollToIndex = index; - this.inputChanges$.next() - } + @Input() scrollToIndex$!: BehaviorSubject; firstRowHeight: number = 24; maxBufferPx: number; private renderedRangeSub!: Subscription; private inputChangesSub!: Subscription; + private scrollToIndexSub!: Subscription; private inputChanges$ = new Subject(); private mutationObserver?: MutationObserver; @@ -73,6 +70,13 @@ export class VirtualScrollTableComponent implements OnDestroy, AfterViewInit { constructor(private elementRef: ElementRef) {} + ngOnInit() { + this.scrollToIndexSub = this.scrollToIndex$.subscribe(index => { + this.pendingScrollToIndex = index; + this.inputChanges$.next() + }); + } + ngAfterViewInit() { this.renderedRangeSub = this.viewport.renderedRangeStream .pipe(debounceTime(50)) @@ -103,6 +107,7 @@ export class VirtualScrollTableComponent implements OnDestroy, AfterViewInit { ngOnDestroy(): void { this.renderedRangeSub.unsubscribe(); + this.scrollToIndexSub.unsubscribe(); this.inputChangesSub.unsubscribe(); this.mutationObserver.disconnect(); } From 6bc39f449ee2f7166c6c5178be575925d2b5814a Mon Sep 17 00:00:00 2001 From: shadowbas Date: Thu, 19 Jun 2025 13:22:43 +0300 Subject: [PATCH 11/30] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! feat(message-list): Implement virtual scroll table --- src/app/app.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 1c376016d..b239d9d59 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -47,7 +47,7 @@ import { WebSocketSearchService } from './websocketsearch/websocketsearch.servic import { WebSocketSearchMailList } from './websocketsearch/websocketsearchmaillist'; import { BUILD_TIMESTAMP } from './buildtimestamp'; -import { from, Subject, Observable, BehaviorSubject, firstValueFrom } from 'rxjs'; +import { from, Observable, BehaviorSubject, firstValueFrom } from 'rxjs'; import { xapianLoadedSubject } from './xapian/xapianwebloader'; import { SwPush } from '@angular/service-worker'; import { exportKeysFromJWK } from './webpush/vapid.tools'; From 47cfacc8909dfcd653dd3c88880026a2457032cd Mon Sep 17 00:00:00 2001 From: shadowbas Date: Wed, 3 Sep 2025 13:47:14 +0300 Subject: [PATCH 12/30] Some more cleanup of canvas code --- src/app/app.component.ts | 30 +-- .../canvastable/canvastable.component.html | 17 +- src/app/canvastable/canvastable.ts | 181 ------------------ 3 files changed, 5 insertions(+), 223 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index b239d9d59..e2c498164 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -19,7 +19,7 @@ import { AfterViewInit, Component, DoCheck, NgZone, OnInit, ViewChild, Renderer2, ChangeDetectorRef, ElementRef, HostListener } from '@angular/core'; import { - CanvasTableSelectListener, CanvasTableComponent, + CanvasTableComponent, CanvasTableContainerComponent } from './canvastable/canvastable'; import { SingleMailViewerComponent } from './mailviewer/singlemailviewer.component'; @@ -86,7 +86,7 @@ const TOOLBAR_LIST_BUTTON_WIDTH = 30; styleUrls: ['app.component.scss'], templateUrl: 'app.component.html', }) -export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectListener, DoCheck { +export class AppComponent implements OnInit, AfterViewInit, DoCheck { showSelectMarkOpMenu: boolean; @@ -379,7 +379,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis } get showSelectOperations() { - return !this.rowsSelectionModel.isEmpty() || !this.rowSelectionModel.isEmpty() + return !this.rowsSelectionModel.isEmpty() } ngDoCheck(): void { @@ -423,10 +423,8 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.setMessageDisplay('messagelist', this.messagelist); if (this.jumpToFragment && res.length > 0) { this.selectMessageFromFragment(this.fragment); - this.canvastable.jumpToOpenMessage(); this.jumpToFragment = false; } - this.canvastable.hasChanges = true; } }); @@ -443,17 +441,6 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis .pipe(map((folders: FolderListEntry[]) => folders.filter(f => f.folderPath.indexOf('Drafts') !== 0)) ); - this.canvastable.scrollLimitHit.subscribe((limit) => - this.messagelistservice.requestMoreData(limit) - ); - - this.canvastable.canvasResizedSubject.pipe( - filter(widthChanged => widthChanged === true), - debounceTime(20) - ).subscribe(() => - this.autoAdjustColumnWidths() - ); - this.route.fragment.subscribe( fragment => { if (!fragment) { @@ -468,7 +455,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.fragment = fragment; this.selectMessageFromFragment(this.fragment); if (this.canvastable.rows && this.canvastable.rows.rowCount() > 0) { - this.canvastable.jumpToOpenMessage(); + return } else { this.jumpToFragment = true; } @@ -997,11 +984,6 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis } } - // CanvasTableSelectListener, columnWidths changed: - saveColumnWidthsPreference(widths: any) { - this.preferenceService.set(this.preferenceService.prefGroup, 'canvasNamedColumnWidthsBySet', widths); - } - updateTime() { const time = new Date(); const hour = time.getHours(); @@ -1092,10 +1074,6 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis this.updateUrlFragment(); } - updateMessageListHeight() { - this.canvastable.jumpToOpenMessage(); - } - searchTextFieldFocus() { if (!this.usewebsocketsearch && !this.dataReady) { this.usewebsocketsearch = true; diff --git a/src/app/canvastable/canvastable.component.html b/src/app/canvastable/canvastable.component.html index f6c53aa7e..d58db9e7b 100644 --- a/src/app/canvastable/canvastable.component.html +++ b/src/app/canvastable/canvastable.component.html @@ -1,17 +1,2 @@ - -
+
diff --git a/src/app/canvastable/canvastable.ts b/src/app/canvastable/canvastable.ts index 6c9176a82..f3ba27155 100644 --- a/src/app/canvastable/canvastable.ts +++ b/src/app/canvastable/canvastable.ts @@ -94,8 +94,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { } } - @ViewChild('thecanvas') canvRef: ElementRef; - @Input() columnWidths = {}; @Output() columnresize = new EventEmitter(); @@ -107,8 +105,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { repaintDoneSubject: Subject = new Subject(); canvasResizedSubject: Subject = new Subject(); - private canv: HTMLCanvasElement; - private _rowheight = 28; private scrollbarwidth = 12; @@ -214,154 +210,15 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { } ngAfterViewInit() { - this.canv = this.canvRef.nativeElement; - - this.canv.onwheel = (event: WheelEvent) => { - event.preventDefault(); - switch (event.deltaMode) { - case 0: - // pixels - this.topindex += (event.deltaY / this.rowheight); - break; - case 1: - // lines - this.topindex += event.deltaY; - break; - case 2: - // pages - this.topindex += (event.deltaY * (this.canv.scrollHeight / this.rowheight)); - break; - } - - }; - /** * Returns true if clientX/Y is inside the scrollbar area and if wholeScrollbar specified then not just the draggable slider * @param clientX * @param clientY * @param wholeScrollbar include whole scrollbar area, not just the draggable slider */ - const checkIfScrollbarArea = (clientX: number, clientY: number, wholeScrollbar?: boolean): boolean => { - if (!this.scrollBarRect) { - return false; - } - const canvrect = this.canv.getBoundingClientRect(); - const x = clientX - canvrect.left; - const y = clientY - canvrect.top; - return x > this.scrollBarRect.x && x < (this.scrollBarRect.x + this.scrollBarRect.width) && - (wholeScrollbar || y > this.scrollBarRect.y && y < this.scrollBarRect.y + this.scrollBarRect.height); - }; - - const checkScrollbarDrag = (clientX: number, clientY: number) => { - - if (!this.scrollBarRect) { - return; - } - - const canvrect = this.canv.getBoundingClientRect(); - if (checkIfScrollbarArea(clientX, clientY)) { - this.scrollbarDragInProgress = true; - } else if (checkIfScrollbarArea(clientX, clientY, true)) { - // Check if click is above or below scrollbar slider - - const y = clientY - canvrect.top; - if (y < this.scrollBarRect.y) { - // above - this.topindex -= this.canv.scrollHeight / this.rowheight; - } else { - // below - this.topindex += this.canv.scrollHeight / this.rowheight; - } - } - }; - - this.canv.onmousedown = (event: MouseEvent) => { - event.preventDefault(); - checkScrollbarDrag(event.clientX, event.clientY); - - if (this.visibleColumnSeparatorIndex > 0) { - this.columnresizestart.emit({ colindex: this.visibleColumnSeparatorIndex, clientx: event.clientX }); - } - }; } - private updateDragImage(selectedRowIndex: number) :HTMLCanvasElement { - const dragImageYCoords: number[][] = []; - let dragImageDestY = 0; - - // FIXME move to message_display?? - this.rows.rows - .forEach((row, ndx) => { - if ( - ndx >= this.topindex && (ndx - this.topindex) <= (this.canv.height / this.rowheight) - && - (this.rows.isSelectedRow(ndx) || ndx === selectedRowIndex) - ) { - const dragImageDataY = Math.floor((ndx - this.topindex) * this.rowheight * devicePixelRatio); - dragImageYCoords.push([dragImageDataY, dragImageDestY]); - - dragImageDestY += this.rowheight * devicePixelRatio; - } - }); - - const dragImageCanvas = document.createElement('canvas'); - dragImageCanvas.width = this.canv.width - 20; - dragImageCanvas.height = dragImageYCoords.length * this.rowheight * devicePixelRatio; - - const dragContext = dragImageCanvas.getContext('2d'); - dragContext.clearRect(0,0,dragImageCanvas.width,dragImageCanvas.height); - dragContext.fillStyle = 'red'; - dragContext.fillRect(0,0,dragImageCanvas.width,dragImageCanvas.height); - dragImageYCoords.forEach(ycoords => { - dragContext.drawImage(this.canv, - 0, ycoords[0], this.canv.width - 20, this.rowheight * devicePixelRatio, - 0, - ycoords[1], - this.canv.width - 20, this.rowheight * devicePixelRatio - ); - }); - - document.body.append(dragImageCanvas); - dragImageCanvas.setAttribute('id', 'thedragcanvas'); - dragImageCanvas.style.position = 'absolute'; dragImageCanvas.style.top = '0px'; dragImageCanvas.style.left = '-'+ dragImageCanvas.width + 'px'; - dragImageCanvas.style.width = Math.floor(((this.canv.width - 20) / devicePixelRatio)) + 'px'; - - return dragImageCanvas; - } - - public dragColumnOverlay(event: DragEvent) { - const canvrect = this.canv.getBoundingClientRect(); - const selectedColIndex = this.getColIndexByClientX(event.clientX - canvrect.left); - const selectedRowIndex = this.getRowIndexByClientY(event.clientY); - - if (!this.columns[selectedColIndex].checkbox) { - this.selectListener.rowSelected(selectedRowIndex, -1); - const dragCanvas = this.updateDragImage(selectedRowIndex); - event.dataTransfer.dropEffect = 'move'; - event.dataTransfer.setDragImage(dragCanvas, 0, 0); - event.dataTransfer.setData('text/plain', 'rowIndex:' + selectedRowIndex); - } else { - event.preventDefault(); - } - - this.hasChanges = true; - } - - public columnOverlayClicked(event: MouseEvent) { - this.selectRow(event.clientX, event.clientY); - } - - public doScrollBarDrag(clientY: number) { - const canvrect = this.canv.getBoundingClientRect(); - this.topindex = this.rows.rowCount() * ((clientY - canvrect.top) / this.canv.scrollHeight); - } - - private getRowIndexByClientY(clientY: number) { - const canvrect = this.canv.getBoundingClientRect(); - return Math.floor(this.topindex + (clientY - canvrect.top) / this.rowheight); - } - public getColIndexByClientX(clientX: number) { if (this.rowWrapMode) { return clientX > this.columns[0].width ? this.rowWrapModeDefaultSelectedColumn : 0; @@ -379,32 +236,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { } } - public updateVisibleColumnSeparatorIndex(clientX: number) { - let x = -this.horizScroll; - let selectedColIndex = 0; - for (; selectedColIndex < this.columns.length; selectedColIndex++) { - const col = this.columns[selectedColIndex]; - if (clientX >= x - 5 && clientX < x + 5) { - break; - } - x += col.width; - } - if (selectedColIndex === this.columns.length) { - selectedColIndex = -1; - } - - if (selectedColIndex !== this.visibleColumnSeparatorIndex && !this.rowWrapMode) { - if (selectedColIndex > 0) { - this.canv.style.cursor = 'col-resize'; - } else { - this.canv.style.cursor = 'pointer'; - } - this.visibleColumnSeparatorAlpha = 0; - this.visibleColumnSeparatorIndex = selectedColIndex; - this.hasChanges = true; - } - } - public isScrollInProgress(): boolean { return this.scrollbarDragInProgress || Math.abs(this.touchScrollSpeedY) > 0; } @@ -451,15 +282,7 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { this.hasChanges = true; } - public selectRow(clientX: number, clientY: number, multiSelect?: boolean) { - const selectedRowIndex = this.getRowIndexByClientY(clientY); - this.selectRowByIndex(clientX, selectedRowIndex, multiSelect); - } - public selectRowByIndex(clientX: number, selectedRowIndex: number, multiSelect?: boolean) { - const canvrect = this.canv.getBoundingClientRect(); - clientX -= canvrect.left; - this.selectListener.rowSelected(selectedRowIndex, this.getColIndexByClientX(clientX), multiSelect); @@ -471,10 +294,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { // Make innert return - if (!this.canv || this._columns.length === 0) { - return; - } - const canvasWidth = Math.floor(window.devicePixelRatio) - this.scrollbarwidth - 2; const columnsTotalWidth = () => this.columns.reduce((prev, curr) => prev + curr.width, 0); From 70673e7143248c987b875207c5493a7209493780 Mon Sep 17 00:00:00 2001 From: shadowbas Date: Wed, 3 Sep 2025 13:47:43 +0300 Subject: [PATCH 13/30] fixup! Some more cleanup of canvas code --- src/app/canvastable/canvastable.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/app/canvastable/canvastable.ts b/src/app/canvastable/canvastable.ts index f3ba27155..79aef4cd3 100644 --- a/src/app/canvastable/canvastable.ts +++ b/src/app/canvastable/canvastable.ts @@ -209,16 +209,6 @@ export class CanvasTableComponent implements AfterViewInit, OnInit { this.calculateColumnWidths(this.columns); } - ngAfterViewInit() { - /** - * Returns true if clientX/Y is inside the scrollbar area and if wholeScrollbar specified then not just the draggable slider - * @param clientX - * @param clientY - * @param wholeScrollbar include whole scrollbar area, not just the draggable slider - */ - - } - public getColIndexByClientX(clientX: number) { if (this.rowWrapMode) { return clientX > this.columns[0].width ? this.rowWrapModeDefaultSelectedColumn : 0; From e2d62bb9fb046a29be0cf0b0ec274cbb7f41b862 Mon Sep 17 00:00:00 2001 From: shadowbas Date: Wed, 3 Sep 2025 15:14:43 +0300 Subject: [PATCH 14/30] fixup! fixup! Some more cleanup of canvas code --- src/app/app.component.ts | 21 ++++++++++----------- src/app/canvastable/canvastable.ts | 4 ++-- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index e2c498164..1e8c07b84 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -46,7 +46,6 @@ import { map, take, skip, mergeMap, filter, tap, debounceTime, distinctUntilChan import { WebSocketSearchService } from './websocketsearch/websocketsearch.service'; import { WebSocketSearchMailList } from './websocketsearch/websocketsearchmaillist'; -import { BUILD_TIMESTAMP } from './buildtimestamp'; import { from, Observable, BehaviorSubject, firstValueFrom } from 'rxjs'; import { xapianLoadedSubject } from './xapian/xapianwebloader'; import { SwPush } from '@angular/service-worker'; @@ -95,7 +94,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { private rowsSubject= new BehaviorSubject(this.rows); debouncedRows$ = this.rowsSubject.asObservable().pipe(debounceTime(300)); - lastCheckedIndex: number = -1; + lastCheckedIndex = -1; scrollToIndex$ = new BehaviorSubject(0); rowSelectionModel = new FilterSelectionModel( false, @@ -1488,9 +1487,9 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { this.rowsSubject.next(this.rows) } - rangeSelectFrom(from: number, to: number, check: boolean) { - const left = Math.min(from, to) - const right = Math.max(from, to) + rangeSelectFrom(fromIndex: number, to: number, check: boolean) { + const left = Math.min(fromIndex, to) + const right = Math.max(fromIndex, to) for (let i = left; i <= right; i++) { if (check) { @@ -1511,12 +1510,12 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } rangeSelect(to: number, check: boolean) { - let from = this.lastCheckedIndex; + const fromIndex = this.lastCheckedIndex; // When nothing is selected yet. - if (from === -1) return this.oneSelect(to, check) + if (fromIndex === -1) return this.oneSelect(to, check) - return this.rangeSelectFrom(from, to, check) + return this.rangeSelectFrom(fromIndex, to, check) } oneSelect(index, check) { @@ -1524,15 +1523,15 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } onRowClick(event, row, index, checkbox = false) { - const shiftKey = event.getModifierState("Shift") + const shiftKey = event.getModifierState('Shift') const check = !this.rowsSelectionModel.isSelected(this.rows[index]) if (shiftKey) { return this.rangeSelect(index, check) } - const ctrlKey = event.getModifierState("Control") - const metaKey = event.getModifierState("Meta") + const ctrlKey = event.getModifierState('Control') + const metaKey = event.getModifierState('Meta') if (ctrlKey || metaKey) { return this.oneSelect(index, check) diff --git a/src/app/canvastable/canvastable.ts b/src/app/canvastable/canvastable.ts index 79aef4cd3..2e3e246fc 100644 --- a/src/app/canvastable/canvastable.ts +++ b/src/app/canvastable/canvastable.ts @@ -23,7 +23,7 @@ import { - NgModule, Component, AfterViewInit, + NgModule, Component, Input, Output, ElementRef, EventEmitter, OnInit, ViewChild @@ -82,7 +82,7 @@ export namespace CanvasTable { selector: 'canvastable', templateUrl: 'canvastable.component.html' }) -export class CanvasTableComponent implements AfterViewInit, OnInit { +export class CanvasTableComponent implements OnInit { static incrementalId = 1; public elementId: string; private _topindex = 0.0; From db20725885e622852f7826ec243ecac21366b1f1 Mon Sep 17 00:00:00 2001 From: shadowbas Date: Wed, 3 Sep 2025 15:39:07 +0300 Subject: [PATCH 15/30] Remove the canvastable components --- src/app/app.component.html | 5 - src/app/app.component.ts | 99 +--- src/app/app.module.ts | 2 - .../canvastable/canvastable.component.html | 2 - src/app/canvastable/canvastable.ts | 536 ------------------ src/app/canvastable/canvastablecolumn.ts | 48 -- .../canvastablecontainer.component.html | 127 ----- .../canvastablecontainer.component.scss | 4 - src/app/common/messagedisplay.ts | 2 - src/app/common/messagelist.ts | 89 --- .../websocketsearchmaillist.ts | 70 +-- src/app/xapian/searchmessagedisplay.ts | 163 ------ 12 files changed, 34 insertions(+), 1113 deletions(-) delete mode 100644 src/app/canvastable/canvastable.component.html delete mode 100644 src/app/canvastable/canvastable.ts delete mode 100644 src/app/canvastable/canvastablecolumn.ts delete mode 100644 src/app/canvastable/canvastablecontainer.component.html delete mode 100644 src/app/canvastable/canvastablecontainer.component.scss diff --git a/src/app/app.component.html b/src/app/app.component.html index f5e164bcf..a1364182a 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -414,11 +414,6 @@

No Message Selected

id="canvasTableContainerArea" [ngStyle]="{'bottom.px': canvasTableBtmOffset}"> - - - = 0) { this.rowSelected(newRowIndex, 3, false); - this.canvastable.scrollUp(); this.canvastable.hasChanges = true; evt.preventDefault(); } @@ -285,7 +287,6 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { const newRowIndex = this.canvastable.rows.openedRowIndex + 1; if (newRowIndex < this.canvastable.rows.rowCount()) { this.rowSelected(newRowIndex, 3, false); - this.canvastable.scrollDown(); this.canvastable.hasChanges = true; evt.preventDefault(); } @@ -304,7 +305,6 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { this.setMessageDisplay('websocketlist', results); this.showingWebSocketSearchResults = true; } - this.resetColumns(); }); this.sideMenuOpened = (mobileQuery.screenSize === ScreenSize.Desktop ? true : false); @@ -332,7 +332,6 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { // message list prefs if (this.canvastable) { this.canvastable.showContentTextPreview = prefs.get(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SHOWCONTENTPREVIEW}`) === 'true'; - this.canvastable.columnWidths = prefs.get(`${this.preferenceService.prefGroup}:canvasNamedColumnWidthsBySet`) || {}; } this.keepMessagePaneOpen = prefs.get(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_KEEP_PANE}`) === 'true'; this.unreadMessagesOnlyCheckbox = prefs.get(`${DefaultPrefGroups.Global}:${LOCAL_STORAGE_SHOW_UNREAD_ONLY}`) === 'true'; @@ -399,18 +398,13 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { async ngOnInit() { await firstValueFrom(this.xapianLoaded); - this.canvastable = this.canvastablecontainer.canvastable; if (this.preferences.has(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SHOWCONTENTPREVIEW}`)) { this.canvastable.showContentTextPreview = this.preferences.get(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SHOWCONTENTPREVIEW}`) === 'true'; } - if (this.preferences.has(`${this.preferenceService.prefGroup}:canvasNamedColumnWidthsBySet`)) { - this.canvastable.columnWidths = this.preferences.get(`${this.preferenceService.prefGroup}:canvasNamedColumnWidthsBySet`) || {}; - } this.orderSelectionModel.selected = { data: 2, direction: Direction.Descending } - this.resetColumns(); this.messagelistservice.messagesInViewSubject.subscribe(res => { this.messagelist = res; @@ -844,7 +838,6 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { public deleteLocalIndex() { if (this.searchService.localSearchActivated || this.dataReady) { this.usewebsocketsearch = true; - this.canvastable.topindex = 0; this.canvastable.rows = null; this.viewmode = 'messages'; this.conversationGroupingCheckbox = this.viewmode === 'conversations'; @@ -853,8 +846,6 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { this.showingSearchResults = false; this.searchText = ''; - this.resetColumns(); - this.usage.report('local-index-deleted'); this.searchService.deleteLocalIndex().subscribe(() => { @@ -868,10 +859,15 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } } + updateRowS(newList) { + this.canvastable.rows.setRows(newList); + this.canvastable.hasChanges = true; + } + public setMessageDisplay(displayType: string, ...args) { if (displayType === 'search') { if (this.canvastable.rows instanceof SearchMessageDisplay) { - this.canvastable.updateRows(args[1]); + this.updateRowS(args[1]); } else { this.canvastable.rows = new SearchMessageDisplay(...args); // messages updated, check if we need to select a message from the fragment @@ -880,7 +876,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } if (displayType === 'messagelist') { if (this.canvastable.rows instanceof MessageList) { - this.canvastable.updateRows(args[0]); + this.updateRowS(args[0]); } else { this.canvastable.rows = new MessageList(...args); // messages updated, check if we need to select a message from the fragment @@ -889,7 +885,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } if (displayType === 'websocketlist') { if (this.canvastable.rows instanceof WebSocketSearchMailList) { - this.canvastable.updateRows(args[0]); + this.updateRowS(args[0]); } else { this.canvastable.rows = new WebSocketSearchMailList(...args); // messages updated, check if we need to select a message from the fragment @@ -899,15 +895,6 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { this.filterMessageDisplay(); - // FIXME: looks weird, should probably rename "rows" to "messagedisplay" - // in canvastable, and anyway get CV to just read the columns itself - // "this" so we can check selectedFolder (FIXME: improve!) - // parts like app.selectedFolder.indexOf('Sent') === 0 etc are - // why we have resetColumns scattered everywhere, if canvas just called getCTC whenever it did a paint, we wouldnt need to? - // would that slow things down? - // NB this triggers hasChanged for us and forces a redraw - this.canvastable.columns = this.canvastable.rows.getCanvasTableColumns(this); - this.updateRows() } @@ -967,7 +954,6 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { if (this.viewmode === 'conversations' && this.canvastable.rows.getCurrentRow()[2] !== '1') { this.viewmode = 'singleconversation'; - this.resetColumns(); this.clearSelection(); // FIXME [0] is searchservice specific! @@ -1181,7 +1167,6 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { if (viewmode !== 'singleconversation') { this.conversationSearchText = null; } - this.resetColumns(); this.updateSearch(true); } } @@ -1216,16 +1201,6 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { this.selectedFolder = folder; - // FIXME: fairly sure this is redundant, the messageDisplay setting - // in the subscribe in ngInit should do it for us - this.messagelistservice.messagesInViewSubject - .pipe( - skip(1), - take(1) - ).subscribe(() => - // Reset columns after folder list is updated - this.resetColumns() - ); this.messagelistservice.setCurrentFolder(folder); if (this.viewmode === 'singleconversation') { @@ -1239,23 +1214,10 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } setTimeout(() => { - if (doResetColumns) { - this.resetColumns(); - } this.updateSearch(true); - this.canvastable.scrollTop(); }, 0); } - resetColumns() { - if (this.canvastable && this.canvastable.rows) { - this.canvastable.columns = this.canvastable.rows.getCanvasTableColumns(this); - } - this.canvastable.rowWrapModeWrapColumn = 3; - this.canvastable.rowWrapModeDefaultSelectedColumn = 3; - this.autoAdjustColumnWidths(); - } - showSaveSearchDialog(): void { const dialog = this.dialog.open(SimpleInputDialog, { data: new SimpleInputDialogParams( @@ -1314,7 +1276,6 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { */ if (this.showingSearchResults) { this.showingSearchResults = false; - this.resetColumns(); } this.setMessageDisplay('messagelist', this.messagelist); @@ -1357,7 +1318,6 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { if (!this.showingSearchResults || this.displayFolderColumn !== previousDisplayFolderColumn) { this.showingSearchResults = true; - this.resetColumns(); } if (querytext.match(/date:/) && querytext.match(/\.\./)) { @@ -1370,8 +1330,8 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { this.ngZone.runOutsideAngular(() => { searchResults = this.searchService.api.sortedXapianQuery( querytext, - this.canvastablecontainer.sortColumn, - this.canvastablecontainer.sortDescending ? 1 : 0, 0, 50000, + this.sort.sortColumn, + this.sort.sortDescending ? 1 : 0, 0, 50000, this.viewmode === 'conversations' ? 1 : -1 ); }); @@ -1382,9 +1342,6 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { this.searchResultsCount = searchResults.length; if (searchResults) { this.setMessageDisplay('search', this.searchService, searchResults); - if (!noscroll) { - this.canvastable.scrollTop(); - } } } catch (e) { console.error(e) @@ -1413,16 +1370,6 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { setTimeout(() => this.singlemailviewer.messageId = currentMessageId, 0); } - horizScroll(evt: any) { - this.canvastable.horizScroll = evt.target.scrollLeft; - } - - autoAdjustColumnWidths() { - setTimeout(() => - this.canvastable.autoAdjustColumnWidths(40, true), 0 - ); - } - promptLocalSearch() { console.log('promptLocalSearch'); this.rmmapi.me.pipe( diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 6d88ad959..ad9dc8049 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -52,7 +52,6 @@ import { MatSidenavModule } from '@angular/material/sidenav'; import { MatLegacySnackBarModule as MatSnackBarModule } from '@angular/material/legacy-snack-bar'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatLegacyTooltipModule as MatTooltipModule } from '@angular/material/legacy-tooltip'; -import { CanvasTableModule } from './canvastable/canvastable'; import { VirtualScrollTableComponent } from './virtual-scroll-table/virtual-scroll-table.component' import { MoveMessageDialogComponent } from './actions/movemessage.action'; import { RunboxWebmailAPI } from './rmmapi/rbwebmail'; @@ -158,7 +157,6 @@ const routes: Routes = [ HttpClientModule, VirtualScrollTableComponent, HttpClientJsonpModule, - CanvasTableModule, ComposeModule, StartDeskModule, WelcomeDeskModule, diff --git a/src/app/canvastable/canvastable.component.html b/src/app/canvastable/canvastable.component.html deleted file mode 100644 index d58db9e7b..000000000 --- a/src/app/canvastable/canvastable.component.html +++ /dev/null @@ -1,2 +0,0 @@ -
-
diff --git a/src/app/canvastable/canvastable.ts b/src/app/canvastable/canvastable.ts deleted file mode 100644 index 2e3e246fc..000000000 --- a/src/app/canvastable/canvastable.ts +++ /dev/null @@ -1,536 +0,0 @@ -// --------- BEGIN RUNBOX LICENSE --------- -// Copyright (C) 2016-2022 Runbox Solutions AS (runbox.com). -// -// This file is part of Runbox 7. -// -// Runbox 7 is free software: You can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// Runbox 7 is distributed in the hope that it will be useful, but -// WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -// General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Runbox 7. If not, see . -// ---------- END RUNBOX LICENSE ---------- - -/* - * Copyright 2010-2018 FinTech Neo AS / Runbox ( fintechneo.com / runbox.com )- All rights reserved - */ - - -import { - NgModule, Component, - Input, Output, - ElementRef, - EventEmitter, OnInit, ViewChild -} from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { MatLegacyButtonModule as MatButtonModule } from '@angular/material/legacy-button'; -import { MatLegacyMenuModule as MatMenuModule, MatLegacyMenuTrigger as MatMenuTrigger } from '@angular/material/legacy-menu'; -import { MatLegacyRadioModule as MatRadioModule } from '@angular/material/legacy-radio'; -import { FormsModule } from '@angular/forms'; -import { MatIconModule } from '@angular/material/icon'; -import { MatLegacyTooltipModule as MatTooltipModule, MatLegacyTooltip as MatTooltip } from '@angular/material/legacy-tooltip'; -import { BehaviorSubject , Subject } from 'rxjs'; -import { MessageDisplay } from '../common/messagedisplay'; -import { CanvasTableColumn } from './canvastablecolumn'; -import { PreferencesService } from '../common/preferences.service'; - -const MIN_COLUMN_WIDTH = 40; - -const getCSSClassProperty = (className, propertyName) => { - const elementId = '_classPropertyLookup_' + className; - let element: HTMLSpanElement = document.getElementById(elementId); - if (!element) { - element = document.createElement('span'); - element.id = elementId; - element.className = className; - element.style.display = 'none'; - document.documentElement.appendChild(element); - } - return window.getComputedStyle(element, null).getPropertyValue(propertyName); -}; - -export interface CanvasTableSelectListener { - rowSelected(rowIndex: number, colIndex: number, multiSelect?: boolean): void; - saveColumnWidthsPreference(widths); -} - -export class FloatingTooltip { - constructor(public top: number, - public left: number, - public width: number, - public height: number, - public tooltipText: string) { - - } -} - -export namespace CanvasTable { - export enum RowSelect { - Visible = 'visible', - All = 'all', - } -} - -@Component({ - // eslint-disable-next-line @angular-eslint/component-selector - selector: 'canvastable', - templateUrl: 'canvastable.component.html' -}) -export class CanvasTableComponent implements OnInit { - static incrementalId = 1; - public elementId: string; - private _topindex = 0.0; - public get topindex(): number { return this._topindex; } - public set topindex(topindex: number) { - if (this._topindex !== topindex) { - this._topindex = topindex; - this.hasChanges = true; - } - } - - @Input() columnWidths = {}; - - @Output() columnresize = new EventEmitter(); - @Output() columnresizeend = new EventEmitter(); - @Output() columnresizestart = new EventEmitter(); - - @ViewChild(MatTooltip) columnOverlay: MatTooltip; - - repaintDoneSubject: Subject = new Subject(); - canvasResizedSubject: Subject = new Subject(); - - private _rowheight = 28; - - private scrollbarwidth = 12; - - public fontFamily = '"Avenir Next Pro Regular", "Helvetica Neue", sans-serif'; - public fontFamilyBold = '"Avenir Next Pro Medium", "Helvetica Neue", sans-serif'; - - private maxVisibleRows: number; - - private scrollBarRect: any; - - private scrollbarDragInProgress = false; - columnResizeInProgress = false; - - visibleColumnSeparatorAlpha = 0; - visibleColumnSeparatorIndex = 0; - lastClientY: number; - - public _horizScroll = 0; - public get horizScroll(): number { return this._horizScroll; } - public set horizScroll(horizScroll: number) { - - if (this._horizScroll !== horizScroll) { - this._horizScroll = horizScroll; - this.hasChanges = true; - } - } - - // public _rows: any[] = []; - public _rows: MessageDisplay; - - columnWidthsDefaults = { - '': 40, - 'Date': 110, - 'To': 300, - 'From': 300, - 'Subject': 300, - 'Size': 80, - 'Count': 80, - }; - - public hasSortColumns = false; - public _columns: CanvasTableColumn[] = []; - public get columns(): CanvasTableColumn[] { return this._columns; } - public set columns(columns: CanvasTableColumn[]) { - if (this._columns !== columns) { - this.calculateColumnWidths(columns); - this._columns = columns; - this.hasSortColumns = columns.filter(col => col.sortColumn !== null).length > 0; - this.hasChanges = true; } - } - - // Colors retrieved from css classes - textColorLink: string = getCSSClassProperty('themePalettePrimary', 'color'); - selectedRowColor: string = getCSSClassProperty('themePaletteAccentLighter', 'color'); - openedRowColor: string = getCSSClassProperty('themePaletteLighterGray', 'color'); - hoverRowColor: string = getCSSClassProperty('themePaletteLightGray', 'color'); - textColor: string = getCSSClassProperty('themePaletteBlack', 'color'); - - - public colpaddingleft = 10; - public colpaddingright = 10; - public seprectextraverticalpadding = 4; // Extra padding above/below for separator rectangles - - // Auto row wrap mode (width based on iphone 5) - set to 0 to disable row wrap mode - public autoRowWrapModeWidth = 540; - - public rowWrapMode = true; - public rowWrapModeWrapColumn = 2; - public rowWrapModeDefaultSelectedColumn = 2; - - public _showContentTextPreview = false; - - public hasChanges: boolean; - - public scrollLimitHit: BehaviorSubject = new BehaviorSubject(0); - - public floatingTooltip: FloatingTooltip; - - @Input() selectListener: CanvasTableSelectListener; - @Output() touchscroll = new EventEmitter(); - - touchScrollSpeedY = 0; - - // Are we selecting all rows, or just the visible ones? - public selectWhichRows = CanvasTable.RowSelect.Visible; - - constructor(elementRef: ElementRef) { - } - - private calculateColumnWidths(columns: CanvasTableColumn[]) { - const colWidthSet = columns.map((col) => col.name).filter((cname) => cname.length > 0).join(','); - for (const c of columns) { - // try the stored settings, then an existing value, then 100px just in case - c.width = this.columnWidths[colWidthSet] - ? this.columnWidths[colWidthSet][c.name] - : this.columnWidthsDefaults[c.name] || c.width || 100; - } - } - - ngOnInit() { - this.calculateColumnWidths(this.columns); - } - - public getColIndexByClientX(clientX: number) { - if (this.rowWrapMode) { - return clientX > this.columns[0].width ? this.rowWrapModeDefaultSelectedColumn : 0; - } else { - let x = -this.horizScroll; - let selectedColIndex = 0; - for (; selectedColIndex < this.columns.length; selectedColIndex++) { - const col = this.columns[selectedColIndex]; - if (clientX >= x && clientX < x + col.width) { - break; - } - x += col.width; - } - return selectedColIndex; - } - } - - public isScrollInProgress(): boolean { - return this.scrollbarDragInProgress || Math.abs(this.touchScrollSpeedY) > 0; - } - - public getVisibleRowIndexes(): number[] { - return new Array(Math.floor(this.maxVisibleRows)) - .fill(0).map((v, n) => Math.round(this.topindex + n)); - } - - public selectRows() { - if (this.selectWhichRows === CanvasTable.RowSelect.Visible) { - this.selectAllVisibleRows(); - } else { - this.selectAllRows(); - } - } - - public selectAllRows() { - const allSelected = this.rows.allSelected(); - - this.rows.rows.forEach((rowobj, rowIndex) => - this.selectListener.rowSelected( - rowIndex, - 0, - !allSelected - ) - ); - } - - public selectAllVisibleRows() { - const visibleRowIndexes = this.getVisibleRowIndexes(); - - const visibleRowsAlreadySelected = visibleRowIndexes.reduce((prev, next) => - prev && - (next >= this.rows.rowCount() || this.rows.isSelectedRow(next)) - , true); - - visibleRowIndexes.forEach(selectedRowIndex => - this.selectListener.rowSelected(selectedRowIndex, - 0, - !visibleRowsAlreadySelected) - ); - - this.hasChanges = true; - } - - public selectRowByIndex(clientX: number, selectedRowIndex: number, multiSelect?: boolean) { - this.selectListener.rowSelected(selectedRowIndex, - this.getColIndexByClientX(clientX), - multiSelect); - - this.hasChanges = true; - } - - public autoAdjustColumnWidths(minwidth: number, tryFitScreenWidth = false) { - // Make innert - return - - const canvasWidth = Math.floor(window.devicePixelRatio) - this.scrollbarwidth - 2; - - const columnsTotalWidth = () => this.columns.reduce((prev, curr) => prev + curr.width, 0); - - if (!this.rowWrapMode && tryFitScreenWidth) { - // Reduce the width of the widest column to fit screen - - const findWidestColumn = () => this.columns.reduce((prev, curr) => - prev.width < curr.width ? curr : prev, this.columns[0]); - - if (columnsTotalWidth() < canvasWidth) { - // Restore original column widths since we are using less space than the canvas width - this.columns - .filter(col => col.originalWidth ? true : false) - .forEach(col => { - col.width = col.originalWidth; - col.originalWidth = null; - }); - } - - let widestColumn = findWidestColumn(); - - // Reduce column widths - while (widestColumn.width > minwidth && columnsTotalWidth() > canvasWidth) { - if (!widestColumn.originalWidth) { - widestColumn.originalWidth = widestColumn.width; - } - widestColumn.width--; - if (widestColumn.width < minwidth) { - widestColumn.width = minwidth; - } - widestColumn = findWidestColumn(); - } - } - - this.hasChanges = true; - } - - public scrollTop() { - this.topindex = 0; - this.hasChanges = true; - } - - public scrollUp() { - this.topindex--; - this.hasChanges = true; - } - - public scrollDown() { - this.topindex++; - this.hasChanges = true; - } - - public get rows(): MessageDisplay { - return this._rows; - } - - public set rows(rows: MessageDisplay) { - if (this._rows !== rows) { - this._rows = rows; - - this.hasChanges = true; - } - } - - public updateRows(newList) { - this.rows.setRows(newList); - this.hasChanges = true; - } - - public get showContentTextPreview(): boolean { - return this._showContentTextPreview; - } - - public set showContentTextPreview(showContentTextPreview: boolean) { - this._showContentTextPreview = showContentTextPreview; - this.hasChanges = true; - } - - // When loading a url with a fragment containing a msg id - scroll to there - public jumpToOpenMessage() { - // currently selected row in the centre: - if (this.rows.rowCount() > 0 && this.rows.openedRowIndex) { - this.topindex = this.rows.openedRowIndex - Math.round(this.maxVisibleRows / 2); - } - } - - // Height of message list rows - public get rowheight(): number { - return (this.rowWrapMode || this.showContentTextPreview ) ? - 1.75 * this._rowheight : this._rowheight; - } - - public set rowheight(rowheight: number) { - if (this._rowheight !== rowheight) { - this._rowheight = rowheight; - this.hasChanges = true; - } - } - -} - -@Component({ - // eslint-disable-next-line @angular-eslint/component-selector - selector: 'canvastablecontainer', - templateUrl: 'canvastablecontainer.component.html', - styleUrls: ['canvastablecontainer.component.scss'] -}) -export class CanvasTableContainerComponent { - colResizeInitialClientX: number; - colResizeColumnIndex: number; - colResizePreviousWidth: number; - - columnResized: boolean; - sortColumn = 0; - sortDescending = false; - - columnWidths = {}; - - preferenceService: PreferencesService; - - @Input() configname = 'default'; - @Input() canvastableselectlistener: CanvasTableSelectListener; - - @Output() sortToggled: EventEmitter = new EventEmitter(); - - @ViewChild(CanvasTableComponent, { static: true }) canvastable: CanvasTableComponent; - @ViewChild('tablecontainer') tablecontainer: ElementRef; - @ViewChild('tablebodycontainer') tablebodycontainer: ElementRef; - @ViewChild(MatMenuTrigger) trigger: MatMenuTrigger; - - RowSelect = CanvasTable.RowSelect; - private selectAllTimeout; - - saveColumnWidths() { - const newColWidths = {}; - const colWidthSet = this.canvastable.columns.map((col) => col.name).filter((cname) => cname.length > 0).join(','); - for (const c of this.canvastable.columns) { - newColWidths[c.name] = c.width; - } - this.columnWidths[colWidthSet] = newColWidths; - this.canvastableselectlistener.saveColumnWidthsPreference(this.columnWidths); - } - - colresizestart(clientX: number, colIndex: number) { - if (colIndex > 0) { - this.colResizeInitialClientX = clientX; - // We're always resizing the column before - this.colResizeColumnIndex = colIndex - 1; - this.colResizePreviousWidth = this.canvastable.columns[this.colResizeColumnIndex].width; - this.canvastable.columnResizeInProgress = true; - } - } - - colresize(clientX: number) { - if (this.colResizeInitialClientX) { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - - const column: CanvasTableColumn = this.canvastable.columns[this.colResizeColumnIndex]; - if (column && column.width) { - column.width = this.colResizePreviousWidth + (clientX - this.colResizeInitialClientX); - if (column.width < MIN_COLUMN_WIDTH) { - column.width = MIN_COLUMN_WIDTH; - } - this.canvastable.hasChanges = true; - this.columnResized = true; - - this.saveColumnWidths(); - } - } - } - - public sumWidthsBefore(colIndex: number) { - let ret = 0; - for (let n = 0; n < colIndex; n++) { - ret += this.canvastable.columns[n].width; - } - return ret; - } - - colresizeend() { - this.colResizeInitialClientX = null; - this.colResizeColumnIndex = null; - this.canvastable.columnResizeInProgress = false; - } - - horizScroll(evt: Event) { - this.canvastable.horizScroll = evt.target['scrollLeft']; - } - - handleTouchScroll(scrollValue: number) { - if (this.tablecontainer.nativeElement.scrollWidth > - this.tablecontainer.nativeElement.clientWidth) { - this.tablecontainer.nativeElement.scrollLeft = scrollValue; - } else { - this.canvastable.horizScroll = 0; - } - } - - public toggleSort(column: number) { - if (column === null) { - return; - } - - if (this.columnResized) { - this.columnResized = false; - return; - } - - if (column === this.sortColumn) { - this.sortDescending = !this.sortDescending; - } else { - this.sortColumn = column; - } - this.sortToggled.emit({ sortColumn: this.sortColumn, sortDescending: this.sortDescending }); - } - - public mouseOverSelectAll() { - this.selectAllTimeout = setTimeout(() => { - this.trigger.openMenu(); - }, 200); - } - - public mouseLeftSelectAll() { - if (this.selectAllTimeout) { - clearTimeout(this.selectAllTimeout); - this.trigger.closeMenu(); - this.selectAllTimeout = null; - } - } - -} - - -@NgModule({ - imports: [ - CommonModule, - MatTooltipModule, - MatButtonModule, - MatMenuModule, - MatRadioModule, - FormsModule, - MatIconModule - ], - declarations: [CanvasTableComponent, CanvasTableContainerComponent], - exports: [CanvasTableComponent, CanvasTableContainerComponent] -}) -export class CanvasTableModule { - -} diff --git a/src/app/canvastable/canvastablecolumn.ts b/src/app/canvastable/canvastablecolumn.ts deleted file mode 100644 index 50d75b4ad..000000000 --- a/src/app/canvastable/canvastablecolumn.ts +++ /dev/null @@ -1,48 +0,0 @@ -// --------- BEGIN RUNBOX LICENSE --------- -// Copyright (C) 2016-2022 Runbox Solutions AS (runbox.com). -// -// This file is part of Runbox 7. -// -// Runbox 7 is free software: You can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the -// Free Software Foundation, either version 3 of the License, or (at your -// option) any later version. -// -// Runbox 7 is distributed in the hope that it will be useful, but -// WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -// General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Runbox 7. If not, see . -// ---------- END RUNBOX LICENSE ---------- - -/* - * Copyright 2010-2020 FinTech Neo AS / Runbox ( fintechneo.com / runbox.com )- All rights reserved - */ - -export interface CanvasTableColumn { - name: string; - cacheKey: string; - - footerText?: string; - - width?: number; - originalWidth?: number; - font?: string; - backgroundColor?: string; - tooltipText?: string | ((rowobj: any) => string); - draggable?: boolean; - sortColumn: number; - rowWrapModeHidden?: boolean; - rowWrapModeMuted?: boolean; - rowWrapModeChipCounter?: boolean; // E.g. for displaying number of messages in conversation in a "chip"/"badge" - checkbox?: boolean; // checkbox for selecting rows - textAlign?: number; // default = left, 1 = right, 2 = center - getContentPreviewText?: (rowobj: any) => string; - - getValue(rowobj: any): any; - - getFormattedValue?(val: any): string; -} - diff --git a/src/app/canvastable/canvastablecontainer.component.html b/src/app/canvastable/canvastablecontainer.component.html deleted file mode 100644 index 8cdc464aa..000000000 --- a/src/app/canvastable/canvastablecontainer.component.html +++ /dev/null @@ -1,127 +0,0 @@ - -
- - - - - - - - Visible only - All rows - - - -
- {{col.name}} - - -
-
-
-
- -
- {{col.name}} -
-
-
- -
- - -
-
- {{col.footerText}} -
-
diff --git a/src/app/canvastable/canvastablecontainer.component.scss b/src/app/canvastable/canvastablecontainer.component.scss deleted file mode 100644 index 30f1578a6..000000000 --- a/src/app/canvastable/canvastablecontainer.component.scss +++ /dev/null @@ -1,4 +0,0 @@ -.sortIcon { - position: relative; - bottom: 3px; -} diff --git a/src/app/common/messagedisplay.ts b/src/app/common/messagedisplay.ts index daf1dedea..65b9c9923 100644 --- a/src/app/common/messagedisplay.ts +++ b/src/app/common/messagedisplay.ts @@ -16,7 +16,6 @@ // You should have received a copy of the GNU General Public License // along with Runbox 7. If not, see . // ---------- END RUNBOX LICENSE ---------- -import { CanvasTableColumn } from '../canvastable/canvastablecolumn'; export abstract class MessageDisplay { public openedRowIndex: number; @@ -202,6 +201,5 @@ export abstract class MessageDisplay { abstract filterBy(options: Map); // columns - abstract getCanvasTableColumns(app: any): CanvasTableColumn[]; abstract getRowData(index: number, app: any): any; } diff --git a/src/app/common/messagelist.ts b/src/app/common/messagelist.ts index b55fae1c5..cb329f66e 100644 --- a/src/app/common/messagelist.ts +++ b/src/app/common/messagelist.ts @@ -20,7 +20,6 @@ import { MessageDisplay } from '../common/messagedisplay'; import { MessageInfo } from './messageinfo'; import { MessageTableRowTool} from '../messagetable/messagetablerow'; -import { CanvasTableColumn } from '../canvastable/canvastablecolumn'; export class MessageList extends MessageDisplay { @@ -70,94 +69,6 @@ export class MessageList extends MessageDisplay { } } - public getCanvasTableColumns(app: any): CanvasTableColumn[] { - const columns: CanvasTableColumn[] = [ - { - sortColumn: null, - name: '', - cacheKey: 'selectbox', - rowWrapModeHidden: false, - getValue: (rowIndex: number): any => this.isSelectedRow(rowIndex), - checkbox: true, - draggable: true - }, - { - name: 'Date', - cacheKey: 'date', - sortColumn: null, - rowWrapModeMuted: true, - getValue: (rowIndex: number): string => this.getRow(rowIndex).messageDate.toJSON(), - getFormattedValue: (datestring) => MessageTableRowTool.formatTimestamp(datestring), - draggable: true - }, - { - name: app.selectedFolder === 'Sent' ? 'To' : 'From', - cacheKey: 'from', - sortColumn: null, - getValue: (rowIndex: number): any => app.selectedFolder === 'Sent' - ? this.getToColumnValueForRow(rowIndex) - : this.getFromColumnValueForRow(rowIndex), - draggable: true - }, - { - name: 'Subject', - cacheKey: 'subject', - sortColumn: null, - getValue: (rowIndex: number): string => this.getRow(rowIndex).subject, - draggable: true, - getContentPreviewText: (rowIndex): string => { - const ret = this.getRow(rowIndex).plaintext; - return ret ? ret.trim() : ''; - }, - // tooltipText: 'Tip: Drag subject to a folder to move message(s)' - }, - { - sortColumn: null, - name: 'Size', - cacheKey: 'size', - rowWrapModeHidden: true, - getValue: (rowIndex: number): number => this.getRow(rowIndex).size, - getFormattedValue: MessageTableRowTool.formatBytes, - draggable: true - }, - { - sortColumn: null, - name: '', - cacheKey: 'attachment', - textAlign: 2, - rowWrapModeHidden: true, - font: '16px \'Material Icons\'', - getValue: (rowIndex: number): boolean => this.getRow(rowIndex).attachment, - getFormattedValue: (val) => val ? '\uE226' : '', - tooltipText: 'Attachment' - }, - { - sortColumn: null, - name: '', - cacheKey: 'answered', - textAlign: 2, - rowWrapModeHidden: true, - font: '16px \'Material Icons\'', - getValue: (rowIndex: number): boolean => this.getRow(rowIndex).answeredFlag, - getFormattedValue: (val) => val ? '\uE15E' : '', - tooltipText: 'Answered' - }, - { - sortColumn: null, - name: '', - cacheKey: 'flagged', - textAlign: 2, - rowWrapModeHidden: true, - font: '16px \'Material Icons\'', - getValue: (rowIndex: number): boolean => this.getRow(rowIndex).flaggedFlag, - getFormattedValue: (val) => val ? '\uE153' : '', - tooltipText: 'Flagged' - } - ]; - - return columns; - } - getRowData(rowIndex, app) { const row = this.rows[rowIndex] diff --git a/src/app/websocketsearch/websocketsearchmaillist.ts b/src/app/websocketsearch/websocketsearchmaillist.ts index ee014182b..0fdbaefa6 100644 --- a/src/app/websocketsearch/websocketsearchmaillist.ts +++ b/src/app/websocketsearch/websocketsearchmaillist.ts @@ -20,7 +20,6 @@ import { MessageDisplay } from '../common/messagedisplay'; import { WebSocketSearchMailRow } from '../websocketsearch/websocketsearchmailrow.class'; import { MessageTableRowTool} from '../messagetable/messagetablerow'; -import { CanvasTableColumn } from '../canvastable/canvastablecolumn'; export class WebSocketSearchMailList extends MessageDisplay { @@ -51,63 +50,16 @@ export class WebSocketSearchMailList extends MessageDisplay { } } - public getCanvasTableColumns(app: any): CanvasTableColumn[] { - const columns: CanvasTableColumn[] = [ - { - sortColumn: null, - name: '', - cacheKey: 'selectbox', - rowWrapModeHidden: true, - getValue: (rowIndex: number): any => this.isSelectedRow(rowIndex), - checkbox: true, - }, - { - name: 'Date', - draggable: true, - cacheKey: 'date', - sortColumn: null, - rowWrapModeMuted: true, - getValue: (rowIndex: number): string => this.getRow(rowIndex).dateTime, - }, - { - name: 'From', - draggable: true, - cacheKey: 'from', - sortColumn: null, - getValue: (rowIndex: number): string => this.getRow(rowIndex).fromName, - }, - { - name: 'Subject', - cacheKey: 'subject', - sortColumn: null, - getValue: (rowIndex: number): string => this.getRow(rowIndex).subject, - draggable: true - // tooltipText: "Tip: Drag subject to a folder to move message(s)" - }, - { - sortColumn: null, - draggable: true, - name: 'Size', - cacheKey: 'size', - rowWrapModeHidden: true, - getValue: (rowIndex: number): number => this.getRow(rowIndex).size, - getFormattedValue: MessageTableRowTool.formatBytes, - } - ]; - - return columns; - } - - getRowData(rowIndex, app) { - return { - id: this.getRowMessageId(rowIndex), - selectbox: this.isSelectedRow(rowIndex), - messageDate: this.getRow(rowIndex).dateTime, - from: this.getRow(rowIndex).fromName, - subject: this.getRow(rowIndex).subject, - size: this.getRow(rowIndex).size, - seen: this.getRowSeen(rowIndex) - }; - } + getRowData(rowIndex, app) { + return { + id: this.getRowMessageId(rowIndex), + selectbox: this.isSelectedRow(rowIndex), + messageDate: this.getRow(rowIndex).dateTime, + from: this.getRow(rowIndex).fromName, + subject: this.getRow(rowIndex).subject, + size: this.getRow(rowIndex).size, + seen: this.getRowSeen(rowIndex) + }; + } } diff --git a/src/app/xapian/searchmessagedisplay.ts b/src/app/xapian/searchmessagedisplay.ts index 1c67d1df8..8463697f2 100644 --- a/src/app/xapian/searchmessagedisplay.ts +++ b/src/app/xapian/searchmessagedisplay.ts @@ -20,7 +20,6 @@ import { MessageDisplay } from '../common/messagedisplay'; import { SearchService } from './searchservice'; import { MessageTableRowTool} from '../messagetable/messagetablerow'; -import { CanvasTableColumn } from '../canvastable/canvastablecolumn'; export class SearchMessageDisplay extends MessageDisplay { private searchService: SearchService; @@ -54,168 +53,6 @@ export class SearchMessageDisplay extends MessageDisplay { filterBy(options: Map) { } - // columns - // app is a Component (currently) - public getCanvasTableColumns(app: any): CanvasTableColumn[] { - const columns: CanvasTableColumn[] = [ - { - sortColumn: null, - name: '', - cacheKey: 'selectbox', - rowWrapModeHidden: false, - getValue: (rowIndex): any => this.isSelectedRow(rowIndex), - checkbox: true - }, - { - name: 'Date', - draggable: true, - cacheKey: 'date', - sortColumn: 2, - rowWrapModeMuted : true, - getValue: (rowIndex): string => this.searchService.api.getStringValue(this.getRowId(rowIndex), 2), - getFormattedValue: (datestring) => MessageTableRowTool.formatTimestampFromStringWithoutSeparators(datestring) - }, - (app.selectedFolder.indexOf('Sent') === 0 && !app.displayFolderColumn) ? { - name: 'To', - draggable: true, - cacheKey: 'from', - sortColumn: null, - getValue: (rowIndex): string => this.searchService.getDocData(this.getRowId(rowIndex)).recipients.join(', '), - } : - { - name: 'From', - draggable: true, - cacheKey: 'from', - sortColumn: 0, - getValue: (rowIndex): string => { - return this.searchService.getDocData(this.getRowId(rowIndex)).from; - }, - }, - { - name: 'Subject', - cacheKey: 'subject', - sortColumn: 1, - getValue: (rowIndex): string => { - return this.searchService.getDocData(this.getRowId(rowIndex)).subject; - }, - draggable: true, - getContentPreviewText: (rowIndex): string => { - const ret = this.searchService.getDocData(this.getRowId(rowIndex)).textcontent; - return ret ? ret.trim() : ''; - }, - // tooltipText: 'Tip: Drag subject to a folder to move message(s)' - } - ]; - - if (app.viewmode === 'conversations') { - // Array containing row (conversation) objects waiting to be counted - let currentCountObject = null; - - const processCurrentCountObject = () => { - // Function for counting messages in a conversation - const rowObj = currentCountObject; - const conversationId = this.searchService.api.getStringValue(rowObj[0], 1); - this.searchService.api.setStringValueRange(1, 'conversation:'); - const conversationSearchText = `conversation:${conversationId}..${conversationId}`; - const results = this.searchService.api.sortedXapianQuery( - conversationSearchText, - 1, 0, 0, 1000, 1 - ); - this.searchService.api.clearValueRange(); - rowObj[2] = `${results[0][1] + 1}`; - - currentCountObject = null; - }; - - columns.push( - { - name: 'Count', - draggable: true, - cacheKey: 'count', - sortColumn: null, - rowWrapModeChipCounter: true, - getValue: (rowIndex): string => { - if (!this.getRow(rowIndex)[2]) { - if (currentCountObject === null) { - currentCountObject = this.getRow(rowIndex); - setTimeout(() => processCurrentCountObject(), 0); - } - return 'RETRY'; - } else { - return this.getRow(rowIndex)[2]; - } - }, - textAlign: 1, - }); - } else { - columns.push( - { - sortColumn: 3, - draggable: true, - name: 'Size', - cacheKey: 'size', - rowWrapModeHidden: true, - getValue: (rowIndex): string => { - return `${this.searchService.api.getNumericValue(this.getRowId(rowIndex), 3)}`; - }, - getFormattedValue: (val) => val === '-1' ? '\u267B' : MessageTableRowTool.formatBytes(val), - tooltipText: (rowIndex) => this.searchService.api.getNumericValue(this.getRowId(rowIndex), 3) === -1 ? - 'This message is marked for deletion by an IMAP client' : null - }); - - if (app.displayFolderColumn) { - columns.push({ - sortColumn: null, - name: 'Folder', - cacheKey: 'folder', - rowWrapModeHidden: true, - getValue: (rowIndex): string => this.searchService.getDocData(this.getRowId(rowIndex)).folder, - width: 200 - }); - } - - // Attachment flag column - columns.push({ - sortColumn: null, - name: '', - cacheKey: 'attachment', - textAlign: 2, - rowWrapModeHidden: true, - font: '16px \'Material Icons\'', - getValue: (rowIndex): boolean => this.searchService.getDocData(this.getRowId(rowIndex)).attachment ? true : false, - width: 35, - getFormattedValue: (val) => val ? '\uE226' : '' - }); - - // Answered flag column - columns.push({ - sortColumn: null, - name: '', - cacheKey: 'answered', - textAlign: 2, - rowWrapModeHidden: true, - font: '16px \'Material Icons\'', - getValue: (rowIndex): boolean => this.searchService.getDocData(this.getRowId(rowIndex)).answered ? true : false, - width: 35, - getFormattedValue: (val) => val ? '\uE15E' : '' - }); - - // Flagged flag column - columns.push({ - sortColumn: null, - name: '', - cacheKey: 'flagged', - textAlign: 2, - rowWrapModeHidden: true, - font: '16px \'Material Icons\'', - getValue: (rowIndex): boolean => this.searchService.getDocData(this.getRowId(rowIndex)).flagged ? true : false, - width: 35, - getFormattedValue: (val) => val ? '\uE153' : '' - }); - } - return columns; - } - public getRowData(index: number, app: any) { const rowData: any = { id: this.getRowMessageId(index), From c3b4e50bc47aca440b5d10f47efd0c1671cca10e Mon Sep 17 00:00:00 2001 From: shadowbas Date: Wed, 3 Sep 2025 15:46:42 +0300 Subject: [PATCH 16/30] Remove the dollar postfix for rxjs streams --- src/app/app.component.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 18196d731..70db3bd48 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -88,10 +88,10 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { rows = []; private rowsSubject= new BehaviorSubject(this.rows); - debouncedRows$ = this.rowsSubject.asObservable().pipe(debounceTime(300)); + debouncedRows = this.rowsSubject.asObservable().pipe(debounceTime(300)); lastCheckedIndex = -1; - scrollToIndex$ = new BehaviorSubject(0); + scrollToIndex = new BehaviorSubject(0); rowSelectionModel = new FilterSelectionModel( false, [], @@ -928,7 +928,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { this.lastCheckedIndex = rowIndex if (shouldScroll) { - this.scrollToIndex$.next(rowIndex - 1); + this.scrollToIndex.next(rowIndex - 1); } if ((this.selectedFolder === this.messagelistservice.templateFolderName) && !isSelect) { @@ -1172,7 +1172,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } onFolderSelect(folder: string) { - this.scrollToIndex$.next(0); + this.scrollToIndex.next(0); this.selectFolder(folder) } From 94f0325dfc409a1142c99e9fd6587fdabdb4682f Mon Sep 17 00:00:00 2001 From: shadowbas Date: Wed, 3 Sep 2025 16:23:35 +0300 Subject: [PATCH 17/30] Do reset column no longer used --- src/app/app.component.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 70db3bd48..99651b87b 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -38,7 +38,7 @@ import { DraftDeskService } from './compose/draftdesk.service'; import { RMM7MessageActions } from './mailviewer/rmm7messageactions'; import { FolderListComponent, CreateFolderEvent, RenameFolderEvent, MoveFolderEvent } from './folder/folder.module'; import { SimpleInputDialog, SimpleInputDialogParams } from './dialog/dialog.module'; -import { map, take, skip, mergeMap, filter, tap, debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { map, mergeMap, filter, tap, debounceTime, distinctUntilChanged } from 'rxjs/operators'; import { WebSocketSearchService } from './websocketsearch/websocketsearch.service'; import { WebSocketSearchMailList } from './websocketsearch/websocketsearchmaillist'; @@ -1194,11 +1194,6 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { console.log('Change selectedFolder'); this.clearSelection(); - let doResetColumns = false; - if (folder.startsWith('Sent') || this.selectedFolder?.startsWith('Sent')) { - doResetColumns = true; - } - this.selectedFolder = folder; this.messagelistservice.setCurrentFolder(folder); @@ -1206,7 +1201,6 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { if (this.viewmode === 'singleconversation') { this.viewmode = 'conversations'; this.conversationSearchText = undefined; - doResetColumns = true; } if (this.hasChildRouterOutlet) { From b7f451b61def45f3e593faa8e286546cb8b6ba7e Mon Sep 17 00:00:00 2001 From: shadowbas Date: Wed, 3 Sep 2025 16:44:49 +0300 Subject: [PATCH 18/30] Fix lint issues of accessible table changes --- src/app/common/human-bytes.ts | 2 +- src/app/models/bindable-selection-model.ts | 2 +- src/app/resizable-button/resizable-button.component.ts | 4 ++-- .../virtual-scroll-table/virtual-scroll-table.component.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/common/human-bytes.ts b/src/app/common/human-bytes.ts index 5eabde644..21f4f2d20 100644 --- a/src/app/common/human-bytes.ts +++ b/src/app/common/human-bytes.ts @@ -17,7 +17,7 @@ // along with Runbox 7. If not, see . // ---------- END RUNBOX LICENSE ---------- -export default function humanBytes(value: number, decimalPlaces: number = 0): string { +export default function humanBytes(value: number, decimalPlaces = 0): string { if (value === 0) { return '0 B'; } diff --git a/src/app/models/bindable-selection-model.ts b/src/app/models/bindable-selection-model.ts index c7fe3a0ea..637b6b22f 100644 --- a/src/app/models/bindable-selection-model.ts +++ b/src/app/models/bindable-selection-model.ts @@ -25,7 +25,7 @@ export class BindableSelectionModel { constructor( multiple: boolean, initialValues: T[] = [], - emitChanges: boolean = true, + emitChanges = true, compareWith: (a: T, b: T) => boolean = (a, b) => a === b, ) { this.selectionModel = new SelectionModel(multiple, initialValues, emitChanges, compareWith); diff --git a/src/app/resizable-button/resizable-button.component.ts b/src/app/resizable-button/resizable-button.component.ts index 184835223..6b8ae84dc 100644 --- a/src/app/resizable-button/resizable-button.component.ts +++ b/src/app/resizable-button/resizable-button.component.ts @@ -35,8 +35,8 @@ export class ResizableButtonComponent implements OnChanges { @Output() widthChange = new EventEmitter(); isResizing = false; - private startX: number = 0; - private startWidth: number = 0; + private startX = 0; + private startWidth = 0; // Hold the reference to the event listeners private onMouseMoveListener: (event: MouseEvent) => void; diff --git a/src/app/virtual-scroll-table/virtual-scroll-table.component.ts b/src/app/virtual-scroll-table/virtual-scroll-table.component.ts index 71bf33a1c..0310339c8 100644 --- a/src/app/virtual-scroll-table/virtual-scroll-table.component.ts +++ b/src/app/virtual-scroll-table/virtual-scroll-table.component.ts @@ -57,7 +57,7 @@ export class VirtualScrollTableComponent implements OnInit, OnDestroy, AfterView @Input() scrollToIndex$!: BehaviorSubject; - firstRowHeight: number = 24; + firstRowHeight = 24; maxBufferPx: number; private renderedRangeSub!: Subscription; @@ -116,7 +116,7 @@ export class VirtualScrollTableComponent implements OnInit, OnDestroy, AfterView return index; } - doScrollToIndex(index: number, retries: number = 5, delayMs: number = 500): void { + doScrollToIndex(index: number, retries = 5, delayMs = 500): void { if (!this.viewport || index == null) return; if (this.pendingScrollToIndex == null) return From 23b040fedc4a56de69a04391de644dd2226c99f5 Mon Sep 17 00:00:00 2001 From: shadowbas Date: Thu, 4 Sep 2025 00:07:56 +0300 Subject: [PATCH 19/30] Fix failing build --- src/app/websocketsearch/websocketsearchmaillist.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/websocketsearch/websocketsearchmaillist.ts b/src/app/websocketsearch/websocketsearchmaillist.ts index 0fdbaefa6..20d65abeb 100644 --- a/src/app/websocketsearch/websocketsearchmaillist.ts +++ b/src/app/websocketsearch/websocketsearchmaillist.ts @@ -19,7 +19,6 @@ import { MessageDisplay } from '../common/messagedisplay'; import { WebSocketSearchMailRow } from '../websocketsearch/websocketsearchmailrow.class'; -import { MessageTableRowTool} from '../messagetable/messagetablerow'; export class WebSocketSearchMailList extends MessageDisplay { From 3e0375c472722673a5568e7b2d175be3a8577c00 Mon Sep 17 00:00:00 2001 From: Bas Date: Thu, 4 Sep 2025 08:40:47 +0000 Subject: [PATCH 20/30] fixup! Fix failing build --- src/app/app.component.ts | 82 ++++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 99651b87b..e3c51fb9c 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -197,7 +197,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { sortColumn: 2, sortDescending: true }; - canvastable = { + messageTable = { rows: null, hasChanges: true, showContentTextPreview: true, @@ -275,19 +275,19 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { if (evt.code === 'ArrowUp') { // slightly ugly as we need to call *this* rowSelected, not // the cvtable one - const newRowIndex = this.canvastable.rows.openedRowIndex - 1; + const newRowIndex = this.messageTable.rows.openedRowIndex - 1; if (newRowIndex >= 0) { this.rowSelected(newRowIndex, 3, false); - this.canvastable.hasChanges = true; + this.messageTable.hasChanges = true; evt.preventDefault(); } } else if (evt.code === 'ArrowDown') { // slightly ugly as we need to call *this* rowSelected, not // the cvtable one - const newRowIndex = this.canvastable.rows.openedRowIndex + 1; - if (newRowIndex < this.canvastable.rows.rowCount()) { + const newRowIndex = this.messageTable.rows.openedRowIndex + 1; + if (newRowIndex < this.messageTable.rows.rowCount()) { this.rowSelected(newRowIndex, 3, false); - this.canvastable.hasChanges = true; + this.messageTable.hasChanges = true; evt.preventDefault(); } } @@ -330,8 +330,8 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { preferenceService.preferences.subscribe((prefs) => { // message list prefs - if (this.canvastable) { - this.canvastable.showContentTextPreview = prefs.get(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SHOWCONTENTPREVIEW}`) === 'true'; + if (this.messageTable) { + this.messageTable.showContentTextPreview = prefs.get(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SHOWCONTENTPREVIEW}`) === 'true'; } this.keepMessagePaneOpen = prefs.get(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_KEEP_PANE}`) === 'true'; this.unreadMessagesOnlyCheckbox = prefs.get(`${DefaultPrefGroups.Global}:${LOCAL_STORAGE_SHOW_UNREAD_ONLY}`) === 'true'; @@ -399,7 +399,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { await firstValueFrom(this.xapianLoaded); if (this.preferences.has(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SHOWCONTENTPREVIEW}`)) { - this.canvastable.showContentTextPreview = this.preferences.get(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SHOWCONTENTPREVIEW}`) === 'true'; + this.messageTable.showContentTextPreview = this.preferences.get(`${this.preferenceService.prefGroup}:${LOCAL_STORAGE_SHOWCONTENTPREVIEW}`) === 'true'; } this.orderSelectionModel.selected = { data: 2, @@ -447,7 +447,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { if (fragment !== this.fragment) { this.fragment = fragment; this.selectMessageFromFragment(this.fragment); - if (this.canvastable.rows && this.canvastable.rows.rowCount() > 0) { + if (this.messageTable.rows && this.messageTable.rows.rowCount() > 0) { return } else { this.jumpToFragment = true; @@ -653,7 +653,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { await this.draftDeskService.newBugReport( this.searchService.localSearchActivated, this.keepMessagePaneOpen, - this.canvastable.showContentTextPreview, + this.messageTable.showContentTextPreview, this.mailViewerOnRightSide, this.unreadMessagesOnlyCheckbox, this.mobileQuery.matches @@ -678,7 +678,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } saveContentPreviewSetting(): void { - const setting = this.canvastable.showContentTextPreview ? 'true' : 'false'; + const setting = this.messageTable.showContentTextPreview ? 'true' : 'false'; this.preferenceService.set(this.preferenceService.prefGroup, LOCAL_STORAGE_SHOWCONTENTPREVIEW, setting); // localStorage.setItem(LOCAL_STORAGE_SHOWCONTENTPREVIEW, setting); } @@ -696,7 +696,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { // Move to spam folder (delete from index), set spam flag if (params.is_spam) { // remove from message display - this.canvastable.rows.removeMessages(messageIds); + this.messageTable.rows.removeMessages(messageIds); this.searchService.deleteMessages(msgIds); this.messagelistservice.moveMessages(msgIds, this.messagelistservice.spamFolderName, true); } else { @@ -819,7 +819,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { messageIds: messageIds, updateLocal: (msgIds: number[]) => { // remove from message display - this.canvastable.rows.removeMessages(messageIds); + this.messageTable.rows.removeMessages(messageIds); this.searchService.deleteMessages(msgIds); if (this.selectedFolder === this.messagelistservice.trashFolderName) { this.messagelistservice.deleteTrashMessages(msgIds); @@ -838,7 +838,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { public deleteLocalIndex() { if (this.searchService.localSearchActivated || this.dataReady) { this.usewebsocketsearch = true; - this.canvastable.rows = null; + this.messageTable.rows = null; this.viewmode = 'messages'; this.conversationGroupingCheckbox = this.viewmode === 'conversations'; this.preferenceService.set(this.preferenceService.prefGroup, LOCAL_STORAGE_VIEWMODE, this.viewmode); @@ -860,34 +860,34 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } updateRowS(newList) { - this.canvastable.rows.setRows(newList); - this.canvastable.hasChanges = true; + this.messageTable.rows.setRows(newList); + this.messageTable.hasChanges = true; } public setMessageDisplay(displayType: string, ...args) { if (displayType === 'search') { - if (this.canvastable.rows instanceof SearchMessageDisplay) { + if (this.messageTable.rows instanceof SearchMessageDisplay) { this.updateRowS(args[1]); } else { - this.canvastable.rows = new SearchMessageDisplay(...args); + this.messageTable.rows = new SearchMessageDisplay(...args); // messages updated, check if we need to select a message from the fragment this.selectMessageFromFragment(this.fragment); } } if (displayType === 'messagelist') { - if (this.canvastable.rows instanceof MessageList) { + if (this.messageTable.rows instanceof MessageList) { this.updateRowS(args[0]); } else { - this.canvastable.rows = new MessageList(...args); + this.messageTable.rows = new MessageList(...args); // messages updated, check if we need to select a message from the fragment this.selectMessageFromFragment(this.fragment); } } if (displayType === 'websocketlist') { - if (this.canvastable.rows instanceof WebSocketSearchMailList) { + if (this.messageTable.rows instanceof WebSocketSearchMailList) { this.updateRowS(args[0]); } else { - this.canvastable.rows = new WebSocketSearchMailList(...args); + this.messageTable.rows = new WebSocketSearchMailList(...args); // messages updated, check if we need to select a message from the fragment this.selectMessageFromFragment(this.fragment); } @@ -899,12 +899,12 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } public filterMessageDisplay() { - if (this.canvastable.rows && this.canvastable.rows.rowCount() > 0) { + if (this.messageTable.rows && this.messageTable.rows.rowCount() > 0) { const options = new Map(); options.set('unreadOnly', this.unreadMessagesOnlyCheckbox); options.set('searchText', this.searchText); - this.canvastable.rows.filterBy(options); - this.canvastable.hasChanges = true; + this.messageTable.rows.filterBy(options); + this.messageTable.hasChanges = true; } } @@ -913,7 +913,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } public selectRowByMessageId(messageId: number) { - const matchingRowIndex = this.canvastable.rows.findRowByMessageId(messageId); + const matchingRowIndex = this.messageTable.rows.findRowByMessageId(messageId); if (matchingRowIndex > -1) { this.rowSelectionModel.select({id: messageId}); this.rowSelected(matchingRowIndex, 1, false); @@ -933,18 +933,18 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { if ((this.selectedFolder === this.messagelistservice.templateFolderName) && !isSelect) { this.draftDeskService.newTemplateDraft( - this.canvastable.rows.getRowMessageId(rowIndex) + this.messageTable.rows.getRowMessageId(rowIndex) ); this.drafts(); return; } - this.canvastable.rows.rowSelected(rowIndex, columnIndex, multiSelect); + this.messageTable.rows.rowSelected(rowIndex, columnIndex, multiSelect); - if (this.canvastable.rows.hasChanges) { - this.updateUrlFragment(this.canvastable.rows.getRowMessageId(rowIndex)); - this.singlemailviewer.messageId = this.canvastable.rows.getRowMessageId(rowIndex); + if (this.messageTable.rows.hasChanges) { + this.updateUrlFragment(this.messageTable.rows.getRowMessageId(rowIndex)); + this.singlemailviewer.messageId = this.messageTable.rows.getRowMessageId(rowIndex); if (!this.mobileQuery.matches && !this.messageSubjectDragTipShown) { this.snackBar.open('Tip: Drag subject to a folder to move message(s)' , 'Got it'); @@ -952,20 +952,20 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } // FIXME: [2] is searchservice specific! - if (this.viewmode === 'conversations' && this.canvastable.rows.getCurrentRow()[2] !== '1') { + if (this.viewmode === 'conversations' && this.messageTable.rows.getCurrentRow()[2] !== '1') { this.viewmode = 'singleconversation'; this.clearSelection(); // FIXME [0] is searchservice specific! const conversationId = - this.searchService.api.getStringValue(this.canvastable.rows.getCurrentRow()[0], 1) + this.searchService.api.getStringValue(this.messageTable.rows.getCurrentRow()[0], 1) .replace(/[^0-9A-Z]/g, '_'); this.conversationSearchText = 'conversation:' + conversationId + '..' + conversationId; this.updateSearch(true); } - this.canvastable.hasChanges = true; + this.messageTable.hasChanges = true; } } @@ -1055,7 +1055,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } singleMailViewerClosed(): void { - this.canvastable.rows.clearOpenedRow(); + this.messageTable.rows.clearOpenedRow(); this.updateUrlFragment(); } @@ -1105,7 +1105,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { // moveMessagesToFolder cant see these cos not in index if (this.messagelistservice.unindexedFolders.includes(this.selectedFolder)) { // remove from current message display - this.canvastable.rows.removeMessages(messageIds); + this.messageTable.rows.removeMessages(messageIds); this.searchService.moveMessagesToFolder(msgIds, folderPath); } this.messagelistservice.moveMessages(msgIds, folderPath); @@ -1141,7 +1141,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { if (this.selectedFolder !== this.messagelistservice.spamFolderName && this.selectedFolder !== this.messagelistservice.trashFolderName) { // remove from current message display - this.canvastable.rows.removeMessages(messageIds); + this.messageTable.rows.removeMessages(messageIds); this.searchService.moveMessagesToFolder(msgIds, folderPath); } this.messagelistservice.moveMessages(msgIds, folderPath); @@ -1405,20 +1405,20 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } updateRows() { - this.rows = this.canvastable?.rows?.rows ? [...this.canvastable.rows.rows] : [] + this.rows = this.messageTable?.rows?.rows ? [...this.messageTable.rows.rows] : [] return this.enrichRows() } async enrichRows() { - if (!this.canvastable.rows) return; + if (!this.messageTable.rows) return; const { start, end } = this.renderedRange; for (let index = start; index < end; index++) { if (index >= this.rows.length) break - this.rows[index] = this.canvastable.rows.getRowData(index, this) + this.rows[index] = this.messageTable.rows.getRowData(index, this) this.rows[index].plaintext = this.searchService.messageText(this.rows[index].id) this.rows[index].loaded = true } From 4122b13ef49ac6a0ee1893f64ac924e7fefbe42c Mon Sep 17 00:00:00 2001 From: Bas Date: Thu, 4 Sep 2025 08:43:33 +0000 Subject: [PATCH 21/30] fixup! fixup! Fix failing build --- src/app/app.component.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index e3c51fb9c..ece43a5e2 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -859,7 +859,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } } - updateRowS(newList) { + setMessageTableRows(newList) { this.messageTable.rows.setRows(newList); this.messageTable.hasChanges = true; } @@ -867,7 +867,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { public setMessageDisplay(displayType: string, ...args) { if (displayType === 'search') { if (this.messageTable.rows instanceof SearchMessageDisplay) { - this.updateRowS(args[1]); + this.setMessageTableRows(args[1]); } else { this.messageTable.rows = new SearchMessageDisplay(...args); // messages updated, check if we need to select a message from the fragment @@ -876,7 +876,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } if (displayType === 'messagelist') { if (this.messageTable.rows instanceof MessageList) { - this.updateRowS(args[0]); + this.setMessageTableRows(args[0]); } else { this.messageTable.rows = new MessageList(...args); // messages updated, check if we need to select a message from the fragment @@ -885,7 +885,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { } if (displayType === 'websocketlist') { if (this.messageTable.rows instanceof WebSocketSearchMailList) { - this.updateRowS(args[0]); + this.setMessageTableRows(args[0]); } else { this.messageTable.rows = new WebSocketSearchMailList(...args); // messages updated, check if we need to select a message from the fragment From 7af8acc7d361570af3004bc9a97277d0a2c7dbd3 Mon Sep 17 00:00:00 2001 From: Bas Date: Thu, 4 Sep 2025 08:48:55 +0000 Subject: [PATCH 22/30] fixup! fixup! fixup! Fix failing build --- src/app/app.component.html | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/app.component.html b/src/app/app.component.html index a1364182a..a624ee6f4 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -332,8 +332,7 @@

No Message Selected

No Message Selected - + @@ -648,7 +647,7 @@

No Message Selected

- + From 3ece24054d7517418253107dc19208137844f57c Mon Sep 17 00:00:00 2001 From: Bas Date: Thu, 4 Sep 2025 08:59:35 +0000 Subject: [PATCH 23/30] fixup! fixup! fixup! fixup! Fix failing build --- src/app/app.component.html | 2 +- src/styles.scss | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/app.component.html b/src/app/app.component.html index a624ee6f4..fcea36763 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -410,7 +410,7 @@

No Message Selected

Date: Thu, 4 Sep 2025 09:02:41 +0000 Subject: [PATCH 24/30] fixup! fixup! fixup! fixup! fixup! Fix failing build --- src/app/app.component.scss | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 8af57bd97..42f76a4d1 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -2,10 +2,6 @@ border-right: 1px solid darkgrey !important; } -canvastablecontainer { - opacity: 0; -} - #rightPane { border-left: 1px solid darkgrey !important; } @@ -80,7 +76,7 @@ canvastablecontainer { } -#canvasTableContainerArea { +#messageTableContainerArea { container-type: inline-size; // or use `container: inline-size;` .messages-table { From cbd1a6786a81d217af7bfe4deded0151c9b0b51d Mon Sep 17 00:00:00 2001 From: Bas Date: Thu, 4 Sep 2025 09:05:06 +0000 Subject: [PATCH 25/30] fixup! fixup! fixup! fixup! fixup! fixup! Fix failing build --- src/styles.scss | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/styles.scss b/src/styles.scss index 32f8700ce..0d7093269 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1718,12 +1718,6 @@ runbox-section-header mat-slide-toggle { display: inline; } -canvastable { - [draggable] { - cursor: pointer; - } -} - // helpers for multi-row mobile-friendly mat-tables tr.detailsRow { height: 0 !important; From 956b58364d47b8017fabe7fe64e32d7cec26511b Mon Sep 17 00:00:00 2001 From: Bas Date: Mon, 8 Sep 2025 10:53:54 +0000 Subject: [PATCH 26/30] Trying to fix reactive bulk action issue --- src/app/app.component.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index ece43a5e2..5ae971240 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -607,7 +607,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { async emptyTrash(trashFolder: FolderListEntry) { console.log('found trash folder with name', trashFolder.folderName); - this.messageActionsHandler.updateMessages({ + await this.updateMessages({ messageIds: [], updateLocal: (msgIds: number[]) => { this.messagelistservice.pretendEmptyTrash(); @@ -628,7 +628,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { ).toPromise(); const messageIds = messageLists.map(idValue); - this.messageActionsHandler.updateMessages({ + await this.updateMessages({ messageIds: messageIds, updateLocal: (msgIds: number[]) => this.messagelistservice.moveMessages(msgIds, this.messagelistservice.trashFolderName), updateRemote: (msgIds: number[]) => @@ -690,7 +690,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { // ensure valid IDs const messageIds = unfilteredMessageIds.filter(id => Number.isInteger(id)); - this.messageActionsHandler.updateMessages({ + this.updateMessages({ messageIds: messageIds, updateLocal: (msgIds: number[]) => { // Move to spam folder (delete from index), set spam flag @@ -758,7 +758,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { this.snackBar.open('Toggling read status...'); const messageIds = this.selectedMessageIds; - this.messageActionsHandler.updateMessages({ + this.updateMessages({ messageIds: messageIds, updateLocal: (msgIds: number[]) => { msgIds.forEach( (id) => { @@ -786,7 +786,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { this.snackBar.open('Toggling flags...'); const messageIds = this.selectedMessageIds; - this.messageActionsHandler.updateMessages({ + this.updateMessages({ messageIds: messageIds, updateLocal: (msgIds: number[]) => { msgIds.forEach( (id) => { @@ -815,7 +815,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { public deleteMessages() { const messageIds = this.selectedMessageIds; - this.messageActionsHandler.updateMessages({ + this.updateMessages({ messageIds: messageIds, updateLocal: (msgIds: number[]) => { // remove from message display @@ -1095,7 +1095,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { dropToFolder(folderId): void { const messageIds = this.selectedMessageIds - this.messageActionsHandler.updateMessages({ + this.updateMessages({ messageIds: messageIds, updateLocal: (msgIds: number[]) => { const folders = this.messagelistservice.folderListSubject.value; @@ -1130,7 +1130,7 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { // dialogRef.componentInstance.selectedMessageIds = messageIds; dialogRef.afterClosed().subscribe(folder => { if (folder) { - this.messageActionsHandler.updateMessages({ + this.updateMessages({ messageIds: messageIds, updateLocal: (msgIds: number[]) => { const folders = this.messagelistservice.folderListSubject.value; @@ -1527,6 +1527,12 @@ export class AppComponent implements OnInit, AfterViewInit, DoCheck { this.enrichRows() } + async updateMessages(args) { + await this.messageActionsHandler.updateMessages(args); + setTimeout(() => { + this.updateSearch(true); + }, 1000); + } } const idValue = (x: any) => x.id From 4e8542ca3a101d69835cda70cae557ea507a3866 Mon Sep 17 00:00:00 2001 From: shadowbas Date: Tue, 9 Sep 2025 14:06:32 +0300 Subject: [PATCH 27/30] Remove update message list height callback from app template --- src/app/app.component.html | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/app.component.html b/src/app/app.component.html index fcea36763..ecccddee2 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -669,7 +669,6 @@

No Message Selected

[adjustableHeight]="true" [showVerticalSplitButton]="allowMailViewerOrientationChange" [messageActionsHandler]="messageActionsHandler" - (afterLoadMessage)="updateMessageListHeight()" (orientationChangeRequest)="mailViewerOrientationChangeRequest($event)" (onClose)="singleMailViewerClosed($event)">
From 706a346c48270fe2d2d9762f3b984607fc18dce3 Mon Sep 17 00:00:00 2001 From: shadowbas Date: Tue, 9 Sep 2025 14:27:31 +0300 Subject: [PATCH 28/30] Fix scroll to index rename --- src/app/app.component.html | 2 +- .../virtual-scroll-table/virtual-scroll-table.component.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/app.component.html b/src/app/app.component.html index ecccddee2..8105842e4 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -414,7 +414,7 @@

No Message Selected

[ngStyle]="{'bottom.px': canvasTableBtmOffset}"> (); @Input() items: any[] = []; - @Input() scrollToIndex$!: BehaviorSubject; + @Input() scrollToIndex!: BehaviorSubject; firstRowHeight = 24; maxBufferPx: number; @@ -71,7 +71,7 @@ export class VirtualScrollTableComponent implements OnInit, OnDestroy, AfterView constructor(private elementRef: ElementRef) {} ngOnInit() { - this.scrollToIndexSub = this.scrollToIndex$.subscribe(index => { + this.scrollToIndexSub = this.scrollToIndex.subscribe(index => { this.pendingScrollToIndex = index; this.inputChanges$.next() }); From e67b0072dbc98548f619da826c87f77d12206a45 Mon Sep 17 00:00:00 2001 From: shadowbas Date: Tue, 9 Sep 2025 14:28:22 +0300 Subject: [PATCH 29/30] Remove unused mutations arg --- src/app/virtual-scroll-table/virtual-scroll-table.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/virtual-scroll-table/virtual-scroll-table.component.ts b/src/app/virtual-scroll-table/virtual-scroll-table.component.ts index bdb5883db..8ff36b735 100644 --- a/src/app/virtual-scroll-table/virtual-scroll-table.component.ts +++ b/src/app/virtual-scroll-table/virtual-scroll-table.component.ts @@ -94,7 +94,7 @@ export class VirtualScrollTableComponent implements OnInit, OnDestroy, AfterView const elem = this.elementRef.nativeElement; - this.mutationObserver = new MutationObserver((mutations) => { + this.mutationObserver = new MutationObserver(() => { this.inputChanges$.next(); }); From 26207f092ea1365c8bb286fea28a3b78a464c8c6 Mon Sep 17 00:00:00 2001 From: shadowbas Date: Tue, 9 Sep 2025 14:40:12 +0300 Subject: [PATCH 30/30] fixup! Remove update message list height callback from app template --- src/app/app.component.html | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/app.component.html b/src/app/app.component.html index 8105842e4..1d7647071 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -167,7 +167,6 @@