From 32d398b4105bfa2c6d9b14cd28677db09407fbef Mon Sep 17 00:00:00 2001 From: arielsrossi Date: Sat, 31 Oct 2026 16:11:04 -0400 Subject: [PATCH 01/24] feat: control the order of many files before converting or merging Files dropped or picked on the Home page are collected in a list below the drop zone, instead of starting a conversion for each drop. Each file shows a thumbnail (the first page of a PDF, or the picture), its place in the order, and an X to remove it. The order is changed by dragging a card, or with the keyboard: the arrows move the selection, Alt+Up / Alt+Down move the selected file, and Delete removes it. "Choose action" opens the donut for the list in that order; the list empties once that conversion finishes. A merge started from the list keeps the order and does not open the order window (merged pictures still do, for their layout). The merge order window shows the same cards and keys. Resolves https://github.com/KnifeLemon/Filee/issues/927 Co-Authored-By: Claude Haiku 5.5 --- src/Filee.App/Assets/i18n/ar.json | 4 + src/Filee.App/Assets/i18n/de.json | 4 + src/Filee.App/Assets/i18n/en.json | 4 + src/Filee.App/Assets/i18n/es.json | 4 + src/Filee.App/Assets/i18n/fr.json | 4 + src/Filee.App/Assets/i18n/hi.json | 4 + src/Filee.App/Assets/i18n/id.json | 4 + src/Filee.App/Assets/i18n/it.json | 4 + src/Filee.App/Assets/i18n/ja.json | 4 + src/Filee.App/Assets/i18n/ko.json | 4 + src/Filee.App/Assets/i18n/ne.json | 4 + src/Filee.App/Assets/i18n/pl.json | 4 + src/Filee.App/Assets/i18n/pt-BR.json | 4 + src/Filee.App/Assets/i18n/pt-PT.json | 4 + src/Filee.App/Assets/i18n/ru.json | 4 + src/Filee.App/Assets/i18n/th.json | 4 + src/Filee.App/Assets/i18n/tr.json | 4 + src/Filee.App/Assets/i18n/zh-CN.json | 4 + src/Filee.App/Assets/i18n/zh-TW.json | 4 + src/Filee.App/Services/FileQueue.cs | 45 +++++ src/Filee.App/Services/RadialController.cs | 18 +- src/Filee.App/Services/WindowService.cs | 6 +- .../ViewModels/MergeOrderViewModel.cs | 139 +++++++++++++- .../ViewModels/Pages/HomePageViewModel.cs | 154 ++++++++++++++- .../ViewModels/Pages/StagedFileViewModel.cs | 78 ++++++++ src/Filee.App/Views/MergeOrderWindow.axaml | 66 ++++++- src/Filee.App/Views/MergeOrderWindow.axaml.cs | 134 +++++++++++++ src/Filee.App/Views/Pages/HomePage.axaml | 59 ++++++ src/Filee.App/Views/Pages/HomePage.axaml.cs | 179 +++++++++++++++++- tests/Filee.App.Tests/FileQueueTests.cs | 78 ++++++++ 30 files changed, 1001 insertions(+), 31 deletions(-) create mode 100644 src/Filee.App/Services/FileQueue.cs create mode 100644 src/Filee.App/ViewModels/Pages/StagedFileViewModel.cs create mode 100644 tests/Filee.App.Tests/FileQueueTests.cs diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 028a9c700..e3c8c20a9 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -25,6 +25,10 @@ "close.dont_ask": "عدم السؤال مجددًا", "home.title": "الرئيسية", "home.drop_title": "أفلت الملفات هنا", + "home.queue_title": "الملفات المراد تحويلها", + "home.queue_convert": "اختر إجراءً", + "home.queue_clear": "مسح القائمة", + "home.queue_remove": "إزالة", "home.drop_select": "انقر نقرًا مزدوجًا لتحديد الملفات", "home.drop_hint": "أو اضغط باستمرار على {0} أثناء سحب الملفات في مستكشف الملفات. يفتح شريط الأدوات الدائري عند المؤشر.", "home.drop_hint_macos": "أو اضغط باستمرار على {0} أثناء سحب الملفات في Finder. يفتح شريط الأدوات الدائري عند المؤشر.", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index d19add201..9eba35ee7 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -25,6 +25,10 @@ "close.dont_ask": "Nicht erneut fragen", "home.title": "Start", "home.drop_title": "Dateien hier ablegen", + "home.queue_title": "Zu konvertierende Dateien", + "home.queue_convert": "Aktion wählen", + "home.queue_clear": "Liste leeren", + "home.queue_remove": "Entfernen", "home.drop_select": "Dateien per Doppelklick auswählen", "home.drop_hint": "Oder beim Ziehen von Dateien im Explorer {0} gedrückt halten. Das Ringmenü öffnet sich am Mauszeiger.", "home.drop_hint_macos": "Oder beim Ziehen von Dateien im Finder {0} gedrückt halten. Das Ringmenü öffnet sich am Mauszeiger.", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index eec07b447..e535334ba 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -28,6 +28,10 @@ "home.title": "Home", "home.drop_title": "Drop files here", + "home.queue_title": "Files to convert", + "home.queue_convert": "Choose action", + "home.queue_clear": "Clear list", + "home.queue_remove": "Remove", "home.drop_select": "Double-click to select files", "home.drop_hint": "Or hold {0} while dragging files in Explorer. The donut toolbar opens at your cursor.", "home.drop_hint_macos": "Or hold {0} while dragging files in Finder. The donut toolbar opens at your cursor.", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 8c7c08294..7075a2e44 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -25,6 +25,10 @@ "close.dont_ask": "No volver a preguntar", "home.title": "Inicio", "home.drop_title": "Suelta archivos aquí", + "home.queue_title": "Archivos para convertir", + "home.queue_convert": "Elegir acción", + "home.queue_clear": "Vaciar lista", + "home.queue_remove": "Quitar", "home.drop_select": "Doble clic para seleccionar archivos", "home.drop_hint": "O mantén {0} mientras arrastras archivos en el Explorador. El menú circular se abre donde esté el cursor.", "home.drop_hint_macos": "O mantén {0} mientras arrastras archivos en Finder. El menú circular se abre donde esté el cursor.", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 1aa508faf..3307ecca2 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -28,6 +28,10 @@ "home.title": "Accueil", "home.drop_title": "Déposez des fichiers ici", + "home.queue_title": "Fichiers à convertir", + "home.queue_convert": "Choisir une action", + "home.queue_clear": "Vider la liste", + "home.queue_remove": "Retirer", "home.drop_select": "Double-cliquez pour sélectionner des fichiers", "home.drop_hint": "Ou maintenez {0} en glissant des fichiers dans l'Explorateur. Le menu circulaire s'ouvre sous le curseur.", "home.drop_hint_macos": "Ou maintenez {0} en glissant des fichiers dans le Finder. Le menu circulaire s'ouvre sous le curseur.", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index afa6648c9..f57c44580 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -25,6 +25,10 @@ "close.dont_ask": "दोबारा न पूछें", "home.title": "होम", "home.drop_title": "फ़ाइलें यहाँ छोड़ें", + "home.queue_title": "रूपांतरित करने वाली फ़ाइलें", + "home.queue_convert": "क्रिया चुनें", + "home.queue_clear": "सूची साफ़ करें", + "home.queue_remove": "हटाएं", "home.drop_select": "फ़ाइलें चुनने के लिए डबल-क्लिक करें", "home.drop_hint": "या Explorer में फ़ाइलें खींचते समय {0} दबाए रखें। डोनट टूलबार आपके कर्सर पर खुलेगा।", "home.drop_hint_macos": "या Finder में फ़ाइलें खींचते समय {0} दबाए रखें। डोनट टूलबार आपके कर्सर पर खुलेगा।", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 8b224b6a9..6b65bee0c 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -25,6 +25,10 @@ "close.dont_ask": "Jangan tanyakan lagi", "home.title": "Beranda", "home.drop_title": "Lepaskan file di sini", + "home.queue_title": "File untuk dikonversi", + "home.queue_convert": "Pilih tindakan", + "home.queue_clear": "Kosongkan daftar", + "home.queue_remove": "Hapus", "home.drop_select": "Klik dua kali untuk memilih file", "home.drop_hint": "Atau tahan {0} saat menyeret file di Explorer. Toolbar donut akan terbuka di kursor Anda.", "home.drop_hint_macos": "Atau tahan {0} saat menyeret file di Finder. Toolbar donut akan terbuka di kursor Anda.", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index ab0af57e0..2f4e09197 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -25,6 +25,10 @@ "close.dont_ask": "Non chiedere più", "home.title": "Home", "home.drop_title": "Rilascia qui i file", + "home.queue_title": "File da convertire", + "home.queue_convert": "Scegli azione", + "home.queue_clear": "Svuota elenco", + "home.queue_remove": "Rimuovi", "home.drop_select": "Doppio clic per selezionare i file", "home.drop_hint": "Oppure tieni premuto {0} mentre trascini i file in Esplora file. La barra donut si apre sul cursore.", "home.drop_hint_macos": "Oppure tieni premuto {0} mentre trascini i file nel Finder. La barra donut si apre sul cursore.", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 5f18b62de..57f198d15 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -25,6 +25,10 @@ "close.dont_ask": "次回から確認しない", "home.title": "ホーム", "home.drop_title": "ここにファイルをドロップ", + "home.queue_title": "変換するファイル", + "home.queue_convert": "操作を選択", + "home.queue_clear": "リストをクリア", + "home.queue_remove": "削除", "home.drop_select": "ダブルクリックしてファイルを選択", "home.drop_hint": "または、{0}を押しながら\nエクスプローラーでファイルをドラッグしてください。\nカーソル位置にドーナツメニューが開きます。", "home.drop_hint_macos": "または、{0}を押しながら\nFinderでファイルをドラッグしてください。\nカーソル位置にドーナツメニューが開きます。", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index f6630867d..ffecf3878 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -28,6 +28,10 @@ "home.title": "홈", "home.drop_title": "여기에 파일을 놓으세요", + "home.queue_title": "변환할 파일", + "home.queue_convert": "작업 선택", + "home.queue_clear": "목록 지우기", + "home.queue_remove": "제거", "home.drop_select": "더블클릭하여 파일 선택", "home.drop_hint": "또는 탐색기에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.", "home.drop_hint_macos": "또는 Finder에서 {0} 키를 누른 채 파일을 드래그하면 커서 위치에 원형 툴바가 열려요.", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index b39570feb..0532872a9 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -25,6 +25,10 @@ "close.dont_ask": "फेरि नसोध्नुहोस्", "home.title": "गृहपृष्ठ", "home.drop_title": "फाइलहरू यहाँ छाड्नुहोस्", + "home.queue_title": "रूपान्तरण गर्नु पर्ने फाइलहरू", + "home.queue_convert": "कार्य छान्नुहोस्", + "home.queue_clear": "सूची खाली गर्नुहोस्", + "home.queue_remove": "हटाउनुहोस्", "home.drop_select": "फाइलहरू छान्न डबल-क्लिक गर्नुहोस्", "home.drop_hint": "वा Explorer मा फाइलहरू तान्दा {0} थिचिराख्नुहोस्। डोनट टुलबार कर्सरमा खुल्नेछ।", "home.drop_hint_macos": "वा Finder मा फाइलहरू तान्दा {0} थिचिराख्नुहोस्। डोनट टुलबार कर्सरमा खुल्नेछ।", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index efdc72b85..3ecdfd2e4 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -28,6 +28,10 @@ "home.title": "Start", "home.drop_title": "Upuść pliki tutaj", + "home.queue_title": "Pliki do konwersji", + "home.queue_convert": "Wybierz działanie", + "home.queue_clear": "Wyczyść listę", + "home.queue_remove": "Usuń", "home.drop_select": "Kliknij dwukrotnie, aby wybrać pliki", "home.drop_hint": "Albo przytrzymaj {0}, przeciągając pliki w Eksploratorze. Pasek-pierścień otworzy się przy kursorze.", "home.drop_hint_macos": "Albo przytrzymaj {0}, przeciągając pliki w Finderze. Pasek-pierścień otworzy się przy kursorze.", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 5f7220f2c..8b55186f7 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -28,6 +28,10 @@ "home.title": "Início", "home.drop_title": "Solte os arquivos aqui", + "home.queue_title": "Arquivos para converter", + "home.queue_convert": "Escolher ação", + "home.queue_clear": "Limpar lista", + "home.queue_remove": "Remover", "home.drop_select": "Clique duas vezes para selecionar arquivos", "home.drop_hint": "Ou segure {0} ao arrastar arquivos no Explorador. O menu circular abre no cursor.", "home.drop_hint_macos": "Ou segure {0} ao arrastar arquivos no Finder. O menu circular abre no cursor.", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 241d43b56..cde2baaef 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -28,6 +28,10 @@ "home.title": "Página inicial", "home.drop_title": "Larga os ficheiros aqui", + "home.queue_title": "Ficheiros a converter", + "home.queue_convert": "Escolher ação", + "home.queue_clear": "Limpar lista", + "home.queue_remove": "Remover", "home.drop_select": "Faz duplo clique para selecionar ficheiros", "home.drop_hint": "Ou mantém {0} premido enquanto arrastas ficheiros no Explorador. A barra circular abre junto ao cursor.", "home.drop_hint_macos": "Ou mantém {0} premido enquanto arrastas ficheiros no Finder. A barra circular abre junto ao cursor.", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 596877e56..d30878473 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -28,6 +28,10 @@ "home.title": "Главная", "home.drop_title": "Перетащите файлы сюда", + "home.queue_title": "Файлы для конвертации", + "home.queue_convert": "Выбрать действие", + "home.queue_clear": "Очистить список", + "home.queue_remove": "Удалить", "home.drop_select": "Дважды щелкните, чтобы выбрать файлы", "home.drop_hint": "Или зажмите {0}, перетаскивая файлы в Проводнике. Круговое меню откроется у курсора.", "home.drop_hint_macos": "Или зажмите {0}, перетаскивая файлы в Finder. Круговое меню откроется у курсора.", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 856bf23a2..96992c8c5 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -25,6 +25,10 @@ "close.dont_ask": "ไม่ต้องถามอีก", "home.title": "หน้าแรก", "home.drop_title": "วางไฟล์ที่นี่", + "home.queue_title": "ไฟล์ที่จะแปลง", + "home.queue_convert": "เลือกการทำงาน", + "home.queue_clear": "ล้างรายการ", + "home.queue_remove": "นำออก", "home.drop_select": "ดับเบิลคลิกเพื่อเลือกไฟล์", "home.drop_hint": "หรือกด {0} ค้างไว้ขณะลากไฟล์ใน Explorer เพื่อเปิดแถบโดนัทตรงตำแหน่งเคอร์เซอร์ของคุณ", "home.drop_hint_macos": "หรือกด {0} ค้างไว้ขณะลากไฟล์ใน Finder เพื่อเปิดแถบโดนัทตรงตำแหน่งเคอร์เซอร์ของคุณ", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index f3e22f330..0714d53df 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -25,6 +25,10 @@ "close.dont_ask": "Bir daha sorma", "home.title": "Ana sayfa", "home.drop_title": "Dosyaları buraya bırakın", + "home.queue_title": "Dönüştürülecek dosyalar", + "home.queue_convert": "İşlem seç", + "home.queue_clear": "Listeyi temizle", + "home.queue_remove": "Kaldır", "home.drop_select": "Dosya seçmek için çift tıklayın", "home.drop_hint": "Ya da Dosya Gezgini'nde dosyaları sürüklerken {0} tuşunu basılı tutun. Halka araç çubuğu imlecin yanında açılır.", "home.drop_hint_macos": "Ya da Finder'da dosyaları sürüklerken {0} tuşunu basılı tutun. Halka araç çubuğu imlecin yanında açılır.", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 95be8e547..d40823980 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -28,6 +28,10 @@ "home.title": "首页", "home.drop_title": "将文件拖放到这里", + "home.queue_title": "待转换的文件", + "home.queue_convert": "选择操作", + "home.queue_clear": "清空列表", + "home.queue_remove": "移除", "home.drop_select": "双击选择文件", "home.drop_hint": "或在资源管理器中按住 {0} 拖动文件,环形工具栏会在光标处打开。", "home.drop_hint_macos": "或在访达中按住 {0} 拖动文件,环形工具栏会在光标处打开。", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 24c4110a0..e7f2af99e 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -28,6 +28,10 @@ "home.title": "首頁", "home.drop_title": "將檔案拖放到這裡", + "home.queue_title": "待轉換的檔案", + "home.queue_convert": "選擇動作", + "home.queue_clear": "清空清單", + "home.queue_remove": "移除", "home.drop_select": "按兩下選取檔案", "home.drop_hint": "或在檔案總管中按住 {0} 拖曳檔案,環形工具列會在游標處開啟。", "home.drop_hint_macos": "或在 Finder 中按住 {0} 拖曳檔案,環形工具列會在游標處開啟。", diff --git a/src/Filee.App/Services/FileQueue.cs b/src/Filee.App/Services/FileQueue.cs new file mode 100644 index 000000000..4355d4afc --- /dev/null +++ b/src/Filee.App/Services/FileQueue.cs @@ -0,0 +1,45 @@ +// The files waiting on the Home page to be converted together, in the order the user put them in. Any file can join: the +// donut offers the conversions that fit the files, as it does for files dropped anywhere else. + +using Filee.Core.Platform; + +namespace Filee.App.Services; + +/// An ordered list of files, kept without duplicates. +internal sealed class FileQueue +{ + private readonly List _paths = []; + + /// The files in the order they are converted. + public IReadOnlyList Paths => _paths; + + /// Adds at the end, in order; a file already in the queue is not added twice. + public void Add(IEnumerable paths) + { + foreach (var path in paths) + if (!_paths.Contains(path, FileSystemPaths.Comparer)) + _paths.Add(path); + } + + /// Takes out. + public bool Remove(string path) + { + var index = _paths.FindIndex(p => FileSystemPaths.Comparer.Equals(p, path)); + if (index < 0) + return false; + _paths.RemoveAt(index); + return true; + } + + /// Moves the file at to position . + public void Move(int from, int to) + { + if (from == to || from < 0 || to < 0 || from >= _paths.Count || to >= _paths.Count) + return; + var path = _paths[from]; + _paths.RemoveAt(from); + _paths.Insert(to, path); + } + + public void Clear() => _paths.Clear(); +} diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index 2ef6fdb2b..e06936e7d 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -66,6 +66,7 @@ public sealed class RadialController( private readonly HashSet _formatRequestsOffered = new(StringComparer.OrdinalIgnoreCase); private WebDrop? _web; // the browser drop the click-mode donut's files stand for, until a conversion takes it private bool _toClipboard; // the click-mode donut's files came from the clipboard: their results go back on it + private bool _keepOrder; // the click-mode donut's files come in the order given (the Home page's queue), not sorted /// /// How long a drag that never brought files may keep the invisible window up, in case the button release is @@ -94,6 +95,7 @@ public void ShowForDrag(int x, int y) _dropped = false; _dragReachedDonut = false; _converted = false; + _keepOrder = false; viewModel.ResetForDrag(); window.DonutControl.AllowContextEdit = false; PlaceAndShow(window, x, y, activate: false); @@ -114,8 +116,11 @@ public void ShowForDrag(int x, int y) /// of this browser drop (the drop zone, a picture on the clipboard), which the donut now owns. /// : the files came from the clipboard, and their results go back on it. /// + /// + /// The files are merged in the order given (the Home page's queue, which the user sorted), not sorted by name first. + /// public void ShowForFiles(int x, int y, IReadOnlyList files, bool forAnotherProcess = false, WebDrop? web = null, - bool toClipboard = false) + bool toClipboard = false, bool keepOrder = false) { if (web is null) files = platform.ResolveShortcuts(files); @@ -127,6 +132,7 @@ public void ShowForFiles(int x, int y, IReadOnlyList files, bool forAnot ReleaseProgressToToast(); ReplaceWeb(web); _toClipboard = toClipboard; + _keepOrder = keepOrder; if (forAnotherProcess && OperatingSystem.IsMacOS()) _grace.Start(); else @@ -364,7 +370,7 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w if ((preset.TargetFormat == "pdf" && preset.Pdf.MergeIntoSingle || preset.JoinsIntoOneMedia || preset.JoinsImages) && sources.Count > 1) { Close(animate: false); - _ = MergeInOrderAsync(sources, preset.Clone(), web, _toClipboard); + _ = MergeInOrderAsync(sources, preset.Clone(), web, _toClipboard, _keepOrder); return; } @@ -389,11 +395,15 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w ShowProgress(job); } - private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset, WebDrop? web, bool toClipboard) + private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset, WebDrop? web, bool toClipboard, bool keepOrder) { try { - var ordered = await windows.ChooseMergeOrderAsync(files, preset); + // The queue's order is the one the user already set on the Home page: the chooser only shows for a merge that + // has a layout to pick (merged pictures). + var ordered = keepOrder && !preset.JoinsImages + ? files + : await windows.ChooseMergeOrderAsync(files, preset, keepOrder); if (ordered is not null && preset.JoinsImages) RememberMergeLayout(preset); if (ordered is not null && preset.Output.AskEveryTime && web is null) diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 955b0851d..367958cf6 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -56,12 +56,13 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem /// /// Reviews the order of a merge (, when given, names the button). For "Merge images" the /// layout is chosen too and written into . Closing or cancelling starts no conversion. + /// : the files start in the order given (see ). /// - public async Task?> ChooseMergeOrderAsync(IReadOnlyList files, Preset? preset = null) + public async Task?> ChooseMergeOrderAsync(IReadOnlyList files, Preset? preset = null, bool keepOrder = false) { _mergeOrder?.Close(); var images = preset is { JoinsImages: true }; - var vm = new MergeOrderViewModel(files, images ? preset!.Image : null); + var vm = new MergeOrderViewModel(files, images ? preset!.Image : null, keepOrder); var dialog = new MergeOrderWindow { DataContext = vm }; if (preset is not null) dialog.StartMerge.Content = loc.DisplayName(preset); @@ -84,6 +85,7 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem await closed.Task; if (dialog.Result is not null && images) vm.ApplyTo(preset!.Image); + vm.Dispose(); return dialog.Result; } diff --git a/src/Filee.App/ViewModels/MergeOrderViewModel.cs b/src/Filee.App/ViewModels/MergeOrderViewModel.cs index 7710534bc..37b97abe8 100644 --- a/src/Filee.App/ViewModels/MergeOrderViewModel.cs +++ b/src/Filee.App/ViewModels/MergeOrderViewModel.cs @@ -5,35 +5,78 @@ using System.Collections.ObjectModel; using Avalonia.Media; +using Avalonia.Media.Imaging; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Filee.App.ViewModels.Pages; +using Filee.Core.Formats; using Filee.Core.Platform; using Filee.Core.Presets; using Filee.Core.Web; namespace Filee.App.ViewModels; -/// One file of the merge: its name, and the folder it is in (none for a file from a browser, not saved yet). -public sealed class MergeSource(string path) +/// +/// One file of the merge: its name, the folder it is in (none for a file from a browser, not saved yet), and its picture +/// (the first page of a PDF, or the picture itself), loaded in the background so the window opens at once. +/// +public sealed partial class MergeSource(string path, Action remove) : ObservableObject, IDisposable { public string Path { get; } = path; public string Name => System.IO.Path.GetFileName(Path); public string Folder => WebDrop.IsPlaceholder(Path) ? "" : System.IO.Path.GetDirectoryName(Path) ?? ""; + + /// The format, or null when Filee doesn't know it (the file then shows no picture). + public FileFormat? Format { get; } = FormatRegistry.Detect(path); + + /// The extension ("PDF", "PNG"), shown when the file has no picture. + public string FormatBadge => Format is null ? "" : Format.PrimaryExtension.TrimStart('.').ToUpperInvariant(); + + /// The place in the merge, from 1: the order the files merge in. + [ObservableProperty] + private int _position; + + [ObservableProperty, NotifyPropertyChangedFor(nameof(HasThumbnail))] + private Bitmap? _thumbnail; + + public bool HasThumbnail => Thumbnail is not null; + + /// The card's X: takes the file out of the merge. + [RelayCommand] + private void Remove() => remove(this); + + /// The window closed: a picture that is still being made is dropped when it arrives. + public bool IsDisposed { get; private set; } + + /// Disposes the picture of a PDF (pictures of images are cached and shared, so they stay). + public void Dispose() + { + IsDisposed = true; + if (Format?.Category == FormatCategory.Pdf) + Thumbnail?.Dispose(); + Thumbnail = null; + } } -public sealed partial class MergeOrderViewModel : ObservableObject +public sealed partial class MergeOrderViewModel : ObservableObject, IDisposable { /// The files to merge. /// /// "Merge images" (#645): the layout the pictures are merged with last time, which the window also offers to change. /// Null for other merges. /// - public MergeOrderViewModel(IReadOnlyList files, ImageOptions? images = null) + /// The files are already in the order to merge them in (the Home page's queue): not sorted by name. + public MergeOrderViewModel(IReadOnlyList files, ImageOptions? images = null, bool keepOrder = false) { - Files = new(files - .OrderBy(file => System.IO.Path.GetFileName(file), NaturalComparer.Instance) - .ThenBy(file => file, NaturalComparer.Instance) - .Select(file => new MergeSource(file))); + IEnumerable ordered = keepOrder + ? files + : files + .OrderBy(file => System.IO.Path.GetFileName(file), NaturalComparer.Instance) + .ThenBy(file => file, NaturalComparer.Instance); + Files = new(ordered.Select(file => new MergeSource(file, RemoveFile))); + Renumber(); + foreach (var source in Files) + _ = LoadThumbnailAsync(source); SelectedFile = Files.FirstOrDefault(); if (images is null) return; @@ -125,6 +168,85 @@ public void ApplyTo(ImageOptions image) [RelayCommand(CanExecute = nameof(CanMoveDown))] private void MoveDown() => Move(1); + /// The picture of a file, made off the UI thread; a file without one shows its extension. + private static async Task LoadThumbnailAsync(MergeSource source) + { + if (source.Format is not { } format) + return; + Bitmap? bitmap = null; + try + { + bitmap = await Task.Run(() => StagedFileViewModel.MakeThumbnail(source.Path, format)); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // No picture: the file shows its extension instead. + } + if (source.IsDisposed) + { + if (format.Category == FormatCategory.Pdf) + bitmap?.Dispose(); + return; + } + source.Thumbnail = bitmap; + } + + /// The window closed: the pictures are released. + public void Dispose() + { + foreach (var file in Files) + file.Dispose(); + } + + /// A card dragged to another place: the file moves there, and it stays selected (the merge follows the order). + internal void MoveFile(int from, int to) + { + if (from == to || from < 0 || to < 0 || from >= Files.Count || to >= Files.Count) + return; + Files.Move(from, to); + Renumber(); + SelectedFile = Files[to]; + MoveUpCommand.NotifyCanExecuteChanged(); + MoveDownCommand.NotifyCanExecuteChanged(); + } + + /// The left and right arrows: the selection moves to the neighbour, and stays on a card after a move. + internal void SelectNeighbour(int delta) + { + if (Files.Count == 0) + return; + var index = SelectedFile is null ? -1 : Files.IndexOf(SelectedFile); + SelectedFile = Files[Math.Clamp(index < 0 ? 0 : index + delta, 0, Files.Count - 1)]; + } + + /// The Delete key: the selected file leaves the merge. + internal void RemoveSelected() + { + if (SelectedFile is { } selected) + RemoveFile(selected); + } + + /// The card's X: the file leaves the merge, and the selection moves to a neighbour. + internal void RemoveFile(MergeSource file) + { + var index = Files.IndexOf(file); + if (index < 0) + return; + Files.RemoveAt(index); + file.Dispose(); + if (ReferenceEquals(SelectedFile, file)) + SelectedFile = Files.Count == 0 ? null : Files[Math.Min(index, Files.Count - 1)]; + Renumber(); + MoveUpCommand.NotifyCanExecuteChanged(); + MoveDownCommand.NotifyCanExecuteChanged(); + } + + private void Renumber() + { + for (var i = 0; i < Files.Count; i++) + Files[i].Position = i + 1; + } + private void Move(int direction) { if (SelectedFile is not { } selected) @@ -134,6 +256,7 @@ private void Move(int direction) if (index < 0 || target < 0 || target >= Files.Count) return; Files.Move(index, target); + Renumber(); // The list box can drop its selection while it moves the row. SelectedFile = selected; MoveUpCommand.NotifyCanExecuteChanged(); diff --git a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs index e6487327d..9387d99cf 100644 --- a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs @@ -206,6 +206,10 @@ public sealed partial class HomePageViewModel : ObservableObject, IDisposable private readonly Services.Triggers.TriggerService? _triggers; private readonly ConversionService? _conversions; private readonly ElevationService? _elevation; + private readonly FileQueue _queue = new(); + + /// The queued files sent to the donut, until their conversion is in the history (). + private List? _sentToDonut; public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform, Services.Triggers.TriggerService? triggers = null, ConversionService? conversions = null, ElevationService? elevation = null) @@ -223,15 +227,22 @@ public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices var drag = store.Settings.Triggers.FirstOrDefault(t => t.Enabled && t.Kind == TriggerKind.Drag); DropHint = loc.Format(OperatingSystem.IsMacOS() ? "home.drop_hint_macos" : OperatingSystem.IsLinux() ? "home.drop_hint_linux" : "home.drop_hint", drag is null ? "—" : GestureText.Modifiers(drag.Modifiers)); store.HistoryChanged += OnHistoryChanged; + Queue.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasQueue)); Reload(); } - private void OnHistoryChanged(object? sender, EventArgs e) => Dispatcher.UIThread.Post(Reload); + private void OnHistoryChanged(object? sender, EventArgs e) => Dispatcher.UIThread.Post(() => + { + FinishSentQueue(); + Reload(); + }); public void Dispose() { _thumbnails?.Cancel(); _store.HistoryChanged -= OnHistoryChanged; + foreach (var card in Queue) + card.Dispose(); if (_triggers is not null) _triggers.StatusChanged -= OnTriggerStatusChanged; if (_elevation is not null) @@ -267,6 +278,147 @@ private void OpenAccessibility() public string DropHint { get; } + /// + /// The files waiting to be converted together, in the order they are converted or merged in. Files are added by + /// drops and the file picker (). + /// + public ObservableCollection Queue { get; } = []; + + public bool HasQueue => Queue.Count > 0; + + /// The queued files' paths in the order they are converted. + public IReadOnlyList QueuedPaths => _queue.Paths.ToArray(); + + /// Adds dropped or picked files to the end of the queue. + public void AddToQueue(IEnumerable paths) + { + _queue.Add(paths); + SyncQueue(); + } + + /// Moves a queued file, from the user dragging its card. + public void MoveQueued(int from, int to) + { + _queue.Move(from, to); + Queue.Move(from, to); + Renumber(); + } + + /// The card's X, or the Delete key: takes its file out of the queue. + internal void RemoveFromQueue(StagedFileViewModel card) + { + _queue.Remove(card.Path); + SyncQueue(); + } + + /// A click on a card selects it (only that one); the Delete key takes the selected file out of the queue. + public void SelectQueued(int index) + { + for (var i = 0; i < Queue.Count; i++) + Queue[i].IsSelected = i == index; + } + + /// Alt+Up / Alt+Down: the selected card moves one place earlier or later, and stays selected. + public void MoveSelectedQueued(int delta) + { + var index = Queue.ToList().FindIndex(card => card.IsSelected); + if (index < 0 || index + delta < 0 || index + delta >= Queue.Count) + return; + MoveQueued(index, index + delta); + } + + /// The left and right arrows: the selection moves to the neighbouring card. + public void SelectNeighbour(int delta) + { + if (Queue.Count == 0) + return; + var index = Queue.ToList().FindIndex(card => card.IsSelected); + SelectQueued(Math.Clamp(index < 0 ? 0 : index + delta, 0, Queue.Count - 1)); + } + + /// The Delete key: takes the selected file out of the queue, if one is selected. + public void RemoveSelectedQueued() + { + if (Queue.FirstOrDefault(card => card.IsSelected) is { } selected) + RemoveFromQueue(selected); + } + + /// + /// The queue's files went to the donut. The queue empties once Filee has converted them, not before: a cancelled + /// donut keeps the files. + /// + public void MarkSentToDonut() => _sentToDonut = [.. _queue.Paths]; + + /// + /// Empties the queue when a finished conversion (not a cancelled one) of exactly the files sent to the donut is in + /// the history. Such an entry lists all of them as its sources. + /// + private void FinishSentQueue() + { + if (_sentToDonut is not { Count: > 0 } sent) + return; + var finished = _store.History.Any(entry => entry.State != JobState.Cancelled + && sent.All(path => entry.Sources.Contains(path, FileSystemPaths.Comparer))); + if (!finished) + return; + _sentToDonut = null; + ClearQueue(); + } + + /// Empties the queue: the "Clear list" button, and once its files were converted. + [RelayCommand] + public void ClearQueue() + { + _queue.Clear(); + foreach (var card in Queue) + card.Dispose(); + Queue.Clear(); + } + + /// Brings the cards in line with the queue: cards of files that left it go, new files get a card at the end. + private void SyncQueue() + { + foreach (var card in Queue.Where(card => !_queue.Paths.Contains(card.Path, FileSystemPaths.Comparer)).ToList()) + { + Queue.Remove(card); + card.Dispose(); + } + foreach (var path in _queue.Paths) + { + if (Queue.Any(card => FileSystemPaths.Comparer.Equals(card.Path, path))) + continue; + var format = FormatRegistry.Detect(path); + var card = new StagedFileViewModel(path, format, RemoveFromQueue); + Queue.Add(card); + _ = LoadThumbnailAsync(card, format); + } + Renumber(); + } + + private void Renumber() + { + for (var i = 0; i < Queue.Count; i++) + Queue[i].Position = i + 1; + } + + /// The picture of a card, made off the UI thread; a card that left the queue meanwhile gets none. + private async Task LoadThumbnailAsync(StagedFileViewModel card, FileFormat? format) + { + Bitmap? bitmap = null; + try + { + bitmap = await Task.Run(() => StagedFileViewModel.MakeThumbnail(card.Path, format)); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // No picture: the card shows its extension instead. + } + if (Queue.Contains(card)) + card.Thumbnail = bitmap; + else if (format?.Category == FormatCategory.Pdf) + bitmap?.Dispose(); + } + /// How many conversions the page lists: the newest, or the newest that match the search. internal const int Shown = 40; diff --git a/src/Filee.App/ViewModels/Pages/StagedFileViewModel.cs b/src/Filee.App/ViewModels/Pages/StagedFileViewModel.cs new file mode 100644 index 000000000..5ce46ade9 --- /dev/null +++ b/src/Filee.App/ViewModels/Pages/StagedFileViewModel.cs @@ -0,0 +1,78 @@ +// One file of the Home page's queue: its number, its picture (the first page of a PDF, a picture itself) and its format. + +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Filee.App.Services; +using Filee.Core.Formats; +using Filee.Engines.Pdf; + +namespace Filee.App.ViewModels.Pages; + +public sealed partial class StagedFileViewModel : ObservableObject, IDisposable +{ + /// Width and height of the picture of a PDF's first page, in pixels (the card shows it smaller). + private const int PdfThumbnailSize = 260; + + private readonly Action _remove; + private readonly bool _ownsThumbnail; + + /// The file's format, or null when Filee doesn't know it (the card then shows no picture). + public StagedFileViewModel(string path, FileFormat? format, Action remove) + { + Path = path; + Name = System.IO.Path.GetFileName(path); + FormatBadge = System.IO.Path.GetExtension(path).TrimStart('.').ToUpperInvariant(); + _remove = remove; + _ownsThumbnail = format?.Category == FormatCategory.Pdf; + } + + /// The card the user selected: the Delete key takes it out of the queue. + [ObservableProperty] private bool _isSelected; + + public string Path { get; } + + /// The file name, shown under its picture. + public string Name { get; } + + /// The extension ("PDF", "PNG"), shown when the file has no picture. + public string FormatBadge { get; } + + /// The place in the queue, from 1: the order the files are converted or merged in. + [ObservableProperty] private int _position; + + [ObservableProperty, NotifyPropertyChangedFor(nameof(HasThumbnail))] + private Bitmap? _thumbnail; + + public bool HasThumbnail => Thumbnail is not null; + + /// + /// Makes the picture off the UI thread: the first page of a PDF, or the picture of an image file. Null for other + /// formats, which show their extension instead. + /// + internal static Bitmap? MakeThumbnail(string path, FileFormat? format) + { + if (format is null) + return null; + if (format.Category == FormatCategory.Pdf) + { + var preview = PdfPageTools.Preview(path, 0, PdfThumbnailSize); + using var stream = new MemoryStream(preview.Png); + return new Bitmap(stream); + } + return format.Category == FormatCategory.Image && HistoryThumbnails.CanShow(path) + ? HistoryThumbnails.Load(path) + : null; + } + + [RelayCommand] + private void Remove() => _remove(this); + + /// Disposes the picture of a PDF (pictures of images are cached and shared, so they stay). + public void Dispose() + { + if (_ownsThumbnail) + Thumbnail?.Dispose(); + Thumbnail = null; + } +} diff --git a/src/Filee.App/Views/MergeOrderWindow.axaml b/src/Filee.App/Views/MergeOrderWindow.axaml index 5f5cb5025..1e334ff3f 100644 --- a/src/Filee.App/Views/MergeOrderWindow.axaml +++ b/src/Filee.App/Views/MergeOrderWindow.axaml @@ -128,20 +128,74 @@ + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + diff --git a/src/Filee.App/Views/MergeOrderWindow.axaml.cs b/src/Filee.App/Views/MergeOrderWindow.axaml.cs index 430c21c40..133496bae 100644 --- a/src/Filee.App/Views/MergeOrderWindow.axaml.cs +++ b/src/Filee.App/Views/MergeOrderWindow.axaml.cs @@ -1,5 +1,12 @@ +// The order of a merge: the cards can be dragged to another place with the mouse, and the Delete key takes the selected +// file out of the merge. The chosen order is the one the files merge in. + +using Avalonia; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.VisualTree; using Filee.App.Services; using Filee.App.ViewModels; @@ -7,11 +14,26 @@ namespace Filee.App.Views; public partial class MergeOrderWindow : Window { + /// Pixels a press may move before it counts as a drag of a card rather than a click. + private const double DragThreshold = 6; + + /// The card under a press: its index, and where the press began (in ). + private (int Index, Point Start)? _pressed; + + /// The press has moved far enough to drag its card. + private bool _dragging; + public MergeOrderWindow() { InitializeComponent(); Motion.Track(this); UiZoom.Track(this); + // Handled too: the list selects a card on press, and the drag still has to start. + FileOrder.AddHandler(PointerPressedEvent, OnCardPressed, RoutingStrategies.Bubble, handledEventsToo: true); + FileOrder.AddHandler(PointerMovedEvent, OnCardMoved, RoutingStrategies.Bubble, handledEventsToo: true); + FileOrder.AddHandler(PointerReleasedEvent, OnCardReleased, RoutingStrategies.Bubble, handledEventsToo: true); + // Tunnelled: the arrows reach the window before the list's own keys, and keep working after Alt+Up / Alt+Down. + AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel); } public IReadOnlyList? Result { get; private set; } @@ -24,4 +46,116 @@ private void OnMerge(object? sender, RoutedEventArgs e) } private void OnCancel(object? sender, RoutedEventArgs e) => Close(); + + private void OnCardPressed(object? sender, PointerPressedEventArgs e) + { + // A card's X takes its file out with its own click: no drag starts there. + if (IsInButton(e.Source)) + return; + var at = e.GetPosition(FileOrder); + if (!e.GetCurrentPoint(FileOrder).Properties.IsLeftButtonPressed || IndexOfCardAt(at) is not { } index) + return; + _pressed = (index, at); + _dragging = false; + e.Pointer.Capture(FileOrder); + } + + private void OnCardMoved(object? sender, PointerEventArgs e) + { + if (_pressed is not { } press) + return; + var at = e.GetPosition(FileOrder); + if (!_dragging && Math.Abs(at.X - press.Start.X) + Math.Abs(at.Y - press.Start.Y) < DragThreshold) + return; + _dragging = true; + // Drawn above the other cards while it is dragged. + if (FileOrder.ContainerFromIndex(press.Index) is { } card) + { + card.ZIndex = 1; + card.RenderTransform = new TranslateTransform(at.X - press.Start.X, at.Y - press.Start.Y); + } + } + + private void OnCardReleased(object? sender, PointerReleasedEventArgs e) + { + if (_pressed is not { } press) + return; + var dragged = _dragging; + var at = e.GetPosition(FileOrder); + _pressed = null; + _dragging = false; + e.Pointer.Capture(null); + + // The card goes back to its place first: the layout is what the drop position is measured on. + if (FileOrder.ContainerFromIndex(press.Index) is { } card) + { + card.RenderTransform = null; + card.ZIndex = 0; + } + if (dragged && DataContext is MergeOrderViewModel vm) + vm.MoveFile(press.Index, IndexNearest(at)); + } + + /// + /// The left and right arrows move the selection between the cards; the Delete key takes the selected file out of the + /// merge, while the list has the focus. Text boxes keep their keys. + /// + private void OnWindowKeyDown(object? sender, KeyEventArgs e) + { + if (DataContext is not MergeOrderViewModel vm || IsTextEntry(e.Source)) + return; + if (e.Key is Key.Left or Key.Right) + { + vm.SelectNeighbour(e.Key == Key.Right ? 1 : -1); + if (vm.SelectedFile is { } selected) + FileOrder.ContainerFromIndex(vm.Files.IndexOf(selected))?.Focus(); + e.Handled = true; + } + else if (e.Key == Key.Delete && e.Source is Visual source && (ReferenceEquals(source, FileOrder) || source.GetVisualAncestors().Contains(FileOrder))) + { + vm.RemoveSelected(); + e.Handled = true; + } + } + + private static bool IsTextEntry(object? source) => + source is TextBox or NumericUpDown or ColorPicker + || source is Visual visual && visual.GetVisualAncestors().Any(ancestor => ancestor is TextBox or NumericUpDown or ColorPicker); + + private static bool IsInButton(object? source) => + source is Visual visual && (visual is Button || visual.GetVisualAncestors().Any(ancestor => ancestor is Button)); + + /// The card the point is over, or null. + private int? IndexOfCardAt(Point point) + { + for (var i = 0; i < CardCount(); i++) + if (CardBounds(i) is { } bounds && bounds.Contains(point)) + return i; + return null; + } + + /// The card whose centre is nearest the point: where a dragged card is dropped. + private int IndexNearest(Point point) + { + var nearest = 0; + var distance = double.MaxValue; + for (var i = 0; i < CardCount(); i++) + { + if (CardBounds(i) is not { } bounds) + continue; + var centre = bounds.Center; + var d = (centre.X - point.X) * (centre.X - point.X) + (centre.Y - point.Y) * (centre.Y - point.Y); + if (d < distance) + (nearest, distance) = (i, d); + } + return nearest; + } + + private int CardCount() => (DataContext as MergeOrderViewModel)?.Files.Count ?? 0; + + /// The bounds of a card, in 's coordinates; null before it is laid out. + private Rect? CardBounds(int index) => + FileOrder.ContainerFromIndex(index) is { } card && card.TranslatePoint(new Point(), FileOrder) is { } origin + ? new Rect(origin, card.Bounds.Size) + : null; } diff --git a/src/Filee.App/Views/Pages/HomePage.axaml b/src/Filee.App/Views/Pages/HomePage.axaml index aae555da6..41b967bb3 100644 --- a/src/Filee.App/Views/Pages/HomePage.axaml +++ b/src/Filee.App/Views/Pages/HomePage.axaml @@ -18,6 +18,15 @@ + + + @@ -69,6 +78,56 @@ + + + + + + + + + + + + + + diff --git a/src/Filee.App/Views/Pages/HomePage.axaml.cs b/src/Filee.App/Views/Pages/HomePage.axaml.cs index 901ebdd3b..a672f4f87 100644 --- a/src/Filee.App/Views/Pages/HomePage.axaml.cs +++ b/src/Filee.App/Views/Pages/HomePage.axaml.cs @@ -1,23 +1,39 @@ -// Drop zone: files dropped on the Home page open the donut (click mode) at the drop position, also pictures, links and -// downloads dragged from a browser. A double-click opens the file picker, and the donut opens for the picked files. +// Drop zone: files dropped on the Home page join the queue below it, and so do the files a double-click picks. The +// queue's button opens the donut (click mode) at itself for the queued files, in the order shown. Pictures, links and +// downloads dragged from a browser still open the donut at the drop position, as before. A card dragged to another +// place in the queue changes the order the files are converted or merged in. A click selects a card, and the Delete key +// takes the selected file out of the queue. using Avalonia; using Avalonia.Controls; using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; using Avalonia.Platform.Storage; +using Avalonia.VisualTree; using Filee.App.Services; +using Filee.App.ViewModels.Pages; using Microsoft.Extensions.Logging; namespace Filee.App.Views.Pages; public partial class HomePage : UserControl { + /// Pixels a press may move before it counts as a drag of a card rather than a click. + private const double DragThreshold = 6; + /// /// The first drag over the drop zone of this run is logged: a log without it after "drops don't work" means the /// drag never reached Filee (Windows refuses drops from Explorer into a Filee running as administrator). /// private static int _dragEnterLogged; + /// The queue card under a press: its index, and where the press began (in ). + private (int Index, Point Start)? _pressed; + + /// The press has moved far enough to drag its card. + private bool _dragging; + public HomePage() { InitializeComponent(); @@ -30,12 +46,26 @@ public HomePage() if (Interlocked.Exchange(ref _dragEnterLogged, 1) == 0) AppHost.Get>().LogInformation("First drag over the drop zone (files: {Files})", files); }); - DragDrop.AddDragOverHandler(DropZone, (_, e) => - e.DragEffects = DraggedFiles.Offered(e.DataTransfer) ? DragDropEffects.Copy : DragDropEffects.None); + DragDrop.AddDragOverHandler(DropZone, OfferDrop); DragDrop.AddDragLeaveHandler(DropZone, (_, _) => DropZone.Classes.Remove("over")); DragDrop.AddDropHandler(DropZone, OnDrop); + // The queue takes drops too, so files can be added next to the ones already waiting. + DragDrop.AddDragEnterHandler(QueueArea, OfferDrop); + DragDrop.AddDragOverHandler(QueueArea, OfferDrop); + DragDrop.AddDropHandler(QueueArea, OnDrop); + QueueList.PointerPressed += OnQueuePressed; + QueueList.PointerMoved += OnQueueMoved; + QueueList.PointerReleased += OnQueueReleased; + QueueList.Focusable = true; + // Tunnelled: the arrows and Delete reach the page before the list's own keys. + AddHandler(KeyDownEvent, OnPageKeyDown, RoutingStrategies.Tunnel); } + private HomePageViewModel? ViewModel => DataContext as HomePageViewModel; + + private static void OfferDrop(object? sender, DragEventArgs e) => + e.DragEffects = DraggedFiles.Offered(e.DataTransfer) ? DragDropEffects.Copy : DragDropEffects.None; + private async void OnDropZoneDoubleTapped(object? sender, TappedEventArgs e) { e.Handled = true; @@ -53,12 +83,7 @@ private async void OnDropZoneDoubleTapped(object? sender, TappedEventArgs e) if (paths.Count == 0) return; - var dropZonePos = e.GetPosition(DropZone); - var targetPos = new Rect(0, 0, DropZone.Bounds.Width, DropZone.Bounds.Height).Contains(dropZonePos) - ? dropZonePos - : new Point(DropZone.Bounds.Width / 2, DropZone.Bounds.Height / 2); - var screenPoint = DropZone.PointToScreen(targetPos); - AppHost.Get().ShowForFiles(screenPoint.X, screenPoint.Y, paths); + ViewModel?.AddToQueue(paths); } private void OnDrop(object? sender, DragEventArgs e) @@ -72,7 +97,141 @@ private void OnDrop(object? sender, DragEventArgs e) } e.DragEffects = DragDropEffects.Copy; + if (dragged.Web is null) + { + ViewModel?.AddToQueue(dragged.Files); + return; + } var screenPoint = this.PointToScreen(e.GetPosition(this)); AppHost.Get().ShowForFiles(screenPoint.X, screenPoint.Y, dragged.Files, web: dragged.Web); } + + /// The queue's button: the donut opens at the button for the queued files, in the order they are in. + private void OnConvertQueue(object? sender, RoutedEventArgs e) + { + if (ViewModel is not { Queue.Count: > 0 } vm) + return; + var screenPoint = ConvertQueueButton.PointToScreen(new Point(ConvertQueueButton.Bounds.Width / 2, ConvertQueueButton.Bounds.Height / 2)); + AppHost.Get().ShowForFiles(screenPoint.X, screenPoint.Y, vm.QueuedPaths, keepOrder: true); + // The queue empties once Filee has converted these files (HomePageViewModel.FinishSentQueue). + vm.MarkSentToDonut(); + } + + private void OnQueuePressed(object? sender, PointerPressedEventArgs e) + { + var at = e.GetPosition(QueueList); + if (!e.GetCurrentPoint(QueueList).Properties.IsLeftButtonPressed || IndexOfCardAt(at) is not { } index) + return; + _pressed = (index, at); + _dragging = false; + e.Pointer.Capture(QueueList); + } + + private void OnQueueMoved(object? sender, PointerEventArgs e) + { + if (_pressed is not { } press) + return; + var at = e.GetPosition(QueueList); + if (!_dragging && Math.Abs(at.X - press.Start.X) + Math.Abs(at.Y - press.Start.Y) < DragThreshold) + return; + _dragging = true; + // Drawn above the other cards while it is dragged. + if (QueueList.ContainerFromIndex(press.Index) is { } card) + { + card.ZIndex = 1; + card.RenderTransform = new TranslateTransform(at.X - press.Start.X, at.Y - press.Start.Y); + } + } + + private void OnQueueReleased(object? sender, PointerReleasedEventArgs e) + { + if (_pressed is not { } press) + return; + var dragged = _dragging; + var at = e.GetPosition(QueueList); + _pressed = null; + _dragging = false; + e.Pointer.Capture(null); + + // The card goes back to its place first: the layout is what the drop position is measured on. + if (QueueList.ContainerFromIndex(press.Index) is { } card) + { + card.RenderTransform = null; + card.ZIndex = 0; + } + if (!dragged) + { + ViewModel?.SelectQueued(press.Index); + QueueList.Focus(); + return; + } + var target = IndexNearest(at); + if (target != press.Index) + ViewModel?.MoveQueued(press.Index, target); + } + + /// + /// The arrows move the selection between the cards (Alt+Up / Alt+Down moves the selected one); the Delete key takes the selected card out of the + /// queue, while the queue has the focus. Text boxes keep their keys. + /// + private void OnPageKeyDown(object? sender, KeyEventArgs e) + { + if (IsTextEntry(e.Source)) + return; + if (e.Key is Key.Up or Key.Down && e.KeyModifiers.HasFlag(KeyModifiers.Alt)) + { + ViewModel?.MoveSelectedQueued(e.Key == Key.Down ? 1 : -1); + QueueList.Focus(); + e.Handled = true; + } + else if (e.Key is Key.Left or Key.Right) + { + ViewModel?.SelectNeighbour(e.Key == Key.Right ? 1 : -1); + QueueList.Focus(); + e.Handled = true; + } + else if (e.Key == Key.Delete && ReferenceEquals(e.Source, QueueList)) + { + ViewModel?.RemoveSelectedQueued(); + e.Handled = true; + } + } + + private static bool IsTextEntry(object? source) => + source is TextBox or NumericUpDown or ColorPicker + || source is Visual visual && visual.GetVisualAncestors().Any(ancestor => ancestor is TextBox or NumericUpDown or ColorPicker); + + /// The queue card the point is over, or null. + private int? IndexOfCardAt(Point point) + { + for (var i = 0; i < QueueCount(); i++) + if (CardBounds(i) is { } bounds && bounds.Contains(point)) + return i; + return null; + } + + /// The card whose centre is nearest the point: where a dragged card is dropped. + private int IndexNearest(Point point) + { + var nearest = 0; + var distance = double.MaxValue; + for (var i = 0; i < QueueCount(); i++) + { + if (CardBounds(i) is not { } bounds) + continue; + var centre = bounds.Center; + var d = (centre.X - point.X) * (centre.X - point.X) + (centre.Y - point.Y) * (centre.Y - point.Y); + if (d < distance) + (nearest, distance) = (i, d); + } + return nearest; + } + + private int QueueCount() => ViewModel?.Queue.Count ?? 0; + + /// The bounds of a queue card, in 's coordinates; null before it is laid out. + private Rect? CardBounds(int index) => + QueueList.ContainerFromIndex(index) is { } card && card.TranslatePoint(new Point(), QueueList) is { } origin + ? new Rect(origin, card.Bounds.Size) + : null; } diff --git a/tests/Filee.App.Tests/FileQueueTests.cs b/tests/Filee.App.Tests/FileQueueTests.cs new file mode 100644 index 000000000..629e36021 --- /dev/null +++ b/tests/Filee.App.Tests/FileQueueTests.cs @@ -0,0 +1,78 @@ +// The Home page's queue (multi-file conversion): any files, in the order the user puts them in, and a merge that keeps +// that order instead of sorting by name. + +using Filee.App.Services; +using Filee.App.ViewModels; +using Xunit; + +namespace Filee.App.Tests; + +public class FileQueueTests +{ + [Fact] + public void FilesOfAnyTypeJoinTheQueueInTheOrderAdded() + { + var queue = new FileQueue(); + + queue.Add(["/docs/one.pdf", "/docs/letter.docx", "/photos/photo.png", "/docs/notes.unknownext"]); + + Assert.Equal(["/docs/one.pdf", "/docs/letter.docx", "/photos/photo.png", "/docs/notes.unknownext"], queue.Paths); + } + + [Fact] + public void AFileAddedTwiceIsKeptOnce() + { + var queue = new FileQueue(); + + queue.Add(["/docs/one.pdf"]); + queue.Add(["/docs/one.pdf", "/docs/two.pdf"]); + + Assert.Equal(["/docs/one.pdf", "/docs/two.pdf"], queue.Paths); + } + + [Fact] + public void MovingAFileChangesTheOrder() + { + var queue = new FileQueue(); + queue.Add(["/docs/a.pdf", "/docs/b.pdf", "/docs/c.pdf"]); + + queue.Move(2, 0); + + Assert.Equal(["/docs/c.pdf", "/docs/a.pdf", "/docs/b.pdf"], queue.Paths); + } + + [Fact] + public void RemovingAFileKeepsTheOthersInOrder() + { + var queue = new FileQueue(); + queue.Add(["/docs/a.pdf", "/photos/b.png", "/docs/c.pdf"]); + + Assert.True(queue.Remove("/photos/b.png")); + + Assert.Equal(["/docs/a.pdf", "/docs/c.pdf"], queue.Paths); + Assert.False(queue.Remove("/photos/b.png")); + } + + [Fact] + public void ClearEmptiesTheQueue() + { + var queue = new FileQueue(); + queue.Add(["/docs/a.pdf"]); + + queue.Clear(); + + Assert.Empty(queue.Paths); + } + + [Fact] + public void AMergeKeepsTheOrderTheFilesCameIn() + { + var files = new[] { "/docs/page 10.pdf", "/docs/page 2.pdf", "/docs/page 1.pdf" }; + + var kept = new MergeOrderViewModel(files, keepOrder: true); + var sorted = new MergeOrderViewModel(files); + + Assert.Equal(files, kept.OrderedFiles); + Assert.Equal(["/docs/page 1.pdf", "/docs/page 2.pdf", "/docs/page 10.pdf"], sorted.OrderedFiles); + } +} From a56a4f9c2078c83a89031793d2053cdfc90b7b6b Mon Sep 17 00:00:00 2001 From: arielsrossi Date: Mon, 2 Nov 2026 14:21:03 -0400 Subject: [PATCH 02/24] Address the review: persistent list, job-based clearing, previews in turn, keys with focus, accessibility The Home list lives for the whole session in a service and empties only when the conversion started from it completes; a failed conversion keeps the list and names the failed files. A PDF's first page is drawn one at a time, a PDF over 50 MB gets a note instead, and a removed card cancels its picture. Dropped and picked files join the list only when the new setting is on (off by default: the donut opens, as before). Arrows and Alt+Up/Down act only when the list has the focus, and mirror in a right-to-left language. Merge needs two files. Cards and their X buttons are named for screen readers, and the selected card is said. Co-Authored-By: Claude Haiku 5.5 --- src/Filee.App/Assets/i18n/ar.json | 8 +- src/Filee.App/Assets/i18n/de.json | 8 +- src/Filee.App/Assets/i18n/en.json | 8 +- src/Filee.App/Assets/i18n/es.json | 8 +- src/Filee.App/Assets/i18n/fr.json | 8 +- src/Filee.App/Assets/i18n/hi.json | 8 +- src/Filee.App/Assets/i18n/id.json | 8 +- src/Filee.App/Assets/i18n/it.json | 8 +- src/Filee.App/Assets/i18n/ja.json | 8 +- src/Filee.App/Assets/i18n/ko.json | 8 +- src/Filee.App/Assets/i18n/ne.json | 8 +- src/Filee.App/Assets/i18n/pl.json | 8 +- src/Filee.App/Assets/i18n/pt-BR.json | 8 +- src/Filee.App/Assets/i18n/pt-PT.json | 8 +- src/Filee.App/Assets/i18n/ru.json | 8 +- src/Filee.App/Assets/i18n/th.json | 8 +- src/Filee.App/Assets/i18n/tr.json | 8 +- src/Filee.App/Assets/i18n/zh-CN.json | 8 +- src/Filee.App/Assets/i18n/zh-TW.json | 8 +- src/Filee.App/Services/AppHost.cs | 1 + src/Filee.App/Services/FilePreviews.cs | 71 ++++++++ src/Filee.App/Services/RadialController.cs | 10 +- src/Filee.App/Services/StagedFiles.cs | 144 ++++++++++++++++ src/Filee.App/Services/WindowService.cs | 2 +- .../ViewModels/MergeOrderViewModel.cs | 105 ++++++++---- .../ViewModels/Pages/GeneralPageViewModel.cs | 5 + .../ViewModels/Pages/HomePageViewModel.cs | 156 ++++++------------ .../ViewModels/Pages/StagedFileViewModel.cs | 86 ++++++---- src/Filee.App/Views/MergeOrderWindow.axaml | 14 +- src/Filee.App/Views/MergeOrderWindow.axaml.cs | 43 +++-- src/Filee.App/Views/Pages/GeneralPage.axaml | 2 + src/Filee.App/Views/Pages/HomePage.axaml | 15 +- src/Filee.App/Views/Pages/HomePage.axaml.cs | 57 ++++--- src/Filee.Core/Settings/AppSettings.cs | 5 + tests/Filee.App.Tests/DiagnosticsTests.cs | 4 +- tests/Filee.App.Tests/MergeOrderTests.cs | 3 +- 36 files changed, 631 insertions(+), 244 deletions(-) create mode 100644 src/Filee.App/Services/FilePreviews.cs create mode 100644 src/Filee.App/Services/StagedFiles.cs diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index e3c8c20a9..3aff1fdd2 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -25,6 +25,10 @@ "close.dont_ask": "عدم السؤال مجددًا", "home.title": "الرئيسية", "home.drop_title": "أفلت الملفات هنا", + "home.queue_error": "تعذر تحويل {0}. تحتفظ القائمة بالملفات.", + "home.queue_too_large": "كبير جدًا للمعاينة ({0} ميغابايت)", + "home.queue_selected": "محدد", + "home.queue_remove_file": "إزالة {0}", "home.queue_title": "الملفات المراد تحويلها", "home.queue_convert": "اختر إجراءً", "home.queue_clear": "مسح القائمة", @@ -379,7 +383,7 @@ "preset.flip_vertical.short": "عمودي", "preset.merge_pdf": "دمج PDF", "merge.order_title": "اختيار ترتيب الدمج", - "merge.order_hint": "حدّد ملفًا وانقله إلى أعلى أو أسفل. تُدمج الملفات بالترتيب من الأعلى إلى الأسفل. (‎Alt+↑ / Alt+↓‎)", + "merge.order_hint": "اسحب بطاقة، أو حددها واستخدم Alt+↑ / Alt+↓، لتغيير الترتيب. تُدمج الملفات من الأعلى إلى الأسفل. مفتاح Delete يحذف الملف المحدد.", "merge.images_hint": "حدّد صورة وانقلها إلى أعلى أو أسفل. تأتي الأولى في الأعلى، أو على اليسار عند وضع الصور جنبًا إلى جنب. (‎Alt+↑ / Alt+↓‎)", "merge.vertical": "من الأعلى إلى الأسفل", "merge.horizontal": "جنبًا إلى جنب", @@ -488,6 +492,8 @@ "general.default_output_hint": "تُحفظ هنا نتائج الإعدادات المسبقة المضبوطة على «موقع الحفظ الافتراضي».\nيحتفظ الإعداد المسبق ذو الموقع الخاص بموقعه.", "general.keep_dates": "الاحتفاظ بتواريخ الملفات الأصلية في جميع التحويلات", "general.keep_dates_hint": "تحتفظ الملفات المحوّلة بتاريخَي إنشاء الأصل وتعديله، مع كل إعداد مسبق.\nعند التعطيل: يُطبَّق ذلك فقط على الإعدادات المسبقة التي تفعّل «الاحتفاظ بتواريخ الملف الأصلي».", + "general.collect_dropped": "جمع الملفات المسحوبة في قائمة", + "general.collect_dropped_hint": "تبقى الملفات المسحوبة أو المختارة في قائمة الصفحة الرئيسية، ويحوّلها «اختر إجراءً» معًا بالترتيب المعروض. مغلق: كل سحب يفتح القائمة الدائرية كما كان من قبل.", "general.browser_drops": "الملفات المسحوبة من المتصفح", "general.browser_drops_hint": "اضغط باستمرار على المفتاح واسحب صورة أو رابط ملف أو تنزيلًا من Chrome أو Edge أو Firefox أو Safari: يفتح شريط الأدوات الدائري كما يحدث مع ملفاتك. يُحفظ الملف في هذا المجلد، وتحفظ هنا أيضًا نتائج الإعدادات المسبقة التي تحفظ بجانب الأصل.\nينزّل Filee الرابط بنفسه، دون استخدام تسجيل دخول المتصفح.", "general.browser_drops_folder": "الحفظ في", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 9eba35ee7..ea55ef587 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -25,6 +25,10 @@ "close.dont_ask": "Nicht erneut fragen", "home.title": "Start", "home.drop_title": "Dateien hier ablegen", + "home.queue_error": "{0} konnte nicht konvertiert werden. Die Liste behält die Dateien.", + "home.queue_too_large": "Zu groß für eine Vorschau ({0} MB)", + "home.queue_selected": "ausgewählt", + "home.queue_remove_file": "{0} entfernen", "home.queue_title": "Zu konvertierende Dateien", "home.queue_convert": "Aktion wählen", "home.queue_clear": "Liste leeren", @@ -379,7 +383,7 @@ "preset.flip_vertical.short": "Vertikal", "preset.merge_pdf": "PDF zusammenführen", "merge.order_title": "Reihenfolge festlegen", - "merge.order_hint": "Wähle eine Datei und verschiebe sie nach oben oder unten. Dateien werden von oben nach unten zusammengeführt. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Ziehe eine Karte oder wähle sie aus und nutze Alt+↑ / Alt+↓, um die Reihenfolge zu ändern. Dateien werden von oben nach unten zusammengeführt. Entf entfernt die ausgewählte Datei.", "merge.images_hint": "Wähle ein Bild und verschiebe es nach oben oder unten. Das erste kommt nach oben, nebeneinander nach links. (Alt+↑ / Alt+↓)", "merge.vertical": "Untereinander", "merge.horizontal": "Nebeneinander", @@ -488,6 +492,8 @@ "general.default_output_hint": "Vorlagen mit der Einstellung „Standardspeicherort“ speichern hier.\nVorlagen mit eigenem Speicherort behalten diesen bei.", "general.keep_dates": "Ursprüngliche Dateizeitstempel bei jeder Konvertierung beibehalten", "general.keep_dates_hint": "Konvertierte Dateien behalten bei jeder Vorlage das Erstellungs- und Änderungsdatum des Originals.\nAusgeschaltet: Gilt nur für Vorlagen mit „Ursprüngliche Dateizeitstempel beibehalten“.", + "general.collect_dropped": "Abgelegte Dateien in einer Liste sammeln", + "general.collect_dropped_hint": "Abgelegte oder ausgewählte Dateien warten auf der Startseite in einer Liste, und Aktion wählen konvertiert sie gemeinsam in der angezeigten Reihenfolge. Aus: jedes Ablegen öffnet das Rad, wie bisher.", "general.browser_drops": "Aus dem Browser gezogene Dateien", "general.browser_drops_hint": "Halte die Taste gedrückt und ziehe ein Bild, einen Dateilink oder einen Download aus Chrome, Edge, Firefox oder Safari. Das Ringmenü öffnet sich wie bei deinen eigenen Dateien. Die Datei wird in diesem Ordner gespeichert. Vorlagen, die neben dem Original speichern, legen auch die konvertierte Datei hier ab.\nFilee lädt Links selbst herunter, ohne die Anmeldung deines Browsers zu verwenden.", "general.browser_drops_folder": "Speichern in", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index e535334ba..ea66a6159 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -28,6 +28,10 @@ "home.title": "Home", "home.drop_title": "Drop files here", + "home.queue_error": "Could not convert {0}. The list keeps its files.", + "home.queue_too_large": "Too large for a preview ({0} MB)", + "home.queue_selected": "selected", + "home.queue_remove_file": "Remove {0}", "home.queue_title": "Files to convert", "home.queue_convert": "Choose action", "home.queue_clear": "Clear list", @@ -389,7 +393,7 @@ "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Merge PDF", "merge.order_title": "Choose merge order", - "merge.order_hint": "Select a file and move it up or down. Files are merged from top to bottom. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Drag a card, or select it and use Alt+↑ / Alt+↓, to change the order. Files merge from top to bottom. Delete removes the selected file.", "merge.images_hint": "Select a picture and move it up or down. The first one goes at the top, or on the left side by side. (Alt+↑ / Alt+↓)", "merge.vertical": "Top to bottom", "merge.horizontal": "Side by side", @@ -500,6 +504,8 @@ "general.default_output_hint": "Presets set to “Default save location” save here.\nA preset with its own location keeps it.", "general.keep_dates": "Keep the original file dates for every conversion", "general.keep_dates_hint": "Converted files keep the original's created and modified dates, with every preset.\nOff: only presets with \"Keep the original file dates\" do it.", + "general.collect_dropped": "Collect dropped files in a list", + "general.collect_dropped_hint": "Dropped and picked files wait in a list on the Home page, and Choose action converts them together in the order shown. Off: each drop opens the donut, as before.", "general.browser_drops": "Files dragged from a browser", "general.browser_drops_hint": "Hold the key and drag a picture, a link to a file or a download out of Chrome, Edge, Firefox or Safari: the donut toolbar opens as it does for your own files. The file is saved in this folder, and presets that save next to the original put the converted file here too.\nFilee downloads a link itself, without your browser's sign-in.", "general.browser_drops_folder": "Save to", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 7075a2e44..be38e899d 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -25,6 +25,10 @@ "close.dont_ask": "No volver a preguntar", "home.title": "Inicio", "home.drop_title": "Suelta archivos aquí", + "home.queue_error": "No se pudo convertir {0}. La lista conserva los archivos.", + "home.queue_too_large": "Demasiado grande para la vista previa ({0} MB)", + "home.queue_selected": "seleccionado", + "home.queue_remove_file": "Quitar {0}", "home.queue_title": "Archivos para convertir", "home.queue_convert": "Elegir acción", "home.queue_clear": "Vaciar lista", @@ -379,7 +383,7 @@ "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Combinar PDF", "merge.order_title": "Elegir el orden de combinación", - "merge.order_hint": "Selecciona un archivo y muévelo arriba o abajo. Los archivos se combinan de arriba abajo. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Arrastra una tarjeta, o selecciónala y usa Alt+↑ / Alt+↓, para cambiar el orden. Los archivos se combinan de arriba abajo. Supr quita el archivo seleccionado.", "merge.images_hint": "Selecciona una imagen y muévela arriba o abajo. La primera va arriba, o a la izquierda si van lado a lado. (Alt+↑ / Alt+↓)", "merge.vertical": "De arriba abajo", "merge.horizontal": "Lado a lado", @@ -488,6 +492,8 @@ "general.default_output_hint": "Los ajustes preestablecidos configurados en «Carpeta de guardado predeterminada» guardan aquí.\nUn ajuste preestablecido con su propia carpeta la conserva.", "general.keep_dates": "Conservar las fechas originales de los archivos en todas las conversiones", "general.keep_dates_hint": "Los archivos convertidos conservan las fechas de creación y modificación del original, con todos los ajustes preestablecidos.\nDesactivado: solo lo hacen los ajustes preestablecidos con «Conservar las fechas del archivo original».", + "general.collect_dropped": "Reunir los archivos soltados en una lista", + "general.collect_dropped_hint": "Los archivos soltados o elegidos esperan en una lista en la pantalla Inicio, y Elegir acción los convierte juntos en el orden mostrado. Desactivado: cada suelta abre el donut, como antes.", "general.browser_drops": "Archivos arrastrados desde un navegador", "general.browser_drops_hint": "Mantén pulsada la tecla y arrastra una imagen, un enlace a un archivo o una descarga desde Chrome, Edge, Firefox o Safari: el menú circular se abre igual que con tus propios archivos. El archivo se guarda en esta carpeta, y los ajustes preestablecidos que guardan junto al original también guardan aquí el archivo convertido.\nFilee descarga los enlaces por sí mismo, sin la sesión iniciada en tu navegador.", "general.browser_drops_folder": "Guardar en", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 3307ecca2..f0a933e22 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -28,6 +28,10 @@ "home.title": "Accueil", "home.drop_title": "Déposez des fichiers ici", + "home.queue_error": "Impossible de convertir {0}. La liste conserve les fichiers.", + "home.queue_too_large": "Trop volumineux pour un aperçu ({0} Mo)", + "home.queue_selected": "sélectionné", + "home.queue_remove_file": "Retirer {0}", "home.queue_title": "Fichiers à convertir", "home.queue_convert": "Choisir une action", "home.queue_clear": "Vider la liste", @@ -389,7 +393,7 @@ "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Fusionner les PDF", "merge.order_title": "Choisir l'ordre de fusion", - "merge.order_hint": "Sélectionnez un fichier et déplacez-le vers le haut ou le bas. Les fichiers sont fusionnés de haut en bas. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Faites glisser une carte, ou sélectionnez-la et utilisez Alt+↑ / Alt+↓, pour changer l'ordre. Les fichiers sont fusionnés de haut en bas. Suppr retire le fichier sélectionné.", "merge.images_hint": "Sélectionnez une image et déplacez-la vers le haut ou le bas. La première va en haut, ou à gauche côte à côte. (Alt+↑ / Alt+↓)", "merge.vertical": "De haut en bas", "merge.horizontal": "Côte à côte", @@ -500,6 +504,8 @@ "general.default_output_hint": "Les préréglages réglés sur « Emplacement d'enregistrement par défaut » enregistrent ici.\nUn préréglage doté de son propre emplacement le conserve.", "general.keep_dates": "Conserver les dates d'origine des fichiers pour toutes les conversions", "general.keep_dates_hint": "Les fichiers convertis gardent les dates de création et de modification de l'original, quel que soit le préréglage.\nDésactivé : seuls les préréglages avec « Conserver les dates du fichier original » le font.", + "general.collect_dropped": "Rassembler les fichiers déposés dans une liste", + "general.collect_dropped_hint": "Les fichiers déposés ou choisis attendent dans une liste sur l'accueil, et Choisir une action les convertit ensemble, dans l'ordre affiché. Désactivé : chaque dépôt ouvre le donut, comme avant.", "general.browser_drops": "Fichiers glissés depuis un navigateur", "general.browser_drops_hint": "Maintenez la touche et faites glisser une image, un lien vers un fichier ou un téléchargement hors de Chrome, Edge, Firefox ou Safari : le menu circulaire s'ouvre comme pour vos propres fichiers. Le fichier est enregistré dans ce dossier, et les préréglages qui enregistrent à côté de l'original y placent aussi le fichier converti.\nFilee télécharge lui-même le lien, sans la session de votre navigateur.", "general.browser_drops_folder": "Enregistrer dans", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index f57c44580..6e895d672 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -25,6 +25,10 @@ "close.dont_ask": "दोबारा न पूछें", "home.title": "होम", "home.drop_title": "फ़ाइलें यहाँ छोड़ें", + "home.queue_error": "{0} को बदला नहीं जा सका। सूची में फ़ाइलें रहेंगी।", + "home.queue_too_large": "पूर्वावलोकन के लिए बहुत बड़ा ({0} MB)", + "home.queue_selected": "चुना गया", + "home.queue_remove_file": "{0} हटाएं", "home.queue_title": "रूपांतरित करने वाली फ़ाइलें", "home.queue_convert": "क्रिया चुनें", "home.queue_clear": "सूची साफ़ करें", @@ -379,7 +383,7 @@ "preset.flip_vertical.short": "खड़ा", "preset.merge_pdf": "PDF मर्ज करें", "merge.order_title": "मर्ज करने का क्रम चुनें", - "merge.order_hint": "फ़ाइल चुनकर उसे ऊपर या नीचे ले जाएँ। फ़ाइलें ऊपर से नीचे के क्रम में मर्ज होंगी। (Alt+↑ / Alt+↓)", + "merge.order_hint": "क्रम बदलने के लिए कार्ड खींचें, या उसे चुनकर Alt+↑ / Alt+↓ दबाएं। फ़ाइलें ऊपर से नीचे जुड़ती हैं। Delete चुनी हुई फ़ाइल हटाता है।", "merge.images_hint": "तस्वीर चुनकर उसे ऊपर या नीचे ले जाएँ। पहली तस्वीर सबसे ऊपर आएगी, या साथ-साथ जोड़ने पर सबसे बाईं ओर। (Alt+↑ / Alt+↓)", "merge.vertical": "ऊपर से नीचे", "merge.horizontal": "साथ-साथ", @@ -488,6 +492,8 @@ "general.default_output_hint": "जिन प्रीसेट में “डिफ़ॉल्ट सेव स्थान” चुना है वे यहाँ सेव होते हैं।\nजिस प्रीसेट का अपना स्थान है वह उसी को रखता है।", "general.keep_dates": "हर कन्वर्ज़न के लिए मूल फ़ाइल की तारीखें रखें", "general.keep_dates_hint": "हर प्रीसेट के साथ, कन्वर्ट की गई फ़ाइलें मूल फ़ाइल की बनाने और बदलने की तारीखें रखती हैं।\nबंद होने पर: केवल वही प्रीसेट ऐसा करते हैं जिनमें \"मूल फ़ाइल की तारीखें रखें\" चालू है।", + "general.collect_dropped": "खींची गई फ़ाइलों को एक सूची में जोड़ें", + "general.collect_dropped_hint": "खींची या चुनी गई फ़ाइलें होम की सूची में रहती हैं, और \"क्रिया चुनें\" उन्हें दिखाए गए क्रम में एक साथ बदलता है। बंद: हर ड्रॉप पहले की तरह डोनट खोलता है।", "general.browser_drops": "ब्राउज़र से खींची गई फ़ाइलें", "general.browser_drops_hint": "कुंजी दबाए रखकर Chrome, Edge, Firefox या Safari से कोई तस्वीर, फ़ाइल का लिंक या डाउनलोड खींचें: डोनट टूलबार वैसे ही खुलता है जैसे आपकी अपनी फ़ाइलों के लिए। फ़ाइल इसी फ़ोल्डर में सेव होती है, और जो प्रीसेट मूल फ़ाइल के पास सेव करते हैं वे कन्वर्ट की गई फ़ाइल भी यहीं रखते हैं।\nलिंक को Filee खुद डाउनलोड करता है, आपके ब्राउज़र के साइन-इन के बिना।", "general.browser_drops_folder": "यहाँ सेव करें", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 6b65bee0c..36ca8d00e 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -25,6 +25,10 @@ "close.dont_ask": "Jangan tanyakan lagi", "home.title": "Beranda", "home.drop_title": "Lepaskan file di sini", + "home.queue_error": "Tidak bisa mengonversi {0}. Daftar tetap menyimpan file.", + "home.queue_too_large": "Terlalu besar untuk pratinjau ({0} MB)", + "home.queue_selected": "dipilih", + "home.queue_remove_file": "Hapus {0}", "home.queue_title": "File untuk dikonversi", "home.queue_convert": "Pilih tindakan", "home.queue_clear": "Kosongkan daftar", @@ -379,7 +383,7 @@ "preset.flip_vertical.short": "Vertikal", "preset.merge_pdf": "Gabungkan PDF", "merge.order_title": "Pilih urutan penggabungan", - "merge.order_hint": "Pilih file lalu pindahkan ke atas atau ke bawah. File digabungkan dari atas ke bawah. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Seret kartu, atau pilih lalu gunakan Alt+↑ / Alt+↓, untuk mengubah urutan. File digabung dari atas ke bawah. Delete menghapus file yang dipilih.", "merge.images_hint": "Pilih gambar lalu pindahkan ke atas atau ke bawah. Gambar pertama berada di atas, atau di sebelah kiri bila berdampingan. (Alt+↑ / Alt+↓)", "merge.vertical": "Atas ke bawah (vertikal)", "merge.horizontal": "Berdampingan (horizontal)", @@ -488,6 +492,8 @@ "general.default_output_hint": "Preset yang disetel ke “Lokasi penyimpanan bawaan” akan disimpan di sini.\nPreset dengan lokasinya sendiri akan tetap menyimpannya di sana.", "general.keep_dates": "Pertahankan tanggal file asli untuk setiap konversi", "general.keep_dates_hint": "File hasil konversi mempertahankan tanggal dibuat dan dimodifikasi dari file asli di semua preset.\nNonaktif: hanya preset dengan setelan \"Pertahankan tanggal file asli\" yang menerapkannya.", + "general.collect_dropped": "Kumpulkan file yang dijatuhkan dalam daftar", + "general.collect_dropped_hint": "File yang dijatuhkan atau dipilih menunggu dalam daftar di halaman Beranda, dan Pilih tindakan mengonversinya bersama sesuai urutan yang ditampilkan. Mati: setiap jatuhan membuka donat, seperti sebelumnya.", "general.browser_drops": "File yang diseret dari peramban", "general.browser_drops_hint": "Tahan tombol pintasan dan seret gambar, tautan file, atau unduhan dari Chrome, Edge, Firefox, atau Safari: toolbar donut akan terbuka seperti pada file lokal Anda. File disimpan di folder ini, dan preset yang menyimpan di samping file asli juga akan menaruh hasil konversi di sini.\nFilee mengunduh tautan sendiri tanpa menggunakan login peramban Anda.", "general.browser_drops_folder": "Simpan ke", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index 2f4e09197..3e2a4a23c 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -25,6 +25,10 @@ "close.dont_ask": "Non chiedere più", "home.title": "Home", "home.drop_title": "Rilascia qui i file", + "home.queue_error": "Impossibile convertire {0}. L'elenco conserva i file.", + "home.queue_too_large": "Troppo grande per l'anteprima ({0} MB)", + "home.queue_selected": "selezionato", + "home.queue_remove_file": "Rimuovi {0}", "home.queue_title": "File da convertire", "home.queue_convert": "Scegli azione", "home.queue_clear": "Svuota elenco", @@ -379,7 +383,7 @@ "preset.flip_vertical.short": "Verticale", "preset.merge_pdf": "Unisci PDF", "merge.order_title": "Scegli l'ordine di unione", -"merge.order_hint": "Seleziona un file e spostalo su o giù. I file vengono uniti dall'alto verso il basso. (Alt+↑ / Alt+↓)", +"merge.order_hint": "Trascina una scheda, oppure selezionala e usa Alt+↑ / Alt+↓, per cambiare l'ordine. I file vengono uniti dall'alto in basso. Canc rimuove il file selezionato.", "merge.images_hint": "Seleziona un'immagine e spostala su o giù. La prima va in alto, o a sinistra nella disposizione affiancata. (Alt+↑ / Alt+↓)", "merge.vertical": "Dall'alto verso il basso", "merge.horizontal": "Affiancate", @@ -488,6 +492,8 @@ "general.default_output_hint": "I preset impostati su “Posizione di salvataggio predefinita” salvano qui.\\nUn preset con una posizione propria la mantiene.", "general.keep_dates": "Mantieni le date originali dei file per ogni conversione", "general.keep_dates_hint": "I file convertiti mantengono le date di creazione e modifica dell'originale, con ogni preset.\\nDisattivato: lo fanno solo i preset con “Mantieni le date originali dei file”.", + "general.collect_dropped": "Raccogli i file trascinati in un elenco", + "general.collect_dropped_hint": "I file trascinati o scelti restano in un elenco nella schermata Inizio, e Scegli azione li converte insieme nell'ordine mostrato. Disattivato: ogni trascinamento apre il donut, come prima.", "general.browser_drops": "File trascinati da un browser", "general.browser_drops_hint": "Tieni premuto il tasto e trascina un'immagine, un link a un file o un download fuori da Chrome, Edge, Firefox o Safari: la barra donut si apre come per i tuoi file. Il file viene salvato in questa cartella e i preset che salvano accanto all'originale mettono qui anche il file convertito.\\nFilee scarica da solo un link, senza l'accesso del tuo browser.", "general.browser_drops_folder": "Salva in", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 57f198d15..26c4fbf9b 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -25,6 +25,10 @@ "close.dont_ask": "次回から確認しない", "home.title": "ホーム", "home.drop_title": "ここにファイルをドロップ", + "home.queue_error": "{0} を変換できませんでした。リストのファイルは残ります。", + "home.queue_too_large": "プレビューには大きすぎます({0} MB)", + "home.queue_selected": "選択中", + "home.queue_remove_file": "{0} を削除", "home.queue_title": "変換するファイル", "home.queue_convert": "操作を選択", "home.queue_clear": "リストをクリア", @@ -379,7 +383,7 @@ "preset.flip_vertical.short": "垂直", "preset.merge_pdf": "結合", "merge.order_title": "結合順序を選択", - "merge.order_hint": "ファイルを選択して上下に移動します。上から順に結合されます。(Alt+↑ / Alt+↓)", + "merge.order_hint": "カードをドラッグするか、選択して Alt+↑ / Alt+↓ で順番を変えます。ファイルは上から下へ結合されます。Delete で選択中のファイルを削除します。", "merge.images_hint": "画像を選択して上下に移動します。最初の画像が一番上(横に並べるときは一番左)になります。(Alt+↑ / Alt+↓)", "merge.vertical": "縦に並べる", "merge.horizontal": "横に並べる", @@ -488,6 +492,8 @@ "general.default_output_hint": "保存先が「既定の保存先」のプリセットは、ここに保存します。\n保存先を個別に指定したプリセットには適用されません。", "general.keep_dates": "すべての変換で元ファイルの日付を保持する", "general.keep_dates_hint": "どのプリセットでも、変換後のファイルは元ファイルの作成日時と更新日時を引き継ぎます。\nオフの場合は、「元ファイルの日付を保持する」を有効にしたプリセットだけに適用されます。", + "general.collect_dropped": "ドロップしたファイルをリストにまとめる", + "general.collect_dropped_hint": "ドロップまたは選択したファイルはホームのリストで待ち、「操作を選択」で表示順にまとめて変換します。オフ:ドロップごとに従来どおりドーナツが開きます。", "general.browser_drops": "ブラウザーからドラッグしたファイル", "general.browser_drops_hint": "キーを押しながら Chrome、Edge、Firefox、Safari から画像、ファイルへのリンク、ダウンロードをドラッグすると、自分のファイルと同じようにドーナツメニューが開きます。ファイルはこのフォルダーに保存され、元ファイルと同じフォルダーに保存するプリセットの変換結果もここに保存されます。\nリンクは Filee が直接ダウンロードします。ブラウザーのログイン情報は使いません。", "general.browser_drops_folder": "保存先", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index ffecf3878..f1408cd63 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -28,6 +28,10 @@ "home.title": "홈", "home.drop_title": "여기에 파일을 놓으세요", + "home.queue_error": "{0}을(를) 변환하지 못했습니다. 목록은 파일을 유지합니다.", + "home.queue_too_large": "미리 보기에는 너무 큽니다({0} MB)", + "home.queue_selected": "선택됨", + "home.queue_remove_file": "{0} 제거", "home.queue_title": "변환할 파일", "home.queue_convert": "작업 선택", "home.queue_clear": "목록 지우기", @@ -389,7 +393,7 @@ "preset.flip_vertical.short": "상하", "preset.merge_pdf": "PDF 병합", "merge.order_title": "병합 순서 선택", - "merge.order_hint": "파일을 골라 위아래로 옮기세요. 위에서부터 순서대로 병합돼요. (Alt+↑ / Alt+↓)", + "merge.order_hint": "카드를 끌거나 선택한 뒤 Alt+↑ / Alt+↓로 순서를 바꿀 수 있습니다. 파일은 위에서 아래로 병합됩니다. Delete는 선택한 파일을 제거합니다.", "merge.images_hint": "사진을 골라 위아래로 옮기세요. 첫 번째 사진이 맨 위에, 나란히 붙일 때는 맨 왼쪽에 와요. (Alt+↑ / Alt+↓)", "merge.vertical": "위아래로", "merge.horizontal": "나란히", @@ -500,6 +504,8 @@ "general.default_output_hint": "저장 위치가 “기본 저장 위치”인 프리셋은 여기에 저장해요.\n저장 위치를 따로 정한 프리셋은 그대로예요.", "general.keep_dates": "모든 변환에서 원본 파일 날짜 유지", "general.keep_dates_hint": "어떤 프리셋이든 원본의 만든 날짜·수정한 날짜를 그대로 써요.\n끄면 \"원본 파일 날짜 유지\"를 켠 프리셋만 그렇게 해요.", + "general.collect_dropped": "끌어 놓은 파일을 목록에 모으기", + "general.collect_dropped_hint": "끌어 놓거나 고른 파일은 홈의 목록에서 기다리며, 작업 선택을 누르면 표시된 순서대로 함께 변환합니다. 끔: 끌어 놓을 때마다 기존처럼 원형 메뉴가 열립니다.", "general.browser_drops": "브라우저에서 드래그한 파일", "general.browser_drops_hint": "키를 누른 채 Chrome, Edge, Firefox, Safari에서 이미지, 파일 링크, 다운로드를 드래그하면 내 파일처럼 원형 툴바가 열려요. 파일은 이 폴더에 저장되고, 원본과 같은 폴더에 저장하는 프리셋의 변환 결과도 여기에 저장돼요.\n링크는 Filee가 직접 내려받아요. 브라우저의 로그인 정보는 쓰지 않아요.", "general.browser_drops_folder": "저장할 폴더", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 0532872a9..405a03fc5 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -25,6 +25,10 @@ "close.dont_ask": "फेरि नसोध्नुहोस्", "home.title": "गृहपृष्ठ", "home.drop_title": "फाइलहरू यहाँ छाड्नुहोस्", + "home.queue_error": "{0} रूपान्तरण गर्न सकिएन। सूचीमा फाइलहरू रहन्छन्।", + "home.queue_too_large": "पूर्वावलोकनका लागि धेरै ठूलो ({0} MB)", + "home.queue_selected": "चयन गरिएको", + "home.queue_remove_file": "{0} हटाउनुहोस्", "home.queue_title": "रूपान्तरण गर्नु पर्ने फाइलहरू", "home.queue_convert": "कार्य छान्नुहोस्", "home.queue_clear": "सूची खाली गर्नुहोस्", @@ -379,7 +383,7 @@ "preset.flip_vertical.short": "ठाडो", "preset.merge_pdf": "PDF मर्ज गर्नुहोस्", "merge.order_title": "मर्ज क्रम मिलाउनुहोस्", - "merge.order_hint": "फाइलहरूलाई इच्छित क्रममा मिलाउन तान्नुहोस् र छाड्नुहोस्", + "merge.order_hint": "क्रम बदल्न कार्ड तान्नुहोस्, वा चयन गरेर Alt+↑ / Alt+↓ प्रयोग गर्नुहोस्। फाइलहरू माथिबाट तलसम्म मर्ज हुन्छन्। Delete ले चयन गरिएको फाइल हटाउँछ।", "merge.images_hint": "तस्बिर छानेर माथि वा तल सार्नुहोस्। पहिलो तस्बिर सबैभन्दा माथि, वा छेउछेउमा राख्दा सबैभन्दा बायाँ आउँछ। (Alt+↑ / Alt+↓)", "merge.vertical": "माथिदेखि तल", "merge.horizontal": "छेउछेउमा", @@ -488,6 +492,8 @@ "general.default_output_hint": "“पूर्वनिर्धारित बचत स्थान” मा सेट गरिएका प्रिसेटहरू यहाँ सुरक्षित हुन्छन्।\nआफ्नै स्थान भएको प्रिसेटले त्यसैलाई कायम राख्छ।", "general.keep_dates": "प्रत्येक रूपान्तरणका लागि मूल फाइलका मितिहरू कायम राख्नुहोस्", "general.keep_dates_hint": "रूपान्तरित फाइलहरूले हरेक प्रिसेटसँग मूल फाइल सिर्जना र परिमार्जन मितिहरू कायम राख्छन्।\nबन्द हुँदा: केवल \"मूल फाइलका मितिहरू कायम राख्नुहोस्\" सक्षम भएका प्रिसेटहरूले मात्र यसो गर्छन्।", + "general.collect_dropped": "तानिएका फाइलहरू सूचीमा जम्मा गर्नुहोस्", + "general.collect_dropped_hint": "तानिएका वा चयन गरिएका फाइलहरू होमको सूचीमा बस्छन्, र \"कार्य छान्नुहोस्\" ले देखाइएको क्रममा सबै एकसाथ रूपान्तरण गर्छ। बन्द: हरेक ड्रप पहिलेजस्तै डोनट खोल्छ।", "general.browser_drops": "ब्राउजरबाट तानिएका फाइलहरू", "general.browser_drops_hint": "कुञ्जी थिचिराख्नुहोस् र Chrome, Edge, Firefox वा Safari बाट तस्बिर, फाइल लिङ्क वा डाउनलोड तान्नुहोस्: डोनट टुलबार तपाईंका आफ्ना फाइलहरूका लागि जस्तै खुल्नेछ। फाइल यस फोल्डरमा सुरक्षित हुन्छ, र मूल फाइलको छेउमा बचत गर्ने प्रिसेटहरूले रूपान्तरित फाइललाई पनि यहाँ राख्नेछन्।\nFilee ले ब्राउजरको साइन-इन बिना आफैं लिङ्क डाउनलोड गर्दछ।", "general.browser_drops_folder": "यहाँ सुरक्षित गर्नुहोस्", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 3ecdfd2e4..fe5922622 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -28,6 +28,10 @@ "home.title": "Start", "home.drop_title": "Upuść pliki tutaj", + "home.queue_error": "Nie udało się przekonwertować: {0}. Lista zachowuje pliki.", + "home.queue_too_large": "Za duży do podglądu ({0} MB)", + "home.queue_selected": "zaznaczony", + "home.queue_remove_file": "Usuń {0}", "home.queue_title": "Pliki do konwersji", "home.queue_convert": "Wybierz działanie", "home.queue_clear": "Wyczyść listę", @@ -389,7 +393,7 @@ "preset.flip_vertical.short": "Pionowo", "preset.merge_pdf": "Połącz PDF", "merge.order_title": "Wybierz kolejność scalania", - "merge.order_hint": "Wybierz plik i przesuń go w górę lub w dół. Pliki są scalane od góry do dołu. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Przeciągnij kartę lub zaznacz ją i użyj Alt+↑ / Alt+↓, aby zmienić kolejność. Pliki są łączone od góry do dołu. Delete usuwa zaznaczony plik.", "merge.images_hint": "Wybierz obraz i przesuń go w górę lub w dół. Pierwszy trafia na górę, a przy układzie obok siebie na lewo. (Alt+↑ / Alt+↓)", "merge.vertical": "Jeden pod drugim", "merge.horizontal": "Obok siebie", @@ -500,6 +504,8 @@ "general.default_output_hint": "Ustawienia z „Domyślne miejsce zapisu” zapisują tutaj.\nUstawienie z własnym miejscem je zachowuje.", "general.keep_dates": "Zachowuj oryginalne daty plików przy każdej konwersji", "general.keep_dates_hint": "Przekonwertowane pliki zachowują daty utworzenia i modyfikacji oryginału, w każdym ustawieniu.\nWyłączone: robią to tylko ustawienia z „Zachowaj oryginalne daty plików”.", + "general.collect_dropped": "Zbieraj upuszczone pliki na liście", + "general.collect_dropped_hint": "Upuszczone lub wybrane pliki czekają na liście na ekranie Start, a Wybierz działanie konwertuje je razem w pokazanej kolejności. Wyłączone: każde upuszczenie otwiera pierścień, jak dotąd.", "general.browser_drops": "Pliki przeciągnięte z przeglądarki", "general.browser_drops_hint": "Przytrzymaj klawisz i przeciągnij obraz, link do pliku lub pobieranie z Chrome, Edge, Firefoksa albo Safari: pasek-pierścień otworzy się tak samo jak dla Twoich plików. Plik zostanie zapisany w tym folderze, a ustawienia zapisujące obok oryginału też zapiszą tu przekonwertowany plik.\nLinki Filee pobiera samo, bez logowania z Twojej przeglądarki.", "general.browser_drops_folder": "Zapisz w", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 8b55186f7..8d44e9ae9 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -28,6 +28,10 @@ "home.title": "Início", "home.drop_title": "Solte os arquivos aqui", + "home.queue_error": "Não foi possível converter {0}. A lista mantém os arquivos.", + "home.queue_too_large": "Grande demais para visualizar ({0} MB)", + "home.queue_selected": "selecionado", + "home.queue_remove_file": "Remover {0}", "home.queue_title": "Arquivos para converter", "home.queue_convert": "Escolher ação", "home.queue_clear": "Limpar lista", @@ -389,7 +393,7 @@ "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Mesclar PDF", "merge.order_title": "Escolha a ordem da mesclagem", - "merge.order_hint": "Selecione um arquivo e mova para cima ou para baixo. Os arquivos são mesclados de cima para baixo. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Arraste um cartão, ou selecione-o e use Alt+↑ / Alt+↓, para mudar a ordem. Os arquivos são mesclados de cima para baixo. Delete remove o arquivo selecionado.", "merge.images_hint": "Selecione uma imagem e mova para cima ou para baixo. A primeira fica no topo, ou à esquerda lado a lado. (Alt+↑ / Alt+↓)", "merge.vertical": "De cima para baixo", "merge.horizontal": "Lado a lado", @@ -500,6 +504,8 @@ "general.default_output_hint": "Predefinições definidas como “Local padrão para salvar” salvam aqui.\nUma predefinição com local próprio mantém o dela.", "general.keep_dates": "Manter as datas originais do arquivo em toda conversão", "general.keep_dates_hint": "Os arquivos convertidos mantêm as datas de criação e modificação do original, em toda predefinição.\nDesligado: só as predefinições com “Manter as datas originais do arquivo” fazem isso.", + "general.collect_dropped": "Juntar arquivos soltos em uma lista", + "general.collect_dropped_hint": "Os arquivos soltos ou escolhidos ficam numa lista na tela Início, e Escolher ação converte todos juntos, na ordem mostrada. Desligado: cada solta abre o donut, como antes.", "general.browser_drops": "Arquivos arrastados de um navegador", "general.browser_drops_hint": "Segure a tecla e arraste uma imagem, um link de arquivo ou um download do Chrome, Edge, Firefox ou Safari: o menu circular abre como nos seus arquivos. O arquivo é salvo nesta pasta, e as predefinições que salvam ao lado do original também colocam o arquivo convertido aqui.\nO Filee baixa o link por conta própria, sem o login do navegador.", "general.browser_drops_folder": "Salvar em", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index cde2baaef..95f605ffa 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -28,6 +28,10 @@ "home.title": "Página inicial", "home.drop_title": "Larga os ficheiros aqui", + "home.queue_error": "Não foi possível converter {0}. A lista mantém os ficheiros.", + "home.queue_too_large": "Grande demais para pré-visualizar ({0} MB)", + "home.queue_selected": "selecionado", + "home.queue_remove_file": "Remover {0}", "home.queue_title": "Ficheiros a converter", "home.queue_convert": "Escolher ação", "home.queue_clear": "Limpar lista", @@ -389,7 +393,7 @@ "preset.flip_vertical.short": "Vertical", "preset.merge_pdf": "Juntar PDF", "merge.order_title": "Escolher a ordem dos ficheiros", - "merge.order_hint": "Seleciona um ficheiro e move-o para cima ou para baixo. Os ficheiros são combinados pela ordem da lista, de cima para baixo. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Arraste um cartão, ou selecione-o e use Alt+↑ / Alt+↓, para mudar a ordem. Os ficheiros são unidos de cima para baixo. Delete remove o ficheiro selecionado.", "merge.images_hint": "Seleciona uma imagem e move-a para cima ou para baixo. A primeira fica no topo ou, lado a lado, à esquerda. (Alt+↑ / Alt+↓)", "merge.vertical": "De cima para baixo", "merge.horizontal": "Lado a lado", @@ -500,6 +504,8 @@ "general.default_output_hint": "As predefinições com «Local predefinido para guardar» guardam aqui.\nUma predefinição com um local próprio mantém-no.", "general.keep_dates": "Manter as datas dos ficheiros originais em todas as conversões", "general.keep_dates_hint": "Os ficheiros convertidos mantêm as datas de criação e modificação do original, em todas as predefinições.\nDesativado: só o fazem as predefinições com «Manter as datas do ficheiro original».", + "general.collect_dropped": "Juntar ficheiros largados numa lista", + "general.collect_dropped_hint": "Os ficheiros largados ou escolhidos ficam numa lista no ecrã Início, e Escolher ação converte-os todos juntos, pela ordem mostrada. Desligado: cada largada abre o donut, como antes.", "general.browser_drops": "Ficheiros arrastados de um navegador", "general.browser_drops_hint": "Mantém a tecla premida e arrasta uma imagem, um link para um ficheiro ou uma transferência do Chrome, Edge, Firefox ou Safari: a barra circular abre tal como ao arrastares os teus ficheiros. O ficheiro é guardado nesta pasta, tal como o resultado das predefinições que guardam junto ao original.\nO Filee descarrega os ficheiros dos links diretamente, sem utilizar a sessão iniciada no navegador.", "general.browser_drops_folder": "Guardar em", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index d30878473..bb0e7c611 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -28,6 +28,10 @@ "home.title": "Главная", "home.drop_title": "Перетащите файлы сюда", + "home.queue_error": "Не удалось конвертировать: {0}. Список сохраняет файлы.", + "home.queue_too_large": "Слишком большой для предпросмотра ({0} МБ)", + "home.queue_selected": "выбрано", + "home.queue_remove_file": "Удалить {0}", "home.queue_title": "Файлы для конвертации", "home.queue_convert": "Выбрать действие", "home.queue_clear": "Очистить список", @@ -389,7 +393,7 @@ "preset.flip_vertical.short": "По верт.", "preset.merge_pdf": "Объединить PDF", "merge.order_title": "Выберите порядок объединения", - "merge.order_hint": "Выберите файл и переместите его вверх или вниз. Файлы объединяются сверху вниз. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Перетащите карточку или выберите её и нажмите Alt+↑ / Alt+↓, чтобы изменить порядок. Файлы объединяются сверху вниз. Delete удаляет выбранный файл.", "merge.images_hint": "Выберите изображение и переместите его вверх или вниз. Первое окажется сверху, а при размещении рядом — слева. (Alt+↑ / Alt+↓)", "merge.vertical": "Сверху вниз", "merge.horizontal": "Рядом", @@ -500,6 +504,8 @@ "general.default_output_hint": "Пресеты с настройкой «Место сохранения по умолчанию» сохраняют сюда.\nПресет со своим местом сохраняет туда, куда указано в нём.", "general.keep_dates": "Сохранять исходные даты файла при любой конвертации", "general.keep_dates_hint": "Сконвертированные файлы получают даты создания и изменения оригинала, с любым пресетом.\nВыключено: так делают только пресеты с включённым «Сохранять исходные даты файла».", + "general.collect_dropped": "Собирать перетащенные файлы в список", + "general.collect_dropped_hint": "Перетащенные или выбранные файлы ждут в списке на главной, и «Выбрать действие» конвертирует их вместе в показанном порядке. Выключено: каждое перетаскивание открывает круговое меню, как раньше.", "general.browser_drops": "Файлы, перетащенные из браузера", "general.browser_drops_hint": "Зажмите клавишу и перетащите картинку, ссылку на файл или загрузку из Chrome, Edge, Firefox или Safari: круговое меню откроется так же, как для ваших файлов. Файл сохраняется в эту папку, и пресеты, которые сохраняют рядом с оригиналом, кладут сюда и сконвертированный файл.\nСсылки Filee скачивает сам, без входа в аккаунт из вашего браузера.", "general.browser_drops_folder": "Сохранять в", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 96992c8c5..c2fbb6a80 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -25,6 +25,10 @@ "close.dont_ask": "ไม่ต้องถามอีก", "home.title": "หน้าแรก", "home.drop_title": "วางไฟล์ที่นี่", + "home.queue_error": "แปลง {0} ไม่สำเร็จ รายการยังเก็บไฟล์ไว้", + "home.queue_too_large": "ใหญ่เกินไปสำหรับตัวอย่าง ({0} MB)", + "home.queue_selected": "เลือกอยู่", + "home.queue_remove_file": "นำ {0} ออก", "home.queue_title": "ไฟล์ที่จะแปลง", "home.queue_convert": "เลือกการทำงาน", "home.queue_clear": "ล้างรายการ", @@ -379,7 +383,7 @@ "preset.flip_vertical.short": "แนวตั้ง", "preset.merge_pdf": "รวมไฟล์ PDF", "merge.order_title": "เลือกลำดับการรวมไฟล์", - "merge.order_hint": "เลือกไฟล์แล้วเลื่อนขึ้นหรือลง ไฟล์จะรวมจากบนลงล่าง (Alt+↑ / Alt+↓)", + "merge.order_hint": "ลากการ์ด หรือเลือกการ์ดแล้วกด Alt+↑ / Alt+↓ เพื่อเปลี่ยนลำดับ ไฟล์จะถูกรวมจากบนลงล่าง กด Delete เพื่อนำไฟล์ที่เลือกออก", "merge.images_hint": "เลือกรูปแล้วเลื่อนขึ้นหรือลง รูปแรกจะอยู่บนสุด หรืออยู่ซ้ายสุดเมื่อวางเรียงข้างกัน (Alt+↑ / Alt+↓)", "merge.vertical": "บนลงล่าง", "merge.horizontal": "เรียงข้างกัน", @@ -488,6 +492,8 @@ "general.default_output_hint": "พรีเซ็ตที่ตั้งเป็น “ตำแหน่งบันทึกเริ่มต้น” จะบันทึกที่นี่\nพรีเซ็ตที่มีตำแหน่งบันทึกของตัวเองจะยังคงบันทึกตามเดิม", "general.keep_dates": "คงวันที่สร้างและแก้ไขไฟล์เดิมไว้สำหรับทุกการแปลง", "general.keep_dates_hint": "ไฟล์ที่แปลงแล้วจะคงวันที่สร้างและวันที่แก้ไขตามไฟล์ต้นฉบับในทุกพรีเซ็ต\nหากปิด: จะทำเฉพาะพรีเซ็ตที่เลือก \"คงวันที่เดิมของไฟล์ไว้\"", + "general.collect_dropped": "รวบรวมไฟล์ที่ลากวางในรายการ", + "general.collect_dropped_hint": "ไฟล์ที่ลากวางหรือเลือกจะรออยู่ในรายการหน้าแรก และ \"เลือกการทำงาน\" จะแปลงทั้งหมดพร้อมกันตามลำดับที่แสดง ปิดอยู่: ทุกครั้งที่ลากวางจะเปิดโดนัทเหมือนเดิม", "general.browser_drops": "ไฟล์ที่ลากมาจากเว็บเบราว์เซอร์", "general.browser_drops_hint": "กดปุ่มค้างไว้แล้วลากรูปภาพ ลิงก์ไฟล์ หรือการดาวน์โหลดออกจาก Chrome, Edge, Firefox หรือ Safari: แถบโดนัทจะเปิดขึ้นเหมือนกับไฟล์ในเครื่องของคุณ ไฟล์จะถูกบันทึกลงในโฟลเดอร์นี้ และพรีเซ็ตที่ตั้งให้บันทึกข้างไฟล์ต้นฉบับจะนำไฟล์ที่แปลงแล้วมาไว้ที่นี่ด้วย\nFilee จะดาวน์โหลดลิงก์โดยตรงโดยไม่ใช้การเข้าสู่ระบบของเบราว์เซอร์คุณ", "general.browser_drops_folder": "บันทึกลงที่", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 0714d53df..26fd50f21 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -25,6 +25,10 @@ "close.dont_ask": "Bir daha sorma", "home.title": "Ana sayfa", "home.drop_title": "Dosyaları buraya bırakın", + "home.queue_error": "{0} dönüştürülemedi. Liste dosyaları tutar.", + "home.queue_too_large": "Önizleme için çok büyük ({0} MB)", + "home.queue_selected": "seçili", + "home.queue_remove_file": "{0} dosyasını kaldır", "home.queue_title": "Dönüştürülecek dosyalar", "home.queue_convert": "İşlem seç", "home.queue_clear": "Listeyi temizle", @@ -379,7 +383,7 @@ "preset.flip_vertical.short": "Dikey", "preset.merge_pdf": "PDF birleştir", "merge.order_title": "Birleştirme sırasını seçin", - "merge.order_hint": "Bir dosya seçip yukarı veya aşağı taşıyın. Dosyalar yukarıdan aşağıya birleştirilir. (Alt+↑ / Alt+↓)", + "merge.order_hint": "Sırayı değiştirmek için bir kartı sürükleyin ya da seçip Alt+↑ / Alt+↓ kullanın. Dosyalar yukarıdan aşağıya birleştirilir. Delete seçili dosyayı kaldırır.", "merge.images_hint": "Bir görsel seçip yukarı veya aşağı taşıyın. İlk görsel en üste, yan yana dizilince en sola gelir. (Alt+↑ / Alt+↓)", "merge.vertical": "Alt alta", "merge.horizontal": "Yan yana", @@ -488,6 +492,8 @@ "general.default_output_hint": "“Varsayılan kayıt konumu” seçili ön ayarlar buraya kaydeder.\nKendi konumu olan ön ayar o konumu kullanır.", "general.keep_dates": "Her dönüştürmede özgün dosya tarihlerini koru", "general.keep_dates_hint": "Tüm ön ayarlarda dönüştürülen dosyalar özgün oluşturulma ve değiştirilme tarihlerini korur.\nKapalıysa yalnızca “Özgün dosya tarihlerini koru” seçili ön ayarlar korur.", + "general.collect_dropped": "Bırakılan dosyaları listede topla", + "general.collect_dropped_hint": "Bırakılan ya da seçilen dosyalar Başlangıç ekranındaki listede bekler; İşlem seç hepsini gösterilen sırayla dönüştürür. Kapalıyken her bırakma donut'u açar, eskisi gibi.", "general.browser_drops": "Tarayıcıdan sürüklenen dosyalar", "general.browser_drops_hint": "Tuşu basılı tutarak Chrome, Edge, Firefox veya Safari'den bir görsel, dosya bağlantısı ya da indirme sürükleyin: halka araç çubuğu kendi dosyalarınızdaki gibi açılır. Dosya bu klasöre kaydedilir; özgün dosyanın yanına kaydeden ön ayarlar da çıktıyı buraya koyar.\nFilee bağlantıyı tarayıcınızdaki oturumu kullanmadan kendi indirir.", "general.browser_drops_folder": "Kayıt yeri", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index d40823980..97ae8933c 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -28,6 +28,10 @@ "home.title": "首页", "home.drop_title": "将文件拖放到这里", + "home.queue_error": "无法转换 {0}。列表保留这些文件。", + "home.queue_too_large": "文件过大,无法预览({0} MB)", + "home.queue_selected": "已选中", + "home.queue_remove_file": "移除 {0}", "home.queue_title": "待转换的文件", "home.queue_convert": "选择操作", "home.queue_clear": "清空列表", @@ -389,7 +393,7 @@ "preset.flip_vertical.short": "垂直", "preset.merge_pdf": "合并 PDF", "merge.order_title": "选择合并顺序", - "merge.order_hint": "选择文件并上下移动。文件将按从上到下的顺序合并。(Alt+↑ / Alt+↓)", + "merge.order_hint": "拖动卡片,或选中后按 Alt+↑ / Alt+↓,即可更改顺序。文件从上到下合并。按 Delete 移除所选文件。", "merge.images_hint": "选择图片并上下移动。第一张放在最上面(左右排列时放在最左边)。(Alt+↑ / Alt+↓)", "merge.vertical": "上下排列", "merge.horizontal": "左右排列", @@ -500,6 +504,8 @@ "general.default_output_hint": "保存位置为“默认保存位置”的预设会保存到这里。\n单独设置了保存位置的预设不受影响。", "general.keep_dates": "所有转换都保留原文件日期", "general.keep_dates_hint": "无论使用哪个预设,转换后的文件都沿用原文件的创建和修改日期。\n关闭后,只有打开“保留原文件日期”的预设会这样做。", + "general.collect_dropped": "将拖放的文件收集到列表中", + "general.collect_dropped_hint": "拖放或选择的文件会在首页列表中等待,“选择操作”会按显示顺序一起转换。关闭时,每次拖放都像以前一样打开圆形菜单。", "general.browser_drops": "从浏览器拖来的文件", "general.browser_drops_hint": "按住按键,从 Chrome、Edge、Firefox 或 Safari 拖出图片、文件链接或下载项,环形工具栏会像拖动本地文件时一样打开。文件保存到这个文件夹,保存到“与源文件相同的文件夹”的预设也会把转换后的文件放在这里。\n链接由 Filee 自己下载,不使用浏览器的登录信息。", "general.browser_drops_folder": "保存到", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index e7f2af99e..8c08d107d 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -28,6 +28,10 @@ "home.title": "首頁", "home.drop_title": "將檔案拖放到這裡", + "home.queue_error": "無法轉換 {0}。清單會保留這些檔案。", + "home.queue_too_large": "檔案過大,無法預覽({0} MB)", + "home.queue_selected": "已選取", + "home.queue_remove_file": "移除 {0}", "home.queue_title": "待轉換的檔案", "home.queue_convert": "選擇動作", "home.queue_clear": "清空清單", @@ -389,7 +393,7 @@ "preset.flip_vertical.short": "垂直", "preset.merge_pdf": "合併 PDF", "merge.order_title": "選擇合併順序", - "merge.order_hint": "選擇檔案並上下移動。檔案將按從上到下的順序合併。(Alt+↑ / Alt+↓)", + "merge.order_hint": "拖曳卡片,或選取後按 Alt+↑ / Alt+↓,即可變更順序。檔案由上到下合併。按 Delete 移除所選檔案。", "merge.images_hint": "選擇圖片並上下移動。第一張放在最上面(左右排列時放在最左邊)。(Alt+↑ / Alt+↓)", "merge.vertical": "上下排列", "merge.horizontal": "左右排列", @@ -500,6 +504,8 @@ "general.default_output_hint": "儲存位置為「預設儲存位置」的預設會儲存到這裡。\n單獨設定了儲存位置的預設不受影響。", "general.keep_dates": "所有轉換都保留原始檔案日期", "general.keep_dates_hint": "無論使用哪個預設,轉換後的檔案都沿用原始檔案的建立和修改日期。\n關閉後,只有開啟「保留原始檔案日期」的預設會這樣做。", + "general.collect_dropped": "將拖放的檔案收集到清單中", + "general.collect_dropped_hint": "拖放或選取的檔案會在首頁清單中等待,「選擇動作」會依顯示順序一起轉換。關閉時,每次拖放都像以前一樣開啟圓形選單。", "general.browser_drops": "從瀏覽器拖來的檔案", "general.browser_drops_hint": "按住按鍵,從 Chrome、Edge、Firefox 或 Safari 拖出圖片、檔案連結或下載項目,環形工具列會像拖曳本地檔案時一樣開啟。檔案儲存到這個資料夾,儲存到「與來源檔案相同的資料夾」的預設也會把轉換後的檔案放在這裡。\n連結由 Filee 自己下載,不使用瀏覽器的登入資訊。", "general.browser_drops_folder": "儲存到", diff --git a/src/Filee.App/Services/AppHost.cs b/src/Filee.App/Services/AppHost.cs index 2eb90c92a..b3810796a 100644 --- a/src/Filee.App/Services/AppHost.cs +++ b/src/Filee.App/Services/AppHost.cs @@ -70,6 +70,7 @@ public static IServiceProvider Build(string? dataDirectory = null, UserDataStore services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Filee.App/Services/FilePreviews.cs b/src/Filee.App/Services/FilePreviews.cs new file mode 100644 index 000000000..56fcad11e --- /dev/null +++ b/src/Filee.App/Services/FilePreviews.cs @@ -0,0 +1,71 @@ +// The pictures of files in the Home page's list and the merge order window. A PDF is read whole to draw its first page, so +// the pictures are drawn one at a time, a PDF above a size limit gets none, and a picture whose card is gone is cancelled. + +using Avalonia.Media.Imaging; +using Filee.Core.Formats; +using Filee.Engines.Pdf; + +namespace Filee.App.Services; + +internal static class FilePreviews +{ + /// Width and height of the picture of a PDF's first page, in pixels (the cards show it smaller). + private const int PdfSize = 260; + + /// The largest PDF whose first page is drawn: a bigger one is read whole, which costs too much memory. + public const long MaxPdfBytes = 50L * 1024 * 1024; + + /// One picture at a time: the pictures of a long list are drawn in turn, not all at once. + private static readonly SemaphoreSlim Gate = new(1, 1); + + /// Whether is a PDF too large for a picture of its first page. + public static bool IsTooLarge(string path, FileFormat? format) + { + if (format?.Category != FormatCategory.Pdf) + return false; + try + { + return new FileInfo(path).Length > MaxPdfBytes; + } + catch (IOException) + { + return false; + } + } + + /// + /// The picture of , drawn in turn with the others. Null when the file has none (a format + /// without a picture, or a PDF too large). A picture is cancelled with . + /// + public static async Task RenderAsync(string path, FileFormat? format, CancellationToken cancel) + { + if (format is null || IsTooLarge(path, format)) + return null; + await Gate.WaitAsync(cancel); + try + { + return await Task.Run(() => Make(path, format), cancel); + } + finally + { + Gate.Release(); + } + } + + /// Draws the picture now. Pictures of images are cached and shared; a PDF's picture belongs to its caller. + private static Bitmap? Make(string path, FileFormat format) + { + if (format.Category == FormatCategory.Pdf) + { + var preview = PdfPageTools.Preview(path, 0, PdfSize); + using var stream = new MemoryStream(preview.Png); + return new Bitmap(stream); + } + return format.Category == FormatCategory.Image && HistoryThumbnails.CanShow(path) + ? HistoryThumbnails.Load(path) + : null; + } + + /// Whether a picture made by is the caller's to dispose. + public static bool Owns(FileFormat? format) => format?.Category == FormatCategory.Pdf; +} diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index e06936e7d..f3f15d3a5 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -67,6 +67,7 @@ public sealed class RadialController( private WebDrop? _web; // the browser drop the click-mode donut's files stand for, until a conversion takes it private bool _toClipboard; // the click-mode donut's files came from the clipboard: their results go back on it private bool _keepOrder; // the click-mode donut's files come in the order given (the Home page's queue), not sorted + private Action? _onJob; // told the conversion the donut starts (the Home page's list follows it) /// /// How long a drag that never brought files may keep the invisible window up, in case the button release is @@ -96,6 +97,7 @@ public void ShowForDrag(int x, int y) _dragReachedDonut = false; _converted = false; _keepOrder = false; + _onJob = null; viewModel.ResetForDrag(); window.DonutControl.AllowContextEdit = false; PlaceAndShow(window, x, y, activate: false); @@ -120,7 +122,7 @@ public void ShowForDrag(int x, int y) /// The files are merged in the order given (the Home page's queue, which the user sorted), not sorted by name first. /// public void ShowForFiles(int x, int y, IReadOnlyList files, bool forAnotherProcess = false, WebDrop? web = null, - bool toClipboard = false, bool keepOrder = false) + bool toClipboard = false, bool keepOrder = false, Action? onJob = null) { if (web is null) files = platform.ResolveShortcuts(files); @@ -133,6 +135,7 @@ public void ShowForFiles(int x, int y, IReadOnlyList files, bool forAnot ReplaceWeb(web); _toClipboard = toClipboard; _keepOrder = keepOrder; + _onJob = onJob; if (forAnotherProcess && OperatingSystem.IsMacOS()) _grace.Start(); else @@ -391,6 +394,7 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w Close(); return; } + _onJob?.Invoke(job.Job); _converted = true; ShowProgress(job); } @@ -410,7 +414,9 @@ private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset, await AskThenConvertAsync(ordered, preset, toClipboard); else if (ordered is not null) { - conversions.Start(ordered, preset, web: web, toClipboard: toClipboard); + var job = conversions.Start(ordered, preset, web: web, toClipboard: toClipboard); + if (job is not null) + _onJob?.Invoke(job.Job); web = null; // the conversion owns the browser drop now } } diff --git a/src/Filee.App/Services/StagedFiles.cs b/src/Filee.App/Services/StagedFiles.cs new file mode 100644 index 000000000..94a32e93e --- /dev/null +++ b/src/Filee.App/Services/StagedFiles.cs @@ -0,0 +1,144 @@ +// The files waiting on the Home page to be converted together, in the order they are converted or merged in. It lives as +// long as the app, so the list survives a change of page or language. The list empties only when the conversion it was +// sent to completes; a conversion that fails keeps the list and names the files that failed. + +using System.Collections.ObjectModel; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using Filee.App.ViewModels.Pages; +using Filee.Core.Conversion; +using Filee.Core.Formats; +using Filee.Core.Localization; +using Filee.Core.Platform; + +namespace Filee.App.Services; + +public sealed partial class StagedFiles : ObservableObject, IDisposable +{ + private readonly FileQueue _queue = new(); + private readonly JobQueue _jobs; + private readonly ILocalizer _loc; + + /// The cards sent to the donut, until the conversion they started is over. + private List _sent = []; + + /// The conversion started from the donut for , once it has started. + private string? _sentJobId; + + public StagedFiles(JobQueue jobs, ILocalizer loc) + { + _jobs = jobs; + _loc = loc; + _jobs.JobFinished += OnJobFinished; + } + + /// The cards, in the order the files are converted or merged in. + public ObservableCollection Cards { get; } = []; + + /// The files' paths in the order they are converted. + public IReadOnlyList Paths => _queue.Paths.ToArray(); + + /// Why the last conversion of the list failed, naming its files; empty when there is nothing to report. + [ObservableProperty] + private string _error = ""; + + /// Adds the files to the end of the list. + public void Add(IEnumerable paths) + { + _queue.Add(paths); + Sync(); + } + + /// Moves a file, from the user dragging its card or the keyboard. + public void Move(int from, int to) + { + _queue.Move(from, to); + Cards.Move(from, to); + Renumber(); + } + + /// Takes a card's file out of the list. + public void Remove(StagedFileViewModel card) + { + _queue.Remove(card.Path); + Sync(); + } + + /// Empties the list. + public void Clear() + { + _queue.Clear(); + foreach (var card in Cards) + card.Dispose(); + Cards.Clear(); + Error = ""; + } + + /// The cards were sent to the donut: they leave the list when the conversion the donut starts completes. + public void MarkSent(IReadOnlyList cards) + { + _sent = cards.ToList(); + _sentJobId = null; + Error = ""; + } + + /// A conversion started from the donut; when it is the one for the cards sent, its outcome is followed. + public void ConversionStarted(ConversionJob job) + { + if (_sent.Count > 0 && _sentJobId is null) + _sentJobId = job.Id; + } + + /// The place of each card follows the list. + private void Renumber() + { + for (var i = 0; i < Cards.Count; i++) + Cards[i].Position = i + 1; + } + + /// Brings the cards in line with the list: cards of files that left it go, new files get a card at the end. + private void Sync() + { + foreach (var card in Cards.Where(card => !_queue.Paths.Contains(card.Path, FileSystemPaths.Comparer)).ToList()) + { + Cards.Remove(card); + _sent.Remove(card); + card.Dispose(); + } + foreach (var path in _queue.Paths) + { + if (Cards.Any(card => FileSystemPaths.Comparer.Equals(card.Path, path))) + continue; + var card = new StagedFileViewModel(path, FormatRegistry.Detect(path), Remove, _loc); + Cards.Add(card); + _ = card.LoadPreviewAsync(); + } + Renumber(); + } + + private void OnJobFinished(object? sender, ConversionJob job) => Dispatcher.UIThread.Post(() => Finished(job)); + + private void Finished(ConversionJob job) + { + if (job.Id != _sentJobId) + return; + var sent = _sent; + _sent = []; + _sentJobId = null; + if (job.State == JobState.Cancelled) + return; + if (job.State == JobState.Completed) + { + foreach (var card in sent.Where(card => Cards.Contains(card))) + Remove(card); + return; + } + var failed = job.Files.Where(file => file.State == FileState.Failed).Select(file => Path.GetFileName(file.SourcePath)); + Error = _loc.Format("home.queue_error", string.Join(", ", failed)); + } + + public void Dispose() + { + _jobs.JobFinished -= OnJobFinished; + } +} diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 367958cf6..6f7d16ae0 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -62,7 +62,7 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem { _mergeOrder?.Close(); var images = preset is { JoinsImages: true }; - var vm = new MergeOrderViewModel(files, images ? preset!.Image : null, keepOrder); + var vm = new MergeOrderViewModel(files, images ? preset!.Image : null, keepOrder, loc); var dialog = new MergeOrderWindow { DataContext = vm }; if (preset is not null) dialog.StartMerge.Content = loc.DisplayName(preset); diff --git a/src/Filee.App/ViewModels/MergeOrderViewModel.cs b/src/Filee.App/ViewModels/MergeOrderViewModel.cs index 37b97abe8..6b8fa4a08 100644 --- a/src/Filee.App/ViewModels/MergeOrderViewModel.cs +++ b/src/Filee.App/ViewModels/MergeOrderViewModel.cs @@ -8,8 +8,9 @@ using Avalonia.Media.Imaging; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using Filee.App.ViewModels.Pages; +using Filee.App.Services; using Filee.Core.Formats; +using Filee.Core.Localization; using Filee.Core.Platform; using Filee.Core.Presets; using Filee.Core.Web; @@ -18,10 +19,12 @@ namespace Filee.App.ViewModels; /// /// One file of the merge: its name, the folder it is in (none for a file from a browser, not saved yet), and its picture -/// (the first page of a PDF, or the picture itself), loaded in the background so the window opens at once. +/// (the first page of a PDF, or the picture itself), drawn in turn with the others so the window opens at once. /// -public sealed partial class MergeSource(string path, Action remove) : ObservableObject, IDisposable +public sealed partial class MergeSource(string path, Action remove, ILocalizer? loc = null) : ObservableObject, IDisposable { + private readonly bool _oversized = FilePreviews.IsTooLarge(path, FormatRegistry.Detect(path)); + public string Path { get; } = path; public string Name => System.IO.Path.GetFileName(Path); public string Folder => WebDrop.IsPlaceholder(Path) ? "" : System.IO.Path.GetDirectoryName(Path) ?? ""; @@ -32,27 +35,74 @@ public sealed partial class MergeSource(string path, Action remove) /// The extension ("PDF", "PNG"), shown when the file has no picture. public string FormatBadge => Format is null ? "" : Format.PrimaryExtension.TrimStart('.').ToUpperInvariant(); + /// A PDF too large for its picture: the card says so where the picture goes. + public bool Oversized => _oversized; + + /// "Too large for a preview (60 MB)", when . + public string OversizedText => _oversized && loc is not null + ? loc.Format("home.queue_too_large", Math.Round(new FileInfo(Path).Length / 1048576.0)) + : ""; + + /// Stops the picture still being drawn when the file leaves the merge. + public CancellationTokenSource Cancellation { get; } = new(); + /// The place in the merge, from 1: the order the files merge in. [ObservableProperty] + [NotifyPropertyChangedFor(nameof(AccessibleName))] private int _position; + /// The card the user selected (the list's selection), for the screen reader. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(AccessibleName))] + private bool _isSelected; + [ObservableProperty, NotifyPropertyChangedFor(nameof(HasThumbnail))] private Bitmap? _thumbnail; public bool HasThumbnail => Thumbnail is not null; + /// What a screen reader says for the card: its place, its name, and whether it is selected. + public string AccessibleName => + $"{Position}. {Name}" + (IsSelected ? ", " + (loc?["home.queue_selected"] ?? "selected") : ""); + + /// What the card's X says for a screen reader: which file it takes out. + public string RemoveName => loc is null ? $"Remove {Name}" : loc.Format("home.queue_remove_file", Name); + /// The card's X: takes the file out of the merge. [RelayCommand] private void Remove() => remove(this); - /// The window closed: a picture that is still being made is dropped when it arrives. - public bool IsDisposed { get; private set; } + /// Draws the picture, in turn with the others; a file that leaves the merge meanwhile gets none. + public async Task LoadPreviewAsync() + { + if (Oversized) + return; + try + { + var bitmap = await FilePreviews.RenderAsync(Path, Format, Cancellation.Token); + if (Cancellation.IsCancellationRequested) + { + if (FilePreviews.Owns(Format)) + bitmap?.Dispose(); + return; + } + Thumbnail = bitmap; + } + catch (OperationCanceledException) + { + // The file left the merge: nothing to show. + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // No picture: the file shows its extension instead. + } + } - /// Disposes the picture of a PDF (pictures of images are cached and shared, so they stay). + /// Cancels a picture still being drawn and disposes the one drawn (a PDF's; images are cached and shared). public void Dispose() { - IsDisposed = true; - if (Format?.Category == FormatCategory.Pdf) + Cancellation.Cancel(); + if (FilePreviews.Owns(Format)) Thumbnail?.Dispose(); Thumbnail = null; } @@ -66,17 +116,18 @@ public sealed partial class MergeOrderViewModel : ObservableObject, IDisposable /// Null for other merges. /// /// The files are already in the order to merge them in (the Home page's queue): not sorted by name. - public MergeOrderViewModel(IReadOnlyList files, ImageOptions? images = null, bool keepOrder = false) + public MergeOrderViewModel(IReadOnlyList files, ImageOptions? images = null, bool keepOrder = false, ILocalizer? loc = null) { IEnumerable ordered = keepOrder ? files : files .OrderBy(file => System.IO.Path.GetFileName(file), NaturalComparer.Instance) .ThenBy(file => file, NaturalComparer.Instance); - Files = new(ordered.Select(file => new MergeSource(file, RemoveFile))); + Files = new(ordered.Select(file => new MergeSource(file, RemoveFile, loc))); + Files.CollectionChanged += (_, _) => OnPropertyChanged(nameof(CanMerge)); Renumber(); foreach (var source in Files) - _ = LoadThumbnailAsync(source); + _ = source.LoadPreviewAsync(); SelectedFile = Files.FirstOrDefault(); if (images is null) return; @@ -91,6 +142,15 @@ public MergeOrderViewModel(IReadOnlyList files, ImageOptions? images = n public ObservableCollection Files { get; } + /// Merge needs at least two files: the button is off below that. + public bool CanMerge => Files.Count >= 2; + + partial void OnSelectedFileChanged(MergeSource? value) + { + foreach (var file in Files) + file.IsSelected = ReferenceEquals(file, value); + } + /// The files are pictures that become one image: the window shows the layout too. public bool MergesImages { get; } @@ -168,29 +228,6 @@ public void ApplyTo(ImageOptions image) [RelayCommand(CanExecute = nameof(CanMoveDown))] private void MoveDown() => Move(1); - /// The picture of a file, made off the UI thread; a file without one shows its extension. - private static async Task LoadThumbnailAsync(MergeSource source) - { - if (source.Format is not { } format) - return; - Bitmap? bitmap = null; - try - { - bitmap = await Task.Run(() => StagedFileViewModel.MakeThumbnail(source.Path, format)); - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - // No picture: the file shows its extension instead. - } - if (source.IsDisposed) - { - if (format.Category == FormatCategory.Pdf) - bitmap?.Dispose(); - return; - } - source.Thumbnail = bitmap; - } - /// The window closed: the pictures are released. public void Dispose() { diff --git a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs index c55d35401..5e3c1eed4 100644 --- a/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs @@ -47,6 +47,7 @@ public GeneralPageViewModel(UserDataStore store, ILocalizer loc, IPlatformServic _defaultSubfolder = output.SubfolderName; _defaultFolder = output.CustomFolder; _keepFileDates = store.Settings.KeepFileDates; + _collectDroppedFiles = store.Settings.CollectDroppedFiles; _browserFolder = store.Settings.BrowserDrops.Folder; _saveOnlyConverted = store.Settings.BrowserDrops.SaveOnlyConverted; _zipGeoResults = store.Settings.ZipGeoResults; @@ -92,6 +93,8 @@ partial void OnDefaultSubfolderChanged(string value) => partial void OnDefaultFolderChanged(string value) => Save(s => s.DefaultOutput.CustomFolder = value.Trim()); [ObservableProperty] private bool _keepFileDates; + + [ObservableProperty] private bool _collectDroppedFiles; [ObservableProperty] private bool _zipGeoResults; /// Where files dragged from a browser are saved; empty for the Downloads folder. @@ -131,6 +134,8 @@ private void OnSettingsChanged(object? sender, EventArgs e) partial void OnKeepFileDatesChanged(bool value) => Save(s => s.KeepFileDates = value); + partial void OnCollectDroppedFilesChanged(bool value) => Save(s => s.CollectDroppedFiles = value); + partial void OnZipGeoResultsChanged(bool value) => Save(s => s.ZipGeoResults = value); [ObservableProperty] private Choice _language; diff --git a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs index 9387d99cf..cd241eb2d 100644 --- a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs @@ -206,17 +206,16 @@ public sealed partial class HomePageViewModel : ObservableObject, IDisposable private readonly Services.Triggers.TriggerService? _triggers; private readonly ConversionService? _conversions; private readonly ElevationService? _elevation; - private readonly FileQueue _queue = new(); + private readonly StagedFiles _staged; - /// The queued files sent to the donut, until their conversion is in the history (). - private List? _sentToDonut; - - public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform, Services.Triggers.TriggerService? triggers = null, + public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices platform, StagedFiles staged, + Services.Triggers.TriggerService? triggers = null, ConversionService? conversions = null, ElevationService? elevation = null) { _store = store; _loc = loc; _platform = platform; + _staged = staged; _triggers = triggers; _conversions = conversions; _elevation = elevation; @@ -228,12 +227,18 @@ public HomePageViewModel(UserDataStore store, ILocalizer loc, IPlatformServices DropHint = loc.Format(OperatingSystem.IsMacOS() ? "home.drop_hint_macos" : OperatingSystem.IsLinux() ? "home.drop_hint_linux" : "home.drop_hint", drag is null ? "—" : GestureText.Modifiers(drag.Modifiers)); store.HistoryChanged += OnHistoryChanged; Queue.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasQueue)); + _staged.PropertyChanged += (_, e) => + { + if (e.PropertyName != nameof(StagedFiles.Error)) + return; + OnPropertyChanged(nameof(QueueError)); + OnPropertyChanged(nameof(HasQueueError)); + }; Reload(); } private void OnHistoryChanged(object? sender, EventArgs e) => Dispatcher.UIThread.Post(() => { - FinishSentQueue(); Reload(); }); @@ -241,8 +246,6 @@ public void Dispose() { _thumbnails?.Cancel(); _store.HistoryChanged -= OnHistoryChanged; - foreach (var card in Queue) - card.Dispose(); if (_triggers is not null) _triggers.StatusChanged -= OnTriggerStatusChanged; if (_elevation is not null) @@ -279,54 +282,37 @@ private void OpenAccessibility() public string DropHint { get; } /// - /// The files waiting to be converted together, in the order they are converted or merged in. Files are added by - /// drops and the file picker (). + /// The files waiting to be converted together, in the order they are converted or merged in. The list is kept by + /// for the whole session, so it survives a change of page or language. /// - public ObservableCollection Queue { get; } = []; + public ObservableCollection Queue => _staged.Cards; public bool HasQueue => Queue.Count > 0; + /// Why the last conversion of the list failed, naming its files; empty otherwise. + public string QueueError => _staged.Error; + + public bool HasQueueError => QueueError.Length > 0; + /// The queued files' paths in the order they are converted. - public IReadOnlyList QueuedPaths => _queue.Paths.ToArray(); + public IReadOnlyList QueuedPaths => _staged.Paths; - /// Adds dropped or picked files to the end of the queue. - public void AddToQueue(IEnumerable paths) - { - _queue.Add(paths); - SyncQueue(); - } + /// Adds dropped or picked files to the end of the list. + public void AddToQueue(IEnumerable paths) => _staged.Add(paths); /// Moves a queued file, from the user dragging its card. - public void MoveQueued(int from, int to) - { - _queue.Move(from, to); - Queue.Move(from, to); - Renumber(); - } + public void MoveQueued(int from, int to) => _staged.Move(from, to); - /// The card's X, or the Delete key: takes its file out of the queue. - internal void RemoveFromQueue(StagedFileViewModel card) - { - _queue.Remove(card.Path); - SyncQueue(); - } + /// The card's X: takes its file out of the list. + internal void RemoveFromQueue(StagedFileViewModel card) => _staged.Remove(card); - /// A click on a card selects it (only that one); the Delete key takes the selected file out of the queue. + /// A click on a card selects it (only that one); the Delete key takes the selected file out of the list. public void SelectQueued(int index) { for (var i = 0; i < Queue.Count; i++) Queue[i].IsSelected = i == index; } - /// Alt+Up / Alt+Down: the selected card moves one place earlier or later, and stays selected. - public void MoveSelectedQueued(int delta) - { - var index = Queue.ToList().FindIndex(card => card.IsSelected); - if (index < 0 || index + delta < 0 || index + delta >= Queue.Count) - return; - MoveQueued(index, index + delta); - } - /// The left and right arrows: the selection moves to the neighbouring card. public void SelectNeighbour(int delta) { @@ -336,7 +322,16 @@ public void SelectNeighbour(int delta) SelectQueued(Math.Clamp(index < 0 ? 0 : index + delta, 0, Queue.Count - 1)); } - /// The Delete key: takes the selected file out of the queue, if one is selected. + /// Alt+Up / Alt+Down: the selected card moves one place earlier or later, and stays selected. + public void MoveSelectedQueued(int delta) + { + var index = Queue.ToList().FindIndex(card => card.IsSelected); + if (index < 0 || index + delta < 0 || index + delta >= Queue.Count) + return; + MoveQueued(index, index + delta); + } + + /// The Delete key: takes the selected file out of the list, if one is selected. public void RemoveSelectedQueued() { if (Queue.FirstOrDefault(card => card.IsSelected) is { } selected) @@ -344,80 +339,21 @@ public void RemoveSelectedQueued() } /// - /// The queue's files went to the donut. The queue empties once Filee has converted them, not before: a cancelled - /// donut keeps the files. + /// The list was sent to the donut: its files leave the list when the conversion the donut starts completes. A + /// cancelled donut keeps them; a failed conversion keeps them and names the files that failed. /// - public void MarkSentToDonut() => _sentToDonut = [.. _queue.Paths]; + public void MarkSentToDonut() => _staged.MarkSent([.. Queue]); - /// - /// Empties the queue when a finished conversion (not a cancelled one) of exactly the files sent to the donut is in - /// the history. Such an entry lists all of them as its sources. - /// - private void FinishSentQueue() - { - if (_sentToDonut is not { Count: > 0 } sent) - return; - var finished = _store.History.Any(entry => entry.State != JobState.Cancelled - && sent.All(path => entry.Sources.Contains(path, FileSystemPaths.Comparer))); - if (!finished) - return; - _sentToDonut = null; - ClearQueue(); - } + /// The donut started a conversion: it is the one whose outcome empties the list. + internal void ConversionStarted(ConversionJob job) => _staged.ConversionStarted(job); - /// Empties the queue: the "Clear list" button, and once its files were converted. + /// Empties the list: the "Clear list" button. [RelayCommand] - public void ClearQueue() - { - _queue.Clear(); - foreach (var card in Queue) - card.Dispose(); - Queue.Clear(); - } + public void ClearQueue() => _staged.Clear(); - /// Brings the cards in line with the queue: cards of files that left it go, new files get a card at the end. - private void SyncQueue() - { - foreach (var card in Queue.Where(card => !_queue.Paths.Contains(card.Path, FileSystemPaths.Comparer)).ToList()) - { - Queue.Remove(card); - card.Dispose(); - } - foreach (var path in _queue.Paths) - { - if (Queue.Any(card => FileSystemPaths.Comparer.Equals(card.Path, path))) - continue; - var format = FormatRegistry.Detect(path); - var card = new StagedFileViewModel(path, format, RemoveFromQueue); - Queue.Add(card); - _ = LoadThumbnailAsync(card, format); - } - Renumber(); - } - - private void Renumber() - { - for (var i = 0; i < Queue.Count; i++) - Queue[i].Position = i + 1; - } - - /// The picture of a card, made off the UI thread; a card that left the queue meanwhile gets none. - private async Task LoadThumbnailAsync(StagedFileViewModel card, FileFormat? format) - { - Bitmap? bitmap = null; - try - { - bitmap = await Task.Run(() => StagedFileViewModel.MakeThumbnail(card.Path, format)); - } - catch (Exception ex) when (ex is not OutOfMemoryException) - { - // No picture: the card shows its extension instead. - } - if (Queue.Contains(card)) - card.Thumbnail = bitmap; - else if (format?.Category == FormatCategory.Pdf) - bitmap?.Dispose(); - } + /// The error notice's X: closes it. + [RelayCommand] + private void DismissQueueError() => _staged.Error = ""; /// How many conversions the page lists: the newest, or the newest that match the search. internal const int Shown = 40; diff --git a/src/Filee.App/ViewModels/Pages/StagedFileViewModel.cs b/src/Filee.App/ViewModels/Pages/StagedFileViewModel.cs index 5ce46ade9..7312f3e51 100644 --- a/src/Filee.App/ViewModels/Pages/StagedFileViewModel.cs +++ b/src/Filee.App/ViewModels/Pages/StagedFileViewModel.cs @@ -1,34 +1,38 @@ -// One file of the Home page's queue: its number, its picture (the first page of a PDF, a picture itself) and its format. +// One file of the Home page's list: its number, its picture (the first page of a PDF, a picture itself) and its format. using Avalonia.Media.Imaging; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Filee.App.Services; using Filee.Core.Formats; -using Filee.Engines.Pdf; +using Filee.Core.Localization; namespace Filee.App.ViewModels.Pages; public sealed partial class StagedFileViewModel : ObservableObject, IDisposable { - /// Width and height of the picture of a PDF's first page, in pixels (the card shows it smaller). - private const int PdfThumbnailSize = 260; - private readonly Action _remove; - private readonly bool _ownsThumbnail; + private readonly ILocalizer _loc; + private readonly FileFormat? _format; /// The file's format, or null when Filee doesn't know it (the card then shows no picture). - public StagedFileViewModel(string path, FileFormat? format, Action remove) + public StagedFileViewModel(string path, FileFormat? format, Action remove, ILocalizer loc) { Path = path; Name = System.IO.Path.GetFileName(path); FormatBadge = System.IO.Path.GetExtension(path).TrimStart('.').ToUpperInvariant(); + _format = format; _remove = remove; - _ownsThumbnail = format?.Category == FormatCategory.Pdf; + _loc = loc; + Oversized = FilePreviews.IsTooLarge(path, format); + if (Oversized) + OversizedText = _loc.Format("home.queue_too_large", Math.Round(new FileInfo(path).Length / 1048576.0)); } - /// The card the user selected: the Delete key takes it out of the queue. - [ObservableProperty] private bool _isSelected; + /// The card the user selected: the Delete key takes it out of the list. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(AccessibleName))] + private bool _isSelected; public string Path { get; } @@ -38,40 +42,66 @@ public StagedFileViewModel(string path, FileFormat? format, ActionThe extension ("PDF", "PNG"), shown when the file has no picture. public string FormatBadge { get; } - /// The place in the queue, from 1: the order the files are converted or merged in. - [ObservableProperty] private int _position; + /// The place in the list, from 1: the order the files are converted or merged in. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(AccessibleName))] + private int _position; [ObservableProperty, NotifyPropertyChangedFor(nameof(HasThumbnail))] private Bitmap? _thumbnail; public bool HasThumbnail => Thumbnail is not null; - /// - /// Makes the picture off the UI thread: the first page of a PDF, or the picture of an image file. Null for other - /// formats, which show their extension instead. - /// - internal static Bitmap? MakeThumbnail(string path, FileFormat? format) + /// A PDF too large for its picture: the card says so where the picture goes. + public bool Oversized { get; } + + /// "Too large for a preview (60 MB)", when . + public string OversizedText { get; } = ""; + + /// Stops the picture still being drawn when the card goes. + public CancellationTokenSource Cancellation { get; } = new(); + + /// What a screen reader says for the card: its place, its name, and whether it is selected. + public string AccessibleName => + $"{Position}. {Name}" + (IsSelected ? ", " + _loc["home.queue_selected"] : ""); + + /// What the card's X says for a screen reader: which file it takes out. + public string RemoveName => _loc.Format("home.queue_remove_file", Name); + + /// Draws the picture, in turn with the others; a card that goes meanwhile gets none. + public async Task LoadPreviewAsync() { - if (format is null) - return null; - if (format.Category == FormatCategory.Pdf) + if (Oversized) + return; + try + { + var bitmap = await FilePreviews.RenderAsync(Path, _format, Cancellation.Token); + if (Cancellation.IsCancellationRequested) + { + if (FilePreviews.Owns(_format)) + bitmap?.Dispose(); + return; + } + Thumbnail = bitmap; + } + catch (OperationCanceledException) + { + // The card went: nothing to show. + } + catch (Exception ex) when (ex is not OutOfMemoryException) { - var preview = PdfPageTools.Preview(path, 0, PdfThumbnailSize); - using var stream = new MemoryStream(preview.Png); - return new Bitmap(stream); + // No picture: the card shows its extension instead. } - return format.Category == FormatCategory.Image && HistoryThumbnails.CanShow(path) - ? HistoryThumbnails.Load(path) - : null; } [RelayCommand] private void Remove() => _remove(this); - /// Disposes the picture of a PDF (pictures of images are cached and shared, so they stay). + /// Cancels a picture still being drawn and disposes the one drawn (a PDF's; images are cached and shared). public void Dispose() { - if (_ownsThumbnail) + Cancellation.Cancel(); + if (FilePreviews.Owns(_format)) Thumbnail?.Dispose(); Thumbnail = null; } diff --git a/src/Filee.App/Views/MergeOrderWindow.axaml b/src/Filee.App/Views/MergeOrderWindow.axaml index 1e334ff3f..307d828b7 100644 --- a/src/Filee.App/Views/MergeOrderWindow.axaml +++ b/src/Filee.App/Views/MergeOrderWindow.axaml @@ -10,10 +10,6 @@ Icon="avares://Filee/Assets/Icons/filee.ico" Width="560" Height="480" MinWidth="440" MinHeight="320" WindowStartupLocation="CenterScreen"> - - - - @@ -77,7 +73,8 @@ diff --git a/src/Filee.App/Views/MergeOrderWindow.axaml.cs b/src/Filee.App/Views/MergeOrderWindow.axaml.cs index 133496bae..b524defb4 100644 --- a/src/Filee.App/Views/MergeOrderWindow.axaml.cs +++ b/src/Filee.App/Views/MergeOrderWindow.axaml.cs @@ -1,6 +1,7 @@ // The order of a merge: the cards can be dragged to another place with the mouse, and the Delete key takes the selected // file out of the merge. The chosen order is the one the files merge in. +using System.Globalization; using Avalonia; using Avalonia.Controls; using Avalonia.Input; @@ -34,6 +35,7 @@ public MergeOrderWindow() FileOrder.AddHandler(PointerReleasedEvent, OnCardReleased, RoutingStrategies.Bubble, handledEventsToo: true); // Tunnelled: the arrows reach the window before the list's own keys, and keep working after Alt+Up / Alt+Down. AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel); + FileOrder.FlowDirection = IsRightToLeft ? Avalonia.Media.FlowDirection.RightToLeft : Avalonia.Media.FlowDirection.LeftToRight; } public IReadOnlyList? Result { get; private set; } @@ -97,30 +99,37 @@ private void OnCardReleased(object? sender, PointerReleasedEventArgs e) } /// - /// The left and right arrows move the selection between the cards; the Delete key takes the selected file out of the - /// merge, while the list has the focus. Text boxes keep their keys. + /// The keys of the list, while the list itself has the focus: the arrows move the selection between the cards (mirrored + /// in a right-to-left language), Alt+Up / Alt+Down move the selected file, and Delete takes it out of the merge. /// private void OnWindowKeyDown(object? sender, KeyEventArgs e) { - if (DataContext is not MergeOrderViewModel vm || IsTextEntry(e.Source)) + if (DataContext is not MergeOrderViewModel vm || !IsWithin(e.Source, FileOrder)) return; - if (e.Key is Key.Left or Key.Right) - { - vm.SelectNeighbour(e.Key == Key.Right ? 1 : -1); - if (vm.SelectedFile is { } selected) - FileOrder.ContainerFromIndex(vm.Files.IndexOf(selected))?.Focus(); - e.Handled = true; - } - else if (e.Key == Key.Delete && e.Source is Visual source && (ReferenceEquals(source, FileOrder) || source.GetVisualAncestors().Contains(FileOrder))) - { + if (e.Key is Key.Up or Key.Down && e.KeyModifiers.HasFlag(KeyModifiers.Alt)) + (e.Key == Key.Up ? vm.MoveUpCommand : vm.MoveDownCommand).Execute(null); + else if (e.Key is Key.Left or Key.Right) + vm.SelectNeighbour(e.Key == Key.Right ^ IsRightToLeft ? 1 : -1); + else if (e.Key == Key.Delete) vm.RemoveSelected(); - e.Handled = true; - } + else + return; + FocusSelected(vm); + e.Handled = true; } - private static bool IsTextEntry(object? source) => - source is TextBox or NumericUpDown or ColorPicker - || source is Visual visual && visual.GetVisualAncestors().Any(ancestor => ancestor is TextBox or NumericUpDown or ColorPicker); + /// Gives the list the focus back on the selected card, so the keys keep working after a move or a removal. + private void FocusSelected(MergeOrderViewModel vm) + { + if (vm.SelectedFile is { } selected) + FileOrder.ContainerFromIndex(vm.Files.IndexOf(selected))?.Focus(); + } + + /// Whether the language is written right to left: the cards are laid out, and read, from the right. + private static bool IsRightToLeft => CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft; + + private static bool IsWithin(object? source, Visual container) => + source is Visual visual && (ReferenceEquals(visual, container) || visual.GetVisualAncestors().Contains(container)); private static bool IsInButton(object? source) => source is Visual visual && (visual is Button || visual.GetVisualAncestors().Any(ancestor => ancestor is Button)); diff --git a/src/Filee.App/Views/Pages/GeneralPage.axaml b/src/Filee.App/Views/Pages/GeneralPage.axaml index 0d30079c2..6f1d2bb9b 100644 --- a/src/Filee.App/Views/Pages/GeneralPage.axaml +++ b/src/Filee.App/Views/Pages/GeneralPage.axaml @@ -57,6 +57,8 @@ + + diff --git a/src/Filee.App/Views/Pages/HomePage.axaml b/src/Filee.App/Views/Pages/HomePage.axaml index 41b967bb3..b1771653a 100644 --- a/src/Filee.App/Views/Pages/HomePage.axaml +++ b/src/Filee.App/Views/Pages/HomePage.axaml @@ -87,7 +87,12 @@ - + + + diff --git a/src/Filee.App/Views/Pages/HomePage.axaml.cs b/src/Filee.App/Views/Pages/HomePage.axaml.cs index a672f4f87..154e21a81 100644 --- a/src/Filee.App/Views/Pages/HomePage.axaml.cs +++ b/src/Filee.App/Views/Pages/HomePage.axaml.cs @@ -4,6 +4,7 @@ // place in the queue changes the order the files are converted or merged in. A click selects a card, and the Delete key // takes the selected file out of the queue. +using System.Globalization; using Avalonia; using Avalonia.Controls; using Avalonia.Input; @@ -13,6 +14,7 @@ using Avalonia.VisualTree; using Filee.App.Services; using Filee.App.ViewModels.Pages; +using Filee.Core.Settings; using Microsoft.Extensions.Logging; namespace Filee.App.Views.Pages; @@ -57,6 +59,7 @@ public HomePage() QueueList.PointerMoved += OnQueueMoved; QueueList.PointerReleased += OnQueueReleased; QueueList.Focusable = true; + QueueList.FlowDirection = IsRightToLeft ? Avalonia.Media.FlowDirection.RightToLeft : Avalonia.Media.FlowDirection.LeftToRight; // Tunnelled: the arrows and Delete reach the page before the list's own keys. AddHandler(KeyDownEvent, OnPageKeyDown, RoutingStrategies.Tunnel); } @@ -83,7 +86,7 @@ private async void OnDropZoneDoubleTapped(object? sender, TappedEventArgs e) if (paths.Count == 0) return; - ViewModel?.AddToQueue(paths); + TakeFiles(paths, DropZone.TranslatePoint(new Point(DropZone.Bounds.Width / 2, DropZone.Bounds.Height / 2), this) ?? new Point()); } private void OnDrop(object? sender, DragEventArgs e) @@ -99,21 +102,36 @@ private void OnDrop(object? sender, DragEventArgs e) e.DragEffects = DragDropEffects.Copy; if (dragged.Web is null) { - ViewModel?.AddToQueue(dragged.Files); + TakeFiles(dragged.Files, e.GetPosition(this)); return; } var screenPoint = this.PointToScreen(e.GetPosition(this)); AppHost.Get().ShowForFiles(screenPoint.X, screenPoint.Y, dragged.Files, web: dragged.Web); } + /// + /// Files dropped or picked: they join the list when the setting asks for it; otherwise the donut opens for them at the + /// point, as before the list existed. Browser drops keep the donut. + /// + private void TakeFiles(IReadOnlyList paths, Point at) + { + if (AppHost.Get().Settings.CollectDroppedFiles) + { + ViewModel?.AddToQueue(paths); + return; + } + var screenPoint = this.PointToScreen(at); + AppHost.Get().ShowForFiles(screenPoint.X, screenPoint.Y, paths); + } + /// The queue's button: the donut opens at the button for the queued files, in the order they are in. private void OnConvertQueue(object? sender, RoutedEventArgs e) { if (ViewModel is not { Queue.Count: > 0 } vm) return; var screenPoint = ConvertQueueButton.PointToScreen(new Point(ConvertQueueButton.Bounds.Width / 2, ConvertQueueButton.Bounds.Height / 2)); - AppHost.Get().ShowForFiles(screenPoint.X, screenPoint.Y, vm.QueuedPaths, keepOrder: true); - // The queue empties once Filee has converted these files (HomePageViewModel.FinishSentQueue). + AppHost.Get().ShowForFiles(screenPoint.X, screenPoint.Y, vm.QueuedPaths, keepOrder: true, onJob: vm.ConversionStarted); + // The cards leave the list when that conversion completes (StagedFiles). vm.MarkSentToDonut(); } @@ -171,32 +189,31 @@ private void OnQueueReleased(object? sender, PointerReleasedEventArgs e) } /// - /// The arrows move the selection between the cards (Alt+Up / Alt+Down moves the selected one); the Delete key takes the selected card out of the - /// queue, while the queue has the focus. Text boxes keep their keys. + /// The keys of the list, while the list itself has the focus: the arrows move the selection between the cards (mirrored + /// in a right-to-left language), Alt+Up / Alt+Down move the selected card, and Delete takes it out of the list. /// private void OnPageKeyDown(object? sender, KeyEventArgs e) { - if (IsTextEntry(e.Source)) + if (!IsWithin(e.Source, QueueList)) return; if (e.Key is Key.Up or Key.Down && e.KeyModifiers.HasFlag(KeyModifiers.Alt)) - { ViewModel?.MoveSelectedQueued(e.Key == Key.Down ? 1 : -1); - QueueList.Focus(); - e.Handled = true; - } else if (e.Key is Key.Left or Key.Right) - { - ViewModel?.SelectNeighbour(e.Key == Key.Right ? 1 : -1); - QueueList.Focus(); - e.Handled = true; - } - else if (e.Key == Key.Delete && ReferenceEquals(e.Source, QueueList)) - { + ViewModel?.SelectNeighbour(e.Key == Key.Right ^ IsRightToLeft ? 1 : -1); + else if (e.Key == Key.Delete) ViewModel?.RemoveSelectedQueued(); - e.Handled = true; - } + else + return; + QueueList.Focus(); + e.Handled = true; } + /// Whether the language is written right to left: the cards are laid out, and read, from the right. + internal static bool IsRightToLeft => CultureInfo.CurrentUICulture.TextInfo.IsRightToLeft; + + private static bool IsWithin(object? source, Visual container) => + source is Visual visual && (ReferenceEquals(visual, container) || visual.GetVisualAncestors().Contains(container)); + private static bool IsTextEntry(object? source) => source is TextBox or NumericUpDown or ColorPicker || source is Visual visual && visual.GetVisualAncestors().Any(ancestor => ancestor is TextBox or NumericUpDown or ColorPicker); diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 45db4a43f..d1723529f 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -252,6 +252,11 @@ public sealed class AppSettings /// Every conversion keeps the created and modified dates of the original, whatever the preset says. public bool KeepFileDates { get; set; } + /// + /// Files dropped or picked on the Home page wait in its list (true), instead of opening the donut for each drop. + /// + public bool CollectDroppedFiles { get; set; } + /// Every geospatial conversion is saved as a ZIP, whatever the preset says (see ). public bool ZipGeoResults { get; set; } diff --git a/tests/Filee.App.Tests/DiagnosticsTests.cs b/tests/Filee.App.Tests/DiagnosticsTests.cs index 0e9e157a0..1de6c6a18 100644 --- a/tests/Filee.App.Tests/DiagnosticsTests.cs +++ b/tests/Filee.App.Tests/DiagnosticsTests.cs @@ -300,7 +300,7 @@ public void The_home_page_warns_and_shows_a_failed_restart() TestServices.EnsureInitialized(); var elevation = Create(relaunchError: "Explorer did not answer."); using var page = new ViewModels.Pages.HomePageViewModel(AppHost.Get(), - AppHost.Get(), AppHost.Get(), elevation: elevation); + AppHost.Get(), AppHost.Get(), AppHost.Get(), elevation: elevation); var changed = new List(); page.PropertyChanged += (_, e) => changed.Add(e.PropertyName); Assert.True(page.IsElevated); @@ -319,7 +319,7 @@ public void The_home_page_has_no_warning_by_default() TestServices.EnsureInitialized(); using var page = new ViewModels.Pages.HomePageViewModel(AppHost.Get(), AppHost.Get(), AppHost.Get(), - elevation: AppHost.Get()); // tests never run Program: the state stays unknown + AppHost.Get(), elevation: AppHost.Get()); // tests never run Program: the state stays unknown Assert.False(page.IsElevated); } } diff --git a/tests/Filee.App.Tests/MergeOrderTests.cs b/tests/Filee.App.Tests/MergeOrderTests.cs index 080193d87..b02e25c9f 100644 --- a/tests/Filee.App.Tests/MergeOrderTests.cs +++ b/tests/Filee.App.Tests/MergeOrderTests.cs @@ -79,7 +79,8 @@ public async Task Donut_merge_waits_for_review_and_uses_the_chosen_order_or_canc Assert.False(dialog.FindControl - diff --git a/src/Filee.App/Views/MergeOrderWindow.axaml.cs b/src/Filee.App/Views/MergeOrderWindow.axaml.cs index ccaf66625..108c20da0 100644 --- a/src/Filee.App/Views/MergeOrderWindow.axaml.cs +++ b/src/Filee.App/Views/MergeOrderWindow.axaml.cs @@ -112,9 +112,13 @@ private void OnCardReleased(object? sender, PointerReleasedEventArgs e) /// private void OnWindowKeyDown(object? sender, KeyEventArgs e) { - if (DataContext is not MergeOrderViewModel vm || !IsListKey(e.Source)) + if (DataContext is not MergeOrderViewModel vm) return; - if (e.Key is Key.Up or Key.Down && e.KeyModifiers.HasFlag(KeyModifiers.Alt)) + // Alt+Up / Alt+Down reorder anywhere in the window; the plain arrows only where they mean selection. + var reorder = e.Key is Key.Up or Key.Down && e.KeyModifiers.HasFlag(KeyModifiers.Alt); + if (!reorder && !IsListKey(e.Source)) + return; + if (reorder) (e.Key == Key.Up ? vm.MoveUpCommand : vm.MoveDownCommand).Execute(null); else if (e.Key is Key.Left or Key.Right) vm.SelectNeighbour(e.Key == Key.Right ^ IsRightToLeft ? 1 : -1); diff --git a/tests/Filee.App.Tests/MergeOrderTests.cs b/tests/Filee.App.Tests/MergeOrderTests.cs index b02e25c9f..080193d87 100644 --- a/tests/Filee.App.Tests/MergeOrderTests.cs +++ b/tests/Filee.App.Tests/MergeOrderTests.cs @@ -79,8 +79,7 @@ public async Task Donut_merge_waits_for_review_and_uses_the_chosen_order_or_canc Assert.False(dialog.FindControl