From cc204cedccef2b77a8c22c273641a25309318897 Mon Sep 17 00:00:00 2001 From: johaven Date: Sat, 31 Oct 2026 01:09:47 +0100 Subject: [PATCH 1/3] feat(users): add single-click navigation preference --- .../users/dto/user-properties.dto.spec.ts | 9 + .../users/dto/user-properties.dto.ts | 1 + .../interfaces/user-preferences.interface.ts | 1 + .../users/models/user.model.spec.ts | 13 +- .../services/users-manager.service.spec.ts | 12 ++ .../users/services/users-manager.service.ts | 1 + .../services/users-queries.service.spec.ts | 33 ++++ .../users/services/users-queries.service.ts | 1 + backend/src/applications/users/utils/user.ts | 1 + .../components/spaces-browser.component.html | 6 +- .../components/spaces-browser.component.ts | 13 +- .../components/user-account.component.html | 178 ++++++++++-------- .../components/user-account.component.ts | 4 + .../app/applications/users/user.service.ts | 8 +- frontend/src/i18n/ca.json | 3 + frontend/src/i18n/de.json | 3 + frontend/src/i18n/es.json | 3 + frontend/src/i18n/fr.json | 3 + frontend/src/i18n/hi.json | 3 + frontend/src/i18n/it.json | 3 + frontend/src/i18n/ja.json | 3 + frontend/src/i18n/ko.json | 3 + frontend/src/i18n/nl.json | 3 + frontend/src/i18n/pl.json | 3 + frontend/src/i18n/pt-BR.json | 3 + frontend/src/i18n/pt.json | 3 + frontend/src/i18n/ru.json | 3 + frontend/src/i18n/tr.json | 3 + frontend/src/i18n/zh.json | 3 + 29 files changed, 242 insertions(+), 84 deletions(-) create mode 100644 backend/src/applications/users/dto/user-properties.dto.spec.ts diff --git a/backend/src/applications/users/dto/user-properties.dto.spec.ts b/backend/src/applications/users/dto/user-properties.dto.spec.ts new file mode 100644 index 00000000..bc6a3886 --- /dev/null +++ b/backend/src/applications/users/dto/user-properties.dto.spec.ts @@ -0,0 +1,9 @@ +import { userPreferencesSchema } from './user-properties.dto.js' + +describe('userPreferencesSchema', () => { + it('accepts only boolean values for the single-click preference', () => { + expect(userPreferencesSchema.parse({ singleClickNavigation: true })).toEqual({ singleClickNavigation: true }) + expect(userPreferencesSchema.parse({})).toEqual({}) + expect(userPreferencesSchema.safeParse({ singleClickNavigation: 'true' }).success).toBe(false) + }) +}) diff --git a/backend/src/applications/users/dto/user-properties.dto.ts b/backend/src/applications/users/dto/user-properties.dto.ts index b134c869..fc1b9906 100644 --- a/backend/src/applications/users/dto/user-properties.dto.ts +++ b/backend/src/applications/users/dto/user-properties.dto.ts @@ -28,6 +28,7 @@ export const userPreferencesSchema = z.object({ theme: z.enum(USER_THEME).optional(), editor: z.enum([COLLABORA_EDITOR, ONLY_OFFICE_EDITOR, EURO_OFFICE_EDITOR]).nullish(), showHiddenFiles: z.boolean().optional(), + singleClickNavigation: z.boolean().optional(), useSystemNotifications: z.boolean().optional(), sidebarQuickAccessVisibility: z.enum(USER_SIDEBAR_QUICK_ACCESS_VISIBILITY).optional(), sidebarQuickAccessPosition: z.enum(USER_SIDEBAR_QUICK_ACCESS_POSITION).optional() diff --git a/backend/src/applications/users/interfaces/user-preferences.interface.ts b/backend/src/applications/users/interfaces/user-preferences.interface.ts index 2badb5ac..b5093018 100644 --- a/backend/src/applications/users/interfaces/user-preferences.interface.ts +++ b/backend/src/applications/users/interfaces/user-preferences.interface.ts @@ -7,6 +7,7 @@ export interface UserPreferences { theme: USER_THEME editor: Exclude | null showHiddenFiles: boolean + singleClickNavigation: boolean useSystemNotifications: boolean sidebarQuickAccessVisibility: USER_SIDEBAR_QUICK_ACCESS_VISIBILITY sidebarQuickAccessPosition: USER_SIDEBAR_QUICK_ACCESS_POSITION diff --git a/backend/src/applications/users/models/user.model.spec.ts b/backend/src/applications/users/models/user.model.spec.ts index 4cccb195..4e1d3a1d 100644 --- a/backend/src/applications/users/models/user.model.spec.ts +++ b/backend/src/applications/users/models/user.model.spec.ts @@ -2,12 +2,19 @@ import { USER_ROLE } from '../constants/user.js' import { UserModel } from './user.model.js' describe(UserModel.name, () => { - it('shows hidden files by default and preserves an explicit opt-out', () => { + it('normalizes file preferences and preserves explicit values', () => { const defaultUser = new UserModel({ id: 1, login: 'alice', role: USER_ROLE.USER }) - const optOutUser = new UserModel({ id: 2, login: 'bob', role: USER_ROLE.USER, preferences: { showHiddenFiles: false } }) + const customizedUser = new UserModel({ + id: 2, + login: 'bob', + role: USER_ROLE.USER, + preferences: { showHiddenFiles: false, singleClickNavigation: true } + }) expect(defaultUser.preferences.showHiddenFiles).toBe(true) - expect(optOutUser.preferences.showHiddenFiles).toBe(false) + expect(defaultUser.preferences.singleClickNavigation).toBe(false) + expect(customizedUser.preferences.showHiddenFiles).toBe(false) + expect(customizedUser.preferences.singleClickNavigation).toBe(true) }) it('serializes only public user properties', () => { diff --git a/backend/src/applications/users/services/users-manager.service.spec.ts b/backend/src/applications/users/services/users-manager.service.spec.ts index b6404f70..6d5512cb 100755 --- a/backend/src/applications/users/services/users-manager.service.spec.ts +++ b/backend/src/applications/users/services/users-manager.service.spec.ts @@ -258,6 +258,18 @@ describe(UsersManager.name, () => { expect(clearWebDAVAuthCache).toHaveBeenCalledWith(userTest.id) }) + it('should update the single-click preference without clearing the WebDAV auth cache', async () => { + const preferences = { ...userTest.preferences, singleClickNavigation: true } + const updatePreferences = vi.spyOn(usersQueriesService, 'updateUserPreferences').mockResolvedValue(preferences) + const clearWebDAVAuthCache = vi.spyOn(usersQueriesService, 'clearWebDAVAuthCache').mockResolvedValue() + clearWebDAVAuthCache.mockClear() + + await expect(usersManager.updatePreferences(userTest, { singleClickNavigation: true })).resolves.toEqual(preferences) + + expect(updatePreferences).toHaveBeenCalledWith(userTest.id, { singleClickNavigation: true }) + expect(clearWebDAVAuthCache).not.toHaveBeenCalled() + }) + it('paths + avatars (default/generate) + create/delete user', async () => { await expect(ensurePaths()).resolves.not.toThrow() expect(await isPathExists(userTest.filesPath)).toBe(true) diff --git a/backend/src/applications/users/services/users-manager.service.ts b/backend/src/applications/users/services/users-manager.service.ts index a5fa4477..e0d29e70 100755 --- a/backend/src/applications/users/services/users-manager.service.ts +++ b/backend/src/applications/users/services/users-manager.service.ts @@ -254,6 +254,7 @@ export class UsersManager { if (dto.theme !== undefined) patch.theme = dto.theme if (dto.editor !== undefined) patch.editor = dto.editor if (dto.showHiddenFiles !== undefined) patch.showHiddenFiles = dto.showHiddenFiles + if (dto.singleClickNavigation !== undefined) patch.singleClickNavigation = dto.singleClickNavigation if (dto.useSystemNotifications !== undefined) patch.useSystemNotifications = dto.useSystemNotifications if (dto.sidebarQuickAccessVisibility !== undefined) patch.sidebarQuickAccessVisibility = dto.sidebarQuickAccessVisibility if (dto.sidebarQuickAccessPosition !== undefined) patch.sidebarQuickAccessPosition = dto.sidebarQuickAccessPosition diff --git a/backend/src/applications/users/services/users-queries.service.spec.ts b/backend/src/applications/users/services/users-queries.service.spec.ts index b193bccd..18383a43 100644 --- a/backend/src/applications/users/services/users-queries.service.spec.ts +++ b/backend/src/applications/users/services/users-queries.service.spec.ts @@ -64,3 +64,36 @@ describe(`${UsersQueries.name}.WebDAVAuthCache`, () => { expect(clearWebDAVAuthCache).toHaveBeenCalledWith(42) }) }) + +describe(`${UsersQueries.name}.updateUserPreferences`, () => { + it('persists a change to the single-click preference', async () => { + const forUpdate = vi.fn().mockResolvedValue([{ preferences: {} }]) + const selectWhere = vi.fn().mockReturnValue({ limit: vi.fn().mockReturnValue({ for: forUpdate }) }) + const select = vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: selectWhere }) }) + const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]) + const set = vi.fn().mockReturnValue({ where: updateWhere }) + const transaction = vi.fn(async (callback: (tx: object) => unknown) => callback({ select, update: vi.fn().mockReturnValue({ set }) })) + const usersQueries = new UsersQueries({ transaction } as unknown as DBSchema, {} as Cache) + + await expect(usersQueries.updateUserPreferences(42, { singleClickNavigation: true })).resolves.toEqual( + expect.objectContaining({ singleClickNavigation: true }) + ) + + expect(set).toHaveBeenCalledWith({ preferences: expect.objectContaining({ singleClickNavigation: true }) }) + }) + + it('does not write the default single-click preference when it is unchanged', async () => { + const forUpdate = vi.fn().mockResolvedValue([{ preferences: {} }]) + const selectWhere = vi.fn().mockReturnValue({ limit: vi.fn().mockReturnValue({ for: forUpdate }) }) + const select = vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: selectWhere }) }) + const update = vi.fn() + const transaction = vi.fn(async (callback: (tx: object) => unknown) => callback({ select, update })) + const usersQueries = new UsersQueries({ transaction } as unknown as DBSchema, {} as Cache) + + await expect(usersQueries.updateUserPreferences(42, { singleClickNavigation: false })).resolves.toEqual( + expect.objectContaining({ singleClickNavigation: false }) + ) + + expect(update).not.toHaveBeenCalled() + }) +}) diff --git a/backend/src/applications/users/services/users-queries.service.ts b/backend/src/applications/users/services/users-queries.service.ts index b045da59..e7d6bb13 100644 --- a/backend/src/applications/users/services/users-queries.service.ts +++ b/backend/src/applications/users/services/users-queries.service.ts @@ -194,6 +194,7 @@ export class UsersQueries { preferences.theme === currentPreferences.theme && preferences.editor === currentPreferences.editor && preferences.showHiddenFiles === currentPreferences.showHiddenFiles && + preferences.singleClickNavigation === currentPreferences.singleClickNavigation && preferences.useSystemNotifications === currentPreferences.useSystemNotifications && preferences.sidebarQuickAccessVisibility === currentPreferences.sidebarQuickAccessVisibility && preferences.sidebarQuickAccessPosition === currentPreferences.sidebarQuickAccessPosition diff --git a/backend/src/applications/users/utils/user.ts b/backend/src/applications/users/utils/user.ts index 0990ee97..724ded9a 100644 --- a/backend/src/applications/users/utils/user.ts +++ b/backend/src/applications/users/utils/user.ts @@ -6,6 +6,7 @@ export function normalizeUserPreferences(preferences?: Partial theme: preferences?.theme ?? USER_THEME.AUTO, editor: preferences?.editor ?? null, showHiddenFiles: preferences?.showHiddenFiles ?? true, + singleClickNavigation: preferences?.singleClickNavigation ?? false, useSystemNotifications: preferences?.useSystemNotifications ?? true, sidebarQuickAccessVisibility: preferences?.sidebarQuickAccessVisibility ?? USER_SIDEBAR_QUICK_ACCESS_VISIBILITY.BOTH, sidebarQuickAccessPosition: preferences?.sidebarQuickAccessPosition ?? USER_SIDEBAR_QUICK_ACCESS_POSITION.TOP diff --git a/frontend/src/app/applications/spaces/components/spaces-browser.component.html b/frontend/src/app/applications/spaces/components/spaces-browser.component.html index 330dc3c9..93afcb35 100644 --- a/frontend/src/app/applications/spaces/components/spaces-browser.component.html +++ b/frontend/src/app/applications/spaces/components/spaces-browser.component.html @@ -290,7 +290,8 @@ @for (f of scrollView?.viewPortItems; track f.id) {
+
+
+
+
+
+ +
+ + +
+
+
+ Open items with a single click instead of a double click. +
+
+
+
+
+
Quick access
@@ -286,102 +318,100 @@
- @if (!user.isLink) { -
-
-
Files
+
+
+
Files
- @if (showEditorPreference) { -
- -
- -
+ @if (showEditorPreference) { +
+ +
+
-
- Used when both editors support the file type. +
+
+ Used when both editors support the file type. +
+ } + +
+
+
+
+
+ +
+ + +
+
+
+ Show files and folders whose names start with a dot. +
+
- } +
+
-
+ @if (user.isUser) { +
-
+
- +
- +
- Show files and folders whose names start with a dot. + Enable content indexation for personal files.
- - @if (user.isUser) { -
-
-
-
-
- -
- - -
-
-
- Enable content indexation for personal files. -
-
-
-
-
- } -
+ }
- } +
diff --git a/frontend/src/app/applications/users/components/user-account.component.ts b/frontend/src/app/applications/users/components/user-account.component.ts index e0624bde..5a72267b 100644 --- a/frontend/src/app/applications/users/components/user-account.component.ts +++ b/frontend/src/app/applications/users/components/user-account.component.ts @@ -229,6 +229,10 @@ export class UserAccountComponent implements OnInit, OnDestroy { this.userService.changePreferences({ editor }).subscribe() } + updateSingleClickNavigation(singleClickNavigation: UserPreferences['singleClickNavigation']) { + this.userService.changePreferences({ singleClickNavigation }).subscribe() + } + updateShowHiddenFiles(showHiddenFiles: UserPreferences['showHiddenFiles']) { if (this.updatingShowHiddenFiles) return this.updatingShowHiddenFiles = true diff --git a/frontend/src/app/applications/users/user.service.ts b/frontend/src/app/applications/users/user.service.ts index f08acf5e..3ea32e91 100644 --- a/frontend/src/app/applications/users/user.service.ts +++ b/frontend/src/app/applications/users/user.service.ts @@ -256,9 +256,11 @@ export class UserService { ? 'Unable to update editor preference' : patch.showHiddenFiles !== undefined ? 'Unable to update hidden files preference' - : patch.sidebarQuickAccessVisibility !== undefined || patch.sidebarQuickAccessPosition !== undefined - ? 'Unable to update sidebar preference' - : 'Unable to update notification preference' + : patch.singleClickNavigation !== undefined + ? 'Unable to update single-click preference' + : patch.sidebarQuickAccessVisibility !== undefined || patch.sidebarQuickAccessPosition !== undefined + ? 'Unable to update sidebar preference' + : 'Unable to update notification preference' this.layout.sendNotification('error', 'Configuration', message, e) return EMPTY }), diff --git a/frontend/src/i18n/ca.json b/frontend/src/i18n/ca.json index 5e0f5efa..8f667479 100644 --- a/frontend/src/i18n/ca.json +++ b/frontend/src/i18n/ca.json @@ -744,6 +744,8 @@ "Ask Me": "Preguntar-me", "Used when both editors support the file type.": "S'usa quan els dos editors admeten el tipus de fitxer.", "Show hidden files": "Mostra els fitxers ocults", + "Single-click navigation": "Navegació amb un sol clic", + "Open items with a single click instead of a double click.": "Obre els elements amb un sol clic en lloc de fer-hi doble clic.", "Show files and folders whose names start with a dot.": "Mostra els fitxers i les carpetes els noms dels quals comencen amb un punt.", "Enable content indexation for personal files.": "Habilitar la indexació de contingut per a fitxers personals.", "Actions": "Accions", @@ -781,6 +783,7 @@ "Unable to update theme preference": "No s'ha pogut actualitzar la preferència de tema", "Unable to update editor preference": "No s'ha pogut actualitzar la preferència d'editor", "Unable to update hidden files preference": "No s'ha pogut actualitzar la preferència dels fitxers ocults", + "Unable to update single-click preference": "No s'ha pogut actualitzar la preferència d'un sol clic", "Unable to update sidebar preference": "No s'ha pogut actualitzar la preferència de la barra lateral", "Sidebar auto-hide enabled": "Ocultació automàtica de la barra lateral activada", "Sidebar pinned": "Barra lateral fixada", diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index ab38c3f9..a38344cf 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -744,6 +744,8 @@ "Ask Me": "Mich fragen", "Used when both editors support the file type.": "Wird verwendet, wenn beide Editoren den Dateityp unterstützen.", "Show hidden files": "Versteckte Dateien anzeigen", + "Single-click navigation": "Navigation mit einfachem Klick", + "Open items with a single click instead of a double click.": "Elemente mit einem einfachen Klick statt mit einem Doppelklick öffnen.", "Show files and folders whose names start with a dot.": "Dateien und Ordner anzeigen, deren Namen mit einem Punkt beginnen.", "Enable content indexation for personal files.": "Inhaltsindexierung für persönliche Dateien aktivieren.", "Actions": "Aktionen", @@ -781,6 +783,7 @@ "Unable to update theme preference": "Die Designeinstellung konnte nicht aktualisiert werden", "Unable to update editor preference": "Die Editoreinstellung konnte nicht aktualisiert werden", "Unable to update hidden files preference": "Die Einstellung für versteckte Dateien konnte nicht aktualisiert werden", + "Unable to update single-click preference": "Die Einstellung für einfaches Klicken konnte nicht aktualisiert werden", "Unable to update sidebar preference": "Die Seitenleisteneinstellung konnte nicht aktualisiert werden", "Sidebar auto-hide enabled": "Automatisches Ausblenden der Seitenleiste aktiviert", "Sidebar pinned": "Seitenleiste angeheftet", diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index 42b70124..824ec622 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -744,6 +744,8 @@ "Ask Me": "Preguntarme", "Used when both editors support the file type.": "Se usa cuando ambos editores admiten el tipo de archivo.", "Show hidden files": "Mostrar archivos ocultos", + "Single-click navigation": "Navegación con un solo clic", + "Open items with a single click instead of a double click.": "Abre los elementos con un solo clic en lugar de hacer doble clic.", "Show files and folders whose names start with a dot.": "Mostrar archivos y carpetas cuyos nombres comienzan por un punto.", "Enable content indexation for personal files.": "Habilitar la indexación de contenido para archivos personales.", "Actions": "Acciones", @@ -781,6 +783,7 @@ "Unable to update theme preference": "No se pudo actualizar la preferencia de tema", "Unable to update editor preference": "No se pudo actualizar la preferencia de editor", "Unable to update hidden files preference": "No se pudo actualizar la preferencia de archivos ocultos", + "Unable to update single-click preference": "No se pudo actualizar la preferencia de un solo clic", "Unable to update sidebar preference": "No se pudo actualizar la preferencia de la barra lateral", "Sidebar auto-hide enabled": "Ocultación automática de la barra lateral activada", "Sidebar pinned": "Barra lateral fijada", diff --git a/frontend/src/i18n/fr.json b/frontend/src/i18n/fr.json index 958d1805..4e252b86 100644 --- a/frontend/src/i18n/fr.json +++ b/frontend/src/i18n/fr.json @@ -744,6 +744,8 @@ "Ask Me": "Me demander", "Used when both editors support the file type.": "Utilisé lorsque les deux éditeurs prennent en charge le type de fichier.", "Show hidden files": "Afficher les fichiers cachés", + "Single-click navigation": "Navigation en un clic", + "Open items with a single click instead of a double click.": "Ouvrez les éléments d’un simple clic plutôt que d’un double-clic.", "Show files and folders whose names start with a dot.": "Afficher les fichiers et dossiers dont le nom commence par un point.", "Enable content indexation for personal files.": "Activer l'indexation du contenu pour les fichiers personnels.", "Actions": "Actions", @@ -781,6 +783,7 @@ "Unable to update theme preference": "Impossible de mettre à jour la préférence de thème", "Unable to update editor preference": "Impossible de mettre à jour la préférence d’éditeur", "Unable to update hidden files preference": "Impossible de mettre à jour la préférence des fichiers cachés", + "Unable to update single-click preference": "Impossible de mettre à jour la préférence de simple clic", "Unable to update sidebar preference": "Impossible de mettre à jour la préférence de la barre latérale", "Sidebar auto-hide enabled": "Masquage automatique de la barre latérale activé", "Sidebar pinned": "Barre latérale épinglée", diff --git a/frontend/src/i18n/hi.json b/frontend/src/i18n/hi.json index 95f32daa..84b38ce6 100644 --- a/frontend/src/i18n/hi.json +++ b/frontend/src/i18n/hi.json @@ -744,6 +744,8 @@ "Ask Me": "मुझसे पूछें", "Used when both editors support the file type.": "जब दोनों संपादक फ़ाइल प्रकार का समर्थन करते हैं तो उपयोग किया जाता है।", "Show hidden files": "छिपी हुई फ़ाइलें दिखाएँ", + "Single-click navigation": "एक-क्लिक नेविगेशन", + "Open items with a single click instead of a double click.": "आइटम को डबल-क्लिक के बजाय एक क्लिक से खोलें।", "Show files and folders whose names start with a dot.": "वे फ़ाइलें और फ़ोल्डर दिखाएँ जिनके नाम बिंदु से शुरू होते हैं।", "Enable content indexation for personal files.": "व्यक्तिगत फ़ाइलों के लिए सामग्री अनुक्रमण सक्षम करें।", "Actions": "क्रियाएँ", @@ -781,6 +783,7 @@ "Unable to update theme preference": "थीम की प्राथमिकता अपडेट नहीं की जा सकी", "Unable to update editor preference": "संपादक की प्राथमिकता अपडेट नहीं की जा सकी", "Unable to update hidden files preference": "छिपी हुई फ़ाइलों की प्राथमिकता अपडेट नहीं की जा सकी", + "Unable to update single-click preference": "एक-क्लिक प्राथमिकता अपडेट नहीं की जा सकी", "Unable to update sidebar preference": "साइडबार की प्राथमिकता अपडेट नहीं की जा सकी", "Sidebar auto-hide enabled": "साइडबार स्वतः छिपाना सक्षम", "Sidebar pinned": "साइडबार पिन किया गया", diff --git a/frontend/src/i18n/it.json b/frontend/src/i18n/it.json index 83d7b4e1..ff628be8 100644 --- a/frontend/src/i18n/it.json +++ b/frontend/src/i18n/it.json @@ -744,6 +744,8 @@ "Ask Me": "Chiedimelo", "Used when both editors support the file type.": "Usato quando entrambi gli editor supportano il tipo di file.", "Show hidden files": "Mostra file nascosti", + "Single-click navigation": "Navigazione con un solo clic", + "Open items with a single click instead of a double click.": "Apri gli elementi con un solo clic anziché con un doppio clic.", "Show files and folders whose names start with a dot.": "Mostra file e cartelle i cui nomi iniziano con un punto.", "Enable content indexation for personal files.": "Abilita l'indicizzazione dei contenuti per i file personali.", "Actions": "Azioni", @@ -781,6 +783,7 @@ "Unable to update theme preference": "Impossibile aggiornare la preferenza del tema", "Unable to update editor preference": "Impossibile aggiornare la preferenza dell'editor", "Unable to update hidden files preference": "Impossibile aggiornare la preferenza per i file nascosti", + "Unable to update single-click preference": "Impossibile aggiornare la preferenza del clic singolo", "Unable to update sidebar preference": "Impossibile aggiornare la preferenza della barra laterale", "Sidebar auto-hide enabled": "Nascondimento automatico della barra laterale attivato", "Sidebar pinned": "Barra laterale bloccata", diff --git a/frontend/src/i18n/ja.json b/frontend/src/i18n/ja.json index 17340280..18ee2277 100644 --- a/frontend/src/i18n/ja.json +++ b/frontend/src/i18n/ja.json @@ -744,6 +744,8 @@ "Ask Me": "毎回確認", "Used when both editors support the file type.": "両方のエディタがファイルタイプをサポートしている場合に使用されます。", "Show hidden files": "隠しファイルを表示", + "Single-click navigation": "シングルクリックナビゲーション", + "Open items with a single click instead of a double click.": "ダブルクリックではなくシングルクリックで項目を開きます。", "Show files and folders whose names start with a dot.": "名前がドットで始まるファイルとフォルダーを表示します。", "Enable content indexation for personal files.": "個人ファイルのコンテンツインデックス作成を有効にする。", "Actions": "アクション", @@ -781,6 +783,7 @@ "Unable to update theme preference": "テーマ設定を更新できませんでした", "Unable to update editor preference": "エディタ設定を更新できませんでした", "Unable to update hidden files preference": "隠しファイルの設定を更新できません", + "Unable to update single-click preference": "シングルクリック設定を更新できません", "Unable to update sidebar preference": "サイドバー設定を更新できませんでした", "Sidebar auto-hide enabled": "サイドバー自動非表示が有効", "Sidebar pinned": "サイドバー固定", diff --git a/frontend/src/i18n/ko.json b/frontend/src/i18n/ko.json index 8f71451f..1d372902 100644 --- a/frontend/src/i18n/ko.json +++ b/frontend/src/i18n/ko.json @@ -744,6 +744,8 @@ "Ask Me": "매번 물어보기", "Used when both editors support the file type.": "두 편집기 모두 파일 유형을 지원할 때 사용됩니다.", "Show hidden files": "숨김 파일 표시", + "Single-click navigation": "한 번 클릭으로 탐색", + "Open items with a single click instead of a double click.": "항목을 두 번 클릭하는 대신 한 번 클릭하여 엽니다.", "Show files and folders whose names start with a dot.": "이름이 점으로 시작하는 파일과 폴더를 표시합니다.", "Enable content indexation for personal files.": "개인 파일에 대한 콘텐츠 인덱싱을 활성화합니다.", "Actions": "작업", @@ -781,6 +783,7 @@ "Unable to update theme preference": "테마 설정을 업데이트할 수 없습니다", "Unable to update editor preference": "편집기 설정을 업데이트할 수 없습니다", "Unable to update hidden files preference": "숨김 파일 설정을 업데이트할 수 없습니다", + "Unable to update single-click preference": "한 번 클릭 설정을 업데이트할 수 없습니다", "Unable to update sidebar preference": "사이드바 설정을 업데이트할 수 없습니다", "Sidebar auto-hide enabled": "사이드바 자동 숨김 활성화", "Sidebar pinned": "사이드바 고정됨", diff --git a/frontend/src/i18n/nl.json b/frontend/src/i18n/nl.json index 61738b4e..5d41b543 100644 --- a/frontend/src/i18n/nl.json +++ b/frontend/src/i18n/nl.json @@ -744,6 +744,8 @@ "Ask Me": "Mij vragen", "Used when both editors support the file type.": "Wordt gebruikt wanneer beide editors het bestandstype ondersteunen.", "Show hidden files": "Verborgen bestanden weergeven", + "Single-click navigation": "Navigatie met één klik", + "Open items with a single click instead of a double click.": "Open items met één klik in plaats van met een dubbelklik.", "Show files and folders whose names start with a dot.": "Bestanden en mappen waarvan de naam met een punt begint weergeven.", "Enable content indexation for personal files.": "Inhoudsindexering inschakelen voor persoonlijke bestanden.", "Actions": "Acties", @@ -781,6 +783,7 @@ "Unable to update theme preference": "Kan de themavoorkeur niet bijwerken", "Unable to update editor preference": "Kan de editorvoorkeur niet bijwerken", "Unable to update hidden files preference": "Kan de voorkeur voor verborgen bestanden niet bijwerken", + "Unable to update single-click preference": "Kan de voorkeur voor één keer klikken niet bijwerken", "Unable to update sidebar preference": "Kan zijbalkvoorkeur niet bijwerken", "Sidebar auto-hide enabled": "Zijbalk automatisch verbergen ingeschakeld", "Sidebar pinned": "Zijbalk vastgemaakt", diff --git a/frontend/src/i18n/pl.json b/frontend/src/i18n/pl.json index 38063016..f2f55b15 100644 --- a/frontend/src/i18n/pl.json +++ b/frontend/src/i18n/pl.json @@ -744,6 +744,8 @@ "Ask Me": "Pytaj mnie", "Used when both editors support the file type.": "Używane, gdy oba edytory obsługują typ pliku.", "Show hidden files": "Pokaż ukryte pliki", + "Single-click navigation": "Nawigacja jednym kliknięciem", + "Open items with a single click instead of a double click.": "Otwieraj elementy jednym kliknięciem zamiast dwukrotnym kliknięciem.", "Show files and folders whose names start with a dot.": "Pokaż pliki i foldery, których nazwy zaczynają się od kropki.", "Enable content indexation for personal files.": "Włącz indeksowanie treści dla plików osobistych.", "Actions": "Akcje", @@ -781,6 +783,7 @@ "Unable to update theme preference": "Nie można zaktualizować preferencji motywu", "Unable to update editor preference": "Nie można zaktualizować preferencji edytora", "Unable to update hidden files preference": "Nie można zaktualizować ustawienia ukrytych plików", + "Unable to update single-click preference": "Nie można zaktualizować ustawienia pojedynczego kliknięcia", "Unable to update sidebar preference": "Nie udało się zaktualizować preferencji paska bocznego", "Sidebar auto-hide enabled": "Włączono automatyczne ukrywanie panelu bocznego", "Sidebar pinned": "Panel boczny przypięty", diff --git a/frontend/src/i18n/pt-BR.json b/frontend/src/i18n/pt-BR.json index c2d2d40e..93bc102d 100644 --- a/frontend/src/i18n/pt-BR.json +++ b/frontend/src/i18n/pt-BR.json @@ -744,6 +744,8 @@ "Ask Me": "Perguntar sempre", "Used when both editors support the file type.": "Usado quando ambos os editores suportam o tipo de arquivo.", "Show hidden files": "Mostrar arquivos ocultos", + "Single-click navigation": "Navegação com um único clique", + "Open items with a single click instead of a double click.": "Abra itens com um único clique em vez de clicar duas vezes.", "Show files and folders whose names start with a dot.": "Mostrar arquivos e pastas cujos nomes começam com um ponto.", "Enable content indexation for personal files.": "Ativar indexação de conteúdo para arquivos pessoais.", "Actions": "Ações", @@ -781,6 +783,7 @@ "Unable to update theme preference": "Não foi possível atualizar a preferência de tema", "Unable to update editor preference": "Não foi possível atualizar a preferência de editor", "Unable to update hidden files preference": "Não foi possível atualizar a preferência de arquivos ocultos", + "Unable to update single-click preference": "Não foi possível atualizar a preferência de clique único", "Unable to update sidebar preference": "Não foi possível atualizar a preferência da barra lateral", "Sidebar auto-hide enabled": "Ocultação automática da barra lateral ativada", "Sidebar pinned": "Barra lateral fixada", diff --git a/frontend/src/i18n/pt.json b/frontend/src/i18n/pt.json index 16c69be8..d336b238 100644 --- a/frontend/src/i18n/pt.json +++ b/frontend/src/i18n/pt.json @@ -744,6 +744,8 @@ "Ask Me": "Perguntar sempre", "Used when both editors support the file type.": "Usado quando ambos os editores suportam o tipo de arquivo.", "Show hidden files": "Mostrar arquivos ocultos", + "Single-click navigation": "Navegação com um único clique", + "Open items with a single click instead of a double click.": "Abra itens com um único clique em vez de fazer duplo clique.", "Show files and folders whose names start with a dot.": "Mostrar arquivos e pastas cujos nomes começam com um ponto.", "Enable content indexation for personal files.": "Ativar indexação de conteúdo para ficheiros pessoais.", "Actions": "Ações", @@ -781,6 +783,7 @@ "Unable to update theme preference": "Não foi possível atualizar a preferência do tema", "Unable to update editor preference": "Não foi possível atualizar a preferência do editor", "Unable to update hidden files preference": "Não foi possível atualizar a preferência de arquivos ocultos", + "Unable to update single-click preference": "Não foi possível atualizar a preferência de clique único", "Unable to update sidebar preference": "Não foi possível atualizar a preferência da barra lateral", "Sidebar auto-hide enabled": "Ocultação automática da barra lateral ativada", "Sidebar pinned": "Barra lateral fixa", diff --git a/frontend/src/i18n/ru.json b/frontend/src/i18n/ru.json index 082bb794..c75bdfcc 100644 --- a/frontend/src/i18n/ru.json +++ b/frontend/src/i18n/ru.json @@ -744,6 +744,8 @@ "Ask Me": "Спрашивать каждый раз", "Used when both editors support the file type.": "Используется, когда оба редактора поддерживают тип файла.", "Show hidden files": "Показывать скрытые файлы", + "Single-click navigation": "Навигация одним щелчком", + "Open items with a single click instead of a double click.": "Открывайте элементы одним щелчком вместо двойного.", "Show files and folders whose names start with a dot.": "Показывать файлы и папки, имена которых начинаются с точки.", "Enable content indexation for personal files.": "Включить индексацию содержимого для личных файлов.", "Actions": "Действия", @@ -781,6 +783,7 @@ "Unable to update theme preference": "Не удалось обновить настройку темы", "Unable to update editor preference": "Не удалось обновить настройку редактора", "Unable to update hidden files preference": "Не удалось обновить настройку скрытых файлов", + "Unable to update single-click preference": "Не удалось обновить настройку открытия одним щелчком", "Unable to update sidebar preference": "Не удалось обновить настройку боковой панели", "Sidebar auto-hide enabled": "Автоскрытие боковой панели включено", "Sidebar pinned": "Боковая панель закреплена", diff --git a/frontend/src/i18n/tr.json b/frontend/src/i18n/tr.json index bd501b24..032a6c22 100644 --- a/frontend/src/i18n/tr.json +++ b/frontend/src/i18n/tr.json @@ -744,6 +744,8 @@ "Ask Me": "Bana sor", "Used when both editors support the file type.": "Her iki editör de dosya türünü desteklediğinde kullanılır.", "Show hidden files": "Gizli dosyaları göster", + "Single-click navigation": "Tek tıklamayla gezinme", + "Open items with a single click instead of a double click.": "Öğeleri çift tıklamak yerine tek tıklamayla açın.", "Show files and folders whose names start with a dot.": "Adları noktayla başlayan dosya ve klasörleri göster.", "Enable content indexation for personal files.": "Kişisel dosyalar için içerik indekslemeyi etkinleştir.", "Actions": "İşlemler", @@ -781,6 +783,7 @@ "Unable to update theme preference": "Tema tercihi güncellenemedi", "Unable to update editor preference": "Düzenleyici tercihi güncellenemedi", "Unable to update hidden files preference": "Gizli dosya tercihi güncellenemedi", + "Unable to update single-click preference": "Tek tıklama tercihi güncellenemedi", "Unable to update sidebar preference": "Kenar çubuğu tercihi güncellenemedi", "Sidebar auto-hide enabled": "Kenar çubuğu otomatik gizleme etkin", "Sidebar pinned": "Kenar çubuğu sabitlendi", diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json index 774e17ef..01790326 100644 --- a/frontend/src/i18n/zh.json +++ b/frontend/src/i18n/zh.json @@ -744,6 +744,8 @@ "Ask Me": "询问我", "Used when both editors support the file type.": "当两个编辑器都支持该文件类型时使用。", "Show hidden files": "显示隐藏文件", + "Single-click navigation": "单击导航", + "Open items with a single click instead of a double click.": "单击而非双击即可打开项目。", "Show files and folders whose names start with a dot.": "显示名称以点开头的文件和文件夹。", "Enable content indexation for personal files.": "启用个人文件内容索引。", "Actions": "操作", @@ -781,6 +783,7 @@ "Unable to update theme preference": "无法更新主题偏好设置", "Unable to update editor preference": "无法更新编辑器偏好设置", "Unable to update hidden files preference": "无法更新隐藏文件偏好设置", + "Unable to update single-click preference": "无法更新单击偏好设置", "Unable to update sidebar preference": "无法更新侧边栏偏好设置", "Sidebar auto-hide enabled": "侧边栏自动隐藏已启用", "Sidebar pinned": "侧边栏已固定", From 5fc77174d64185e0145a55e3b3cbb6749d669c8f Mon Sep 17 00:00:00 2001 From: johaven Date: Sat, 31 Oct 2026 12:42:03 +0100 Subject: [PATCH 2/3] fix(frontend:tap): simplify directive and improve long-press handling --- .../components/spaces-browser.component.html | 4 +- .../app/common/directives/tap.directive.ts | 193 ++++++++---------- .../app/common/interfaces/tap.interface.ts | 14 -- frontend/src/styles/components/_app.scss | 2 +- frontend/src/styles/components/_gallery.scss | 2 +- .../src/styles/components/_theme_dark.scss | 4 +- .../src/styles/components/_theme_light.scss | 4 +- 7 files changed, 88 insertions(+), 135 deletions(-) delete mode 100644 frontend/src/app/common/interfaces/tap.interface.ts diff --git a/frontend/src/app/applications/spaces/components/spaces-browser.component.html b/frontend/src/app/applications/spaces/components/spaces-browser.component.html index 93afcb35..efc964c5 100644 --- a/frontend/src/app/applications/spaces/components/spaces-browser.component.html +++ b/frontend/src/app/applications/spaces/components/spaces-browser.component.html @@ -290,7 +290,7 @@ @for (f of scrollView?.viewPortItems; track f.id) {
1)) @Injectable({ providedIn: 'root' }) class TapGhostClickGuard { @@ -29,7 +44,12 @@ class TapGhostClickGuard { } private onClick = (event: MouseEvent) => { - if (performance.now() >= this.blockedUntil || Math.hypot(event.clientX - this.x, event.clientY - this.y) > this.slop) return + if (this.blockedUntil === 0) return + if (performance.now() >= this.blockedUntil) { + this.blockedUntil = 0 + return + } + if (Math.hypot(event.clientX - this.x, event.clientY - this.y) > this.slop) return this.blockedUntil = 0 event.preventDefault() @@ -45,47 +65,21 @@ class TapGhostClickGuard { } }) export class TapDirective implements OnDestroy { - /** Movement tolerance (in px) for a valid tap */ - @Input() maxMove = 10 - /** Maximum duration (in ms) for a single tap */ - @Input() maxDuration = 350 - /** Maximum interval (in ms) between two taps to be considered a double tap */ - @Input() doubleTapInterval = 600 - /** Maximum distance (in px) between the two taps for a double tap */ - @Input() doubleTapSlop = 24 /** Enables or disables double-tap detection */ @Input() enableDoubleTap = true /** Emits a single tap immediately for touch pointers. */ @Input() singleTapOnTouch = false - /** - * ⚙️ Hammer-like behavior control. - * false → STRICT Hammer mode: no single tap is emitted if the second tap does not occur (default) - * true → emits a delayed single tap if no second tap is detected within the interval - */ - @Input() emitSingleWhenNoDouble = false - - @Input() preventGhostClick = true - @Input() preventDefault = false - @Input() disabled = false - /** Synthesizes a contextmenu event for touch-and-hold on iOS, where WebKit does not emit one. */ - @Input() enableLongPressContextMenu = true - /** Minimum touch duration (in ms) before opening the context menu. */ - @Input() longPressDuration = 550 - - @Output() appTap = new EventEmitter() - - private readonly elRef = inject>(ElementRef as any) + @Output() appTap = new EventEmitter() + private readonly el = inject>(ElementRef as any).nativeElement private readonly zone = inject(NgZone) private readonly ghostClickGuard = inject(TapGhostClickGuard) - private el: HTMLElement - private removeFns: (() => void)[] = [] - private active = false + private readonly removeFns: (() => void)[] = [] private pointerId: number | null = null private startX = 0 private startY = 0 private startTime = 0 private moved = false - private lastEmitTs = 0 + private lastEmitTs = Number.NEGATIVE_INFINITY private longPressPointerId: number | null = null private longPressTimer: ReturnType | null = null private longPressTriggered = false @@ -93,54 +87,45 @@ export class TapDirective implements OnDestroy { private longPressStartY = 0 private lastImmediateTouchTap: TapPoint | null = null private pendingTap: TapPoint | null = null - private pendingTapTimer: ReturnType | null = null constructor() { - this.el = this.elRef.nativeElement this.zone.runOutsideAngular(() => { - this.add('pointerdown', this.onLongPressDown, { passive: true, capture: true }) - this.add('pointermove', this.onLongPressMove, { passive: true, capture: true }) - this.add('pointerup', this.onLongPressEnd, { passive: true, capture: true }) - this.add('pointercancel', this.onLongPressEnd, { passive: true, capture: true }) + // Android and desktop emit contextmenu natively; iOS/iPadOS need a synthesized long-press fallback. + if (NEEDS_SYNTHETIC_LONG_PRESS) { + const onLongPressEnd = this.onLongPressEnd.bind(this) + this.add('pointerdown', this.onLongPressDown.bind(this), { passive: true, capture: true }) + this.add('pointermove', this.onLongPressMove.bind(this), { passive: true, capture: true }) + this.add('pointerup', onLongPressEnd, { passive: true, capture: true }) + this.add('pointercancel', onLongPressEnd, { passive: true, capture: true }) + } this.add('pointerdown', this.onDown, { passive: true }) this.add('pointermove', this.onMove, { passive: true }) - this.add('pointerup', this.onUp, { passive: false }) + this.add('pointerup', this.onUp, { passive: true }) this.add('pointercancel', this.onCancel, { passive: true }) this.add('pointerleave', this.onCancel, { passive: true }) - this.add('contextmenu', this.onContextMenu, { passive: false }) this.add('click', this.onNativeClick, { passive: false, capture: true }) }) } ngOnDestroy() { for (const off of this.removeFns) off() - this.removeFns = [] - this.clearPendingTap() + this.removeFns.length = 0 this.cancelLongPress() } - private onLongPressDown = (ev: PointerEvent) => { + private onLongPressDown(ev: PointerEvent) { // A secondary pointer must not cancel the primary pointer's long press. if (!ev.isPrimary) return this.cancelLongPress() this.longPressTriggered = false - if ( - !this.enableLongPressContextMenu || - this.disabled || - !this.isIosTouchDevice() || - ev.pointerType !== 'touch' || - ev.button !== 0 || - this.isIgnoredTarget(ev.target) - ) { - return - } + if (ev.pointerType !== 'touch' || ev.button !== 0 || this.isIgnoredTarget(ev.target)) return - this.longPressPointerId = ev.pointerId - const { clientX, clientY } = ev + const { pointerId, clientX, clientY } = ev + this.longPressPointerId = pointerId this.longPressStartX = clientX this.longPressStartY = clientY this.longPressTimer = setTimeout(() => { - if (this.longPressPointerId !== ev.pointerId) return + if (this.longPressPointerId !== pointerId) return this.cancelLongPress() if (!this.el.isConnected) return @@ -158,23 +143,22 @@ export class TapDirective implements OnDestroy { }) ) ) - }, this.longPressDuration) + }, LONG_PRESS_DURATION) } - private onLongPressMove = (ev: PointerEvent) => { + private onLongPressMove(ev: PointerEvent) { if (ev.pointerId !== this.longPressPointerId) return - if (Math.abs(ev.clientX - this.longPressStartX) > this.maxMove || Math.abs(ev.clientY - this.longPressStartY) > this.maxMove) { + if (Math.abs(ev.clientX - this.longPressStartX) > MAX_MOVE || Math.abs(ev.clientY - this.longPressStartY) > MAX_MOVE) { this.cancelLongPress() } } - private onLongPressEnd = (ev: PointerEvent) => { + private onLongPressEnd(ev: PointerEvent) { if (ev.pointerId === this.longPressPointerId) this.cancelLongPress() } private onDown = (ev: PointerEvent) => { - if (this.disabled || !ev.isPrimary || ev.button !== 0 || this.active || this.isIgnoredTarget(ev.target)) return - this.active = true + if (!ev.isPrimary || ev.button !== 0 || this.pointerId !== null || this.isIgnoredTarget(ev.target)) return this.pointerId = ev.pointerId this.startX = ev.clientX this.startY = ev.clientY @@ -184,38 +168,45 @@ export class TapDirective implements OnDestroy { } private onMove = (ev: PointerEvent) => { - if (!this.active || ev.pointerId !== this.pointerId) return + if (ev.pointerId !== this.pointerId) return if (!this.moved) { const dx = Math.abs(ev.clientX - this.startX) const dy = Math.abs(ev.clientY - this.startY) - if (dx > this.maxMove || dy > this.maxMove) this.moved = true + if (dx > MAX_MOVE || dy > MAX_MOVE) { + this.moved = true + this.pendingTap = null + } } } private onUp = (ev: PointerEvent) => { - if (!this.active || ev.pointerId !== this.pointerId) return + if (ev.pointerId !== this.pointerId) return if (this.longPressTriggered) { + this.pendingTap = null this.resetGesture() return } const dt = ev.timeStamp - this.startTime const dx = Math.abs(ev.clientX - this.startX) const dy = Math.abs(ev.clientY - this.startY) - const isTap = !this.moved && dx <= this.maxMove && dy <= this.maxMove && dt <= this.maxDuration + const isTap = !this.moved && dx <= MAX_MOVE && dy <= MAX_MOVE && dt <= MAX_DURATION this.resetGesture() - if (!isTap) return - - if (this.preventDefault) ev.preventDefault() - - const tap: TapPoint = { x: ev.clientX, y: ev.clientY, timestamp: performance.now() } + if (!isTap) { + this.pendingTap = null + return + } - if (this.singleTapOnTouch && ev.pointerType === 'touch') { - this.handleImmediateTouchTap(ev, tap) + const isImmediateTouchTap = this.singleTapOnTouch && ev.pointerType === 'touch' + if (!isImmediateTouchTap && !this.enableDoubleTap) { + this.pendingTap = null + this.emit(ev) return } - if (!this.enableDoubleTap) { - this.emit('single', tap.x, tap.y, ev) + const tap: TapPoint = { x: ev.clientX, y: ev.clientY, timestamp: performance.now() } + if (isImmediateTouchTap) { + this.pendingTap = null + this.handleImmediateTouchTap(ev, tap) return } @@ -223,19 +214,16 @@ export class TapDirective implements OnDestroy { } private onCancel = (ev: PointerEvent) => { - if (ev.pointerId === this.pointerId) this.resetGesture() - } - - private onContextMenu = (ev: MouseEvent) => { - if (this.preventDefault) ev.preventDefault() + if (ev.pointerId === this.pointerId) { + this.pendingTap = null + this.resetGesture() + } } private onNativeClick = (ev: MouseEvent) => { - if (!this.preventGhostClick) return - if (this.longPressTriggered || performance.now() - this.lastEmitTs < 350) { + if (this.longPressTriggered || performance.now() - this.lastEmitTs < NATIVE_CLICK_SUPPRESSION_DURATION) { this.longPressTriggered = false ev.stopImmediatePropagation() - ev.stopPropagation() ev.preventDefault() } } @@ -249,49 +237,35 @@ export class TapDirective implements OnDestroy { this.armGhostClickGuard(event) return } - this.emit('single', tap.x, tap.y, event) + this.emit(event) } private handleTapSequence(event: PointerEvent, tap: TapPoint) { if (this.isNearbyTap(this.pendingTap, tap)) { - this.clearPendingTap() - this.emit('double', tap.x, tap.y, event) + this.pendingTap = null + this.emit(event) return } - this.clearPendingTap() this.pendingTap = tap - this.pendingTapTimer = setTimeout(() => { - if (this.emitSingleWhenNoDouble && this.pendingTap) { - this.emit('single', this.pendingTap.x, this.pendingTap.y) - } - this.pendingTap = null - this.pendingTapTimer = null - }, this.doubleTapInterval) } private isNearbyTap(previous: TapPoint | null, current: TapPoint): boolean { return ( previous !== null && - current.timestamp - previous.timestamp <= this.doubleTapInterval && - Math.hypot(current.x - previous.x, current.y - previous.y) <= this.doubleTapSlop + current.timestamp - previous.timestamp <= DOUBLE_TAP_INTERVAL && + Math.hypot(current.x - previous.x, current.y - previous.y) <= DOUBLE_TAP_SLOP ) } - private clearPendingTap() { - if (this.pendingTapTimer !== null) clearTimeout(this.pendingTapTimer) - this.pendingTapTimer = null - this.pendingTap = null - } - private armGhostClickGuard(event: PointerEvent) { - if (this.preventGhostClick && event.pointerType === 'touch') this.ghostClickGuard.arm(event) + if (event.pointerType === 'touch') this.ghostClickGuard.arm(event) } - private emit(type: TapKind, x: number, y: number, sourceEvent?: PointerEvent) { - if (sourceEvent) this.armGhostClickGuard(sourceEvent) + private emit(event: PointerEvent) { + this.armGhostClickGuard(event) this.lastEmitTs = performance.now() - this.zone.run(() => this.appTap.emit({ x, y, type, sourceEvent })) + this.zone.run(() => this.appTap.emit(event)) } private add(type: K, handler: (ev: any) => void, opts?: AddEventListenerOptions & { capture?: boolean }) { @@ -301,9 +275,7 @@ export class TapDirective implements OnDestroy { private resetGesture() { this.cancelLongPress() - this.active = false this.pointerId = null - this.startTime = 0 } private cancelLongPress() { @@ -319,9 +291,4 @@ export class TapDirective implements OnDestroy { ) return !!ignoredTarget && ignoredTarget !== this.el && this.el.contains(ignoredTarget) } - - private isIosTouchDevice(): boolean { - if (typeof navigator === 'undefined') return false - return /iPad|iPhone|iPod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) - } } diff --git a/frontend/src/app/common/interfaces/tap.interface.ts b/frontend/src/app/common/interfaces/tap.interface.ts deleted file mode 100644 index 956d94be..00000000 --- a/frontend/src/app/common/interfaces/tap.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type TapKind = 'single' | 'double' - -export interface TapPoint { - x: number - y: number - timestamp: number -} - -export interface TapEvent { - x: number - y: number - type: TapKind - sourceEvent?: PointerEvent -} diff --git a/frontend/src/styles/components/_app.scss b/frontend/src/styles/components/_app.scss index ee25b432..93144b0d 100755 --- a/frontend/src/styles/components/_app.scss +++ b/frontend/src/styles/components/_app.scss @@ -175,7 +175,7 @@ background-color: transparent; } - &.selected { + &:is(.selected, :active) { background-color: transparent; } diff --git a/frontend/src/styles/components/_gallery.scss b/frontend/src/styles/components/_gallery.scss index 4adcade6..4855491c 100644 --- a/frontend/src/styles/components/_gallery.scss +++ b/frontend/src/styles/components/_gallery.scss @@ -18,7 +18,7 @@ border-width: .5px; border-color: transparent; - &.selected { + &:is(.selected, :active) { background-color: transparent; .card-footer { diff --git a/frontend/src/styles/components/_theme_dark.scss b/frontend/src/styles/components/_theme_dark.scss index 9eec397b..6df74023 100755 --- a/frontend/src/styles/components/_theme_dark.scss +++ b/frontend/src/styles/components/_theme_dark.scss @@ -338,7 +338,7 @@ background-color: $dark-table-row-hover-bg !important; } - &.selected { + &:is(.selected, :active) { background-color: $sidebar-dark-apps-menu-title-bg !important; box-shadow: inset 2px 0 0 $primary; } @@ -421,7 +421,7 @@ background-color: $sidebar-dark-icons-menu-bg; border-color: $sidebar-dark-icons-menu-border-color; - &.selected { + &:is(.selected, :active) { background-color: $sidebar-dark-apps-menu-title-bg; .card-footer { diff --git a/frontend/src/styles/components/_theme_light.scss b/frontend/src/styles/components/_theme_light.scss index 806416d0..c6c4be1e 100755 --- a/frontend/src/styles/components/_theme_light.scss +++ b/frontend/src/styles/components/_theme_light.scss @@ -342,7 +342,7 @@ background-color: $light-table-row-hover-bg !important; } - &.selected { + &:is(.selected, :active) { background-color: $light-table-row-selected-bg !important; box-shadow: inset 2px 0 0 darken($primary, 4%); } @@ -392,7 +392,7 @@ background-color: $white; border-color: $gray-mid; - &.selected { + &:is(.selected, :active) { background-color: $table-bg-hover; .card-footer { From 4f2078e69ab6c87808580ce320b99e4d9b438a90 Mon Sep 17 00:00:00 2001 From: johaven Date: Sat, 31 Oct 2026 15:22:30 +0100 Subject: [PATCH 3/3] feat(frontend): centralize single-click navigation handling --- .../components/admin-groups.component.html | 2 +- .../components/admin-groups.component.ts | 5 +++ .../components/admin-spaces.component.html | 4 +- .../components/admin-spaces.component.ts | 5 +++ .../components/admin-users.component.html | 2 +- .../admin/components/admin-users.component.ts | 7 +++- .../components/favorites.component.html | 15 ++++++- .../components/favorites.component.ts | 5 +++ .../sidebar/files-tree.component.html | 1 + .../links/components/links.component.html | 4 +- .../links/components/links.component.ts | 5 +++ .../search/components/search.component.html | 10 +++-- .../search/components/search.component.scss | 13 ++---- .../search/components/search.component.ts | 5 +++ .../shared-children-dialog.component.html | 5 ++- .../shared-children-dialog.component.ts | 8 +++- .../shares/components/shared.component.html | 10 +++-- .../shares/components/shared.component.ts | 5 +++ .../components/spaces-browser.component.html | 4 -- .../components/spaces-browser.component.ts | 2 +- .../spaces/components/spaces.component.html | 12 +++--- .../spaces/components/spaces.component.ts | 5 +++ .../spaces/components/trash.component.html | 4 +- .../spaces/components/trash.component.ts | 5 +++ .../components/sync-clients.component.html | 2 +- .../components/sync-clients.component.scss | 5 +++ .../sync/components/sync-clients.component.ts | 2 + .../sync/components/sync-paths.component.html | 4 +- .../sync/components/sync-paths.component.ts | 9 ++++- .../components/user-account.component.html | 2 +- .../components/user-groups.component.html | 2 +- .../users/components/user-groups.component.ts | 5 +++ .../components/user-guests.component.html | 2 +- .../users/components/user-guests.component.ts | 5 +++ .../app/common/directives/tap.directive.ts | 40 ++++++++++--------- frontend/src/styles/components/_app.scss | 2 +- .../src/styles/components/_theme_dark.scss | 3 +- .../src/styles/components/_theme_light.scss | 3 +- 38 files changed, 160 insertions(+), 69 deletions(-) diff --git a/frontend/src/app/applications/admin/components/admin-groups.component.html b/frontend/src/app/applications/admin/components/admin-groups.component.html index f3623585..ed50bac8 100644 --- a/frontend/src/app/applications/admin/components/admin-groups.component.html +++ b/frontend/src/app/applications/admin/components/admin-groups.component.html @@ -102,7 +102,7 @@ @for (m of scrollView?.viewPortItems; track m.mid) { @for (th of tableHeaders | keyvalue: originalOrderKeyValue; track th.key) { @if (th.value.show) { diff --git a/frontend/src/app/applications/admin/components/admin-groups.component.ts b/frontend/src/app/applications/admin/components/admin-groups.component.ts index 0335ab8a..d4722785 100644 --- a/frontend/src/app/applications/admin/components/admin-groups.component.ts +++ b/frontend/src/app/applications/admin/components/admin-groups.component.ts @@ -173,6 +173,11 @@ export class AdminGroupsComponent { this.selected = member } + onMemberActivate(member: MemberModel) { + this.onSelect(member) + this.browse(member) + } + onContextMenu(ev: any) { ev.preventDefault() ev.stopPropagation() diff --git a/frontend/src/app/applications/admin/components/admin-spaces.component.html b/frontend/src/app/applications/admin/components/admin-spaces.component.html index 6ef94410..f69606fd 100644 --- a/frontend/src/app/applications/admin/components/admin-spaces.component.html +++ b/frontend/src/app/applications/admin/components/admin-spaces.component.html @@ -67,7 +67,7 @@ @for (s of scrollView?.viewPortItems; track s.id) { @for (th of tableHeaders | keyvalue: originalOrderKeyValue; track th.key) { @@ -100,7 +100,7 @@ } @case ('info') { @if (s.counts.shares) { - + {{ s.counts.shares }} diff --git a/frontend/src/app/applications/admin/components/admin-spaces.component.ts b/frontend/src/app/applications/admin/components/admin-spaces.component.ts index 744102b6..7c5e0ac5 100644 --- a/frontend/src/app/applications/admin/components/admin-spaces.component.ts +++ b/frontend/src/app/applications/admin/components/admin-spaces.component.ts @@ -165,6 +165,11 @@ export class AdminSpacesComponent { this.selected = space ?? null } + onSpaceActivate(space: SpaceModel) { + this.onSelect(space) + this.openSpaceDialog() + } + sortBy(column: string, toUpdate = true, collection?: SpaceModel[]) { this.spaces = this.sortTable.sortBy(column, toUpdate, collection || this.spaces) } diff --git a/frontend/src/app/applications/admin/components/admin-users.component.html b/frontend/src/app/applications/admin/components/admin-users.component.html index 421f0cf8..90e08a90 100644 --- a/frontend/src/app/applications/admin/components/admin-users.component.html +++ b/frontend/src/app/applications/admin/components/admin-users.component.html @@ -92,7 +92,7 @@ @for (u of scrollView?.viewPortItems; track u.id) { @for (th of tableHeaders | keyvalue: originalOrderKeyValue; track th.key) { diff --git a/frontend/src/app/applications/admin/components/admin-users.component.ts b/frontend/src/app/applications/admin/components/admin-users.component.ts index a6b124b2..dd4ba277 100644 --- a/frontend/src/app/applications/admin/components/admin-users.component.ts +++ b/frontend/src/app/applications/admin/components/admin-users.component.ts @@ -68,6 +68,7 @@ export class AdminUsersComponent { @ViewChild('TargetContextMenu', { static: true }) targetContextMenu: ContextMenuComponent protected readonly locale = inject(L10N_LOCALE) protected readonly navbarSearch = inject(NavbarSearchService) + private readonly userService = inject(UserService) protected readonly originalOrderKeyValue = originalOrderKeyValue protected readonly icons = { LucideRotateCw, @@ -147,7 +148,6 @@ export class AdminUsersComponent { private readonly activatedRoute = inject(ActivatedRoute) private readonly layout = inject(LayoutService) private readonly adminService = inject(AdminService) - private readonly userService = inject(UserService) private readonly sortSettings: SortSettings = { default: [{ prop: 'login', type: 'string' }], login: [{ prop: 'login', type: 'string' }], @@ -189,6 +189,11 @@ export class AdminUsersComponent { this.selected = user } + onUserActivate(user: AdminUserModel) { + this.onSelect(user) + this.openDialog() + } + onGuestsView(state: boolean) { this.guestsView = state this.router.navigate([ADMIN_PATH.BASE, state ? ADMIN_PATH.GUESTS : ADMIN_PATH.USERS]).catch(console.error) diff --git a/frontend/src/app/applications/favorites/components/favorites.component.html b/frontend/src/app/applications/favorites/components/favorites.component.html index 89b4c832..bccdcfab 100644 --- a/frontend/src/app/applications/favorites/components/favorites.component.html +++ b/frontend/src/app/applications/favorites/components/favorites.component.html @@ -48,7 +48,7 @@ @for (f of scrollView?.viewPortItems; track f.fileId) {
@@ -71,12 +71,14 @@ @switch (badge) { @case ('comments') { } @case ('shares') { @if (f.shares.length > 1) { @@ -86,6 +88,7 @@ } @case ('spaces') { @if (f.spaces.length > 1) { @@ -95,6 +98,7 @@ } @case ('links') { @if (f.links.length > 1) { @@ -104,6 +108,7 @@ } @case ('syncs') { @if (f.syncs.length > 1) { @@ -116,6 +121,7 @@ } @if (hiddenBadges > 0) { {{ hiddenBadges }} @@ -172,7 +178,7 @@ @for (f of scrollView?.viewPortItems; track f.fileId) { @for (th of tableHeaders | keyvalue: originalOrderKeyValue; track th.key) { @@ -205,12 +211,14 @@ @case ('info') { @if (f.hasComments) { } @if (f.shares.length) { @if (f.shares.length > 1) { @@ -220,6 +228,7 @@ } @if (f.links.length) { @if (f.links.length > 1) { @@ -229,6 +238,7 @@ } @if (f.spaces.length) { @if (f.spaces.length > 1) { @@ -238,6 +248,7 @@ } @if (f.syncs.length) { @if (f.syncs.length > 1) { diff --git a/frontend/src/app/applications/favorites/components/favorites.component.ts b/frontend/src/app/applications/favorites/components/favorites.component.ts index 80e2c62c..715cb061 100644 --- a/frontend/src/app/applications/favorites/components/favorites.component.ts +++ b/frontend/src/app/applications/favorites/components/favorites.component.ts @@ -191,6 +191,11 @@ export class FavoritesComponent implements OnInit { this.selected = favorite } + onFavoriteActivate(favorite: FileFavoriteModel) { + this.onSelect(favorite) + this.goTo(favorite) + } + onContextMenu(ev: Event) { ev.preventDefault() ev.stopPropagation() diff --git a/frontend/src/app/applications/files/components/sidebar/files-tree.component.html b/frontend/src/app/applications/files/components/sidebar/files-tree.component.html index 6d920c5d..e354fd8d 100644 --- a/frontend/src/app/applications/files/components/sidebar/files-tree.component.html +++ b/frontend/src/app/applications/files/components/sidebar/files-tree.component.html @@ -133,6 +133,7 @@
@@ -134,7 +134,7 @@ @for (s of scrollView?.viewPortItems; track s.id) { @for (th of tableHeaders | keyvalue: originalOrderKeyValue; track th.key) { diff --git a/frontend/src/app/applications/links/components/links.component.ts b/frontend/src/app/applications/links/components/links.component.ts index 4cdd5cd7..7f246c51 100644 --- a/frontend/src/app/applications/links/components/links.component.ts +++ b/frontend/src/app/applications/links/components/links.component.ts @@ -192,6 +192,11 @@ export class LinksComponent implements OnInit { this.store.linkSelection.set(this.selected) } + onLinkActivate(share: ShareLinkModel) { + this.onSelect(share) + this.openLinkDialog() + } + openLinkDialog() { this.linksService.shareLink(this.selected.id).subscribe((share: ShareLinkModel) => { const modalRef: BsModalRef = this.layout.openDialog(LinkDialogComponent, 'lg', { diff --git a/frontend/src/app/applications/search/components/search.component.html b/frontend/src/app/applications/search/components/search.component.html index dba7d859..1c67b468 100644 --- a/frontend/src/app/applications/search/components/search.component.html +++ b/frontend/src/app/applications/search/components/search.component.html @@ -1,11 +1,15 @@ -
+
@if (errorMessage) {
{{ errorMessage }}
} @for (file of store.filesSearch() | searchFilter:navbarSearch.viewFilter(); track $index) {
@if (file.matches?.length) { -
+
@for (match of file.matches; track $index) {

} diff --git a/frontend/src/app/applications/search/components/search.component.scss b/frontend/src/app/applications/search/components/search.component.scss index e3c3a6f8..fc5db94c 100644 --- a/frontend/src/app/applications/search/components/search.component.scss +++ b/frontend/src/app/applications/search/components/search.component.scss @@ -27,23 +27,15 @@ background-color: var(--search-result-hover-bg); } - &:active { - background-color: var(--search-result-hover-bg); - } - &:focus-visible { z-index: 1; outline: 2px solid var(--search-result-focus-ring); outline-offset: -2px; } - &.selected { + &:is(.selected, :active) { background-color: var(--search-result-selected-bg); - &:active { - background-color: var(--search-result-hover-bg); - } - &::before { position: absolute; inset: 0 auto 0 0; @@ -88,6 +80,9 @@ display: flex; flex-direction: column; gap: .3rem; + cursor: text; + user-select: text; + -webkit-touch-callout: default; } .search-result-match { diff --git a/frontend/src/app/applications/search/components/search.component.ts b/frontend/src/app/applications/search/components/search.component.ts index 32d5c17b..82b645e3 100644 --- a/frontend/src/app/applications/search/components/search.component.ts +++ b/frontend/src/app/applications/search/components/search.component.ts @@ -96,6 +96,11 @@ export class SearchComponent { this.router.navigate([SPACES_PATH.SPACES, ...file.path.split('/')], { queryParams: { select: file.name } }).catch(console.error) } + protected onFileActivate(file: FileContentModel, index: number) { + this.selectedId = index + this.goTo(file) + } + private searchFromParams(params: ParamMap): NavbarGlobalSearch | null { const content = params.get(SEARCH_QUERY_PARAM.QUERY)?.trim() ?? '' const mode = params.get(SEARCH_QUERY_PARAM.SCOPE) diff --git a/frontend/src/app/applications/shares/components/dialogs/shared-children-dialog.component.html b/frontend/src/app/applications/shares/components/dialogs/shared-children-dialog.component.html index d7fc5f7e..88544760 100644 --- a/frontend/src/app/applications/shares/components/dialogs/shared-children-dialog.component.html +++ b/frontend/src/app/applications/shares/components/dialogs/shared-children-dialog.component.html @@ -14,7 +14,10 @@