diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json
index 028a9c70..b08ada36 100644
--- a/src/Filee.App/Assets/i18n/ar.json
+++ b/src/Filee.App/Assets/i18n/ar.json
@@ -25,6 +25,13 @@
"close.dont_ask": "عدم السؤال مجددًا",
"home.title": "الرئيسية",
"home.drop_title": "أفلت الملفات هنا",
+ "home.queue_title": "الملفات المراد تحويلها",
+ "home.queue_hint": "اسحب بطاقة لتغيير ترتيبها. يمكن إضافة الملفات من النوع نفسه للملف الأول فقط.",
+ "home.queue_convert": "اختر إجراءً",
+ "home.queue_clear": "مسح القائمة",
+ "home.queue_remove": "إزالة",
+ "home.queue_only_type": "لا يمكن إضافة إلا ملفات {0} إلى هذه القائمة.",
+ "home.queue_unsupported": "لا يمكن تحويل نوع الملف هذا.",
"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 d19add20..33550668 100644
--- a/src/Filee.App/Assets/i18n/de.json
+++ b/src/Filee.App/Assets/i18n/de.json
@@ -25,6 +25,13 @@
"close.dont_ask": "Nicht erneut fragen",
"home.title": "Start",
"home.drop_title": "Dateien hier ablegen",
+ "home.queue_title": "Zu konvertierende Dateien",
+ "home.queue_hint": "Ziehe eine Karte, um die Reihenfolge zu ändern. Es können nur Dateien desselben Typs wie die erste hinzugefügt werden.",
+ "home.queue_convert": "Aktion wählen",
+ "home.queue_clear": "Liste leeren",
+ "home.queue_remove": "Entfernen",
+ "home.queue_only_type": "Zu dieser Liste können nur {0}-Dateien hinzugefügt werden.",
+ "home.queue_unsupported": "Dieser Dateityp kann nicht konvertiert werden.",
"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 eec07b44..b381cc9d 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -28,6 +28,13 @@
"home.title": "Home",
"home.drop_title": "Drop files here",
+ "home.queue_title": "Files to convert",
+ "home.queue_hint": "Drag a card to change the order. Only files of the same type as the first one can be added.",
+ "home.queue_convert": "Choose action",
+ "home.queue_clear": "Clear list",
+ "home.queue_remove": "Remove",
+ "home.queue_only_type": "Only {0} files can be added to this list.",
+ "home.queue_unsupported": "This file type can't be converted.",
"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 8c7c0829..ea54c067 100644
--- a/src/Filee.App/Assets/i18n/es.json
+++ b/src/Filee.App/Assets/i18n/es.json
@@ -25,6 +25,13 @@
"close.dont_ask": "No volver a preguntar",
"home.title": "Inicio",
"home.drop_title": "Suelta archivos aquí",
+ "home.queue_title": "Archivos para convertir",
+ "home.queue_hint": "Arrastra una tarjeta para cambiar el orden. Solo se pueden añadir archivos del mismo tipo que el primero.",
+ "home.queue_convert": "Elegir acción",
+ "home.queue_clear": "Vaciar lista",
+ "home.queue_remove": "Quitar",
+ "home.queue_only_type": "Solo se pueden añadir archivos {0} a esta lista.",
+ "home.queue_unsupported": "Este tipo de archivo no se puede convertir.",
"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 1aa508fa..a4a4a109 100644
--- a/src/Filee.App/Assets/i18n/fr.json
+++ b/src/Filee.App/Assets/i18n/fr.json
@@ -28,6 +28,13 @@
"home.title": "Accueil",
"home.drop_title": "Déposez des fichiers ici",
+ "home.queue_title": "Fichiers à convertir",
+ "home.queue_hint": "Faites glisser une carte pour changer l'ordre. Seuls les fichiers du même type que le premier peuvent être ajoutés.",
+ "home.queue_convert": "Choisir une action",
+ "home.queue_clear": "Vider la liste",
+ "home.queue_remove": "Retirer",
+ "home.queue_only_type": "Seuls les fichiers {0} peuvent être ajoutés à cette liste.",
+ "home.queue_unsupported": "Ce type de fichier ne peut pas être converti.",
"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 afa6648c..0df3ccbf 100644
--- a/src/Filee.App/Assets/i18n/hi.json
+++ b/src/Filee.App/Assets/i18n/hi.json
@@ -25,6 +25,13 @@
"close.dont_ask": "दोबारा न पूछें",
"home.title": "होम",
"home.drop_title": "फ़ाइलें यहाँ छोड़ें",
+ "home.queue_title": "रूपांतरित करने वाली फ़ाइलें",
+ "home.queue_hint": "क्रम बदलने के लिए कार्ड खींचें। केवल पहली फ़ाइल जैसे प्रकार की फ़ाइलें जोड़ी जा सकती हैं।",
+ "home.queue_convert": "क्रिया चुनें",
+ "home.queue_clear": "सूची साफ़ करें",
+ "home.queue_remove": "हटाएं",
+ "home.queue_only_type": "इस सूची में केवल {0} फ़ाइलें जोड़ी जा सकती हैं।",
+ "home.queue_unsupported": "यह फ़ाइल प्रकार रूपांतरित नहीं किया जा सकता।",
"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 8b224b6a..19d24b15 100644
--- a/src/Filee.App/Assets/i18n/id.json
+++ b/src/Filee.App/Assets/i18n/id.json
@@ -25,6 +25,13 @@
"close.dont_ask": "Jangan tanyakan lagi",
"home.title": "Beranda",
"home.drop_title": "Lepaskan file di sini",
+ "home.queue_title": "File untuk dikonversi",
+ "home.queue_hint": "Seret kartu untuk mengubah urutan. Hanya file dengan jenis yang sama dengan file pertama yang bisa ditambahkan.",
+ "home.queue_convert": "Pilih tindakan",
+ "home.queue_clear": "Kosongkan daftar",
+ "home.queue_remove": "Hapus",
+ "home.queue_only_type": "Hanya file {0} yang bisa ditambahkan ke daftar ini.",
+ "home.queue_unsupported": "Jenis file ini tidak dapat dikonversi.",
"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 ab0af57e..ee2ecda0 100644
--- a/src/Filee.App/Assets/i18n/it.json
+++ b/src/Filee.App/Assets/i18n/it.json
@@ -25,6 +25,13 @@
"close.dont_ask": "Non chiedere più",
"home.title": "Home",
"home.drop_title": "Rilascia qui i file",
+ "home.queue_title": "File da convertire",
+ "home.queue_hint": "Trascina una scheda per cambiare l'ordine. Si possono aggiungere solo file dello stesso tipo del primo.",
+ "home.queue_convert": "Scegli azione",
+ "home.queue_clear": "Svuota elenco",
+ "home.queue_remove": "Rimuovi",
+ "home.queue_only_type": "A questo elenco si possono aggiungere solo file {0}.",
+ "home.queue_unsupported": "Questo tipo di file non può essere convertito.",
"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 5f18b62d..5a3c23ff 100644
--- a/src/Filee.App/Assets/i18n/ja.json
+++ b/src/Filee.App/Assets/i18n/ja.json
@@ -25,6 +25,13 @@
"close.dont_ask": "次回から確認しない",
"home.title": "ホーム",
"home.drop_title": "ここにファイルをドロップ",
+ "home.queue_title": "変換するファイル",
+ "home.queue_hint": "カードをドラッグして順番を変えられます。最初のファイルと同じ種類のファイルだけを追加できます。",
+ "home.queue_convert": "操作を選択",
+ "home.queue_clear": "リストをクリア",
+ "home.queue_remove": "削除",
+ "home.queue_only_type": "このリストには{0}ファイルだけを追加できます。",
+ "home.queue_unsupported": "この種類のファイルは変換できません。",
"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 f6630867..51494c24 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -28,6 +28,13 @@
"home.title": "홈",
"home.drop_title": "여기에 파일을 놓으세요",
+ "home.queue_title": "변환할 파일",
+ "home.queue_hint": "카드를 끌어서 순서를 바꿀 수 있습니다. 첫 번째 파일과 같은 종류의 파일만 추가할 수 있습니다.",
+ "home.queue_convert": "작업 선택",
+ "home.queue_clear": "목록 지우기",
+ "home.queue_remove": "제거",
+ "home.queue_only_type": "이 목록에는 {0} 파일만 추가할 수 있습니다.",
+ "home.queue_unsupported": "이 파일 형식은 변환할 수 없습니다.",
"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 b39570fe..87ae1fcd 100644
--- a/src/Filee.App/Assets/i18n/ne.json
+++ b/src/Filee.App/Assets/i18n/ne.json
@@ -25,6 +25,13 @@
"close.dont_ask": "फेरि नसोध्नुहोस्",
"home.title": "गृहपृष्ठ",
"home.drop_title": "फाइलहरू यहाँ छाड्नुहोस्",
+ "home.queue_title": "रूपान्तरण गर्नु पर्ने फाइलहरू",
+ "home.queue_hint": "क्रम बदल्न कार्ड तान्नुहोस्। पहिलो फाइलकै प्रकारका फाइल मात्र थप्न सकिन्छ।",
+ "home.queue_convert": "कार्य छान्नुहोस्",
+ "home.queue_clear": "सूची खाली गर्नुहोस्",
+ "home.queue_remove": "हटाउनुहोस्",
+ "home.queue_only_type": "यस सूचीमा केवल {0} फाइल थप्न सकिन्छ।",
+ "home.queue_unsupported": "यो फाइल प्रकार रूपान्तरण गर्न सकिँदैन।",
"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 efdc72b8..18ba0616 100644
--- a/src/Filee.App/Assets/i18n/pl.json
+++ b/src/Filee.App/Assets/i18n/pl.json
@@ -28,6 +28,13 @@
"home.title": "Start",
"home.drop_title": "Upuść pliki tutaj",
+ "home.queue_title": "Pliki do konwersji",
+ "home.queue_hint": "Przeciągnij kartę, aby zmienić kolejność. Można dodawać tylko pliki tego samego typu co pierwszy.",
+ "home.queue_convert": "Wybierz działanie",
+ "home.queue_clear": "Wyczyść listę",
+ "home.queue_remove": "Usuń",
+ "home.queue_only_type": "Do tej listy można dodać tylko pliki {0}.",
+ "home.queue_unsupported": "Tego typu pliku nie można przekonwertować.",
"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 5f7220f2..6dba60c4 100644
--- a/src/Filee.App/Assets/i18n/pt-BR.json
+++ b/src/Filee.App/Assets/i18n/pt-BR.json
@@ -28,6 +28,13 @@
"home.title": "Início",
"home.drop_title": "Solte os arquivos aqui",
+ "home.queue_title": "Arquivos para converter",
+ "home.queue_hint": "Arraste um cartão para mudar a ordem. Só entram arquivos do mesmo tipo do primeiro.",
+ "home.queue_convert": "Escolher ação",
+ "home.queue_clear": "Limpar lista",
+ "home.queue_remove": "Remover",
+ "home.queue_only_type": "Só é possível adicionar arquivos {0} nesta lista.",
+ "home.queue_unsupported": "Este tipo de arquivo não pode ser convertido.",
"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 241d43b5..7672021d 100644
--- a/src/Filee.App/Assets/i18n/pt-PT.json
+++ b/src/Filee.App/Assets/i18n/pt-PT.json
@@ -28,6 +28,13 @@
"home.title": "Página inicial",
"home.drop_title": "Larga os ficheiros aqui",
+ "home.queue_title": "Ficheiros a converter",
+ "home.queue_hint": "Arraste um cartão para mudar a ordem. Só entram ficheiros do mesmo tipo do primeiro.",
+ "home.queue_convert": "Escolher ação",
+ "home.queue_clear": "Limpar lista",
+ "home.queue_remove": "Remover",
+ "home.queue_only_type": "Só é possível adicionar ficheiros {0} a esta lista.",
+ "home.queue_unsupported": "Este tipo de ficheiro não pode ser convertido.",
"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 596877e5..2f8165b3 100644
--- a/src/Filee.App/Assets/i18n/ru.json
+++ b/src/Filee.App/Assets/i18n/ru.json
@@ -28,6 +28,13 @@
"home.title": "Главная",
"home.drop_title": "Перетащите файлы сюда",
+ "home.queue_title": "Файлы для конвертации",
+ "home.queue_hint": "Перетащите карточку, чтобы изменить порядок. Можно добавлять только файлы того же типа, что и первый.",
+ "home.queue_convert": "Выбрать действие",
+ "home.queue_clear": "Очистить список",
+ "home.queue_remove": "Удалить",
+ "home.queue_only_type": "В этот список можно добавлять только файлы {0}.",
+ "home.queue_unsupported": "Этот тип файла нельзя конвертировать.",
"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 856bf23a..c38bc79c 100644
--- a/src/Filee.App/Assets/i18n/th.json
+++ b/src/Filee.App/Assets/i18n/th.json
@@ -25,6 +25,13 @@
"close.dont_ask": "ไม่ต้องถามอีก",
"home.title": "หน้าแรก",
"home.drop_title": "วางไฟล์ที่นี่",
+ "home.queue_title": "ไฟล์ที่จะแปลง",
+ "home.queue_hint": "ลากการ์ดเพื่อเปลี่ยนลำดับ เพิ่มได้เฉพาะไฟล์ชนิดเดียวกับไฟล์แรก",
+ "home.queue_convert": "เลือกการทำงาน",
+ "home.queue_clear": "ล้างรายการ",
+ "home.queue_remove": "นำออก",
+ "home.queue_only_type": "เพิ่มลงในรายการนี้ได้เฉพาะไฟล์ {0} เท่านั้น",
+ "home.queue_unsupported": "ไม่สามารถแปลงไฟล์ชนิดนี้ได้",
"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 f3e22f33..b3776152 100644
--- a/src/Filee.App/Assets/i18n/tr.json
+++ b/src/Filee.App/Assets/i18n/tr.json
@@ -25,6 +25,13 @@
"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_hint": "Sırayı değiştirmek için bir kartı sürükleyin. Yalnızca ilkiyle aynı türdeki dosyalar eklenebilir.",
+ "home.queue_convert": "İşlem seç",
+ "home.queue_clear": "Listeyi temizle",
+ "home.queue_remove": "Kaldır",
+ "home.queue_only_type": "Bu listeye yalnızca {0} dosyaları eklenebilir.",
+ "home.queue_unsupported": "Bu dosya türü dönüştürülemez.",
"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 95be8e54..2276d673 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -28,6 +28,13 @@
"home.title": "首页",
"home.drop_title": "将文件拖放到这里",
+ "home.queue_title": "待转换的文件",
+ "home.queue_hint": "拖动卡片可更改顺序。只能添加与第一个文件类型相同的文件。",
+ "home.queue_convert": "选择操作",
+ "home.queue_clear": "清空列表",
+ "home.queue_remove": "移除",
+ "home.queue_only_type": "此列表只能添加 {0} 文件。",
+ "home.queue_unsupported": "无法转换此文件类型。",
"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 24c4110a..079f7af3 100644
--- a/src/Filee.App/Assets/i18n/zh-TW.json
+++ b/src/Filee.App/Assets/i18n/zh-TW.json
@@ -28,6 +28,13 @@
"home.title": "首頁",
"home.drop_title": "將檔案拖放到這裡",
+ "home.queue_title": "待轉換的檔案",
+ "home.queue_hint": "拖曳卡片可變更順序。只能加入與第一個檔案類型相同的檔案。",
+ "home.queue_convert": "選擇動作",
+ "home.queue_clear": "清空清單",
+ "home.queue_remove": "移除",
+ "home.queue_only_type": "此清單只能加入 {0} 檔案。",
+ "home.queue_unsupported": "無法轉換此檔案類型。",
"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 00000000..a634bcdd
--- /dev/null
+++ b/src/Filee.App/Services/FileQueue.cs
@@ -0,0 +1,85 @@
+// The files waiting on the Home page to be converted together. The first file fixes the type: only files of the same
+// format join the queue, and the order shown is the order the files are converted (or merged) in.
+
+using Filee.Core.Formats;
+using Filee.Core.Platform;
+
+namespace Filee.App.Services;
+
+/// Why a file didn't join the queue.
+internal enum QueueRefusal
+{
+ /// Filee doesn't know the format, so it can't convert the file.
+ Unsupported,
+
+ /// The queue holds another format; the first file's format decides.
+ OtherFormat,
+}
+
+/// An ordered list of files of one format (), kept without duplicates.
+internal sealed class FileQueue
+{
+ private readonly List _paths = [];
+
+ /// The format every file in the queue has; null while the queue is empty.
+ public FileFormat? Format { get; private set; }
+
+ /// The files in the order they are converted.
+ public IReadOnlyList Paths => _paths;
+
+ ///
+ /// Adds in order. Files of another format, or of a format Filee doesn't know, are left out
+ /// and reported in the result; the queue's format is set by the first file added to an empty queue.
+ ///
+ public IReadOnlyList<(string Path, QueueRefusal Reason)> Add(IEnumerable paths)
+ {
+ var refused = new List<(string, QueueRefusal)>();
+ foreach (var path in paths)
+ {
+ if (_paths.Contains(path, FileSystemPaths.Comparer))
+ continue;
+ var format = FormatRegistry.Detect(path);
+ if (format is null)
+ {
+ refused.Add((path, QueueRefusal.Unsupported));
+ continue;
+ }
+ if (Format is { } queued && format.Id != queued.Id)
+ {
+ refused.Add((path, QueueRefusal.OtherFormat));
+ continue;
+ }
+ Format = format;
+ _paths.Add(path);
+ }
+ return refused;
+ }
+
+ /// Takes out. An empty queue forgets its format.
+ public bool Remove(string path)
+ {
+ var index = _paths.FindIndex(p => FileSystemPaths.Comparer.Equals(p, path));
+ if (index < 0)
+ return false;
+ _paths.RemoveAt(index);
+ if (_paths.Count == 0)
+ Format = null;
+ 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();
+ Format = null;
+ }
+}
diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs
index 2ef6fdb2..e06936e7 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 955b0851..f05dbc45 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);
diff --git a/src/Filee.App/ViewModels/MergeOrderViewModel.cs b/src/Filee.App/ViewModels/MergeOrderViewModel.cs
index 7710534b..dff136e7 100644
--- a/src/Filee.App/ViewModels/MergeOrderViewModel.cs
+++ b/src/Filee.App/ViewModels/MergeOrderViewModel.cs
@@ -28,12 +28,15 @@ public sealed partial class MergeOrderViewModel : ObservableObject
/// "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)));
SelectedFile = Files.FirstOrDefault();
if (images is null)
return;
diff --git a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs
index e6487327..7b53959e 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,135 @@ 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, one type at a time ().
+ ///
+ public ObservableCollection Queue { get; } = [];
+
+ public bool HasQueue => Queue.Count > 0;
+
+ /// Why the last files didn't join the queue, or empty.
+ [ObservableProperty, NotifyPropertyChangedFor(nameof(HasQueueMessage))]
+ private string _queueMessage = "";
+
+ public bool HasQueueMessage => QueueMessage.Length > 0;
+
+ /// The queued files' paths in the order they are converted.
+ public IReadOnlyList QueuedPaths => _queue.Paths.ToArray();
+
+ ///
+ /// Adds dropped or picked files to the queue. The first file's format decides which files may join: the others are
+ /// left out, and says why.
+ ///
+ public void AddToQueue(IEnumerable paths)
+ {
+ var refused = _queue.Add(paths);
+ SyncQueue();
+ QueueMessage = refused.Count == 0 ? "" : refused[0].Reason switch
+ {
+ QueueRefusal.OtherFormat => _loc.Format("home.queue_only_type", FormatLabels.Name(_loc, _queue.Format!)),
+ _ => _loc["home.queue_unsupported"],
+ };
+ }
+
+ /// 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: takes its file out of the queue.
+ internal void RemoveFromQueue(StagedFileViewModel card)
+ {
+ _queue.Remove(card.Path);
+ SyncQueue();
+ QueueMessage = "";
+ }
+
+ ///
+ /// 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();
+ QueueMessage = "";
+ }
+
+ /// The notice's X: closes it.
+ [RelayCommand]
+ private void DismissQueueMessage() => QueueMessage = "";
+
+ /// 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();
+ }
+ var format = _queue.Format;
+ foreach (var path in _queue.Paths)
+ {
+ if (format is null || Queue.Any(card => FileSystemPaths.Comparer.Equals(card.Path, path)))
+ continue;
+ 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 00000000..470581c1
--- /dev/null
+++ b/src/Filee.App/ViewModels/Pages/StagedFileViewModel.cs
@@ -0,0 +1,72 @@
+// 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;
+
+ public StagedFileViewModel(string path, FileFormat format, Action remove)
+ {
+ Path = path;
+ Name = System.IO.Path.GetFileName(path);
+ FormatBadge = format.PrimaryExtension.TrimStart('.').ToUpperInvariant();
+ _remove = remove;
+ _ownsThumbnail = format.Category == FormatCategory.Pdf;
+ }
+
+ 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.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/MainWindow.axaml b/src/Filee.App/Views/MainWindow.axaml
index be53bc76..a5204e05 100644
--- a/src/Filee.App/Views/MainWindow.axaml
+++ b/src/Filee.App/Views/MainWindow.axaml
@@ -104,5 +104,11 @@
+
+
+
+
+
diff --git a/src/Filee.App/Views/Pages/HomePage.axaml b/src/Filee.App/Views/Pages/HomePage.axaml
index aae555da..cfa34fe2 100644
--- a/src/Filee.App/Views/Pages/HomePage.axaml
+++ b/src/Filee.App/Views/Pages/HomePage.axaml
@@ -69,6 +69,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Filee.App/Views/Pages/HomePage.axaml.cs b/src/Filee.App/Views/Pages/HomePage.axaml.cs
index 901ebdd3..9d6647ae 100644
--- a/src/Filee.App/Views/Pages/HomePage.axaml.cs
+++ b/src/Filee.App/Views/Pages/HomePage.axaml.cs
@@ -1,23 +1,40 @@
-// 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.
+using System.ComponentModel;
using Avalonia;
+using Avalonia.Automation;
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 +47,110 @@ 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;
+ }
+
+ private HomePageViewModel? ViewModel => DataContext as HomePageViewModel;
+
+ /// The view model whose notice this page shows (its changes are followed while the page is on screen).
+ private HomePageViewModel? _notified;
+
+ /// The window's notice host and its slot while the queue notice shows (see ).
+ private Grid? _noticeHost;
+ private Border? _noticeSlot;
+
+ protected override void OnDataContextChanged(EventArgs e)
+ {
+ base.OnDataContextChanged(e);
+ if (_notified is not null)
+ _notified.PropertyChanged -= OnViewModelChanged;
+ _notified = DataContext as HomePageViewModel;
+ if (_notified is not null)
+ _notified.PropertyChanged += OnViewModelChanged;
}
+ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnDetachedFromVisualTree(e);
+ HideQueueNotice();
+ }
+
+ private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(HomePageViewModel.QueueMessage) && _notified is { } vm)
+ ShowQueueNotice(vm.QueueMessage);
+ }
+
+ ///
+ /// The refusal of files of another format: a card on top of the drop zone, and the window dimmed behind it. The
+ /// window's notice host dims everything (navigation included) and holds the card in its slot, placed over the drop
+ /// zone. Its X closes the notice.
+ ///
+ private void ShowQueueNotice(string message)
+ {
+ HideQueueNotice();
+ if (message.Length == 0 || _notified is not { } vm || TopLevel.GetTopLevel(this) is not MainWindow window
+ || window.FindControl("NoticeHost") is not { } host || window.FindControl("NoticeSlot") is not { } slot)
+ return;
+
+ var close = new Button
+ {
+ Width = 26,
+ Height = 26,
+ Padding = new Thickness(0),
+ CornerRadius = new CornerRadius(999),
+ Margin = new Thickness(12, 0, 0, 0),
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ Command = vm.DismissQueueMessageCommand,
+ Content = new PathIcon { Width = 10, Height = 10, Data = this.FindResource("Icon.Close") as Geometry },
+ };
+ ToolTip.SetTip(close, this.FindResource("common.close") as string);
+ var row = new DockPanel();
+ DockPanel.SetDock(close, Dock.Right);
+ row.Children.Add(close);
+ row.Children.Add(new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center });
+ var card = new Border
+ {
+ Padding = new Thickness(14, 10),
+ Child = row,
+ MaxWidth = 420,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ };
+ card.Classes.Add("card");
+ card.Classes.Add("history-card");
+
+ // The slot covers the drop zone's box, in the window's own coordinates, and centres the card in it.
+ var origin = DropZone.TranslatePoint(new Point(0, 0), window) ?? new Point();
+ slot.Margin = new Thickness(origin.X, origin.Y, 0, 0);
+ slot.Width = DropZone.Bounds.Width;
+ slot.Height = DropZone.Bounds.Height;
+ slot.Child = card;
+ host.IsVisible = true;
+ (_noticeHost, _noticeSlot) = (host, slot);
+ }
+
+ private void HideQueueNotice()
+ {
+ if (_noticeHost is null || _noticeSlot is null)
+ return;
+ _noticeHost.IsVisible = false;
+ _noticeSlot.Child = null;
+ (_noticeHost, _noticeSlot) = (null, null);
+ }
+
+ 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 +168,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 +182,106 @@ 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)
+ return;
+ var target = IndexNearest(at);
+ if (target != press.Index)
+ ViewModel?.MoveQueued(press.Index, target);
+ }
+
+ /// 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 00000000..2ccd9d34
--- /dev/null
+++ b/tests/Filee.App.Tests/FileQueueTests.cs
@@ -0,0 +1,97 @@
+// The Home page's queue (multi-file conversion): one format at a time, in the order the user puts it in, and a merge that
+// keeps that order instead of sorting by name.
+
+using Filee.App.Services;
+using Filee.App.ViewModels;
+using Filee.Core.Formats;
+using Xunit;
+
+namespace Filee.App.Tests;
+
+public class FileQueueTests
+{
+ [Fact]
+ public void TheFirstFileSetsTheTypeAndOtherFormatsAreRefused()
+ {
+ var queue = new FileQueue();
+
+ var refused = queue.Add(["/docs/one.pdf", "/docs/letter.docx", "/docs/three.pdf", "/docs/photo.png"]);
+
+ Assert.Equal(["/docs/one.pdf", "/docs/three.pdf"], queue.Paths);
+ Assert.Equal(FormatCategory.Pdf, queue.Format?.Category);
+ Assert.Equal<(string, QueueRefusal)[]>(
+ [("/docs/letter.docx", QueueRefusal.OtherFormat), ("/docs/photo.png", QueueRefusal.OtherFormat)],
+ refused.Select(r => (r.Path, r.Reason)).ToArray());
+ }
+
+ [Fact]
+ public void FilesOfAFormatFileeDoesNotKnowAreRefusedAsUnsupported()
+ {
+ var queue = new FileQueue();
+
+ var refused = queue.Add(["/docs/notes.unknownext"]);
+
+ Assert.Empty(queue.Paths);
+ Assert.Null(queue.Format);
+ Assert.Equal(QueueRefusal.Unsupported, Assert.Single(refused).Reason);
+ }
+
+ [Fact]
+ public void AFileAddedTwiceIsKeptOnce()
+ {
+ var queue = new FileQueue();
+
+ queue.Add(["/docs/one.pdf"]);
+ var refused = queue.Add(["/docs/one.pdf", "/docs/two.pdf"]);
+
+ Assert.Equal(["/docs/one.pdf", "/docs/two.pdf"], queue.Paths);
+ Assert.Empty(refused);
+ }
+
+ [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 RemovingTheLastFileLetsAnotherTypeStart()
+ {
+ var queue = new FileQueue();
+ queue.Add(["/docs/a.pdf"]);
+
+ Assert.True(queue.Remove("/docs/a.pdf"));
+ Assert.Null(queue.Format);
+ Assert.Empty(queue.Add(["/photos/b.png"]));
+ Assert.Equal(["/photos/b.png"], queue.Paths);
+ }
+
+ [Fact]
+ public void ClearEmptiesTheQueueAndItsType()
+ {
+ var queue = new FileQueue();
+ queue.Add(["/docs/a.pdf"]);
+
+ queue.Clear();
+
+ Assert.Empty(queue.Paths);
+ Assert.Null(queue.Format);
+ }
+
+ [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);
+ }
+}