diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0fc1b0509..6aaf84959 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -148,6 +148,22 @@ A group is one slice that opens up to six non-group actions. Its members may be editor offers both kinds, and `UserDataStore.EnsureConsistency` removes missing, duplicate, nested or excess members without excluding tools. +## Annotate tool + +"Annotate" (`tool-annotate`, palette only, settings schema 60) draws pen lines, arrows, boxes, highlights, text and +numbers on pictures (`ImageOptions.Annotations`): points are parts of the picture, sizes parts of its shorter side. + +- `Engines/Annotations/AnnotationPainter` (SkiaSharp) draws the marks, always at the picture's own size in pixels. The + window's `AnnotationCanvas` calls it on Avalonia's own Skia canvas (a custom draw operation through + `ISkiaSharpApiLeaseFeature`), scaled to where the picture is shown, and `Annotator` on layers over the part the marks + cover, a band at a time, laid over the picture in `ImageEncoder.ApplyOptions` after hidden areas (converted to a CMYK + picture's colours; a greyscale picture becomes a colour one): the preview is the file. Text is shaped with HarfBuzz + (`Vector/GlyphOutlines`) in the system's bold sans-serif, each run of letters it lacks in a font the system finds. +- `AnnotationSet` keeps the marks, the selection and undo / redo (a drag is one step). A mark just drawn isn't selected: + a colour picked next goes to the next mark; with Select, colour and width restyle the selected one. +- `AnnotateTool.ConversionOf` (Core) turns the tool into a conversion in the picture's own format (as Pixelate / blur), + run by the job queue; `ReworksPixels` includes the marks. + ## Crop overlay and PDF page editor The image Crop tool (`tool-crop`) and PDF Crop (`tool-pdf-crop`) open `Views/CropOverlayWindow` instead of running a job: diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 160f4ef08..12e3aaf28 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -390,6 +390,7 @@ "preset.rotate_180": "تدوير 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "انعكاس", + "preset.annotate": "تعليقات", "preset.flip_horizontal": "انعكاس أفقي", "preset.flip_horizontal.short": "أفقي", "preset.flip_vertical": "انعكاس عمودي", @@ -927,6 +928,35 @@ "presets.conflict.Rename": "إضافة رقم", "presets.conflict.Overwrite": "استبداله", "presets.conflict.Skip": "تخطٍّ", + "annotate.select": "تحديد", + "annotate.select_hint": "حدّد علامة أو انقلها أو غيّر حجمها (V)", + "annotate.pen": "قلم", + "annotate.pen_hint": "ارسم بحرية (P)", + "annotate.arrow": "سهم", + "annotate.arrow_hint": "ارسم سهمًا من الذيل إلى الرأس (A)", + "annotate.box": "مربع", + "annotate.box_hint": "ارسم إطارًا (B)", + "annotate.highlight": "تمييز", + "annotate.highlight_hint": "ميّز بلون شفاف (H)", + "annotate.text": "نص", + "annotate.text_hint": "انقر واكتب تسمية (T)", + "annotate.number": "رقم", + "annotate.number_hint": "انقر لإضافة الرقم التالي (N)", + "annotate.thin": "رفيع", + "annotate.medium": "متوسط", + "annotate.thick": "سميك", + "annotate.red": "أحمر", + "annotate.orange": "برتقالي", + "annotate.yellow": "أصفر", + "annotate.green": "أخضر", + "annotate.blue": "أزرق", + "annotate.black": "أسود", + "annotate.undo": "تراجع (Ctrl+Z)", + "annotate.redo": "إعادة (Ctrl+Shift+Z)", + "annotate.remove": "إزالة العلامة المحددة (Del)", + "annotate.keys": "V للتحديد · Tab لاختيار علامة والأسهم لتحريكها · P A B H T N لاختيار أداة · Del للإزالة · Ctrl+Z للتراجع · Esc للإغلاق", + "annotate.save": "حفظ", + "annotate.save_next": "حفظ والتالي", "presets.editor_title": "تعديل الإعداد المسبق", "presets.delete_confirm": "هل تريد حذف «{0}»؟ سيُزال أيضًا من جميع أشرطة الأدوات والمجموعات.", "presets.name_required": "أدخل اسمًا", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 85a07f2f6..ffbaf2541 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -390,6 +390,7 @@ "preset.rotate_180": "Um 180° drehen", "preset.rotate_180.short": "180°", "preset.group_flip": "Spiegeln", + "preset.annotate": "Beschriften", "preset.flip_horizontal": "Horizontal spiegeln", "preset.flip_horizontal.short": "Horizontal", "preset.flip_vertical": "Vertikal spiegeln", @@ -927,6 +928,35 @@ "presets.conflict.Rename": "Nummer anhängen", "presets.conflict.Overwrite": "Ersetzen", "presets.conflict.Skip": "Überspringen", + "annotate.select": "Auswählen", + "annotate.select_hint": "Markierung auswählen, verschieben oder skalieren (V)", + "annotate.pen": "Stift", + "annotate.pen_hint": "Freihand zeichnen (P)", + "annotate.arrow": "Pfeil", + "annotate.arrow_hint": "Pfeil vom Ende zur Spitze zeichnen (A)", + "annotate.box": "Rahmen", + "annotate.box_hint": "Rahmen zeichnen (B)", + "annotate.highlight": "Textmarker", + "annotate.highlight_hint": "Mit durchscheinender Farbe markieren (H)", + "annotate.text": "Text", + "annotate.text_hint": "Klicken und Beschriftung tippen (T)", + "annotate.number": "Nummer", + "annotate.number_hint": "Klicken für die nächste Nummer (N)", + "annotate.thin": "Dünn", + "annotate.medium": "Mittel", + "annotate.thick": "Dick", + "annotate.red": "Rot", + "annotate.orange": "Orange", + "annotate.yellow": "Gelb", + "annotate.green": "Grün", + "annotate.blue": "Blau", + "annotate.black": "Schwarz", + "annotate.undo": "Rückgängig (Strg+Z)", + "annotate.redo": "Wiederholen (Strg+Umschalt+Z)", + "annotate.remove": "Ausgewählte Markierung entfernen (Entf)", + "annotate.keys": "V wählt aus · Tab wählt eine Markierung, Pfeiltasten verschieben sie · P A B H T N wählen ein Werkzeug · Entf entfernt · Strg+Z macht rückgängig · Esc schließt", + "annotate.save": "Speichern", + "annotate.save_next": "Speichern & weiter", "presets.editor_title": "Vorlage bearbeiten", "presets.delete_confirm": "„{0}“ löschen? Die Vorlage wird auch aus allen Werkzeugleisten und Gruppen entfernt.", "presets.name_required": "Namen eingeben", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 0e4ace308..c13ed7c1f 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -400,6 +400,7 @@ "preset.rotate_180": "Rotate 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Flip", + "preset.annotate": "Annotate", "preset.flip_horizontal": "Flip horizontal", "preset.flip_horizontal.short": "Horizontal", "preset.flip_vertical": "Flip vertical", @@ -942,6 +943,35 @@ "presets.conflict.Rename": "Add a number", "presets.conflict.Overwrite": "Replace it", "presets.conflict.Skip": "Skip", + "annotate.select": "Select", + "annotate.select_hint": "Select, move or resize a mark (V)", + "annotate.pen": "Pen", + "annotate.pen_hint": "Draw freehand (P)", + "annotate.arrow": "Arrow", + "annotate.arrow_hint": "Draw an arrow, tail to tip (A)", + "annotate.box": "Box", + "annotate.box_hint": "Draw a frame (B)", + "annotate.highlight": "Highlight", + "annotate.highlight_hint": "Mark with a see-through colour (H)", + "annotate.text": "Text", + "annotate.text_hint": "Click and type a label (T)", + "annotate.number": "Number", + "annotate.number_hint": "Click to add the next number (N)", + "annotate.thin": "Thin", + "annotate.medium": "Medium", + "annotate.thick": "Thick", + "annotate.red": "Red", + "annotate.orange": "Orange", + "annotate.yellow": "Yellow", + "annotate.green": "Green", + "annotate.blue": "Blue", + "annotate.black": "Black", + "annotate.undo": "Undo (Ctrl+Z)", + "annotate.redo": "Redo (Ctrl+Shift+Z)", + "annotate.remove": "Remove the selected mark (Del)", + "annotate.keys": "V selects · Tab picks a mark, arrows move it · P A B H T N pick a tool · Del removes · Ctrl+Z undoes · Esc closes", + "annotate.save": "Save", + "annotate.save_next": "Save & next", "presets.editor_title": "Edit preset", "presets.delete_confirm": "Delete “{0}”? It will also be removed from every toolbar and group.", "presets.name_required": "Enter a name", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index ee5de439f..2c176a684 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -390,6 +390,7 @@ "preset.rotate_180": "Rotar 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Voltear", + "preset.annotate": "Anotar", "preset.flip_horizontal": "Volteo horizontal", "preset.flip_horizontal.short": "Horizontal", "preset.flip_vertical": "Volteo vertical", @@ -927,6 +928,35 @@ "presets.conflict.Rename": "Añadir un número", "presets.conflict.Overwrite": "Reemplazarlo", "presets.conflict.Skip": "Omitir", + "annotate.select": "Seleccionar", + "annotate.select_hint": "Seleccionar, mover o redimensionar una marca (V)", + "annotate.pen": "Lápiz", + "annotate.pen_hint": "Dibujar a mano alzada (P)", + "annotate.arrow": "Flecha", + "annotate.arrow_hint": "Dibujar una flecha de la cola a la punta (A)", + "annotate.box": "Recuadro", + "annotate.box_hint": "Dibujar un recuadro (B)", + "annotate.highlight": "Resaltador", + "annotate.highlight_hint": "Marcar con un color transparente (H)", + "annotate.text": "Texto", + "annotate.text_hint": "Haz clic y escribe una etiqueta (T)", + "annotate.number": "Número", + "annotate.number_hint": "Haz clic para añadir el siguiente número (N)", + "annotate.thin": "Fino", + "annotate.medium": "Medio", + "annotate.thick": "Grueso", + "annotate.red": "Rojo", + "annotate.orange": "Naranja", + "annotate.yellow": "Amarillo", + "annotate.green": "Verde", + "annotate.blue": "Azul", + "annotate.black": "Negro", + "annotate.undo": "Deshacer (Ctrl+Z)", + "annotate.redo": "Rehacer (Ctrl+Mayús+Z)", + "annotate.remove": "Quitar la marca seleccionada (Supr)", + "annotate.keys": "V selecciona · Tab elige una marca, las flechas la mueven · P A B H T N eligen herramienta · Supr quita · Ctrl+Z deshace · Esc cierra", + "annotate.save": "Guardar", + "annotate.save_next": "Guardar y siguiente", "presets.editor_title": "Editar ajuste preestablecido", "presets.delete_confirm": "¿Eliminar «{0}»? También se quitará de todos los menús circulares y grupos.", "presets.name_required": "Escribe un nombre", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 1c33e0dbb..59dc91144 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -400,6 +400,7 @@ "preset.rotate_180": "Pivoter de 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Retourner", + "preset.annotate": "Annoter", "preset.flip_horizontal": "Retourner horizontalement", "preset.flip_horizontal.short": "Horizontal", "preset.flip_vertical": "Retourner verticalement", @@ -942,6 +943,35 @@ "presets.conflict.Rename": "Ajouter un numéro", "presets.conflict.Overwrite": "Le remplacer", "presets.conflict.Skip": "Ignorer", + "annotate.select": "Sélection", + "annotate.select_hint": "Sélectionner, déplacer ou redimensionner une marque (V)", + "annotate.pen": "Stylo", + "annotate.pen_hint": "Dessiner à main levée (P)", + "annotate.arrow": "Flèche", + "annotate.arrow_hint": "Tracer une flèche, de la base à la pointe (A)", + "annotate.box": "Cadre", + "annotate.box_hint": "Tracer un cadre (B)", + "annotate.highlight": "Surligneur", + "annotate.highlight_hint": "Marquer d'une couleur transparente (H)", + "annotate.text": "Texte", + "annotate.text_hint": "Cliquer et taper une étiquette (T)", + "annotate.number": "Numéro", + "annotate.number_hint": "Cliquer pour ajouter le numéro suivant (N)", + "annotate.thin": "Fin", + "annotate.medium": "Moyen", + "annotate.thick": "Épais", + "annotate.red": "Rouge", + "annotate.orange": "Orange", + "annotate.yellow": "Jaune", + "annotate.green": "Vert", + "annotate.blue": "Bleu", + "annotate.black": "Noir", + "annotate.undo": "Annuler (Ctrl+Z)", + "annotate.redo": "Rétablir (Ctrl+Maj+Z)", + "annotate.remove": "Supprimer la marque sélectionnée (Suppr)", + "annotate.keys": "V sélectionne · Tab choisit une marque, les flèches la déplacent · P A B H T N choisissent un outil · Suppr supprime · Ctrl+Z annule · Échap ferme", + "annotate.save": "Enregistrer", + "annotate.save_next": "Enregistrer et suivant", "presets.editor_title": "Modifier le préréglage", "presets.delete_confirm": "Supprimer « {0} » ? Il sera aussi retiré de tous les menus circulaires et groupes.", "presets.name_required": "Saisissez un nom", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index a4414c46f..542b9d4bc 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -390,6 +390,7 @@ "preset.rotate_180": "180° घुमाएँ", "preset.rotate_180.short": "180°", "preset.group_flip": "पलटें", + "preset.annotate": "टिप्पणी", "preset.flip_horizontal": "आड़ा पलटें", "preset.flip_horizontal.short": "आड़ा", "preset.flip_vertical": "खड़ा पलटें", @@ -927,6 +928,35 @@ "presets.conflict.Rename": "नंबर जोड़ें", "presets.conflict.Overwrite": "बदल दें", "presets.conflict.Skip": "छोड़ दें", + "annotate.select": "चुनें", + "annotate.select_hint": "निशान चुनें, खिसकाएँ या आकार बदलें (V)", + "annotate.pen": "पेन", + "annotate.pen_hint": "हाथ से बनाएँ (P)", + "annotate.arrow": "तीर", + "annotate.arrow_hint": "पूँछ से नोक तक तीर बनाएँ (A)", + "annotate.box": "बॉक्स", + "annotate.box_hint": "फ़्रेम बनाएँ (B)", + "annotate.highlight": "हाइलाइट", + "annotate.highlight_hint": "पारदर्शी रंग से निशान लगाएँ (H)", + "annotate.text": "टेक्स्ट", + "annotate.text_hint": "क्लिक करके लेबल लिखें (T)", + "annotate.number": "नंबर", + "annotate.number_hint": "अगला नंबर जोड़ने के लिए क्लिक करें (N)", + "annotate.thin": "पतला", + "annotate.medium": "मध्यम", + "annotate.thick": "मोटा", + "annotate.red": "लाल", + "annotate.orange": "नारंगी", + "annotate.yellow": "पीला", + "annotate.green": "हरा", + "annotate.blue": "नीला", + "annotate.black": "काला", + "annotate.undo": "पूर्ववत (Ctrl+Z)", + "annotate.redo": "फिर से करें (Ctrl+Shift+Z)", + "annotate.remove": "चुना निशान हटाएँ (Del)", + "annotate.keys": "V चुनता है · Tab निशान चुनता है, तीर उसे खिसकाते हैं · P A B H T N टूल चुनते हैं · Del हटाता है · Ctrl+Z पूर्ववत · Esc बंद करता है", + "annotate.save": "सहेजें", + "annotate.save_next": "सहेजें और अगला", "presets.editor_title": "प्रीसेट संपादित करें", "presets.delete_confirm": "“{0}” हटाएँ? यह हर टूलबार और समूह से भी हट जाएगा।", "presets.name_required": "नाम दर्ज करें", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index cf0e344b6..c9e592166 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -390,6 +390,7 @@ "preset.rotate_180": "Putar 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Balik", + "preset.annotate": "Anotasi", "preset.flip_horizontal": "Balik horizontal", "preset.flip_horizontal.short": "Horizontal", "preset.flip_vertical": "Balik vertikal", @@ -927,6 +928,35 @@ "presets.conflict.Rename": "Tambahkan nomor", "presets.conflict.Overwrite": "Timpa file", "presets.conflict.Skip": "Lewati", + "annotate.select": "Pilih", + "annotate.select_hint": "Pilih, pindahkan, atau ubah ukuran tanda (V)", + "annotate.pen": "Pena", + "annotate.pen_hint": "Gambar bebas (P)", + "annotate.arrow": "Panah", + "annotate.arrow_hint": "Gambar panah, dari pangkal ke ujung (A)", + "annotate.box": "Kotak", + "annotate.box_hint": "Gambar bingkai (B)", + "annotate.highlight": "Sorot", + "annotate.highlight_hint": "Tandai dengan warna tembus pandang (H)", + "annotate.text": "Teks", + "annotate.text_hint": "Klik dan ketik label (T)", + "annotate.number": "Nomor", + "annotate.number_hint": "Klik untuk menambah nomor berikutnya (N)", + "annotate.thin": "Tipis", + "annotate.medium": "Sedang", + "annotate.thick": "Tebal", + "annotate.red": "Merah", + "annotate.orange": "Oranye", + "annotate.yellow": "Kuning", + "annotate.green": "Hijau", + "annotate.blue": "Biru", + "annotate.black": "Hitam", + "annotate.undo": "Urungkan (Ctrl+Z)", + "annotate.redo": "Ulangi (Ctrl+Shift+Z)", + "annotate.remove": "Hapus tanda yang dipilih (Del)", + "annotate.keys": "V memilih · Tab memilih tanda, panah menggesernya · P A B H T N memilih alat · Del menghapus · Ctrl+Z urungkan · Esc menutup", + "annotate.save": "Simpan", + "annotate.save_next": "Simpan & Berikutnya", "presets.editor_title": "Edit preset", "presets.delete_confirm": "Hapus “{0}”? Preset ini juga akan dihapus dari semua toolbar dan grup.", "presets.name_required": "Masukkan nama", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index 3cf6b96ad..38ebbc16e 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -390,6 +390,7 @@ "preset.rotate_180": "Ruota di 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Capovolgi", +"preset.annotate": "Annota", "preset.flip_horizontal": "Capovolgi orizzontalmente", "preset.flip_horizontal.short": "Orizzontale", "preset.flip_vertical": "Capovolgi verticalmente", @@ -927,6 +928,35 @@ "presets.conflict.Rename": "Aggiungi un numero", "presets.conflict.Overwrite": "Sostituiscilo", "presets.conflict.Skip": "Salta", +"annotate.select": "Seleziona", +"annotate.select_hint": "Seleziona, sposta o ridimensiona un segno (V)", +"annotate.pen": "Penna", +"annotate.pen_hint": "Disegna a mano libera (P)", +"annotate.arrow": "Freccia", +"annotate.arrow_hint": "Disegna una freccia, dalla coda alla punta (A)", +"annotate.box": "Riquadro", +"annotate.box_hint": "Disegna una cornice (B)", +"annotate.highlight": "Evidenzia", +"annotate.highlight_hint": "Segna con un colore trasparente (H)", +"annotate.text": "Testo", +"annotate.text_hint": "Fai clic e scrivi un'etichetta (T)", +"annotate.number": "Numero", +"annotate.number_hint": "Fai clic per aggiungere il numero successivo (N)", +"annotate.thin": "Sottile", +"annotate.medium": "Medio", +"annotate.thick": "Spesso", +"annotate.red": "Rosso", +"annotate.orange": "Arancione", +"annotate.yellow": "Giallo", +"annotate.green": "Verde", +"annotate.blue": "Blu", +"annotate.black": "Nero", +"annotate.undo": "Annulla (Ctrl+Z)", +"annotate.redo": "Ripeti (Ctrl+Maiusc+Z)", +"annotate.remove": "Rimuovi il segno selezionato (Canc)", +"annotate.keys": "V seleziona · Tab sceglie un segno, le frecce lo spostano · P A B H T N scelgono uno strumento · Canc rimuove · Ctrl+Z annulla · Esc chiude", +"annotate.save": "Salva", +"annotate.save_next": "Salva e avanti", "presets.editor_title": "Modifica preset", "presets.delete_confirm": "Eliminare “{0}”? Verrà rimosso anche da ogni barra e gruppo.", "presets.name_required": "Inserisci un nome", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 448162e2c..33fc2c144 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -390,6 +390,7 @@ "preset.rotate_180": "180°回転", "preset.rotate_180.short": "180°", "preset.group_flip": "反転", + "preset.annotate": "注釈", "preset.flip_horizontal": "水平方向に反転", "preset.flip_horizontal.short": "水平", "preset.flip_vertical": "垂直方向に反転", @@ -927,6 +928,35 @@ "presets.conflict.Rename": "連番を付ける", "presets.conflict.Overwrite": "上書き", "presets.conflict.Skip": "スキップ", + "annotate.select": "選択", + "annotate.select_hint": "マークを選択・移動・サイズ変更 (V)", + "annotate.pen": "ペン", + "annotate.pen_hint": "フリーハンドで描く (P)", + "annotate.arrow": "矢印", + "annotate.arrow_hint": "根元から先端へ矢印を描く (A)", + "annotate.box": "枠", + "annotate.box_hint": "枠を描く (B)", + "annotate.highlight": "ハイライト", + "annotate.highlight_hint": "透ける色で塗る (H)", + "annotate.text": "テキスト", + "annotate.text_hint": "クリックして文字を入力 (T)", + "annotate.number": "番号", + "annotate.number_hint": "クリックで次の番号を追加 (N)", + "annotate.thin": "細い", + "annotate.medium": "ふつう", + "annotate.thick": "太い", + "annotate.red": "赤", + "annotate.orange": "オレンジ", + "annotate.yellow": "黄", + "annotate.green": "緑", + "annotate.blue": "青", + "annotate.black": "黒", + "annotate.undo": "元に戻す (Ctrl+Z)", + "annotate.redo": "やり直し (Ctrl+Shift+Z)", + "annotate.remove": "選択したマークを削除 (Del)", + "annotate.keys": "V 選択 · Tab で選択、矢印で移動 · P A B H T N ツール · Del 削除 · Ctrl+Z 元に戻す · Esc 閉じる", + "annotate.save": "保存", + "annotate.save_next": "保存して次へ", "presets.editor_title": "プリセットを編集", "presets.delete_confirm": "「{0}」を削除しますか?すべてのドーナツメニューとグループからも削除されます。", "presets.name_required": "名前を入力してください", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 88a2c0b06..8d57b94a0 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -400,6 +400,7 @@ "preset.rotate_180": "180° 회전", "preset.rotate_180.short": "180°", "preset.group_flip": "뒤집기", + "preset.annotate": "주석", "preset.flip_horizontal": "좌우 반전", "preset.flip_horizontal.short": "좌우", "preset.flip_vertical": "상하 반전", @@ -942,6 +943,35 @@ "presets.conflict.Rename": "번호 붙이기", "presets.conflict.Overwrite": "덮어쓰기", "presets.conflict.Skip": "건너뛰기", + "annotate.select": "선택", + "annotate.select_hint": "표시를 선택하고 옮기거나 크기 바꾸기 (V)", + "annotate.pen": "펜", + "annotate.pen_hint": "자유롭게 그리기 (P)", + "annotate.arrow": "화살표", + "annotate.arrow_hint": "꼬리에서 끝까지 화살표 그리기 (A)", + "annotate.box": "상자", + "annotate.box_hint": "테두리 그리기 (B)", + "annotate.highlight": "형광펜", + "annotate.highlight_hint": "비치는 색으로 칠하기 (H)", + "annotate.text": "텍스트", + "annotate.text_hint": "눌러서 글자 넣기 (T)", + "annotate.number": "번호", + "annotate.number_hint": "눌러서 다음 번호 넣기 (N)", + "annotate.thin": "가늘게", + "annotate.medium": "보통", + "annotate.thick": "굵게", + "annotate.red": "빨강", + "annotate.orange": "주황", + "annotate.yellow": "노랑", + "annotate.green": "초록", + "annotate.blue": "파랑", + "annotate.black": "검정", + "annotate.undo": "되돌리기 (Ctrl+Z)", + "annotate.redo": "다시 실행 (Ctrl+Shift+Z)", + "annotate.remove": "선택한 표시 지우기 (Del)", + "annotate.keys": "V 선택 · Tab 표시 고르기, 화살표 옮기기 · P A B H T N 도구 고르기 · Del 지우기 · Ctrl+Z 되돌리기 · Esc 닫기", + "annotate.save": "저장", + "annotate.save_next": "저장하고 다음", "presets.editor_title": "프리셋 편집", "presets.delete_confirm": "“{0}”을(를) 삭제할까요? 모든 툴바와 묶음에서도 빠져요.", "presets.name_required": "이름을 입력하세요", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 87ad2d56c..4ebbd105f 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -390,6 +390,7 @@ "preset.rotate_180": "१८०° घुमाउनुहोस्", "preset.rotate_180.short": "१८०°", "preset.group_flip": "उल्ट्याउनुहोस्", + "preset.annotate": "टिप्पणी", "preset.flip_horizontal": "तेर्सो उल्ट्याउनुहोस्", "preset.flip_horizontal.short": "तेर्सो", "preset.flip_vertical": "ठाडो उल्ट्याउनुहोस्", @@ -927,6 +928,35 @@ "presets.conflict.Rename": "नम्बर थप्नुहोस्", "presets.conflict.Overwrite": "बदल्नुहोस् (अधिलेखन)", "presets.conflict.Skip": "छोड्नुहोस्", + "annotate.select": "छान्नुहोस्", + "annotate.select_hint": "चिन्ह छान्नुहोस्, सार्नुहोस् वा आकार बदल्नुहोस् (V)", + "annotate.pen": "कलम", + "annotate.pen_hint": "हातले कोर्नुहोस् (P)", + "annotate.arrow": "तीर", + "annotate.arrow_hint": "पुच्छरदेखि टुप्पोसम्म तीर कोर्नुहोस् (A)", + "annotate.box": "बाकस", + "annotate.box_hint": "फ्रेम कोर्नुहोस् (B)", + "annotate.highlight": "हाइलाइट", + "annotate.highlight_hint": "पारदर्शी रङले चिन्ह लगाउनुहोस् (H)", + "annotate.text": "पाठ", + "annotate.text_hint": "क्लिक गरेर लेबल लेख्नुहोस् (T)", + "annotate.number": "नम्बर", + "annotate.number_hint": "अर्को नम्बर थप्न क्लिक गर्नुहोस् (N)", + "annotate.thin": "पातलो", + "annotate.medium": "मध्यम", + "annotate.thick": "बाक्लो", + "annotate.red": "रातो", + "annotate.orange": "सुन्तला", + "annotate.yellow": "पहेँलो", + "annotate.green": "हरियो", + "annotate.blue": "निलो", + "annotate.black": "कालो", + "annotate.undo": "पूर्ववत (Ctrl+Z)", + "annotate.redo": "फेरि गर्नुहोस् (Ctrl+Shift+Z)", + "annotate.remove": "छानिएको चिन्ह हटाउनुहोस् (Del)", + "annotate.keys": "V छान्छ · Tab चिन्ह छान्छ, तीरले सार्छ · P A B H T N उपकरण छान्छन् · Del हटाउँछ · Ctrl+Z पूर्ववत · Esc बन्द गर्छ", + "annotate.save": "सुरक्षित गर्नुहोस्", + "annotate.save_next": "सुरक्षित गरेर अर्को", "presets.editor_title": "प्रिसेट सम्पादन गर्नुहोस्", "presets.delete_confirm": "“{0}” हटाउने? यो प्रत्येक टुलबार र समूहबाट पनि हटाइनेछ।", "presets.name_required": "नाम प्रविष्ट गर्नुहोस्", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 29ffeba15..5ce6887e6 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -400,6 +400,7 @@ "preset.rotate_180": "Obróć o 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Odbij", + "preset.annotate": "Adnotacje", "preset.flip_horizontal": "Odbij w poziomie", "preset.flip_horizontal.short": "Poziomo", "preset.flip_vertical": "Odbij w pionie", @@ -942,6 +943,35 @@ "presets.conflict.Rename": "Dodaj numer", "presets.conflict.Overwrite": "Zastąp", "presets.conflict.Skip": "Pomiń", + "annotate.select": "Zaznacz", + "annotate.select_hint": "Zaznacz, przesuń lub zmień rozmiar znaku (V)", + "annotate.pen": "Pióro", + "annotate.pen_hint": "Rysuj odręcznie (P)", + "annotate.arrow": "Strzałka", + "annotate.arrow_hint": "Narysuj strzałkę od końca do grotu (A)", + "annotate.box": "Ramka", + "annotate.box_hint": "Narysuj ramkę (B)", + "annotate.highlight": "Zakreślacz", + "annotate.highlight_hint": "Zaznacz przezroczystym kolorem (H)", + "annotate.text": "Tekst", + "annotate.text_hint": "Kliknij i wpisz etykietę (T)", + "annotate.number": "Numer", + "annotate.number_hint": "Kliknij, by dodać kolejny numer (N)", + "annotate.thin": "Cienka", + "annotate.medium": "Średnia", + "annotate.thick": "Gruba", + "annotate.red": "Czerwony", + "annotate.orange": "Pomarańczowy", + "annotate.yellow": "Żółty", + "annotate.green": "Zielony", + "annotate.blue": "Niebieski", + "annotate.black": "Czarny", + "annotate.undo": "Cofnij (Ctrl+Z)", + "annotate.redo": "Ponów (Ctrl+Shift+Z)", + "annotate.remove": "Usuń zaznaczony znak (Del)", + "annotate.keys": "V zaznacza · Tab wybiera znak, strzałki go przesuwają · P A B H T N wybierają narzędzie · Del usuwa · Ctrl+Z cofa · Esc zamyka", + "annotate.save": "Zapisz", + "annotate.save_next": "Zapisz i dalej", "presets.editor_title": "Edytuj ustawienie", "presets.delete_confirm": "Usunąć „{0}”? Zostanie też usunięte z każdego paska i grupy.", "presets.name_required": "Podaj nazwę", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 7148afbde..3866137f2 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -400,6 +400,7 @@ "preset.rotate_180": "Girar 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Espelhar", + "preset.annotate": "Anotar", "preset.flip_horizontal": "Espelhar na horizontal", "preset.flip_horizontal.short": "Horizontal", "preset.flip_vertical": "Espelhar na vertical", @@ -942,6 +943,35 @@ "presets.conflict.Rename": "Adicionar um número", "presets.conflict.Overwrite": "Substituir", "presets.conflict.Skip": "Ignorar", + "annotate.select": "Selecionar", + "annotate.select_hint": "Selecionar, mover ou redimensionar uma marca (V)", + "annotate.pen": "Caneta", + "annotate.pen_hint": "Desenhar à mão livre (P)", + "annotate.arrow": "Seta", + "annotate.arrow_hint": "Desenhar uma seta da base à ponta (A)", + "annotate.box": "Caixa", + "annotate.box_hint": "Desenhar uma moldura (B)", + "annotate.highlight": "Marca-texto", + "annotate.highlight_hint": "Marcar com uma cor transparente (H)", + "annotate.text": "Texto", + "annotate.text_hint": "Clique e digite um rótulo (T)", + "annotate.number": "Número", + "annotate.number_hint": "Clique para adicionar o próximo número (N)", + "annotate.thin": "Fino", + "annotate.medium": "Médio", + "annotate.thick": "Grosso", + "annotate.red": "Vermelho", + "annotate.orange": "Laranja", + "annotate.yellow": "Amarelo", + "annotate.green": "Verde", + "annotate.blue": "Azul", + "annotate.black": "Preto", + "annotate.undo": "Desfazer (Ctrl+Z)", + "annotate.redo": "Refazer (Ctrl+Shift+Z)", + "annotate.remove": "Remover a marca selecionada (Del)", + "annotate.keys": "V seleciona · Tab escolhe uma marca, as setas a movem · P A B H T N escolhem a ferramenta · Del remove · Ctrl+Z desfaz · Esc fecha", + "annotate.save": "Salvar", + "annotate.save_next": "Salvar e próximo", "presets.editor_title": "Editar predefinição", "presets.delete_confirm": "Excluir “{0}”? Também será removida de todo menu e grupo.", "presets.name_required": "Digite um nome", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 511eca0d3..d25bab36a 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -400,6 +400,7 @@ "preset.rotate_180": "Rodar 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Inverter", + "preset.annotate": "Anotar", "preset.flip_horizontal": "Inverter na horizontal", "preset.flip_horizontal.short": "Horizontal", "preset.flip_vertical": "Inverter na vertical", @@ -942,6 +943,35 @@ "presets.conflict.Rename": "Adicionar um número", "presets.conflict.Overwrite": "Substituir", "presets.conflict.Skip": "Ignorar", + "annotate.select": "Selecionar", + "annotate.select_hint": "Selecionar, mover ou redimensionar uma marca (V)", + "annotate.pen": "Caneta", + "annotate.pen_hint": "Desenhar à mão livre (P)", + "annotate.arrow": "Seta", + "annotate.arrow_hint": "Desenhar uma seta da base à ponta (A)", + "annotate.box": "Caixa", + "annotate.box_hint": "Desenhar uma moldura (B)", + "annotate.highlight": "Marcador", + "annotate.highlight_hint": "Marcar com uma cor transparente (H)", + "annotate.text": "Texto", + "annotate.text_hint": "Clique e escreva uma etiqueta (T)", + "annotate.number": "Número", + "annotate.number_hint": "Clique para adicionar o número seguinte (N)", + "annotate.thin": "Fino", + "annotate.medium": "Médio", + "annotate.thick": "Grosso", + "annotate.red": "Vermelho", + "annotate.orange": "Laranja", + "annotate.yellow": "Amarelo", + "annotate.green": "Verde", + "annotate.blue": "Azul", + "annotate.black": "Preto", + "annotate.undo": "Anular (Ctrl+Z)", + "annotate.redo": "Refazer (Ctrl+Shift+Z)", + "annotate.remove": "Remover a marca selecionada (Del)", + "annotate.keys": "V seleciona · Tab escolhe uma marca, as setas movem-na · P A B H T N escolhem a ferramenta · Del remove · Ctrl+Z anula · Esc fecha", + "annotate.save": "Guardar", + "annotate.save_next": "Guardar e seguinte", "presets.editor_title": "Editar predefinição", "presets.delete_confirm": "Eliminar «{0}»? Também será removida de todas as barras e grupos.", "presets.name_required": "Introduz um nome", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index c7225a78f..0ebb46e5f 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -400,6 +400,7 @@ "preset.rotate_180": "Повернуть на 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Отразить", + "preset.annotate": "Пометки", "preset.flip_horizontal": "Отразить по горизонтали", "preset.flip_horizontal.short": "По гориз.", "preset.flip_vertical": "Отразить по вертикали", @@ -942,6 +943,35 @@ "presets.conflict.Rename": "Добавить номер", "presets.conflict.Overwrite": "Заменить", "presets.conflict.Skip": "Пропустить", + "annotate.select": "Выбор", + "annotate.select_hint": "Выбрать, переместить или изменить размер пометки (V)", + "annotate.pen": "Перо", + "annotate.pen_hint": "Рисовать от руки (P)", + "annotate.arrow": "Стрелка", + "annotate.arrow_hint": "Нарисовать стрелку от хвоста к острию (A)", + "annotate.box": "Рамка", + "annotate.box_hint": "Нарисовать рамку (B)", + "annotate.highlight": "Маркер", + "annotate.highlight_hint": "Выделить полупрозрачным цветом (H)", + "annotate.text": "Текст", + "annotate.text_hint": "Щёлкните и введите подпись (T)", + "annotate.number": "Номер", + "annotate.number_hint": "Щёлкните, чтобы добавить следующий номер (N)", + "annotate.thin": "Тонкая", + "annotate.medium": "Средняя", + "annotate.thick": "Толстая", + "annotate.red": "Красный", + "annotate.orange": "Оранжевый", + "annotate.yellow": "Жёлтый", + "annotate.green": "Зелёный", + "annotate.blue": "Синий", + "annotate.black": "Чёрный", + "annotate.undo": "Отменить (Ctrl+Z)", + "annotate.redo": "Повторить (Ctrl+Shift+Z)", + "annotate.remove": "Удалить выбранную пометку (Del)", + "annotate.keys": "V выбор · Tab выбирает пометку, стрелки двигают · P A B H T N инструмент · Del удаляет · Ctrl+Z отменяет · Esc закрывает", + "annotate.save": "Сохранить", + "annotate.save_next": "Сохранить и далее", "presets.editor_title": "Изменить пресет", "presets.delete_confirm": "Удалить «{0}»? Он также пропадёт из всех круговых меню и групп.", "presets.name_required": "Введите название", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 6edb89604..bb4799280 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -390,6 +390,7 @@ "preset.rotate_180": "หมุน 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "พลิกภาพ", + "preset.annotate": "ใส่คำอธิบาย", "preset.flip_horizontal": "พลิกแนวนอน", "preset.flip_horizontal.short": "แนวนอน", "preset.flip_vertical": "พลิกแนวตั้ง", @@ -927,6 +928,35 @@ "presets.conflict.Rename": "เติมตัวเลขต่อท้าย", "presets.conflict.Overwrite": "เขียนทับ", "presets.conflict.Skip": "ข้าม", + "annotate.select": "เลือก", + "annotate.select_hint": "เลือก ย้าย หรือปรับขนาดเครื่องหมาย (V)", + "annotate.pen": "ปากกา", + "annotate.pen_hint": "วาดอิสระ (P)", + "annotate.arrow": "ลูกศร", + "annotate.arrow_hint": "วาดลูกศรจากหางไปหัว (A)", + "annotate.box": "กรอบ", + "annotate.box_hint": "วาดกรอบ (B)", + "annotate.highlight": "ไฮไลต์", + "annotate.highlight_hint": "ทำเครื่องหมายด้วยสีโปร่ง (H)", + "annotate.text": "ข้อความ", + "annotate.text_hint": "คลิกแล้วพิมพ์ป้าย (T)", + "annotate.number": "หมายเลข", + "annotate.number_hint": "คลิกเพื่อเพิ่มหมายเลขถัดไป (N)", + "annotate.thin": "บาง", + "annotate.medium": "กลาง", + "annotate.thick": "หนา", + "annotate.red": "แดง", + "annotate.orange": "ส้ม", + "annotate.yellow": "เหลือง", + "annotate.green": "เขียว", + "annotate.blue": "น้ำเงิน", + "annotate.black": "ดำ", + "annotate.undo": "เลิกทำ (Ctrl+Z)", + "annotate.redo": "ทำซ้ำ (Ctrl+Shift+Z)", + "annotate.remove": "ลบเครื่องหมายที่เลือก (Del)", + "annotate.keys": "V เลือก · Tab เลือกเครื่องหมาย ลูกศรเลื่อน · P A B H T N เลือกเครื่องมือ · Del ลบ · Ctrl+Z เลิกทำ · Esc ปิด", + "annotate.save": "บันทึก", + "annotate.save_next": "บันทึกและถัดไป", "presets.editor_title": "แก้ไขพรีเซ็ต", "presets.delete_confirm": "ลบ “{0}” หรือไม่? พรีเซ็ตนี้จะถูกลบออกจากทุกแถบเครื่องมือและกลุ่มด้วย", "presets.name_required": "โปรดระบุชื่อ", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index f28e3fb8b..282c43857 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -390,6 +390,7 @@ "preset.rotate_180": "180° döndür", "preset.rotate_180.short": "180°", "preset.group_flip": "Çevir", + "preset.annotate": "Not ekle", "preset.flip_horizontal": "Yatay çevir", "preset.flip_horizontal.short": "Yatay", "preset.flip_vertical": "Dikey çevir", @@ -927,6 +928,35 @@ "presets.conflict.Rename": "Numara ekle", "presets.conflict.Overwrite": "Üzerine yaz", "presets.conflict.Skip": "Atla", + "annotate.select": "Seç", + "annotate.select_hint": "İşareti seç, taşı veya boyutlandır (V)", + "annotate.pen": "Kalem", + "annotate.pen_hint": "Serbest çiz (P)", + "annotate.arrow": "Ok", + "annotate.arrow_hint": "Kuyruktan uca ok çiz (A)", + "annotate.box": "Kutu", + "annotate.box_hint": "Çerçeve çiz (B)", + "annotate.highlight": "Vurgu", + "annotate.highlight_hint": "Saydam renkle işaretle (H)", + "annotate.text": "Metin", + "annotate.text_hint": "Tıklayıp etiket yaz (T)", + "annotate.number": "Numara", + "annotate.number_hint": "Sonraki numarayı eklemek için tıkla (N)", + "annotate.thin": "İnce", + "annotate.medium": "Orta", + "annotate.thick": "Kalın", + "annotate.red": "Kırmızı", + "annotate.orange": "Turuncu", + "annotate.yellow": "Sarı", + "annotate.green": "Yeşil", + "annotate.blue": "Mavi", + "annotate.black": "Siyah", + "annotate.undo": "Geri al (Ctrl+Z)", + "annotate.redo": "Yinele (Ctrl+Shift+Z)", + "annotate.remove": "Seçili işareti kaldır (Del)", + "annotate.keys": "V seçer · Tab işaret seçer, oklar taşır · P A B H T N araç seçer · Del kaldırır · Ctrl+Z geri alır · Esc kapatır", + "annotate.save": "Kaydet", + "annotate.save_next": "Kaydet ve sonraki", "presets.editor_title": "Ön ayarı düzenle", "presets.delete_confirm": "“{0}” silinsin mi? Tüm araç çubuklarından ve gruplardan da kaldırılır.", "presets.name_required": "Bir ad girin", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 0a456e97c..550607853 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -400,6 +400,7 @@ "preset.rotate_180": "旋转 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "翻转", + "preset.annotate": "批注", "preset.flip_horizontal": "水平翻转", "preset.flip_horizontal.short": "水平", "preset.flip_vertical": "垂直翻转", @@ -942,6 +943,35 @@ "presets.conflict.Rename": "添加编号", "presets.conflict.Overwrite": "覆盖", "presets.conflict.Skip": "跳过", + "annotate.select": "选择", + "annotate.select_hint": "选择、移动或调整标记大小 (V)", + "annotate.pen": "画笔", + "annotate.pen_hint": "自由绘制 (P)", + "annotate.arrow": "箭头", + "annotate.arrow_hint": "从尾到头画箭头 (A)", + "annotate.box": "方框", + "annotate.box_hint": "画一个框 (B)", + "annotate.highlight": "高亮", + "annotate.highlight_hint": "用半透明颜色标记 (H)", + "annotate.text": "文字", + "annotate.text_hint": "点击并输入标签 (T)", + "annotate.number": "编号", + "annotate.number_hint": "点击添加下一个编号 (N)", + "annotate.thin": "细", + "annotate.medium": "中", + "annotate.thick": "粗", + "annotate.red": "红色", + "annotate.orange": "橙色", + "annotate.yellow": "黄色", + "annotate.green": "绿色", + "annotate.blue": "蓝色", + "annotate.black": "黑色", + "annotate.undo": "撤销 (Ctrl+Z)", + "annotate.redo": "重做 (Ctrl+Shift+Z)", + "annotate.remove": "删除所选标记 (Del)", + "annotate.keys": "V 选择 · Tab 选择标记,方向键移动 · P A B H T N 选择工具 · Del 删除 · Ctrl+Z 撤销 · Esc 关闭", + "annotate.save": "保存", + "annotate.save_next": "保存并下一个", "presets.editor_title": "编辑预设", "presets.delete_confirm": "删除“{0}”?它也会从所有工具栏和组中移除。", "presets.name_required": "请输入名称", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index eb733ba80..c984280cb 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -400,6 +400,7 @@ "preset.rotate_180": "旋轉 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "翻轉", + "preset.annotate": "註解", "preset.flip_horizontal": "水平翻轉", "preset.flip_horizontal.short": "水平", "preset.flip_vertical": "垂直翻轉", @@ -942,6 +943,35 @@ "presets.conflict.Rename": "新增編號", "presets.conflict.Overwrite": "覆蓋", "presets.conflict.Skip": "跳過", + "annotate.select": "選取", + "annotate.select_hint": "選取、移動或調整標記大小 (V)", + "annotate.pen": "畫筆", + "annotate.pen_hint": "自由繪製 (P)", + "annotate.arrow": "箭頭", + "annotate.arrow_hint": "從尾到頭畫箭頭 (A)", + "annotate.box": "方框", + "annotate.box_hint": "畫一個框 (B)", + "annotate.highlight": "螢光筆", + "annotate.highlight_hint": "用半透明顏色標記 (H)", + "annotate.text": "文字", + "annotate.text_hint": "點一下並輸入標籤 (T)", + "annotate.number": "編號", + "annotate.number_hint": "點一下加入下一個編號 (N)", + "annotate.thin": "細", + "annotate.medium": "中", + "annotate.thick": "粗", + "annotate.red": "紅色", + "annotate.orange": "橙色", + "annotate.yellow": "黃色", + "annotate.green": "綠色", + "annotate.blue": "藍色", + "annotate.black": "黑色", + "annotate.undo": "復原 (Ctrl+Z)", + "annotate.redo": "重做 (Ctrl+Shift+Z)", + "annotate.remove": "移除所選標記 (Del)", + "annotate.keys": "V 選取 · Tab 選取標記,方向鍵移動 · P A B H T N 選擇工具 · Del 移除 · Ctrl+Z 復原 · Esc 關閉", + "annotate.save": "儲存", + "annotate.save_next": "儲存並下一個", "presets.editor_title": "編輯預設", "presets.delete_confirm": "刪除「{0}」?它也會從所有工具列和群組中移除。", "presets.name_required": "請輸入名稱", diff --git a/src/Filee.App/Controls/AnnotationCanvas.cs b/src/Filee.App/Controls/AnnotationCanvas.cs new file mode 100644 index 000000000..c7e5b707d --- /dev/null +++ b/src/Filee.App/Controls/AnnotationCanvas.cs @@ -0,0 +1,533 @@ +// The "Annotate" window's picture and its marks: the marks are painted by AnnotationPainter on Avalonia's own Skia +// canvas in the picture's own pixels, scaled to the picture's place, so they look exactly as they will in the file (a +// small picture shown large gets the file's smallest line, text and number, not the screen's); the selected one has its +// handles. A drag with a drawing tool draws a mark; with Select it moves a mark or, on a handle, resizes it. Text is +// typed in the window (TextRequested). + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using Filee.Core.Presets; +using Filee.Engines.Annotations; +using SkiaSharp; + +namespace Filee.App.Controls; + +/// What a press on the picture does. +public enum AnnotateMode +{ + Select, + Pen, + Arrow, + Box, + Highlight, + Text, + Number, +} + +public sealed class AnnotationCanvas : Control +{ + public static readonly StyledProperty SourceImageProperty = + AvaloniaProperty.Register(nameof(SourceImage)); + + public static readonly StyledProperty MarksProperty = + AvaloniaProperty.Register(nameof(Marks)); + + public static readonly StyledProperty ToolProperty = + AvaloniaProperty.Register(nameof(Tool)); + + /// The colour new marks get, "#RRGGBB". + public static readonly StyledProperty MarkColorProperty = + AvaloniaProperty.Register(nameof(MarkColor), AnnotateTool.Colors[0]); + + public static readonly StyledProperty MarkWidthProperty = + AvaloniaProperty.Register(nameof(MarkWidth), AnnotationWidth.Medium); + + /// + /// The picture's own size in pixels, upright, which the file's marks are painted at; the preview can be smaller. + /// + public static readonly StyledProperty PictureSizeProperty = + AvaloniaProperty.Register(nameof(PictureSize)); + + public static readonly StyledProperty ImagePaddingProperty = + AvaloniaProperty.Register(nameof(ImagePadding)); + + public static readonly StyledProperty AccentBrushProperty = + AvaloniaProperty.Register(nameof(AccentBrush)); + + public Bitmap? SourceImage { get => GetValue(SourceImageProperty); set => SetValue(SourceImageProperty, value); } + public AnnotationSet? Marks { get => GetValue(MarksProperty); set => SetValue(MarksProperty, value); } + public AnnotateMode Tool { get => GetValue(ToolProperty); set => SetValue(ToolProperty, value); } + public string MarkColor { get => GetValue(MarkColorProperty); set => SetValue(MarkColorProperty, value); } + public AnnotationWidth MarkWidth { get => GetValue(MarkWidthProperty); set => SetValue(MarkWidthProperty, value); } + public PixelSize PictureSize { get => GetValue(PictureSizeProperty); set => SetValue(PictureSizeProperty, value); } + public Thickness ImagePadding { get => GetValue(ImagePaddingProperty); set => SetValue(ImagePaddingProperty, value); } + public IBrush? AccentBrush { get => GetValue(AccentBrushProperty); set => SetValue(AccentBrushProperty, value); } + + /// + /// Text is to be typed: a new one at a point (in parts of the picture, index -1), or text mark Index again. + /// The window shows the box to type in at Screen, the tag of an empty text there: the box follows the text + /// from that size, as the tag will. + /// + public event EventHandler? TextRequested; + + public readonly record struct TextRequest(int Index, AnnotationPoint At, Rect Screen, double FontSize); + + /// Handles are this many device-independent pixels across; a press this near a line hits it. + private const double HandleSize = 12, HitSlop = 7; + + private enum Drag { None, Draw, Move, Handle } + + private Drag _drag; + private int _handle; + private Point _start; + private List _startPoints = []; + + static AnnotationCanvas() + { + AffectsRender(SourceImageProperty, PictureSizeProperty, ImagePaddingProperty, AccentBrushProperty); + FocusableProperty.OverrideDefaultValue(false); + } + + // The picture is never mirrored, also in a right-to-left UI. + public AnnotationCanvas() + { + FlowDirection = FlowDirection.LeftToRight; + ClipToBounds = true; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == MarksProperty) + { + if (change.GetOldValue() is { } old) + old.Changed -= InvalidateVisual; + if (change.GetNewValue() is { } marks) + marks.Changed += InvalidateVisual; + InvalidateVisual(); + } + else if (change.Property == ToolProperty) + { + Cursor = new Cursor(Tool == AnnotateMode.Select ? StandardCursorType.Arrow : StandardCursorType.Cross); + } + } + + /// The picture's place: as large as fits inside , centred. + public Rect CalculateImageBounds() + { + if (SourceImage is null || Bounds.Width <= 0 || Bounds.Height <= 0) + return default; + var area = new Rect(Bounds.Size).Deflate(ImagePadding); + if (area.Width <= 0 || area.Height <= 0) + area = new Rect(Bounds.Size); + var size = SourceImage.Size; + var scale = Math.Min(area.Width / size.Width, area.Height / size.Height); + return new Rect(area.X + (area.Width - size.Width * scale) / 2, area.Y + (area.Height - size.Height * scale) / 2, + size.Width * scale, size.Height * scale); + } + + /// The size the marks are painted at: , else the preview's. + internal Size Pixels => PictureSize is { Width: > 0, Height: > 0 } size ? new Size(size.Width, size.Height) + : SourceImage is { } image ? new Size(image.PixelSize.Width, image.PixelSize.Height) : default; + + /// The screen rectangle covers. + internal Rect BoundsOf(Annotation mark) => BoundsOf(mark, CalculateImageBounds(), Pixels); + + // ───────── Drawing ───────── + + public override void Render(DrawingContext context) + { + base.Render(context); + var picture = CalculateImageBounds(); + if (SourceImage is null || picture.Width <= 0) + return; + context.DrawRectangle(Brushes.White, null, picture, 0, 0, new BoxShadows(new BoxShadow { Blur = 24, OffsetY = 6, Color = Color.FromArgb(0x33, 0, 0, 0) })); + using (context.PushRenderOptions(new RenderOptions { BitmapInterpolationMode = BitmapInterpolationMode.HighQuality })) + context.DrawImage(SourceImage, new Rect(SourceImage.Size), picture); + if (Marks is not { Count: > 0 } marks) + return; + var pixels = Pixels; + context.Custom(new MarksDrawing(new Rect(Bounds.Size), picture, pixels, [.. marks.Marks.Where((_, i) => i != TypingIndex).Select(m => m.Clone())])); + if (ShowsSelection && marks.Selected != TypingIndex) + DrawSelection(context, marks.SelectedMark!, picture, pixels); + } + + /// + /// The text mark being typed again (-1 for none): not drawn, the window's box stands in for it as it will be. + /// + internal int TypingIndex + { + get => _typingIndex; + set + { + _typingIndex = value; + InvalidateVisual(); + } + } + + private int _typingIndex = -1; + + /// + /// Whether the selected mark shows its outline or handles. A mark being drawn is the selected one (the drag extends + /// it) but shows neither: they would only get in the way of the stroke. + /// + internal bool ShowsSelection => Marks?.SelectedMark is not null && _drag != Drag.Draw; + + /// + /// The marks, painted by the file's own painter on Avalonia's Skia canvas at the picture's size and scaled to fit. + /// + private sealed class MarksDrawing(Rect bounds, Rect picture, Size pixels, IReadOnlyList marks) : ICustomDrawOperation + { + public Rect Bounds => bounds; + public bool HitTest(Point p) => false; + public bool Equals(ICustomDrawOperation? other) => false; + public void Dispose() { } + + public void Render(ImmediateDrawingContext context) + { + if (context.TryGetFeature(typeof(ISkiaSharpApiLeaseFeature)) is not ISkiaSharpApiLeaseFeature feature) + return; + using var lease = feature.Lease(); + var canvas = lease.SkCanvas; + canvas.Save(); + canvas.ClipRect(new SKRect((float)picture.Left, (float)picture.Top, (float)picture.Right, (float)picture.Bottom)); + canvas.Translate((float)picture.X, (float)picture.Y); + canvas.Scale((float)(picture.Width / pixels.Width), (float)(picture.Height / pixels.Height)); + AnnotationPainter.Paint(canvas, marks, (float)pixels.Width, (float)pixels.Height); + canvas.Restore(); + } + } + + private void DrawSelection(DrawingContext context, Annotation mark, Rect picture, Size pixels) + { + var accent = AccentBrush ?? Brushes.MediumPurple; + var handles = HandlesOf(mark, picture); + if (handles.Count == 0) + { + // Pen, text and number: their outline, dashed, to show which is selected. + var box = BoundsOf(mark, picture, pixels).Inflate(4); + context.DrawRectangle(null, new Pen(accent, 1.5, new DashStyle([4, 3], 0)), box, 3, 3); + return; + } + foreach (var handle in handles) + { + context.DrawEllipse(Brushes.White, new Pen(accent, 2), handle, HandleSize / 2, HandleSize / 2); + } + } + + // ───────── Geometry ───────── + + private static Point ToScreen(AnnotationPoint point, Rect picture) => + new(picture.X + point.X * picture.Width, picture.Y + point.Y * picture.Height); + + private static AnnotationPoint ToPicture(Point screen, Rect picture) => new() + { + X = Math.Clamp((screen.X - picture.X) / picture.Width, 0, 1), + Y = Math.Clamp((screen.Y - picture.Y) / picture.Height, 0, 1), + }; + + /// + /// A mark's handles on screen: an arrow's two ends; a box's or highlight's four corners; none for the rest. + /// + internal static IReadOnlyList HandlesOf(Annotation mark, Rect picture) + { + if (mark.Points.Count < 2) + return []; + var a = ToScreen(mark.Points[0], picture); + var b = ToScreen(mark.Points[1], picture); + return mark.Kind switch + { + AnnotationKind.Arrow => [a, b], + AnnotationKind.Box or AnnotationKind.Highlight => [a, b, new Point(a.X, b.Y), new Point(b.X, a.Y)], + _ => [], + }; + } + + /// + /// The screen rectangle a mark covers: worked out in the picture's own , as it is painted, + /// and scaled to the picture's place. + /// + internal static Rect BoundsOf(Annotation mark, Rect picture, Size pixels) + { + float w = (float)pixels.Width, h = (float)pixels.Height; + SKRect rect; + switch (mark.Kind) + { + case AnnotationKind.Text: + rect = AnnotationPainter.TagOf(mark, w, h); + break; + case AnnotationKind.Number: + rect = AnnotationPainter.CircleOf(mark, w, h); + break; + default: + var xs = mark.Points.Select(p => p.X * w).ToList(); + var ys = mark.Points.Select(p => p.Y * h).ToList(); + rect = new SKRect((float)xs.Min(), (float)ys.Min(), (float)xs.Max(), (float)ys.Max()); + break; + } + double sx = picture.Width / w, sy = picture.Height / h; + return new Rect(picture.X + rect.Left * sx, picture.Y + rect.Top * sy, rect.Width * sx, rect.Height * sy); + } + + /// The mark on top under , -1 for none; the selected one first. + internal static int HitTest(IReadOnlyList marks, int selected, Point point, Rect picture, Size pixels) + { + if (selected >= 0 && selected < marks.Count && Hits(marks[selected], point, picture, pixels)) + return selected; + for (var i = marks.Count - 1; i >= 0; i--) + { + if (Hits(marks[i], point, picture, pixels)) + return i; + } + return -1; + } + + private static bool Hits(Annotation mark, Point point, Rect picture, Size pixels) + { + if (mark.Points.Count == 0) + return false; + var slop = HitSlop + AnnotationPainter.StrokeOf(mark.Width, (float)pixels.Width, (float)pixels.Height) * picture.Width / pixels.Width / 2; + var screen = mark.Points.Select(p => ToScreen(p, picture)).ToList(); + switch (mark.Kind) + { + case AnnotationKind.Pen: + case AnnotationKind.Arrow: + for (var i = 1; i < screen.Count; i++) + { + if (Distance(point, screen[i - 1], screen[i]) <= slop) + return true; + } + return false; + case AnnotationKind.Box when screen.Count >= 2: + { + var rect = new Rect(screen[0], screen[1]).Normalize(); + return rect.Inflate(slop).Contains(point) && !rect.Deflate(slop).Contains(point); + } + case AnnotationKind.Highlight when screen.Count >= 2: + return new Rect(screen[0], screen[1]).Normalize().Contains(point); + default: + return BoundsOf(mark, picture, pixels).Inflate(2).Contains(point); + } + } + + private static double Distance(Point p, Point a, Point b) + { + var dx = b.X - a.X; + var dy = b.Y - a.Y; + var lengthSquared = dx * dx + dy * dy; + var t = lengthSquared == 0 ? 0 : Math.Clamp(((p.X - a.X) * dx + (p.Y - a.Y) * dy) / lengthSquared, 0, 1); + var (x, y) = (a.X + t * dx, a.Y + t * dy); + return Math.Sqrt((p.X - x) * (p.X - x) + (p.Y - y) * (p.Y - y)); + } + + // ───────── Pointer ───────── + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + var picture = CalculateImageBounds(); + if (Marks is not { } marks || picture.Width <= 0 || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + return; + var point = e.GetPosition(this); + var at = ToPicture(point, picture); + _start = point; + e.Handled = true; + + switch (Tool) + { + case AnnotateMode.Select: + if (marks.SelectedMark is { } selected && HandleAt(selected, point, picture) is >= 0 and var handle) + { + _handle = handle; + Begin(Drag.Handle, e, selected); + return; + } + var hit = HitTest(marks.Marks, marks.Selected, point, picture, Pixels); + marks.Select(hit); + if (hit < 0) + return; + if (e.ClickCount == 2 && marks.Marks[hit].Kind == AnnotationKind.Text) + { + TypeAgain(hit); + return; + } + Begin(Drag.Move, e, marks.Marks[hit]); + return; + case AnnotateMode.Text: + RequestText(-1, at, picture); + return; + case AnnotateMode.Number: + var number = NewMark(AnnotationKind.Number, at); + number.Number = marks.NextNumber; + marks.Add(number); + // Drawn, not picked: the next colour goes to the next mark, not to this one (Select picks a mark). + marks.Select(-1); + return; + default: + var kind = Tool switch + { + AnnotateMode.Pen => AnnotationKind.Pen, + AnnotateMode.Arrow => AnnotationKind.Arrow, + AnnotateMode.Box => AnnotationKind.Box, + _ => AnnotationKind.Highlight, + }; + marks.Begin(); + var mark = NewMark(kind, at); + mark.Points.Add(at.Clone()); + if (!marks.Add(mark)) + { + marks.Cancel(); + return; + } + _drag = Drag.Draw; + e.Pointer.Capture(this); + return; + } + } + + private Annotation NewMark(AnnotationKind kind, AnnotationPoint at) => + new() { Kind = kind, Color = MarkColor, Width = MarkWidth, Points = [at] }; + + private void Begin(Drag drag, PointerPressedEventArgs e, Annotation mark) + { + Marks!.Begin(); + _drag = drag; + _startPoints = [.. mark.Points.Select(p => p.Clone())]; + e.Pointer.Capture(this); + } + + /// The handle of under , -1 for none. + private static int HandleAt(Annotation mark, Point point, Rect picture) + { + var handles = HandlesOf(mark, picture); + for (var i = 0; i < handles.Count; i++) + { + if (Math.Abs(handles[i].X - point.X) <= HandleSize && Math.Abs(handles[i].Y - point.Y) <= HandleSize) + return i; + } + return -1; + } + + /// + /// Asks for text mark to be typed again (a double-click on it with Select). + /// + internal void TypeAgain(int index) => RequestText(index, Marks!.Marks[index].Points[0], CalculateImageBounds()); + + private void RequestText(int index, AnnotationPoint at, Rect picture) => TextRequested?.Invoke(this, RequestFor(index, at, picture)); + + /// + /// Text mark (or a new one, -1) at : where its box goes on screen + /// now, and its size. + /// + internal TextRequest RequestFor(int index, AnnotationPoint at) => RequestFor(index, at, CalculateImageBounds()); + + private TextRequest RequestFor(int index, AnnotationPoint at, Rect picture) + { + var width = index >= 0 ? Marks!.Marks[index].Width : MarkWidth; + var empty = new Annotation { Kind = AnnotationKind.Text, Width = width, Points = [at], Text = " " }; + var pixels = Pixels; + return new TextRequest(index, at, BoundsOf(empty, picture, pixels), + AnnotationPainter.FontSizeOf(width, (float)pixels.Width, (float)pixels.Height) * picture.Width / pixels.Width); + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (_drag == Drag.None || Marks is not { } marks || marks.Selected < 0) + return; + var picture = CalculateImageBounds(); + var point = e.GetPosition(this); + var at = ToPicture(point, picture); + var index = marks.Selected; + switch (_drag) + { + case Drag.Draw: + marks.Change(index, mark => + { + if (mark.Kind == AnnotationKind.Pen) + { + // A point each time the pointer has gone a little way: a smooth line, not thousands of points. + var last = ToScreen(mark.Points[^1], picture); + if (Math.Abs(point.X - last.X) + Math.Abs(point.Y - last.Y) >= 1.5) + mark.Points.Add(at); + } + else + { + mark.Points[1] = at; + } + }); + break; + case Drag.Move: + { + var dx = (point.X - _start.X) / picture.Width; + var dy = (point.Y - _start.Y) / picture.Height; + // The whole mark stays on the picture. + dx = Math.Clamp(dx, -_startPoints.Min(p => p.X), 1 - _startPoints.Max(p => p.X)); + dy = Math.Clamp(dy, -_startPoints.Min(p => p.Y), 1 - _startPoints.Max(p => p.Y)); + marks.Change(index, mark => + { + for (var i = 0; i < mark.Points.Count && i < _startPoints.Count; i++) + mark.Points[i] = new AnnotationPoint { X = _startPoints[i].X + dx, Y = _startPoints[i].Y + dy }; + }); + break; + } + case Drag.Handle: + marks.Change(index, mark => + { + switch (_handle) + { + case 0: + mark.Points[0] = at; + break; + case 1: + mark.Points[1] = at; + break; + case 2: // the corner left of the first point's column, on the second's row + mark.Points[0] = new AnnotationPoint { X = at.X, Y = mark.Points[0].Y }; + mark.Points[1] = new AnnotationPoint { X = mark.Points[1].X, Y = at.Y }; + break; + case 3: + mark.Points[1] = new AnnotationPoint { X = at.X, Y = mark.Points[1].Y }; + mark.Points[0] = new AnnotationPoint { X = mark.Points[0].X, Y = at.Y }; + break; + } + }); + break; + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (_drag == Drag.None || Marks is not { } marks) + return; + var drew = _drag == Drag.Draw; + _drag = Drag.None; + e.Pointer.Capture(null); + // A click that drew nothing (a line of one point, a box with no size) leaves no mark. + if (drew && marks.SelectedMark is { } mark && !IsBigEnough(mark)) + marks.Cancel(); + else + marks.End(); + // Drawn, not picked: the next colour goes to the next mark, not to this one (Select picks a mark). + if (drew) + marks.Select(-1); + } + + private static bool IsBigEnough(Annotation mark) + { + if (mark.Points.Count < 2) + return false; + var width = mark.Points.Max(p => p.X) - mark.Points.Min(p => p.X); + var height = mark.Points.Max(p => p.Y) - mark.Points.Min(p => p.Y); + return mark.Kind == AnnotationKind.Pen || mark.Kind == AnnotationKind.Arrow + ? Math.Max(width, height) >= AnnotateTool.MinSize + : width >= AnnotateTool.MinSize && height >= AnnotateTool.MinSize; + } +} diff --git a/src/Filee.App/Controls/AnnotationSet.cs b/src/Filee.App/Controls/AnnotationSet.cs new file mode 100644 index 000000000..9731c5e59 --- /dev/null +++ b/src/Filee.App/Controls/AnnotationSet.cs @@ -0,0 +1,197 @@ +// The marks the "Annotate" window draws on a picture, without a window: the marks in the order drawn (the last on top), +// the one selected, and undo / redo of adding, moving, resizing, restyling, retyping and removing them. A drag is one +// undo step however far it goes. + +using Filee.Core.Presets; + +namespace Filee.App.Controls; + +public sealed class AnnotationSet +{ + /// Most marks one picture takes. + public const int MaxMarks = 500; + + /// Undo steps kept. + private const int MaxUndo = 100; + + private readonly List _marks = []; + private readonly List _undo = []; + private readonly List _redo = []; + private Snapshot? _gesture; + private bool _gestureChanged; + + private sealed record Snapshot(Annotation[] Marks, int Selected); + + /// The marks, in the order drawn. + public IReadOnlyList Marks => _marks; + + /// The selected mark, -1 for none. + public int Selected { get; private set; } = -1; + + public Annotation? SelectedMark => Selected >= 0 ? _marks[Selected] : null; + public int Count => _marks.Count; + public bool CanUndo => _undo.Count > 0; + public bool CanRedo => _redo.Count > 0; + + /// The next number a Number mark gets: one more than the highest on the picture. + public int NextNumber => _marks.Where(m => m.Kind == AnnotationKind.Number).Select(m => m.Number).DefaultIfEmpty(0).Max() + 1; + + /// Something changed: a mark, the selection or what can be undone. + public event Action? Changed; + + /// A drag starts: what it does until is one undo step. + public void Begin() + { + _gesture ??= Take(); + _gestureChanged = false; + } + + /// The drag ends: one undo step when it changed anything. + public void End() + { + if (_gesture is not { } before) + return; + _gesture = null; + if (_gestureChanged) + Push(before); + Changed?.Invoke(); + } + + /// The drag is dropped: everything as it was before it (a click that drew nothing). + public void Cancel() + { + if (_gesture is not { } before) + return; + _gesture = null; + Restore(before); + Changed?.Invoke(); + } + + /// Adds a mark and selects it; false when there are already. + public bool Add(Annotation mark) + { + if (_marks.Count >= MaxMarks) + return false; + Record(); + _marks.Add(mark); + Selected = _marks.Count - 1; + Changed?.Invoke(); + return true; + } + + /// Changes mark (a drag's step, or one undo step on its own). + public void Change(int index, Action change) + { + if (index < 0 || index >= _marks.Count) + return; + Record(); + change(_marks[index]); + Changed?.Invoke(); + } + + public void Select(int index) + { + index = index >= 0 && index < _marks.Count ? index : -1; + if (index == Selected) + return; + Selected = index; + Changed?.Invoke(); + } + + /// + /// The next (or previous) mark in the order they were drawn, round and round: from none, the first (or the last). + /// + public void SelectNext(int step) + { + if (_marks.Count == 0) + return; + Select(Selected < 0 + ? (step >= 0 ? 0 : _marks.Count - 1) + : ((Selected + step) % _marks.Count + _marks.Count) % _marks.Count); + } + + /// + /// Moves the selected mark by whole pixels of a × picture, as + /// far as it stays on the picture (one undo step). + /// + public void Nudge(int dx, int dy, int width, int height) + { + if (SelectedMark is not { } mark || width <= 0 || height <= 0) + return; + var moveX = Math.Clamp((double)dx / width, -mark.Points.Min(p => p.X), 1 - mark.Points.Max(p => p.X)); + var moveY = Math.Clamp((double)dy / height, -mark.Points.Min(p => p.Y), 1 - mark.Points.Max(p => p.Y)); + if (moveX == 0 && moveY == 0) + return; + Change(Selected, moved => + { + for (var i = 0; i < moved.Points.Count; i++) + moved.Points[i] = new AnnotationPoint { X = moved.Points[i].X + moveX, Y = moved.Points[i].Y + moveY }; + }); + } + + /// Removes the selected mark; none is selected afterwards. False when none was. + public bool RemoveSelected() + { + if (Selected < 0) + return false; + Record(); + _marks.RemoveAt(Selected); + Selected = -1; + Changed?.Invoke(); + return true; + } + + /// Starts over with no marks and nothing to undo (a new picture). + public void Reset() + { + _gesture = null; + _marks.Clear(); + _undo.Clear(); + _redo.Clear(); + Selected = -1; + Changed?.Invoke(); + } + + public void Undo() => Step(_undo, _redo); + + public void Redo() => Step(_redo, _undo); + + private void Step(List from, List to) + { + if (from.Count == 0 || _gesture is not null) + return; + var snapshot = from[^1]; + from.RemoveAt(from.Count - 1); + to.Add(Take()); + Restore(snapshot); + Changed?.Invoke(); + } + + /// An undo step for a change made now, unless a drag records one for itself. + private void Record() + { + if (_gesture is not null) + { + _gestureChanged = true; + return; + } + Push(Take()); + } + + private void Push(Snapshot snapshot) + { + _undo.Add(snapshot); + if (_undo.Count > MaxUndo) + _undo.RemoveAt(0); + _redo.Clear(); + } + + private Snapshot Take() => new([.. _marks.Select(m => m.Clone())], Selected); + + private void Restore(Snapshot snapshot) + { + _marks.Clear(); + _marks.AddRange(snapshot.Marks.Select(m => m.Clone())); + Selected = Math.Min(snapshot.Selected, _marks.Count - 1); + } +} diff --git a/src/Filee.App/Services/AnnotationFonts.cs b/src/Filee.App/Services/AnnotationFonts.cs new file mode 100644 index 000000000..5a2e3d6d2 --- /dev/null +++ b/src/Filee.App/Services/AnnotationFonts.cs @@ -0,0 +1,97 @@ +// The fonts Annotate's labels are drawn in, handed to the painter (Filee.Engines): the UI's font chain (SoftPalette) in +// bold, as the box a label is typed in shows it, so the label drawn is the label typed. The embedded Noto fonts are +// read the first time a label is drawn, their bold faces only. + +using Avalonia.Media; +using Avalonia.Platform; +using Filee.Engines.Annotations; +using SkiaSharp; + +namespace Filee.App.Services; + +internal static class AnnotationFonts +{ + private const string EmbeddedPrefix = "fonts:Filee#"; + + private static readonly Lazy> Embedded = new(LoadEmbedded); + private static string? _language; + + /// + /// Gives the painter the chain of 's UI font, in bold: at start and when the language + /// changes, so a conversion run later (a queued one, a retry) draws its labels as the window showed them. The fonts + /// are read the first time a label is drawn. + /// + public static void Use(string language) + { + if (_language == language) + return; + _language = language; + AnnotationPainter.UseFonts(() => Chain(language)); + var family = SoftPalette.FontFamilyFor(language); + AnnotationPainter.UseFallback(codePoint => FallbackFor(codePoint, family)); + } + + /// + /// The face the text box draws a letter in that its first font lacks, and whether it makes it bold: Avalonia's font + /// manager asked as its text layout asks it (the whole chain, bold, the UI's language), then that face, an embedded + /// one or the system's, by its name. + /// + private static (SKTypeface Face, bool MadeBold)? FallbackFor(int codePoint, FontFamily family) + { + try + { + if (!FontManager.Current.TryMatchCharacter(codePoint, FontStyle.Normal, FontWeight.Bold, FontStretch.Normal, family, null, out var typeface) + || !FontManager.Current.TryGetGlyphTypeface(typeface, out var glyphs)) + return null; + var weight = (int)glyphs.Weight; + var face = Embedded.Value.Where(f => f.FamilyName == glyphs.FamilyName).OrderBy(f => Math.Abs(f.FontWeight - weight)).FirstOrDefault() + ?? SKFontManager.Default.MatchFamily(glyphs.FamilyName, new SKFontStyle((SKFontStyleWeight)weight, + (SKFontStyleWidth)(int)glyphs.Stretch, glyphs.Style == FontStyle.Normal ? SKFontStyleSlant.Upright : SKFontStyleSlant.Italic)); + return face is null ? null : (face, glyphs.FontSimulations.HasFlag(FontSimulations.Bold)); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return null; + } + } + + private static List Chain(string language) + { + var chain = new List(); + foreach (var family in SoftPalette.FontFamilies(language).Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + // "sans-serif" ends the chain: what no family has, the painter finds as the text box's fallback does. + var face = family.StartsWith(EmbeddedPrefix, StringComparison.Ordinal) + ? Embedded.Value.FirstOrDefault(f => f.FamilyName == family[EmbeddedPrefix.Length..]) + : family == "sans-serif" ? null + : SKFontManager.Default.MatchFamily(family, SKFontStyle.Bold) is { } system + && string.Equals(system.FamilyName, family, StringComparison.OrdinalIgnoreCase) ? system : null; + if (face is not null && !chain.Contains(face)) + chain.Add(face); + } + return chain; + } + + /// The embedded fonts' bold faces; none when they weren't fetched (build/fetch-fonts.ps1). + private static IReadOnlyList LoadEmbedded() + { + var faces = new List(); + try + { + foreach (var uri in AssetLoader.GetAssets(new Uri("avares://Filee/Assets/Fonts"), null)) + { + if (!uri.AbsolutePath.Contains("-Bold.", StringComparison.Ordinal)) + continue; + using var stream = AssetLoader.Open(uri); + using var data = SKData.Create(stream); + if (SKTypeface.FromData(data) is { } face) + faces.Add(face); + } + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or FileNotFoundException) + { + // Without them the system's fonts are used, as the UI does. + } + return faces; + } +} diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs index 21249dbfd..1422d32b8 100644 --- a/src/Filee.App/Services/PresetAvailability.cs +++ b/src/Filee.App/Services/PresetAvailability.cs @@ -133,6 +133,10 @@ private PresetJudgement Evaluate(Preset preset, IReadOnlyList format return formats.Any(f => ImageEditTool.CanEdit(f.Id)) ? new(PresetUse.Usable, null) : new(PresetUse.Unusable, _loc["error.images_only"]); + if (preset.IsAnnotateTool) + return formats.Any(f => HideService.CanOpen(f.Id)) + ? new(PresetUse.Usable, null) + : new(PresetUse.Unusable, _loc["error.images_only"]); if (preset.IsHideTool) return formats.Any(f => HideService.CanOpen(f.Id)) ? new(PresetUse.Usable, null) diff --git a/src/Filee.App/Services/ThemeService.cs b/src/Filee.App/Services/ThemeService.cs index 55fc9399b..28b9bae73 100644 --- a/src/Filee.App/Services/ThemeService.cs +++ b/src/Filee.App/Services/ThemeService.cs @@ -96,6 +96,8 @@ public void Apply(ThemeSettings theme, string language, CustomTheme? custom = nu // Radii, fonts, flow direction SoftPalette.ApplyCornerRadius(res, theme.CornerRadius); res["AppFontFamily"] = SoftPalette.FontFamilyFor(language); + // Annotate draws its labels in the same fonts (read when a label is first drawn). + AnnotationFonts.Use(language); SoftPalette.ApplyFlowDirection(res, language); if (theme.Style == ThemeStyle.MaterialYou) diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index b60f94c89..5af7c97e2 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -37,10 +37,12 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem private CropOverlayWindow? _cropOverlay; private ClipWindow? _clipWindow; private HideWindow? _hideWindow; + private AnnotateWindow? _annotateWindow; private ImageEditWindow? _imageEdit; internal ClipWindow? ClipWindow => _clipWindow; internal HideWindow? HideWindow => _hideWindow; + internal AnnotateWindow? AnnotateWindow => _annotateWindow; internal ImageEditWindow? ImageEdit => _imageEdit; private readonly HashSet _spreadsheetStudios = []; @@ -152,6 +154,8 @@ public void ShowTool(IReadOnlyList files, Preset preset, PixelPoint? nea ShowClipWindow(files, preset, near); else if (preset.IsHideTool) ShowHideWindow(files, preset, near); + else if (preset.IsAnnotateTool) + ShowAnnotateWindow(files, preset); else if (preset.IsImageEditTool) ShowImageEditWindow(files, preset); // Before the crop: the print studio and the PDF page tools have no Tool of their own, which reads as a crop @@ -262,6 +266,34 @@ public void ShowHideWindow(IReadOnlyList files, Preset preset, PixelPoin win.Activate(); } + /// + /// Opens the "Annotate" window on the pictures among the files. Each picture is written by an ordinary conversion, + /// shown in the toast. + /// + public void ShowAnnotateWindow(IReadOnlyList files, Preset preset) + { + var pictures = files.Where(f => FormatRegistry.Detect(f) is { } format && HideService.CanOpen(format.Id)).ToList(); + if (pictures.Count == 0) + return; + _annotateWindow?.Close(); + var conversions = services.GetRequiredService(); + var planner = services.GetRequiredService().CreatePlanner(); + var vm = new AnnotateViewModel(pictures, preset, loc, services.GetRequiredService(), + path => AnnotateTool.TargetFor(FormatRegistry.Detect(path)?.Id ?? "", planner), + (path, conversion) => conversions.Start([path], conversion), services.GetService>()); + var win = new AnnotateWindow { DataContext = vm }; + PrepareWindow(win); + win.Closed += (_, _) => + { + if (_annotateWindow == win) + _annotateWindow = null; + trimmer.Request(); + }; + _annotateWindow = win; + win.Show(); + win.Activate(); + } + /// /// The pixel size of a picture as it is seen, read from its headers: turned as its EXIF orientation says, and the /// largest of an icon's sizes (the one converted). Null when ImageMagick can't read it. diff --git a/src/Filee.App/ViewModels/AnnotateViewModel.cs b/src/Filee.App/ViewModels/AnnotateViewModel.cs new file mode 100644 index 000000000..468d57aa5 --- /dev/null +++ b/src/Filee.App/ViewModels/AnnotateViewModel.cs @@ -0,0 +1,408 @@ +// State of the "Annotate" window: one picture at a time with the marks drawn on it (AnnotationSet), the tool, colour +// and width in use (a colour or width picked while a mark is selected with Select restyles it; a mark just drawn isn't +// selected, so a colour picked next goes to the next mark), and the conversion that writes the picture with its marks, +// which runs in the toast like any other. The colour and width are kept in the tool. + +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Filee.App.Controls; +using Filee.App.Services; +using Filee.Core.Localization; +using Filee.Core.Presets; +using Filee.Core.Settings; +using Microsoft.Extensions.Logging; + +namespace Filee.App.ViewModels; + +public sealed partial class AnnotateViewModel : ObservableObject, IDisposable +{ + private readonly IReadOnlyList _files; + private readonly Preset _preset; + private readonly ILocalizer _loc; + private readonly UserDataStore _store; + private readonly Func _targetFor; + private readonly Action _export; + private readonly ILogger? _log; + private bool _closed; + + /// How each picture went: saved, skipped or failed (none yet while it is open). + private readonly CropItemState?[] _outcomes; + + public AnnotateViewModel(IReadOnlyList files, Preset tool, ILocalizer loc, UserDataStore store, + Func targetFor, Action export, ILogger? log = null) + { + _files = files; + _preset = tool; + _loc = loc; + _store = store; + _targetFor = targetFor; + _export = export; + _log = log; + _color = AnnotateTool.Colors.Contains(tool.Image.AnnotationColor) ? tool.Image.AnnotationColor! : AnnotateTool.Colors[0]; + _width = tool.Image.AnnotationWidth; + _hasMultipleFiles = files.Count > 1; + _outcomes = new CropItemState?[files.Count]; + Swatches = [.. AnnotateTool.Colors.Select((c, i) => new AnnotateSwatch(c, AnnotateTool.ColorNameKeys[i], this) { Name = loc[AnnotateTool.ColorNameKeys[i]] })]; + Marks.Changed += OnMarksChanged; + loc.LanguageChanged += OnLanguageChanged; + // The fonts labels are drawn in, read now off the UI thread rather than on the first text. + _ = Task.Run(Filee.Engines.Annotations.AnnotationPainter.WarmFonts); + UpdateSwatches(); + _ = LoadFileAsync(0); + } + + /// The marks on the picture shown. + public AnnotationSet Marks { get; } = new(); + + public IReadOnlyList Swatches { get; } + + [ObservableProperty] private int _currentIndex; + [ObservableProperty] private string _currentFileName = ""; + [ObservableProperty] private string _counterText = ""; + [ObservableProperty] private bool _hasMultipleFiles; + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand))] + private Bitmap? _previewImage; + + /// The picture's own size, upright: the window paints the marks at it, as the file gets them. + [ObservableProperty] private Avalonia.PixelSize _pictureSize; + [ObservableProperty] private string _statusNotice = ""; + + /// + /// All pictures are done and some could not be opened or saved: the window stays open on and + /// until it is closed (as "Pixelate / blur" does). + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsEditing))] + private bool _isFinished; + + [ObservableProperty] private string _summary = ""; + [ObservableProperty] private int _doneCount; + [ObservableProperty] private int _skippedCount; + [ObservableProperty] private int _failedCount; + + /// "file: reason" for each picture that could not be opened or saved. + public System.Collections.ObjectModel.ObservableCollection Problems { get; } = []; + + public bool IsEditing => !IsFinished; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand))] + private bool _isBusy; + + /// The tool in use (what a press on the picture does). + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsSelect), nameof(IsPen), nameof(IsArrow), nameof(IsBox), nameof(IsHighlight), nameof(IsText), nameof(IsNumber))] + private AnnotateMode _mode = AnnotateMode.Arrow; + + [ObservableProperty] private string _color; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsThin), nameof(IsMedium), nameof(IsThick))] + private AnnotationWidth _width; + + public bool IsSelect => Mode == AnnotateMode.Select; + public bool IsPen => Mode == AnnotateMode.Pen; + public bool IsArrow => Mode == AnnotateMode.Arrow; + public bool IsBox => Mode == AnnotateMode.Box; + public bool IsHighlight => Mode == AnnotateMode.Highlight; + public bool IsText => Mode == AnnotateMode.Text; + public bool IsNumber => Mode == AnnotateMode.Number; + public bool IsThin => Width == AnnotationWidth.Thin; + public bool IsMedium => Width == AnnotationWidth.Medium; + public bool IsThick => Width == AnnotationWidth.Thick; + + public bool CanUndo => Marks.CanUndo; + public bool CanRedo => Marks.CanRedo; + public bool HasSelection => Marks.Selected >= 0; + + public string SaveText => _loc[CurrentIndex < _files.Count - 1 ? "annotate.save_next" : "annotate.save"]; + + public event Action? CloseRequested; + + partial void OnModeChanged(AnnotateMode value) + { + // A drawing tool leaves the selection: what it draws gets selected instead. + if (value != AnnotateMode.Select) + Marks.Select(-1); + } + + private void OnMarksChanged() + { + OnPropertyChanged(nameof(CanUndo)); + OnPropertyChanged(nameof(CanRedo)); + OnPropertyChanged(nameof(HasSelection)); + SaveCommand.NotifyCanExecuteChanged(); + // A selected mark's colour and width show in the bar. + if (Marks.SelectedMark is { } mark && (mark.Color != Color || mark.Width != Width)) + { + Color = mark.Color; + OnPropertyChanged(nameof(Color)); + Width = mark.Width; + OnPropertyChanged(nameof(Width)); + OnPropertyChanged(nameof(IsThin)); + OnPropertyChanged(nameof(IsMedium)); + OnPropertyChanged(nameof(IsThick)); + UpdateSwatches(); + } + } + + [RelayCommand] + private void SetMode(AnnotateMode mode) => Mode = mode; + + /// The colour for new marks, and the selected mark's. + [RelayCommand] + internal void SetColor(string color) + { + Color = color; + if (Marks.Selected >= 0 && Marks.SelectedMark!.Color != color) + Marks.Change(Marks.Selected, mark => mark.Color = color); + UpdateSwatches(); + } + + /// The width for new marks, and the selected mark's. + [RelayCommand] + private void SetWidth(AnnotationWidth width) + { + Width = width; + if (Marks.Selected >= 0 && Marks.SelectedMark!.Width != width) + Marks.Change(Marks.Selected, mark => mark.Width = width); + } + + private void UpdateSwatches() + { + foreach (var swatch in Swatches) + swatch.IsSelected = swatch.Color == Color; + } + + /// A new text typed at ; nothing for blank text. + public void AddText(AnnotationPoint at, string text) + { + if (string.IsNullOrWhiteSpace(text)) + return; + Marks.Add(new Annotation { Kind = AnnotationKind.Text, Color = Color, Width = Width, Points = [at], Text = text.Trim() }); + // Typed with the Text tool, not picked: the next colour goes to the next mark. + if (Mode != AnnotateMode.Select) + Marks.Select(-1); + } + + /// Text mark typed again; blank text removes it. + public void EditText(int index, string text) + { + if (index < 0 || index >= Marks.Count) + return; + Marks.Select(index); + if (string.IsNullOrWhiteSpace(text)) + Marks.RemoveSelected(); + else if (Marks.Marks[index].Text != text.Trim()) + Marks.Change(index, mark => mark.Text = text.Trim()); + } + + [RelayCommand] + private void Undo() => Marks.Undo(); + + [RelayCommand] + private void Redo() => Marks.Redo(); + + [RelayCommand] + private void RemoveSelected() => Marks.RemoveSelected(); + + // ───────── Pictures and saving ───────── + + /// + /// Opens picture with no marks; past the last one, closes, or shows the summary when a + /// picture could not be opened or saved. + /// + public async Task LoadFileAsync(int index) + { + if (_closed) + return; + if (index >= _files.Count) + { + Finish(); + return; + } + IsBusy = true; + CurrentIndex = index; + var path = _files[index]; + CurrentFileName = Path.GetFileName(path); + CounterText = _loc.Format("crop.page_counter", index + 1, _files.Count); + OnPropertyChanged(nameof(SaveText)); + Marks.Reset(); + try + { + var (preview, width, height) = await HideService.LoadPreviewAsync(path, _preset); + Bitmap bitmap; + using (preview) + bitmap = await Task.Run(() => HideService.ToBitmap(preview)); + if (_closed) + { + bitmap.Dispose(); + return; + } + var old = PreviewImage; + PictureSize = new Avalonia.PixelSize(width, height); + PreviewImage = bitmap; + old?.Dispose(); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _log?.LogInformation(ex, "Could not open {File} to annotate", CurrentFileName); + AddProblem(index, ex is NotSupportedException ? "error.unsupported_source" : "error.conversion_failed"); + IsBusy = false; + await LoadFileAsync(index + 1); + return; + } + IsBusy = false; + } + + /// + /// Only with a mark on the picture, as "Pixelate / blur" only with a part: Skip moves on without one. + /// + private bool CanSave() => !IsBusy && PreviewImage is not null && Marks.Marks.Any(AnnotateTool.IsDrawable); + + /// + /// Starts the conversion that writes the picture with its marks (shown in the toast), keeps the colour and width, + /// and opens the next picture or closes. When it can't be started, the picture stays with its marks, to save again + /// or skip. + /// + [RelayCommand(CanExecute = nameof(CanSave))] + private async Task SaveAsync() + { + var path = _files[CurrentIndex]; + Remember(); + try + { + _export(path, AnnotateTool.ConversionOf(_preset, _targetFor(path), Marks.Marks)); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _log?.LogError(ex, "Could not start annotating {File}", CurrentFileName); + AddProblem(CurrentIndex, "error.conversion_failed"); + return; + } + _outcomes[CurrentIndex] = CropItemState.Done; + ClearProblems(CurrentIndex); + await LoadFileAsync(CurrentIndex + 1); + } + + [RelayCommand] + private async Task SkipAsync() + { + if (IsBusy || IsFinished) + return; + _outcomes[CurrentIndex] ??= CropItemState.Skipped; + await LoadFileAsync(CurrentIndex + 1); + } + + private void AddProblem(int index, string key) + { + _outcomes[index] = CropItemState.Failed; + var line = $"{Path.GetFileName(_files[index])}: {_loc[key]}"; + Problems.Add(line); + _problemFiles.Add(index); + StatusNotice = line; + } + + /// A picture saved after all: what went wrong with it before is no failure any more. + private void ClearProblems(int index) + { + for (var i = _problemFiles.Count - 1; i >= 0; i--) + { + if (_problemFiles[i] != index) + continue; + _problemFiles.RemoveAt(i); + Problems.RemoveAt(i); + } + StatusNotice = Problems.LastOrDefault() ?? ""; + } + + /// The picture each of is about. + private readonly List _problemFiles = []; + + /// + /// Closes after the last picture, or stays open on the summary when a picture could not be opened or saved. + /// + private void Finish() + { + if (Problems.Count == 0) + { + Close(); + return; + } + Remember(); + FailedCount = _outcomes.Count(o => o == CropItemState.Failed); + DoneCount = _outcomes.Count(o => o == CropItemState.Done); + SkippedCount = _outcomes.Count(o => o == CropItemState.Skipped); + Summary = _loc.Format("toast.done_errors", FailedCount, _files.Count); + StatusNotice = ""; + HasMultipleFiles = false; + IsBusy = false; + Marks.Reset(); + var old = PreviewImage; + PreviewImage = null; + old?.Dispose(); + IsFinished = true; + } + + [RelayCommand] + private void Close() + { + Remember(); + CloseRequested?.Invoke(); + } + + private void Remember() + { + AnnotateTool.Remember(_preset, Color, Width); + if (_store.FindPreset(_preset.Id) is not { IsAnnotateTool: true } saved + || (saved.Image.AnnotationColor == Color && saved.Image.AnnotationWidth == Width)) + return; + AnnotateTool.Remember(saved, Color, Width); + try + { + _store.SaveLibrary(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log?.LogWarning(ex, "Could not remember the annotation style"); + } + } + + private void OnLanguageChanged(object? sender, EventArgs e) + { + if (CurrentIndex < _files.Count) + CounterText = _loc.Format("crop.page_counter", CurrentIndex + 1, _files.Count); + OnPropertyChanged(nameof(SaveText)); + foreach (var swatch in Swatches) + swatch.Name = _loc[swatch.NameKey]; + } + + public void Dispose() + { + // Closed with the title bar or Alt+F4 too: the colour and width are kept for next time. + if (!_closed && PreviewImage is not null) + Remember(); + _closed = true; + _loc.LanguageChanged -= OnLanguageChanged; + Marks.Changed -= OnMarksChanged; + PreviewImage?.Dispose(); + PreviewImage = null; + } +} + +/// A colour in the bar, named for screen readers and its tooltip. +public sealed partial class AnnotateSwatch(string color, string nameKey, AnnotateViewModel owner) : ObservableObject +{ + public string Color { get; } = color; + public string NameKey { get; } = nameKey; + + [ObservableProperty] private string _name = ""; + public Avalonia.Media.IBrush Brush { get; } = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse(color)); + + [ObservableProperty] private bool _isSelected; + + [RelayCommand] + private void Choose() => owner.SetColor(Color); +} diff --git a/src/Filee.App/Views/AnnotateWindow.axaml b/src/Filee.App/Views/AnnotateWindow.axaml new file mode 100644 index 000000000..d5819e2f6 --- /dev/null +++ b/src/Filee.App/Views/AnnotateWindow.axaml @@ -0,0 +1,248 @@ + + + + M10.07,14.27C10.57,14.03 11.16,14.25 11.4,14.75L13.7,19.74L15.5,18.89L13.19,13.91C12.95,13.41 13.17,12.81 13.67,12.58L13.95,12.5L16.25,12.05L8,5.12V15.9L9.82,14.43L10.07,14.27M13.64,21.97C13.14,22.21 12.54,22 12.31,21.5L10.13,16.76L7.62,18.78C7.45,18.92 7.24,19 7,19A1,1 0 0,1 6,18V3A1,1 0 0,1 7,2C7.24,2 7.47,2.09 7.64,2.23L7.65,2.22L19.14,11.86C19.57,12.22 19.62,12.85 19.27,13.27C19.12,13.45 18.91,13.57 18.7,13.61L15.54,14.23L17.74,18.96C18,19.46 17.76,20.05 17.26,20.28L13.64,21.97Z + M20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18,2.9 17.35,2.9 16.96,3.29L15.12,5.12L18.87,8.87M3,17.25V21H6.75L17.81,9.93L14.06,6.18L3,17.25Z + M5,17.59L15.59,7H9V5H19V15H17V8.41L6.41,19L5,17.59Z + M4,6V18H20V6H4M18,16H6V8H18V16Z + M18.5,1.15C17.97,1.15 17.46,1.34 17.07,1.73L11.26,7.55L16.91,13.2L22.73,7.39C23.5,6.61 23.5,5.35 22.73,4.56L19.89,1.73C19.5,1.34 19,1.15 18.5,1.15M10.3,8.5L4.34,14.46C3.56,15.24 3.56,16.5 4.36,17.31C3.14,18.54 1.9,19.77 0.67,21H6.33L7.19,20.14C7.97,20.9 9.22,20.89 10,20.12L15.95,14.16 + M18.5,4L19.66,8.35L18.7,8.61C18.25,7.74 17.79,6.87 17.26,6.43C16.73,6 16.11,6 15.5,6H13V16.5C13,17 13,17.5 13.33,17.75C13.67,18 14.33,18 15,18V19H9V18C9.67,18 10.33,18 10.67,17.75C11,17.5 11,17 11,16.5V6H8.5C7.89,6 7.27,6 6.74,6.43C6.21,6.87 5.75,7.74 5.3,8.61L4.34,8.35L5.5,4H18.5Z + M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M10,7V9H12V17H14V7H10Z + M11.5,8C14.15,8 16.55,9 18.4,10.6L22,7V16H13L16.62,12.38C15.23,11.22 13.46,10.5 11.5,10.5C7.96,10.5 4.95,12.81 3.9,16L1.53,15.22C2.92,11.03 6.85,8 11.5,8Z + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +