diff --git a/Cargo.toml b/Cargo.toml index 7e477a207..06291eb0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,6 +122,9 @@ web-sys = { version = "=0.3.85", features = [ "File", "FileSystemDirectoryHandle", "FileSystemFileHandle", + # FileSystemFileHandle derefs to FileSystemHandle for the `name()` + # getter the in-place save (io::fs_access) reads off a picked handle. + "FileSystemHandle", "FileSystemGetDirectoryOptions", "FileSystemGetFileOptions", "FileSystemWritableFileStream", @@ -144,6 +147,10 @@ web-sys = { version = "=0.3.85", features = [ "MessageEvent", "ErrorEvent", "Clipboard", + # The web image paste reads `navigator.clipboard.read()` items + # (sys::read_clipboard_image): their `types()`/`getType()` surface lives + # on ClipboardItem. + "ClipboardItem", "KeyboardEvent", "KeyboardEventInit", "EventInit", diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 143763596..ba9161c0b 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = لا تحتوي الحافظة على نص أو كائنات رسم مدعومة. + .image-unsupported = تعذّر فك ترميز الصورة الملصقة "{ $name }". + .pasted-image = تم لصق الصورة "{ $name }": { $width }×{ $height } px .unavailable = حافظة النظام غير متاحة. .occupied = حافظة النظام قيد الاستخدام حاليًا. حاول مرة أخرى. .conversion-failed = تعذرت قراءة نص الحافظة. @@ -3594,6 +3596,8 @@ view = .opening-ocs-discussions-for-help-and-questions = يجري فتح مناقشات OCS للمساعدة والأسئلة... .opening-ocs-web = يجري فتح OCS Web... .browser-cannot-open-typed-path = لا يستطيع المتصفح فتح مسار مكتوب؛ اختر الملف بدلاً من ذلك. + .save-picker-failed-downloading = فشل اختيار وجهة الحفظ: __ocs_fmt_0__؛ يتم تنزيل نسخة + .in-place-save-failed-downloaded = فشل الحفظ في الموقع (__ocs_fmt_0__)؛ تم تنزيل نسخة .opening-the-command-reference = فتح مرجع الأوامر... .already-loaded-different-contents-kept = "__ocs_fmt_0__" محمّل بالفعل بمحتوى مختلف؛ تم الاحتفاظ بالملف الموجود. .opening-patreon-page = يجري فتح صفحة Patreon... diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 986e9bdd1..2a99e378d 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = Клипбордът не съдържа поддържан текст или чертожни обекти. + .image-unsupported = Вставеното изображение "{ $name }" не може да бъде декодирано. + .pasted-image = Вставено изображение "{ $name }": { $width }×{ $height } px .unavailable = Системният клипборд не е наличен. .occupied = Системният клипборд в момента се използва. Опитай отново. .conversion-failed = Текстът от клипборда не можа да бъде прочетен. @@ -3581,6 +3583,8 @@ view = .opening-ocs-discussions-for-help-and-questions = Отваряне на OCS дискусии за помощ и въпроси... .opening-ocs-web = Отваряне на OCS уебсайта... .browser-cannot-open-typed-path = Браузърът не може да отвори въведен път; изберете файла. + .save-picker-failed-downloading = Изборът на място за запис се провали: __ocs_fmt_0__; изтегля се копие + .in-place-save-failed-downloaded = Записът на място се провали (__ocs_fmt_0__); изтеглено е копие .opening-the-command-reference = Отваряне на справочника за команди... .already-loaded-different-contents-kept = "__ocs_fmt_0__" вече е зареден с различно съдържание; съществуващият файл е запазен. .opening-patreon-page = Отваряне на страницата в Patreon... diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 2999d9c7b..6705f9974 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = Schránka neobsahuje podporovaný text ani objekty výkresu. + .image-unsupported = Vložený obrázek "{ $name }" se nepodařilo dekódovat. + .pasted-image = Vložen obrázek "{ $name }": { $width }×{ $height } px .unavailable = Systémová schránka není dostupná. .occupied = Systémová schránka se právě používá. Zkuste to znovu. .conversion-failed = Text ze schránky se nepodařilo přečíst. @@ -3579,6 +3581,8 @@ view = .opening-ocs-discussions-for-help-and-questions = Otevírání diskuzí OCS pro nápovědu a dotazy... .opening-ocs-web = Otevírání webu OCS... .browser-cannot-open-typed-path = Prohlížeč nemůže otevřít zadanou cestu; vyberte soubor. + .save-picker-failed-downloading = Výběr cíle uložení selhal: __ocs_fmt_0__; stahuje se kopie + .in-place-save-failed-downloaded = Uložení na místo selhalo (__ocs_fmt_0__); kopie byla stažena .opening-the-command-reference = Otevírání referenční příručky příkazů... .already-loaded-different-contents-kept = "__ocs_fmt_0__" je již načten s jiným obsahem; existující soubor byl zachován. .opening-patreon-page = Otevírání stránky Patreon... diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index abca3dde8..94a89f2d7 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = Die Zwischenablage enthält keinen unterstützten Text und keine Zeichnungsobjekte. + .image-unsupported = Das eingefügte Bild "{ $name }" konnte nicht dekodiert werden. + .pasted-image = Bild "{ $name }" eingefügt: { $width }×{ $height } px .unavailable = Die Systemzwischenablage ist nicht verfügbar. .occupied = Die Systemzwischenablage wird gerade verwendet. Versuchen Sie es erneut. .conversion-failed = Der Text in der Zwischenablage konnte nicht gelesen werden. @@ -3580,6 +3582,8 @@ view = .opening-ocs-discussions-for-help-and-questions = Eröffnung OCS Diskussionen für Hilfe und Fragen... .opening-ocs-web = Öffnen OCS Web... .browser-cannot-open-typed-path = Der Browser kann einen eingegebenen Pfad nicht öffnen; wähle die Datei. + .save-picker-failed-downloading = Speicherziel-Auswahl fehlgeschlagen: __ocs_fmt_0__; eine Kopie wird heruntergeladen + .in-place-save-failed-downloaded = Direktes Speichern fehlgeschlagen (__ocs_fmt_0__); eine Kopie wurde heruntergeladen .opening-the-command-reference = Öffnen der Befehlsreferenz... .already-loaded-different-contents-kept = "__ocs_fmt_0__" ist bereits mit anderem Inhalt geladen; die vorhandene Datei wurde behalten. .opening-patreon-page = Eröffnungsseite Patreon... diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index b2bcb1a1b..7075c4ccb 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = Το πρόχειρο δεν περιέχει υποστηριζόμενο κείμενο ή αντικείμενα σχεδίου. + .image-unsupported = Δεν ήταν δυνατή η αποκωδικοποίηση της επικολλημένης εικόνας "{ $name }". + .pasted-image = Επικολλήθηκε η εικόνα "{ $name }": { $width }×{ $height } px .unavailable = Το πρόχειρο του συστήματος δεν είναι διαθέσιμο. .occupied = Το πρόχειρο του συστήματος χρησιμοποιείται αυτή τη στιγμή. Δοκιμάστε ξανά. .conversion-failed = Δεν ήταν δυνατή η ανάγνωση του κειμένου του προχείρου. @@ -3576,6 +3578,8 @@ view = .opening-ocs-discussions-for-help-and-questions = Άνοιγμα των συζητήσεων OCS για βοήθεια και ερωτήσεις... .opening-ocs-web = Άνοιγμα του OCS Web... .browser-cannot-open-typed-path = Ο browser δεν μπορεί να ανοίξει πληκτρολογημένη διαδρομή· διάλεξε το αρχείο. + .save-picker-failed-downloading = Απέτυχε η επιλογή προορισμού αποθήκευσης: __ocs_fmt_0__· γίνεται λήψη αντιγράφου + .in-place-save-failed-downloaded = Απέτυχε η αποθήκευση επί τόπου (__ocs_fmt_0__)· λήφθηκε αντίγραφο .opening-the-command-reference = Άνοιγμα της αναφοράς εντολών... .already-loaded-different-contents-kept = Το "__ocs_fmt_0__" είναι ήδη φορτωμένο με διαφορετικό περιεχόμενο· διατηρήθηκε το υπάρχον αρχείο. .opening-patreon-page = Άνοιγμα της σελίδας Patreon... diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index ffe773016..b955a20f4 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = Clipboard does not contain supported text or drawing objects. + .image-unsupported = The pasted image "{ $name }" could not be decoded. + .pasted-image = Pasted image "{ $name }": { $width }×{ $height } px .unavailable = System clipboard is unavailable. .occupied = System clipboard is currently in use. Try again. .conversion-failed = Clipboard text could not be read. @@ -3577,6 +3579,8 @@ view = .opening-ocs-discussions-for-help-and-questions = Opening OCS Discussions for help and questions... .opening-ocs-web = Opening OCS Web... .browser-cannot-open-typed-path = The browser cannot open a typed path; pick the file instead. + .save-picker-failed-downloading = Save destination picker failed: __ocs_fmt_0__; downloading a copy instead + .in-place-save-failed-downloaded = In-place save failed (__ocs_fmt_0__); a copy was downloaded instead .opening-the-command-reference = Opening the command reference... .already-loaded-different-contents-kept = "__ocs_fmt_0__" is already loaded with different contents; the existing file was kept. .opening-patreon-page = Opening Patreon page... diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index d149e9808..41cc2ecc0 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = El portapapeles no contiene texto ni objetos de dibujo compatibles. + .image-unsupported = No se pudo decodificar la imagen pegada "{ $name }". + .pasted-image = Imagen "{ $name }" pegada: { $width }×{ $height } px .unavailable = El portapapeles del sistema no está disponible. .occupied = El portapapeles del sistema está en uso. Inténtelo de nuevo. .conversion-failed = No se pudo leer el texto del portapapeles. @@ -3582,6 +3584,8 @@ view = .opening-ocs-discussions-for-help-and-questions = Abriendo Debates de OCS para obtener ayuda y hacer preguntas... .opening-ocs-web = Abriendo OCS Web... .browser-cannot-open-typed-path = El navegador no puede abrir una ruta escrita; elige el archivo. + .save-picker-failed-downloading = Falló la selección del destino de guardado: __ocs_fmt_0__; descargando una copia + .in-place-save-failed-downloaded = Error al guardar en el sitio (__ocs_fmt_0__); se descargó una copia .opening-the-command-reference = Abriendo la referencia de comandos... .already-loaded-different-contents-kept = "__ocs_fmt_0__" ya está cargado con contenido diferente; se conservó el archivo existente. .opening-patreon-page = Abriendo la página de Patreon... diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 2b5606083..d1c86a6f3 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = Leikepöydällä ei ole tuettua tekstiä tai piirustusobjekteja. + .image-unsupported = Liitettyä kuvaa "{ $name }" ei voitu purkaa. + .pasted-image = Liitetty kuva "{ $name }": { $width }×{ $height } px .unavailable = Järjestelmän leikepöytä ei ole käytettävissä. .occupied = Järjestelmän leikepöytä on parhaillaan käytössä. Yritä uudelleen. .conversion-failed = Leikepöydän tekstiä ei voitu lukea. @@ -3581,6 +3583,8 @@ view = .opening-ocs-discussions-for-help-and-questions = OCS-keskustelujen avaaminen apua ja kysymyksiä varten... .opening-ocs-web = Avataan OCS Web... .browser-cannot-open-typed-path = Selain ei voi avata kirjoitettua polkua; valitse tiedosto. + .save-picker-failed-downloading = Tallennuskohteen valinta epäonnistui: __ocs_fmt_0__; ladataan kopio + .in-place-save-failed-downloaded = Tallennus paikallaan epäonnistui (__ocs_fmt_0__); kopio ladattiin .opening-the-command-reference = Avataan komentoviittausta... .already-loaded-different-contents-kept = "__ocs_fmt_0__" on jo ladattu eri sisällöllä; olemassa oleva tiedosto säilytettiin. .opening-patreon-page = Avataan Patreon-sivua... diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 8c1ff23f1..ebf1fd15c 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = Le presse-papiers ne contient aucun texte ni objet de dessin pris en charge. + .image-unsupported = L'image collée "{ $name }" n'a pas pu être décodée. + .pasted-image = Image collée "{ $name }" : { $width }×{ $height } px .unavailable = Le presse-papiers système n’est pas disponible. .occupied = Le presse-papiers système est en cours d’utilisation. Réessayez. .conversion-failed = Impossible de lire le texte du presse-papiers. @@ -3582,6 +3584,8 @@ view = .opening-ocs-discussions-for-help-and-questions = Ouverture OCS Discussions pour l'aide et les questions... .opening-ocs-web = Ouverture du site Web OCS... .browser-cannot-open-typed-path = Le navigateur ne peut pas ouvrir un chemin saisi ; choisissez le fichier. + .save-picker-failed-downloading = Échec du sélecteur de destination : __ocs_fmt_0__ ; téléchargement d’une copie + .in-place-save-failed-downloaded = Échec de l’enregistrement local (__ocs_fmt_0__) ; une copie a été téléchargée .opening-the-command-reference = Ouverture de la référence des commandes... .already-loaded-different-contents-kept = "__ocs_fmt_0__" est déjà chargé avec un contenu différent ; le fichier existant a été conservé. .opening-patreon-page = Ouverture de la page Patreon... diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index ab4637d48..481a96f98 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = क्लिपबोर्ड में समर्थित टेक्स्ट या ड्रॉइंग ऑब्जेक्ट नहीं है। + .image-unsupported = पेस्ट की गई छवि "{ $name }" को डिकोड नहीं किया जा सका। + .pasted-image = छवि "{ $name }" पेस्ट की गई: { $width }×{ $height } px .unavailable = सिस्टम क्लिपबोर्ड उपलब्ध नहीं है। .occupied = सिस्टम क्लिपबोर्ड अभी उपयोग में है। फिर प्रयास करें। .conversion-failed = क्लिपबोर्ड का टेक्स्ट पढ़ा नहीं जा सका। @@ -3570,6 +3572,8 @@ view = .opening-ocs-discussions-for-help-and-questions = OCS मदद और प्रश्नों के लिए चर्चा खोलना। .opening-ocs-web = OCS वेब खोलना ... .browser-cannot-open-typed-path = ब्राउज़र टाइप किए गए पथ को नहीं खोल सकता; इसके बजाय फ़ाइल चुनें। + .save-picker-failed-downloading = सेव गंतव्य चुनना विफल: __ocs_fmt_0__; प्रतिलिपि डाउनलोड हो रही है + .in-place-save-failed-downloaded = इन-प्लेस सेव विफल (__ocs_fmt_0__); प्रतिलिपि डाउनलोड की गई .opening-the-command-reference = कमांड संदर्भ खोला जा रहा है... .already-loaded-different-contents-kept = "__ocs_fmt_0__" अलग सामग्री के साथ पहले से लोड है; मौजूदा फ़ाइल रखी गई। .opening-patreon-page = Patreon पृष्ठ खोला जा रहा है… diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 46a5dd2b2..4cde80283 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = A vágólap nem tartalmaz támogatott szöveget vagy rajzobjektumot. + .image-unsupported = A beillesztett "{ $name }" kép nem dekódolható. + .pasted-image = "{ $name }" kép beillesztve: { $width }×{ $height } px .unavailable = A rendszer vágólapja nem érhető el. .occupied = A rendszer vágólapja jelenleg használatban van. Próbálja újra. .conversion-failed = A vágólap szövege nem olvasható. @@ -3581,6 +3583,8 @@ view = .opening-ocs-discussions-for-help-and-questions = A OCS beszélgetések megnyitása segítségért és kérdésekért... .opening-ocs-web = A OCS web megnyitása... .browser-cannot-open-typed-path = A böngésző nem nyithat meg beírt útvonalat; válaszd a fájlt. + .save-picker-failed-downloading = A mentési cél kiválasztása nem sikerült: __ocs_fmt_0__; másolat letöltése + .in-place-save-failed-downloaded = A helyben mentés nem sikerült (__ocs_fmt_0__); másolat lett letöltve .opening-the-command-reference = A parancsreferencia megnyitása... .already-loaded-different-contents-kept = A(z) "__ocs_fmt_0__" már betöltve eltérő tartalommal; a meglévő fájl megőrizve. .opening-patreon-page = Patreon oldal megnyitása... diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index acbe5e50f..d61827b68 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = Gli Appunti non contengono testo o oggetti di disegno supportati. + .image-unsupported = Impossibile decodificare l'immagine incollata "{ $name }". + .pasted-image = Immagine "{ $name }" incollata: { $width }×{ $height } px .unavailable = Gli Appunti di sistema non sono disponibili. .occupied = Gli Appunti di sistema sono attualmente in uso. Riprova. .conversion-failed = Impossibile leggere il testo dagli Appunti. @@ -3581,6 +3583,8 @@ view = .opening-ocs-discussions-for-help-and-questions = Apertura delle discussioni OCS per assistenza e domande... .opening-ocs-web = Apertura del Web OCS... .browser-cannot-open-typed-path = Il browser non può aprire un percorso digitato; scegli il file. + .save-picker-failed-downloading = Selezione della destinazione non riuscita: __ocs_fmt_0__; scarico una copia + .in-place-save-failed-downloaded = Salvataggio sul posto non riuscito (__ocs_fmt_0__); è stata scaricata una copia .opening-the-command-reference = Apertura del riferimento dei comandi... .already-loaded-different-contents-kept = "__ocs_fmt_0__" è già caricato con contenuti diversi; il file esistente è stato mantenuto. .opening-patreon-page = Apertura della pagina Patreon... diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 90b793788..d07db05fe 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = クリップボードに対応するテキストまたは図面オブジェクトがありません。 + .image-unsupported = 貼り付けた画像 "{ $name }" をデコードできませんでした。 + .pasted-image = 画像 "{ $name }" を貼り付けました: { $width }×{ $height } px .unavailable = システムのクリップボードを使用できません。 .occupied = システムのクリップボードは現在使用中です。もう一度お試しください。 .conversion-failed = クリップボードのテキストを読み取れませんでした。 @@ -3570,6 +3572,8 @@ view = .opening-ocs-discussions-for-help-and-questions = ヘルプと質問のため OCS ディスカッションを開いています... .opening-ocs-web = OCS Web を開いています... .browser-cannot-open-typed-path = ブラウザは入力されたパスを開けません。ファイルを選択してください。 + .save-picker-failed-downloading = 保存先の選択に失敗しました: __ocs_fmt_0__。代わりにコピーをダウンロードしています + .in-place-save-failed-downloaded = その場での保存に失敗しました(__ocs_fmt_0__)。代わりにコピーをダウンロードしました .opening-the-command-reference = コマンドリファレンスを開いています... .already-loaded-different-contents-kept = "__ocs_fmt_0__" は既に別の内容で読み込まれています。既存のファイルを保持しました。 .opening-patreon-page = Patreon ページを開いています... diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 30dd254fe..7927148c1 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = 클립보드에 지원되는 텍스트나 도면 객체가 없습니다. + .image-unsupported = 붙여넣은 이미지 "{ $name }"을(를) 디코딩할 수 없습니다. + .pasted-image = 이미지 "{ $name }" 붙여넣음: { $width }×{ $height } px .unavailable = 시스템 클립보드를 사용할 수 없습니다. .occupied = 시스템 클립보드가 현재 사용 중입니다. 다시 시도하세요. .conversion-failed = 클립보드 텍스트를 읽을 수 없습니다. @@ -3581,6 +3583,8 @@ view = .opening-ocs-discussions-for-help-and-questions = OCS 열기 도움말 및 질문에 대한 토론... .opening-ocs-web = OCS 웹을 여는 중... .browser-cannot-open-typed-path = 브라우저는 입력한 경로를 열 수 없습니다. 파일을 선택하세요. + .save-picker-failed-downloading = 저장 대상 선택 실패: __ocs_fmt_0__; 대신 사본을 내려받는 중 + .in-place-save-failed-downloaded = 제자리 저장 실패(__ocs_fmt_0__); 대신 사본을 내려받았습니다 .opening-the-command-reference = 명령 참조 열기... .already-loaded-different-contents-kept = "__ocs_fmt_0__"이(가) 이미 다른 내용으로 로드되었습니다. 기존 파일을 유지했습니다. .opening-patreon-page = Patreon 페이지를 여는 중... diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 6e4184028..633f340e6 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = Het klembord bevat geen ondersteunde tekst of tekenobjecten. + .image-unsupported = De geplakte afbeelding "{ $name }" kon niet worden gedecodeerd. + .pasted-image = Afbeelding "{ $name }" geplakt: { $width }×{ $height } px .unavailable = Het systeemklembord is niet beschikbaar. .occupied = Het systeemklembord is momenteel in gebruik. Probeer het opnieuw. .conversion-failed = De tekst op het klembord kon niet worden gelezen. @@ -3580,6 +3582,8 @@ view = .opening-ocs-discussions-for-help-and-questions = Openen OCS Discussie voor hulp en vragen... .opening-ocs-web = Openen OCS Web... .browser-cannot-open-typed-path = De browser kan een getypt pad niet openen; kies het bestand. + .save-picker-failed-downloading = Kiezen van opslagbestemming mislukt: __ocs_fmt_0__; een kopie wordt gedownload + .in-place-save-failed-downloaded = Ter plekke opslaan mislukt (__ocs_fmt_0__); een kopie is gedownload .opening-the-command-reference = Commandoreferentie openen... .already-loaded-different-contents-kept = "__ocs_fmt_0__" is al geladen met andere inhoud; het bestaande bestand is bewaard. .opening-patreon-page = Patreon pagina openen... diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index ed6f563f4..2edc98cce 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = Schowek nie zawiera obsługiwanego tekstu ani obiektów rysunku. + .image-unsupported = Nie udało się zdekodować wklejonego obrazu "{ $name }". + .pasted-image = Wklejono obraz "{ $name }": { $width }×{ $height } px .unavailable = Schowek systemowy jest niedostępny. .occupied = Schowek systemowy jest obecnie używany. Spróbuj ponownie. .conversion-failed = Nie można odczytać tekstu ze schowka. @@ -3581,6 +3583,8 @@ view = .opening-ocs-discussions-for-help-and-questions = Otwieranie dyskusji OCS w celu uzyskania pomocy i pytań... .opening-ocs-web = Otwieranie OCS Web... .browser-cannot-open-typed-path = Przeglądarka nie otworzy wpisanej ścieżki; wybierz plik. + .save-picker-failed-downloading = Wybór miejsca zapisu nie powiódł się: __ocs_fmt_0__; pobieranie kopii + .in-place-save-failed-downloaded = Zapis w miejscu nie powiódł się (__ocs_fmt_0__); pobrano kopię .opening-the-command-reference = Otwieranie dokumentacji poleceń... .already-loaded-different-contents-kept = "__ocs_fmt_0__" jest już wczytany z inną zawartością; zachowano istniejący plik. .opening-patreon-page = Otwieranie strony Patreon... diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index e7e3956c8..199672420 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = A área de transferência não contém texto nem objetos de desenho compatíveis. + .image-unsupported = Não foi possível decodificar a imagem colada "{ $name }". + .pasted-image = Imagem "{ $name }" colada: { $width }×{ $height } px .unavailable = A área de transferência do sistema não está disponível. .occupied = A área de transferência do sistema está em uso. Tente novamente. .conversion-failed = Não foi possível ler o texto da área de transferência. @@ -3582,6 +3584,8 @@ view = .opening-ocs-discussions-for-help-and-questions = Abrindo Discussões do OCS para ajuda e perguntas... .opening-ocs-web = Abrindo OCS Web... .browser-cannot-open-typed-path = O navegador não pode abrir um caminho digitado; escolha o ficheiro. + .save-picker-failed-downloading = A escolha do destino de gravação falhou: __ocs_fmt_0__; a descarregar uma cópia + .in-place-save-failed-downloaded = A gravação no local falhou (__ocs_fmt_0__); foi descarregada uma cópia .opening-the-command-reference = Abrindo a referência de comandos... .already-loaded-different-contents-kept = "__ocs_fmt_0__" já está carregado com conteúdo diferente; o ficheiro existente foi mantido. .opening-patreon-page = Abrindo página do Patreon... diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 4f7832559..8b9541afb 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -161,6 +161,8 @@ modal = clipboard = .no-supported-content = Буфер обмена не содержит поддерживаемого текста или объектов чертежа. + .image-unsupported = Не удалось декодировать вставленное изображение "{ $name }". + .pasted-image = Вставлено изображение "{ $name }": { $width }×{ $height } px .unavailable = Системный буфер обмена недоступен. .occupied = Системный буфер обмена сейчас используется. Повторите попытку. .conversion-failed = Не удалось прочитать текст из буфера обмена. @@ -3583,6 +3585,8 @@ view = .opening-ocs-discussions-for-help-and-questions = Открытие OCS Дискуссии о помощи и вопросах .opening-ocs-web = Открытие OCS Веб-сайт .browser-cannot-open-typed-path = Браузер не может открыть введённый путь; выберите файл. + .save-picker-failed-downloading = Не удалось выбрать место сохранения: __ocs_fmt_0__; загружается копия + .in-place-save-failed-downloaded = Не удалось сохранить в файл (__ocs_fmt_0__); копия загружена .opening-the-command-reference = Открытие справочника команд... .already-loaded-different-contents-kept = "__ocs_fmt_0__" уже загружен с другим содержимым; существующий файл сохранён. .opening-patreon-page = Открытие страницы Patreon... diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index adfab5524..3e08248ad 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -159,6 +159,8 @@ modal = clipboard = .no-supported-content = Panoda desteklenen metin veya çizim nesnesi yok. + .image-unsupported = Yapıştırılan "{ $name }" görseli çözümlenemedi. + .pasted-image = "{ $name }" görseli yapıştırıldı: { $width }×{ $height } px .unavailable = Sistem panosuna erişilemiyor. .occupied = Sistem panosu şu anda kullanılıyor. Yeniden deneyin. .conversion-failed = Panodaki metin okunamadı. @@ -3532,6 +3534,8 @@ view = .opening-ocs-discussions-for-help-and-questions = Yardım ve sorular için OCS Tartışmaları açılıyor... .opening-ocs-web = OCS Web açılıyor... .browser-cannot-open-typed-path = Tarayıcı yazılan bir yolu açamaz; dosyayı seçin. + .save-picker-failed-downloading = Kayıt hedefi seçimi başarısız: __ocs_fmt_0__; bir kopya indiriliyor + .in-place-save-failed-downloaded = Yerine kayıt başarısız (__ocs_fmt_0__); bir kopya indirildi .opening-the-command-reference = Komut başvurusu açılıyor... .already-loaded-different-contents-kept = "__ocs_fmt_0__" zaten farklı içerikle yüklü; mevcut dosya tutuldu. .opening-patreon-page = Patreon sayfası açılıyor... diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 0b157968c..9780d4e9a 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -160,6 +160,8 @@ modal = clipboard = .no-supported-content = 剪贴板中没有受支持的文本或图形对象。 + .image-unsupported = 无法解码粘贴的图像 "{ $name }"。 + .pasted-image = 已粘贴图像 "{ $name }":{ $width }×{ $height } px .unavailable = 系统剪贴板不可用。 .occupied = 系统剪贴板当前正被使用,请重试。 .conversion-failed = 无法读取剪贴板文本。 @@ -3565,6 +3567,8 @@ view = .opening-ocs-discussions-for-help-and-questions = 开幕OCS 讨论帮助和问题... .opening-ocs-web = 打开OCS网络... .browser-cannot-open-typed-path = 浏览器无法打开键入的路径;请改为选择文件。 + .save-picker-failed-downloading = 选择保存目标失败:__ocs_fmt_0__;正在下载副本 + .in-place-save-failed-downloaded = 就地保存失败(__ocs_fmt_0__);已下载副本 .opening-the-command-reference = 正在打开命令参考... .already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同内容加载;保留了现有文件。 .opening-patreon-page = 打开帕特伦的页面... diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 43fe1f8fb..80e1b61c2 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -160,6 +160,8 @@ modal = clipboard = .no-supported-content = 剪貼簿中沒有受支援的文字或圖形物件。 + .image-unsupported = 無法解碼貼上的影像 "{ $name }"。 + .pasted-image = 已貼上影像 "{ $name }":{ $width }×{ $height } px .unavailable = 系統剪貼簿不可用。 .occupied = 系統剪貼簿目前正被使用,請重試。 .conversion-failed = 無法讀取剪貼簿文字。 @@ -3577,6 +3579,8 @@ view = .opening-ocs-discussions-for-help-and-questions = 開幕OCS 討論幫助和問題... .opening-ocs-web = 開啟OCS網路... .browser-cannot-open-typed-path = 瀏覽器無法開啟輸入的路徑;請改為選擇檔案。 + .save-picker-failed-downloading = 選擇儲存目標失敗:__ocs_fmt_0__;正在下載副本 + .in-place-save-failed-downloaded = 就地儲存失敗(__ocs_fmt_0__);已下載副本 .opening-the-command-reference = 正在開啟命令參考... .already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同內容載入;保留了現有檔案。 .opening-patreon-page = 開啟帕特倫的頁面... diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index e215ad0c2..1588dd8eb 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -113,6 +113,13 @@ pub(super) struct State { serial: u64, events: VecDeque, pub(super) routing: bool, + /// Web `capture` op's destination path, parked until the next presented + /// frame arrives: the canvas readback (sys::capture_canvas) only sees the + /// drawing before the browser clears the WebGL drawing buffer, so the op + /// stays pending across frames instead of using iced's GPU-mapped window + /// screenshot, which cannot run on wasm. + #[cfg(target_arch = "wasm32")] + pub(super) pending_capture: Option, } impl State { pub(super) fn new() -> Self { @@ -895,12 +902,26 @@ impl OpenCADStudio { } } "capture" => { - let window = self - .main_window - .ok_or_else(|| failure("gui_required", "Capture requires a GUI window"))?; let path = string(req, "path")?.to_owned(); - iced::window::screenshot(window) - .map(move |s| Message::ControlScreenshot(path.clone(), Some(s))) + // Web: iced's window screenshot needs a synchronous GPU map + // wasm cannot provide. Park the path until the next presented + // frame (the view's frames subscription turns it into + // ControlCaptureFrame) and read the canvas there — the same + // readback the save-thumbnail path uses. The op stays pending + // until ControlScreenshot lands from that frame. + #[cfg(target_arch = "wasm32")] + { + self.control.pending_capture = Some(path); + Task::none() + } + #[cfg(not(target_arch = "wasm32"))] + { + let window = self.main_window.ok_or_else(|| { + failure("gui_required", "Capture requires a GUI window") + })?; + iced::window::screenshot(window) + .map(move |s| Message::ControlScreenshot(path.clone(), Some(s))) + } } "stop" => { self.control.enabled = false; @@ -998,6 +1019,12 @@ impl OpenCADStudio { if !self.active_save_jobs.is_empty() || self.pending_native_thumbnail_save.is_some() { return; } + // A web capture is still waiting for its presented frame; completing + // now would report the op done before ControlScreenshot exists. + #[cfg(target_arch = "wasm32")] + if self.control.pending_capture.is_some() { + return; + } let p = self.control.pending.take().unwrap(); let previous = self.active_tab; if let Some(index) = p @@ -1089,6 +1116,20 @@ impl OpenCADStudio { json!({"ok":true,"document_id":tab.id,"geometry_revision":tab.scene.geometry_epoch,"measurements":out}) } + /// Web capture frame: the just-presented frame's canvas contents are + /// still readable, so a parked `capture` op takes its screenshot here and + /// settles. Same window of validity the save-thumbnail flow reads the + /// canvas in — one frame after the request, before the buffer clears. + #[cfg(target_arch = "wasm32")] + pub(super) fn on_control_capture_frame(&mut self) -> Task { + if let Some(path) = self.control.pending_capture.take() { + let screenshot = crate::sys::capture_canvas(); + self.control_screenshot(path, screenshot); + self.control_settle(); + } + Task::none() + } + pub(super) fn control_screenshot( &mut self, path: String, @@ -1151,6 +1192,23 @@ impl OpenCADStudio { image::imageops::FilterType::Triangle, ); } + #[cfg(target_arch = "wasm32")] + { + // No filesystem behind a web "path": encode the PNG in + // memory and hand it to the browser as a download, named + // after the request's file name — the delivery every other + // web output uses. + let mut png = Vec::new(); + image + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|e| e.to_string())?; + let download = std::path::Path::new(&path) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.clone()); + crate::sys::download_bytes(&download, &png); + } + #[cfg(not(target_arch = "wasm32"))] image .save_with_format(&path, image::ImageFormat::Png) .map_err(|e| e.to_string())?; diff --git a/src/app/mod.rs b/src/app/mod.rs index eb902f3ba..3cdda01ec 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -724,6 +724,14 @@ pub(super) struct OpenCADStudio { pending_native_thumbnail_save: Option, #[cfg(target_arch = "wasm32")] pending_web_thumbnail_save: Option, + /// Web: whether the save in flight came from a plain Save + /// (Save/QSAVE or the save-before-close flow), so the tab's retained + /// File System Access handle (see io::fs_access) may be written + /// through directly; Save As opens the dialog modal and must re-pick. + /// Set true by `save_with_default_format`, false by + /// `open_save_dialog_window`. + #[cfg(target_arch = "wasm32")] + save_plain_reuse_fs_handle: bool, /// One web autosave round is running; ticks that fire while it lasts are /// dropped instead of queued (the next interval retries). #[cfg(target_arch = "wasm32")] @@ -1955,6 +1963,12 @@ pub enum Message { ControlStep(String, Box), ControlTaskDone(String), ControlScreenshot(String, Option), + /// Web: a presented frame arrived while a control `capture` op is parked + /// waiting for one, so the canvas readback can run before the browser + /// clears the WebGL drawing buffer. Native uses iced's window screenshot + /// directly and never emits this. + #[cfg(target_arch = "wasm32")] + ControlCaptureFrame, ControlToggle, Tick(Instant), /// Periodic drain of plugin-to-host requests that arrived outside a host @@ -1978,6 +1992,12 @@ pub enum Message { PasteShortcut, /// Completion of a system text clipboard read requested by PASTECLIP. SystemClipboardPaste(SystemClipboardText), + /// Web: completion of the clipboard-image read the paste fallback starts + /// when no text was found — `Some((name, bytes))` attaches the image, + /// `None` reports the usual unsupported-content error. Native never + /// constructs it. + #[cfg(target_arch = "wasm32")] + SystemClipboardPasteImage(Option<(String, Vec)>), /// Ctrl/Cmd+A — select all layer rows when the Layer Manager is open, or all /// drawing objects otherwise (#236). SelectAllShortcut, @@ -2372,6 +2392,41 @@ pub enum Message { camera_generation: u64, result: Result, String>, }, + /// Web SAVE with the File System Access API: the showSaveFilePicker + /// promise settled. The picker ran in the save-confirm handler — the + /// user-gesture moment — because the serialization that follows may + /// outlast the transient-activation window; the retained handle (kept + /// in io::fs_access's tab-keyed registry, since a JsValue handle cannot + /// ride in a Send Message) is written to later without needing a + /// gesture. `Ok(Some(name))` = destination chosen (`name` is its real, + /// possibly renamed name); `Ok(None)` = the user dismissed the picker + /// (cancel the save, like desktop); `Err` = the picker itself failed + /// (fall back to the legacy download). + #[cfg(target_arch = "wasm32")] + WebSaveHandlePicked { + tab_id: u64, + filename: String, + ext: String, + version: acadrust::DxfVersion, + picked: Result, String>, + }, + /// Web SAVE in-place completion: the retained FS Access handle's + /// createWritable write settled (on failure the task already fell back + /// to a download); run the shared save bookkeeping, including the OPFS + /// recent-copy store the download path performs. Same race-guard + /// markers as `WebSaveSerialized`. + #[cfg(target_arch = "wasm32")] + WebSaveWritten { + tab_id: u64, + filename: String, + autosave_key: String, + version: acadrust::DxfVersion, + geometry_epoch: u64, + edit_revision: u64, + camera_generation: u64, + result: Result<(), String>, + bytes: Vec, + }, /// Web WBLOCK: the serializer worker (or the inline fallback) produced /// the block document bytes; download them under `filename`, then run /// the same command-line bookkeeping as `WblockWriteFinished`. @@ -3919,6 +3974,8 @@ impl OpenCADStudio { #[cfg(target_arch = "wasm32")] pending_web_thumbnail_save: None, #[cfg(target_arch = "wasm32")] + save_plain_reuse_fs_handle: false, + #[cfg(target_arch = "wasm32")] web_autosave_in_flight: false, #[cfg(target_arch = "wasm32")] web_autosave_warned: false, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 466414b21..4d36012ca 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -117,6 +117,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // This tab is closing for good — drop its autosave recovery copy. #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::remove_file(self.autosave_target(idx)); + // …and, on the web, its retained in-place save handle: the + // next tab to reuse that id must start with a fresh pick + // instead of silently writing over the closed drawing's file. + #[cfg(target_arch = "wasm32")] + if let Some(closing_id) = self.tabs.get(idx).map(|t| t.id) { + crate::io::fs_access::drop_handle(closing_id); + } // Only-tab case: when the lone non-start tab closes, fall // back to the Start tab if it exists; otherwise spawn a // fresh blank drawing (legacy behaviour). @@ -1883,6 +1890,68 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } + /// Attach a clipboard image read on the web (a pasted screenshot) the way + /// the IMAGE command's pick does: register the bytes name-keyed, decode + /// for the file's true pixel size, then commit the entity — but placed + /// directly at the cursor (0,0 before any pointer move) with the command's + /// default width, skipping the pick prompts a paste should not run. + #[cfg(target_arch = "wasm32")] + pub(super) fn paste_clipboard_image(&mut self, name: String, bytes: Vec) -> Task { + let i = self.active_tab; + if self.tabs[i].is_start { + self.command_line.push_error( + crate::tr!("common", "no-drawing-open-use-new-or-open-first").as_ref(), + ); + return Task::none(); + } + crate::scene::model::image_model::register_source(&name, std::sync::Arc::new(bytes)); + let Some(decoded) = crate::scene::model::image_model::resolve_image(&name) else { + self.command_line.push_error( + crate::tr!("clipboard", "image-unsupported", name = name.clone()).as_ref(), + ); + return Task::none(); + }; + let (pixels_w, pixels_h) = (decoded.source_width, decoded.source_height); + // Same default footprint as the IMAGE command's Enter placement: one + // world unit per 100 px (min 1), height from the true aspect ratio. + let width = (pixels_w as f64 / 100.0).max(1.0); + let aspect = if pixels_h == 0 { + 1.0 + } else { + pixels_w as f64 / pixels_h as f64 + }; + let height = width / aspect; + let origin = self.tabs[i].last_cursor_world; + let mut image = acadrust::entities::RasterImage::with_size( + &name, + acadrust::types::Vector3::new(origin.x, origin.y, origin.z), + pixels_w as f64, + pixels_h as f64, + width, + height, + ); + image.flags = acadrust::entities::ImageDisplayFlags::SHOW_IMAGE + | acadrust::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY; + let short = std::path::Path::new(&name) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&name) + .to_string(); + self.command_line.push_output( + crate::tr!( + "clipboard", + "pasted-image", + name = short, + width = pixels_w, + height = pixels_h + ) + .as_ref(), + ); + self.apply_cmd_result(crate::command::CmdResult::CommitAndExit( + acadrust::EntityType::RasterImage(image), + )) + } + pub(super) fn on_qselect_open(&mut self) -> Task { let i = self.active_tab; self.tabs[i].scene.selection.borrow_mut().context_menu = None; diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index ed013ebb2..0e8f9df31 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -22,6 +22,15 @@ use iced::{mouse, Point, Task}; impl OpenCADStudio { pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task { + // Every entry here is Save-As semantics (the SAVEAS command, the + // recovery save-as, the native failure redirects): the user is + // choosing a destination, so on the web the FS Access picker must + // open even when the tab already holds a handle (plain Save sets + // the flag back in save_with_default_format). + #[cfg(target_arch = "wasm32")] + { + self.save_plain_reuse_fs_handle = false; + } // Default the format dropdown to the loaded file's own format — its // DWG-vs-DXF kind (from the extension) and its version (from the parsed // document) — so Save-As round-trips the format instead of silently diff --git a/src/app/update/file.rs b/src/app/update/file.rs index eab4d1755..1f2b51873 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1508,7 +1508,7 @@ impl OpenCADStudio { #[cfg(target_arch = "wasm32")] if self.web_session_focus_pending && self.web_autosave_restore_all_remaining == 0 { self.web_session_focus_pending = false; - self.select_remembered_session_tab(); + let _ = self.select_remembered_session_tab(); } let mut recovery_report = recovery_needed.then(|| { @@ -2193,10 +2193,12 @@ impl OpenCADStudio { } /// Web SAVE completion: the serializer worker (or inline fallback) has the - /// drawing bytes. The download and tab bookkeeping that used to run - /// synchronously inside `on_web_save_screenshot`, unchanged in semantics — - /// except the dirty flag is only cleared when the serialized snapshot is - /// still the tab's current state. + /// drawing bytes. When the tab holds a File System Access handle the + /// bytes go to it in place (createWritable needs no user gesture, so the + /// write may happen long after the pick); completion continues in + /// `on_web_save_written`. Otherwise the legacy download fires — the only + /// artifact of the save, launched even if the tab was closed while + /// serialization was in flight. #[cfg(target_arch = "wasm32")] pub(super) fn on_web_save_serialized( &mut self, @@ -2217,9 +2219,111 @@ impl OpenCADStudio { return self.finish_web_save(false); } }; + // In-place write through the handle picked at the confirm gesture + // (or an earlier one). The bookkeeping runs at write completion + // (WebSaveWritten) so a failed write cannot clear the dirty flag; + // the bytes ride back with the completion so the OPFS recent store + // gets the same copy the download path uses. + if let Some(picked_name) = crate::io::fs_access::handle_name(tab_id) { + let download_name = picked_name.clone(); + return Task::perform( + async move { + let result = if let Err(error) = + crate::io::fs_access::write_handle(tab_id, &bytes).await + { + // Keep the save alive: the drawing still lands + // somewhere via the legacy download (which needs no + // activation), and the error travels with the + // completion message for the command-line note. + crate::sys::download_bytes(&download_name, &bytes); + Err(error) + } else { + Ok(()) + }; + (result, bytes) + }, + move |(result, bytes)| Message::WebSaveWritten { + tab_id, + filename: picked_name, + autosave_key, + version, + geometry_epoch, + edit_revision, + camera_generation, + result, + bytes, + }, + ); + } // The download is the only artifact of the save — fire it even if the // tab was closed while serialization was in flight. crate::sys::download_bytes(&filename, &bytes); + self.finalize_web_save( + tab_id, + filename, + autosave_key, + version, + geometry_epoch, + edit_revision, + camera_generation, + bytes, + ) + } + + /// Web SAVE in-place completion: the retained handle's write settled. + /// Success keeps the handle for the next plain Save; failure drops it + /// (the task already downloaded a copy — note that in the command line, + /// and re-pick on the next save instead of failing the same way again). + #[cfg(target_arch = "wasm32")] + pub(super) fn on_web_save_written( + &mut self, + tab_id: u64, + filename: String, + autosave_key: String, + version: acadrust::DxfVersion, + geometry_epoch: u64, + edit_revision: u64, + camera_generation: u64, + result: Result<(), String>, + bytes: Vec, + ) -> Task { + if let Err(error) = result { + crate::io::fs_access::drop_handle(tab_id); + self.command_line.push_error( + crate::tf!( + "In-place save failed ({error}); a copy was downloaded instead" + ) + .as_ref(), + ); + } + self.finalize_web_save( + tab_id, + filename, + autosave_key, + version, + geometry_epoch, + edit_revision, + camera_generation, + bytes, + ) + } + + /// Shared completion half of the web save — download and in-place write + /// alike: recent-copy store task, tab bookkeeping under the race guard + /// (the dirty flag only clears when the persisted snapshot is still the + /// tab's current state), autosave-key retirement and the "Saved" line. + #[cfg(target_arch = "wasm32")] + fn finalize_web_save( + &mut self, + tab_id: u64, + filename: String, + autosave_key: String, + version: acadrust::DxfVersion, + geometry_epoch: u64, + edit_revision: u64, + camera_generation: u64, + bytes: Vec, + ) -> Task { let cache_name = std::path::Path::new(&filename) .file_name() .map(|name| name.to_string_lossy().into_owned()) @@ -2992,6 +3096,14 @@ impl OpenCADStudio { /// save-before-close flow — the version picker is reserved for Save As. pub(in crate::app) fn save_with_default_format(&mut self, tab_idx: usize) -> Task { self.active_tab = tab_idx; + // Every caller of this entry (Save/QSAVE, the save-before-close + // flow) is plain-save semantics: on the web, the tab's retained + // FS Access handle may be written through directly. Save As opens + // the dialog modal instead (open_save_dialog_window resets this). + #[cfg(target_arch = "wasm32")] + { + self.save_plain_reuse_fs_handle = true; + } self.save_dialog_format = if self.tabs[tab_idx].recovery_save_as_required { let document = &self.tabs[tab_idx].scene.document; let is_dxf = @@ -3088,7 +3200,9 @@ impl OpenCADStudio { ); Task::batch([close, pick]) } - // Web: no filesystem — serialize and hand the browser a download. + // Web: no filesystem path — the destination is either a retained + // File System Access handle (in-place write), a fresh + // showSaveFilePicker pick, or the legacy download. #[cfg(target_arch = "wasm32")] { let close = self.close_save_dialog_window(); @@ -3098,30 +3212,147 @@ impl OpenCADStudio { self.sync_solid_models_for_save(i); self.tabs[i].scene.sync_native_parametric_graph(); let tab_id = self.tabs[i].id; - let bounds = - crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID); - let Some(bounds) = bounds else { - return Task::batch([ - close, - Task::done(Message::WebSaveScreenshot { - tab_id, - filename, - ext: ext.to_string(), - version, - bounds: None, - screenshot: None, - }), - ]); - }; - self.pending_web_thumbnail_save = Some(crate::app::PendingWebThumbnailSave { + // File System Access routing, decided HERE: showSaveFilePicker + // may only run while the page holds the Save click's transient + // user activation, and the thumbnail capture + worker + // serialization that follow can outlast that window. So the + // handle is secured at this gesture moment (or reused from a + // previous pick — retained handles need no activation to write) + // and only the write happens later. + let route = crate::io::fs_access::save_route( + crate::io::fs_access::supported(), + self.save_plain_reuse_fs_handle, + crate::io::fs_access::handle_ext(tab_id).as_deref(), + ext, + ); + match route { + crate::io::fs_access::SaveRoute::WriteInPlace => { + // Plain Save on a matching handle — THE point: Ctrl+S + // updates the picked file, no dialog at all. The real + // name (the user may have renamed it in the picker) + // drives the save bookkeeping. + let picked_name = + crate::io::fs_access::handle_name(tab_id).unwrap_or(filename); + Task::batch([ + close, + self.web_save_capture(tab_id, picked_name, ext.to_string(), version), + ]) + } + crate::io::fs_access::SaveRoute::PickHandle => { + // The picker IS the save dialog here: it opens inside + // the click's activation window, before any serialize + // task starts. Cancelling it cancels the save (same as + // a native save-dialog cancel); a picker failure falls + // back to the legacy download. + let suggested = filename; + let pick_suggested = suggested.clone(); + Task::batch([ + close, + Task::perform( + async move { + crate::io::fs_access::pick_save_handle( + tab_id, + &pick_suggested, + ext, + ) + .await + }, + move |picked| Message::WebSaveHandlePicked { + tab_id, + filename: suggested, + ext: ext.to_string(), + version, + picked, + }, + ), + ]) + } + // Firefox/Safari (no showSaveFilePicker): untouched legacy + // download flow. + crate::io::fs_access::SaveRoute::Download => { + Task::batch([ + close, + self.web_save_capture(tab_id, filename, ext.to_string(), version), + ]) + } + } + } + } + + /// Web save, destination-known half shared by the legacy download path + /// and both File System Access paths: queue the viewport thumbnail + /// capture (or skip straight to the serializer when the bounds are + /// unavailable), which continues in `on_web_save_screenshot`. + #[cfg(target_arch = "wasm32")] + fn web_save_capture( + &mut self, + tab_id: u64, + filename: String, + ext: String, + version: acadrust::DxfVersion, + ) -> Task { + let bounds = crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID); + let Some(bounds) = bounds else { + return Task::done(Message::WebSaveScreenshot { tab_id, filename, - ext: ext.to_string(), + ext, version, - bounds, + bounds: None, + screenshot: None, }); - self.thumbnail_capture_clean = true; - close + }; + self.pending_web_thumbnail_save = Some(crate::app::PendingWebThumbnailSave { + tab_id, + filename, + ext, + version, + bounds, + }); + self.thumbnail_capture_clean = true; + Task::none() + } + + /// Web SAVE: the showSaveFilePicker promise from the confirm handler + /// settled. `Ok(Some(name))` continues the save against the picked + /// file's real name (the registry already retains the handle, replacing + /// any previous one — Save As re-picks); `Ok(None)` (dismissed picker) + /// cancels the save like a native save-dialog cancel; `Err` keeps the + /// save alive through the legacy download, with a note that the copy + /// was downloaded. + #[cfg(target_arch = "wasm32")] + pub(super) fn on_web_save_handle_picked( + &mut self, + tab_id: u64, + filename: String, + ext: String, + version: acadrust::DxfVersion, + picked: Result, String>, + ) -> Task { + match picked { + Ok(Some(picked_name)) => { + self.web_save_capture(tab_id, picked_name, ext, version) + } + Ok(None) => { + // The picker was the save dialog: dismissing it means no + // save — the download fallback must NOT fire (that would + // surprise the user with a stray "drawing (1).dwg"). If + // this came from the unsaved-changes → save → close flow, + // re-show the confirmation so the tab isn't lost. + if self.save_dialog_for_unsaved && self.pending_close.is_some() { + return self.open_unsaved_dialog_window(); + } + Task::none() + } + Err(error) => { + self.command_line.push_error( + crate::tf!( + "Save destination picker failed: {error}; downloading a copy instead" + ) + .as_ref(), + ); + self.web_save_capture(tab_id, filename, ext, version) + } } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9862c5975..ad304779a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -488,6 +488,10 @@ impl OpenCADStudio { self.control_screenshot(path, screenshot); Task::none() } + // Web: a presented frame arrived for a parked capture op (see + // control::on_control_capture_frame). + #[cfg(target_arch = "wasm32")] + Message::ControlCaptureFrame => self.on_control_capture_frame(), Message::ControlToggle => { self.control.enabled = !self.control.enabled; Task::none() @@ -5244,9 +5248,24 @@ impl OpenCADStudio { self.focus_cmd_input() } Text::EmptyOrUnsupported => { - self.command_line - .push_error(crate::tr!("clipboard", "no-supported-content").as_ref()); - Task::none() + // Web: no text — the clipboard may still hold an IMAGE + // (a pasted screenshot). Read it through the async + // Clipboard API and attach it the way the IMAGE + // command's pick does; only when that also finds + // nothing does the unsupported-content error surface. + // Native has no image route off the sync clipboard. + #[cfg(target_arch = "wasm32")] + return Task::perform( + crate::sys::read_clipboard_image(), + Message::SystemClipboardPasteImage, + ); + #[cfg(not(target_arch = "wasm32"))] + { + self.command_line.push_error( + crate::tr!("clipboard", "no-supported-content").as_ref(), + ); + Task::none() + } } Text::Unavailable => { self.command_line @@ -5266,6 +5285,19 @@ impl OpenCADStudio { } } + // Web: the clipboard-image fallback of a text-less paste either + // attaches the image or falls back to the same error the text + // path reports when nothing readable was on the clipboard. + #[cfg(target_arch = "wasm32")] + Message::SystemClipboardPasteImage(image) => match image { + Some((name, bytes)) => self.paste_clipboard_image(name, bytes), + None => { + self.command_line + .push_error(crate::tr!("clipboard", "no-supported-content").as_ref()); + Task::none() + } + }, + Message::SelectAllShortcut => { let i = self.active_tab; if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) { @@ -8401,6 +8433,38 @@ impl OpenCADStudio { result, ), + #[cfg(target_arch = "wasm32")] + Message::WebSaveHandlePicked { + tab_id, + filename, + ext, + version, + picked, + } => self.on_web_save_handle_picked(tab_id, filename, ext, version, picked), + + #[cfg(target_arch = "wasm32")] + Message::WebSaveWritten { + tab_id, + filename, + autosave_key, + version, + geometry_epoch, + edit_revision, + camera_generation, + result, + bytes, + } => self.on_web_save_written( + tab_id, + filename, + autosave_key, + version, + geometry_epoch, + edit_revision, + camera_generation, + result, + bytes, + ), + #[cfg(target_arch = "wasm32")] Message::WebSaveallSerialized { skipped, results } => { self.on_web_saveall_serialized(skipped, results) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index d7b39ceb9..68381c945 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2531,6 +2531,17 @@ impl OpenCADStudio { } else { Subscription::none() }; + // Web control capture: the parked op needs one presented frame so the + // canvas readback runs before the browser clears the WebGL drawing + // buffer (same timing requirement as the thumbnail capture above). + #[cfg(target_arch = "wasm32")] + let control_capture = if self.control.pending_capture.is_some() { + window::frames().map(|_| Message::ControlCaptureFrame) + } else { + Subscription::none() + }; + #[cfg(not(target_arch = "wasm32"))] + let control_capture = Subscription::none(); // Blink the MText preview caret while the editor is open. let caret_blink = if self.mtext_editor.is_some() { iced::time::every(std::time::Duration::from_millis(530)) @@ -2735,6 +2746,7 @@ impl OpenCADStudio { nav_settle, gpu_probe, thumbnail_capture, + control_capture, caret_blink, web_fonts, autosave, diff --git a/src/io/fs_access.rs b/src/io/fs_access.rs new file mode 100644 index 000000000..4d710df75 --- /dev/null +++ b/src/io/fs_access.rs @@ -0,0 +1,377 @@ +//! In-place web saves through the File System Access API. +//! +//! The legacy web save serialized the drawing and handed the bytes to a +//! browser download — every SAVE/SAVEAS produced a fresh "drawing (1).dwg" +//! in the Downloads folder, and Ctrl+S could never update the file the user +//! is working on. Where the browser exposes `showSaveFilePicker` (Chromium +//! desktop; Firefox and Safari do not implement it), the picker replaces +//! the download as the save dialog: it returns a `FileSystemFileHandle` +//! whose `createWritable()` writes the real file in place, and — once +//! picked — the handle keeps its read/write permission for the rest of the +//! session, so later plain saves write through it with no picker at all. +//! +//! Gesture timing drives the whole design: `showSaveFilePicker` may only be +//! called while the page holds transient user activation (the click on the +//! Save button), but the serialization that produces the bytes runs as a +//! thumbnail capture + worker round-trip that can far outlast that window. +//! The handle is therefore acquired in the save-confirm handler — the +//! gesture moment, before any serialize task starts — and the later write +//! goes through the retained handle, which needs no activation. +//! +//! `web-sys` 0.3.85 does ship `Window::show_save_file_picker_with_options`, +//! but every File System Access *picker* binding sits behind the +//! unstable-API cfg (`--cfg=web_sys_unstable_apis`), which this project's +//! wasm target does not enable (see `.cargo/config.toml` — turning it on +//! would change build flags for every crate in the graph). The picker is +//! therefore invoked dynamically: `Reflect::get(window, +//! "showSaveFilePicker")` + `Function::call` with a plain `js_sys::Object` +//! of options. Only the *handle* half goes through typed web-sys +//! (`FileSystemFileHandle`/`createWritable` are stable features the OPFS +//! code in `io::web_recent` already uses). +//! +//! Split like `io::web_session`: the routing decision and error +//! classification below are pure and compile (and test) on the native host; +//! everything touching the browser is wasm-only. + +/// How a web save should reach the disk, decided at the save-confirm +/// gesture (see `save_route`). +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SaveRoute { + /// Plain Save on a tab whose retained handle matches the target type: + /// write through the handle — no picker, no download. + WriteInPlace, + /// File System Access is available but no (matching) handle is held: + /// open `showSaveFilePicker` now, inside the user-activation window. + PickHandle, + /// No File System Access (Firefox/Safari): the legacy download flow. + Download, +} + +/// The save-routing decision the web confirm path makes at gesture time. +/// +/// `handle_ext` is the extension the tab's retained handle was picked for +/// (`None` when the tab holds no handle). Only a *plain* Save (Save/QSAVE +/// and the save-before-close flow) may reuse it: Save As must re-pick, and +/// a handle picked for `.dwg` must not receive DXF bytes (the file name +/// would lie about its contents). +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +pub fn save_route( + supported: bool, + plain_save: bool, + handle_ext: Option<&str>, + target_ext: &str, +) -> SaveRoute { + if !supported { + return SaveRoute::Download; + } + match handle_ext { + Some(handle_ext) if plain_save && exts_match(handle_ext, target_ext) => { + SaveRoute::WriteInPlace + } + _ => SaveRoute::PickHandle, + } +} + +/// Case-insensitive extension comparison that tolerates a leading dot on +/// either side, so registry keys and format-table extensions ("dwg") never +/// mismatch on ".DWG". +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // via save_route's tests +fn exts_match(a: &str, b: &str) -> bool { + let strip = |ext: &str| ext.trim_start_matches('.').to_ascii_lowercase(); + strip(a) == strip(b) +} + +/// `showSaveFilePicker` rejects with a `DOMException` named "AbortError" +/// when the user dismisses the dialog. That name check is the whole cancel +/// classification — kept pure so it compiles and tests on every target. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native +pub(crate) fn is_cancel_error_name(name: &str) -> bool { + name == "AbortError" +} + +/// The picker's file-type filter for a CAD extension: the MIME key Chrome's +/// own examples use, with the extension list doing the actual filtering. +#[cfg(target_arch = "wasm32")] +fn accept_mime(ext: &str) -> &'static str { + if ext.eq_ignore_ascii_case("dxf") { + "application/dxf" + } else { + "application/acad" + } +} + +#[cfg(target_arch = "wasm32")] +mod browser { + use std::cell::RefCell; + use std::collections::HashMap; + use wasm_bindgen::{JsCast, JsValue}; + use wasm_bindgen_futures::JsFuture; + + /// A retained save destination: the browser file handle behind one + /// tab's in-place saves, plus the extension it was picked for. + /// + /// The handle's permission persists for the session once picked, so + /// writing through it needs no user gesture — which is what lets the + /// write happen long after the click that opened the picker. + #[derive(Clone, Debug)] + struct SaveHandle { + /// Extension ("dwg"/"dxf") the picker filtered for. A later plain + /// save may only reuse the handle while its target type matches. + ext: String, + name: String, + handle: web_sys::FileSystemFileHandle, + } + + thread_local! { + /// One in-place destination per tab, keyed by tab id. The registry — + /// not an App field or a Message payload — owns the handles because + /// iced requires `Message: Send` even on wasm, and a JsValue-backed + /// handle is not Send. (Same pattern as web_recent's OPEN_STORES.) + /// Closing a tab, and a failed write, drop its entry; Save As + /// replaces it. + static SAVE_HANDLES: RefCell> = + RefCell::new(HashMap::new()); + } + + /// The extension the tab's retained handle was picked for, if any — + /// the `handle_ext` input of `save_route`. + pub fn handle_ext(tab_id: u64) -> Option { + SAVE_HANDLES.with(|handles| { + handles + .borrow() + .get(&tab_id) + .map(|handle| handle.ext.clone()) + }) + } + + /// The retained destination's real name (the user may have renamed it + /// inside the picker) — what save bookkeeping should use. + pub fn handle_name(tab_id: u64) -> Option { + SAVE_HANDLES.with(|handles| { + handles.borrow().get(&tab_id).map(|handle| handle.name.clone()) + }) + } + + /// Forget the tab's retained destination (tab closed, or its handle + /// stopped working — the next save re-picks). + pub fn drop_handle(tab_id: u64) { + SAVE_HANDLES.with(|handles| { + handles.borrow_mut().remove(&tab_id); + }); + } + + /// Feature-detect `showSaveFilePicker` — never assume it: Firefox and + /// Safari keep the download flow precisely because this returns false. + pub fn supported() -> bool { + web_sys::window().is_some_and(|window| { + js_sys::Reflect::has(window.as_ref(), &JsValue::from_str("showSaveFilePicker")) + .unwrap_or(false) + }) + } + + /// Open `showSaveFilePicker` for `tab_id` and retain the chosen + /// destination in the registry. + /// + /// MUST be called while the page holds transient user activation (the + /// Save click); the thumbnail capture + worker serialization that + /// follow can outlast that window, which is why the handle is picked + /// up front and only written to later. + /// + /// * `Ok(Some(name))` — destination chosen and retained; `name` is its + /// real (possibly renamed) file name. + /// * `Ok(None)` — user dismissed the picker: cancel the save (the + /// picker IS the save dialog on this path, same as desktop cancel). + /// * `Err(message)` — the picker itself failed (blocked, no gesture, + /// …); the caller falls back to the legacy download flow. + pub async fn pick_save_handle( + tab_id: u64, + suggested_name: &str, + ext: &str, + ) -> Result, String> { + let window = web_sys::window().ok_or_else(|| "browser window unavailable".to_string())?; + // Dynamic call — see the module docs for why the typed web-sys + // binding (`show_save_file_picker_with_options`) is not used. + let picker = js_sys::Reflect::get(window.as_ref(), &JsValue::from_str("showSaveFilePicker")) + .ok() + .and_then(|picker| picker.dyn_into::().ok()) + .ok_or_else(|| "showSaveFilePicker unavailable".to_string())?; + let options = picker_options(suggested_name, ext); + let promise = picker + .call1(window.as_ref(), &options) + .map_err(crate::io::web_recent::js_error)? + .dyn_into::() + .map_err(crate::io::web_recent::js_error)?; + match JsFuture::from(promise).await { + Ok(value) => { + let handle = value + .dyn_into::() + .map_err(crate::io::web_recent::js_error)?; + let name = handle.name(); + SAVE_HANDLES.with(|handles| { + handles.borrow_mut().insert( + tab_id, + SaveHandle { + ext: ext.trim_start_matches('.').to_ascii_lowercase(), + name: name.clone(), + handle, + }, + ); + }); + Ok(Some(name)) + } + // AbortError = the user dismissed the dialog; anything else is a + // real failure the caller should report/fall back on. + Err(error) => { + let cancelled = js_sys::Reflect::get(&error, &JsValue::from_str("name")) + .ok() + .and_then(|name| name.as_string()) + .is_some_and(|name| super::is_cancel_error_name(&name)); + if cancelled { + Ok(None) + } else { + Err(crate::io::web_recent::js_error(error)) + } + } + } + } + + /// Write `bytes` through the tab's retained handle: `createWritable` → + /// write → close, the OPFS pattern from `io::web_recent::write_entry`. + /// A failed write still aborts the stream so the exclusive lock is + /// released — otherwise every later write to the same handle would fail + /// the same way for the rest of the session. + pub async fn write_handle(tab_id: u64, bytes: &[u8]) -> Result<(), String> { + let handle = SAVE_HANDLES.with(|handles| { + handles.borrow().get(&tab_id).map(|save| save.handle.clone()) + }); + let Some(handle) = handle else { + return Err("no retained save destination for this drawing".to_string()); + }; + let writable = JsFuture::from(handle.create_writable()) + .await + .map_err(crate::io::web_recent::js_error)? + .dyn_into::() + .map_err(crate::io::web_recent::js_error)?; + if let Err(error) = write_and_close(&writable, bytes).await { + let _ = JsFuture::from(writable.abort()).await; + return Err(error); + } + Ok(()) + } + + /// The createWritable write+close pair from `io::web_recent`'s OPFS + /// writer — same failure contract (either promise rejecting is an + /// error), kept local so the abort-on-failure dance above owns the + /// stream lifecycle in one place. + async fn write_and_close( + writable: &web_sys::FileSystemWritableFileStream, + bytes: &[u8], + ) -> Result<(), String> { + let write = writable + .write_with_u8_array(bytes) + .map_err(crate::io::web_recent::js_error)?; + JsFuture::from(write) + .await + .map_err(crate::io::web_recent::js_error)?; + JsFuture::from(writable.close()) + .await + .map_err(crate::io::web_recent::js_error)?; + Ok(()) + } + + /// Build the `SaveFilePickerOptions`-shaped object dynamically (the + /// typed `web_sys::SaveFilePickerOptions` shares the picker's + /// unstable-API cfg, see module docs): `suggestedName` plus a types + /// filter restricted to the target extension. + fn picker_options(suggested_name: &str, ext: &str) -> js_sys::Object { + let set = |object: &js_sys::Object, key: &str, value: &JsValue| { + let _ = js_sys::Reflect::set(object, &JsValue::from_str(key), value); + }; + let accept = js_sys::Object::new(); + let extensions = js_sys::Array::new(); + extensions.push(&JsValue::from_str(&format!(".{}", ext.trim_start_matches('.')))); + set(&accept, super::accept_mime(ext), &extensions); + let type_entry = js_sys::Object::new(); + set( + &type_entry, + "description", + &JsValue::from_str(if ext.eq_ignore_ascii_case("dxf") { + "DXF Drawing" + } else { + "DWG Drawing" + }), + ); + set(&type_entry, "accept", &accept); + let types = js_sys::Array::new(); + types.push(&type_entry); + let options = js_sys::Object::new(); + set(&options, "suggestedName", &JsValue::from_str(suggested_name)); + set(&options, "types", &types); + options + } +} + +#[cfg(target_arch = "wasm32")] +pub use browser::{ + drop_handle, handle_ext, handle_name, pick_save_handle, supported, write_handle, +}; + +#[cfg(test)] +mod tests { + use super::{is_cancel_error_name, save_route, SaveRoute}; + + /// Firefox/Safari: no showSaveFilePicker — every save stays a download, + /// whatever handle state the tab happens to hold. + #[test] + fn unsupported_browsers_always_download() { + assert_eq!(save_route(false, true, Some("dwg"), "dwg"), SaveRoute::Download); + assert_eq!(save_route(false, false, None, "dxf"), SaveRoute::Download); + } + + /// The whole point: plain Save on a tab with a matching handle writes + /// in place — no picker, no download. + #[test] + fn plain_save_with_matching_handle_writes_in_place() { + assert_eq!(save_route(true, true, Some("dwg"), "dwg"), SaveRoute::WriteInPlace); + assert_eq!(save_route(true, true, Some("dxf"), "dxf"), SaveRoute::WriteInPlace); + } + + /// Extension comparison is case-insensitive and dot-tolerant — the + /// registry stores the normalized form, the format table's is bare. + #[test] + fn handle_ext_match_ignores_case_and_dot() { + assert_eq!(save_route(true, true, Some(".DWG"), "dwg"), SaveRoute::WriteInPlace); + assert_eq!(save_route(true, true, Some("dwg"), ".DWG"), SaveRoute::WriteInPlace); + } + + /// A plain Save targeting the other format must re-pick: writing DXF + /// bytes through a .dwg handle would corrupt the file's identity. + #[test] + fn mismatched_format_re_picks() { + assert_eq!(save_route(true, true, Some("dwg"), "dxf"), SaveRoute::PickHandle); + assert_eq!(save_route(true, true, Some("dxf"), "dwg"), SaveRoute::PickHandle); + } + + /// Save As never reuses the handle — the user asked for a destination. + #[test] + fn save_as_with_existing_handle_re_picks() { + assert_eq!(save_route(true, false, Some("dwg"), "dwg"), SaveRoute::PickHandle); + } + + /// No handle yet (first save, or the previous write dropped it): pick. + #[test] + fn missing_handle_picks() { + assert_eq!(save_route(true, true, None, "dwg"), SaveRoute::PickHandle); + assert_eq!(save_route(true, false, None, "dwg"), SaveRoute::PickHandle); + } + + /// Only the picker's AbortError means "user cancelled". + #[test] + fn only_abort_error_is_a_cancel() { + assert!(is_cancel_error_name("AbortError")); + assert!(!is_cancel_error_name("SecurityError")); + assert!(!is_cancel_error_name("TypeError")); + assert!(!is_cancel_error_name("")); + } +} diff --git a/src/io/mod.rs b/src/io/mod.rs index 216ee0865..6deb314d0 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -5,6 +5,10 @@ #[cfg(not(target_arch = "wasm32"))] pub mod edit_lock; +// Same split as web_session/web_autosave: the save-routing decision (and +// its tests) is pure and compiles everywhere; the showSaveFilePicker half +// inside is wasm-only. +pub(crate) mod fs_access; pub mod file_association; pub mod linetypes; pub mod obj; diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 3b2695488..bd648f51e 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -1888,6 +1888,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Opening OCS Web..." => Some(("view", "opening-ocs-web")), "Opening the command reference..." => Some(("view", "opening-the-command-reference")), "The browser cannot open a typed path; pick the file instead." => Some(("view", "browser-cannot-open-typed-path")), + "Save destination picker failed: {error}; downloading a copy instead" => Some(("file", "save-picker-failed-downloading")), + "In-place save failed ({error}); a copy was downloaded instead" => Some(("file", "in-place-save-failed-downloaded")), "\"{name}\" could not be sized from its header and decoding it exceeded the {limit} MB image limit of this build — it cannot be attached." => Some(("file", "image-refusal-unknown-size")), "\"{name}\" is already loaded with different contents; the existing file was kept." => Some(("view", "already-loaded-different-contents-kept")), "Opening Patreon page..." => Some(("view", "opening-patreon-page")), diff --git a/src/sys.rs b/src/sys.rs index af4c2c819..26fa38e92 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -242,6 +242,98 @@ pub async fn read_clipboard_text() -> Option { value.as_string() } +/// Extension the pasted-image registry name carries for a clipboard MIME +/// type. Unknown image types fall back to `png` — the flavor every OS +/// screenshot paste uses — so the reference still names a decodable format. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // tested everywhere, used on wasm +pub(crate) fn clipboard_image_extension(mime_type: &str) -> &'static str { + match mime_type.trim().to_ascii_lowercase().as_str() { + "image/jpeg" | "image/jpg" => "jpg", + "image/webp" => "webp", + "image/gif" => "gif", + "image/bmp" | "image/x-ms-bmp" | "image/x-bmp" => "bmp", + _ => "png", + } +} + +#[test] +fn clipboard_image_extensions_cover_the_paste_flavors() { + assert_eq!(clipboard_image_extension("image/png"), "png"); + assert_eq!(clipboard_image_extension("IMAGE/JPEG"), "jpg"); + assert_eq!(clipboard_image_extension(" image/webp "), "webp"); + assert_eq!(clipboard_image_extension("image/gif"), "gif"); + assert_eq!(clipboard_image_extension("image/x-ms-bmp"), "bmp"); + // Anything else (a vendor flavor, a bare "image/*") still gets a name + // the image registry can key and the `image` crate can decode. + assert_eq!(clipboard_image_extension("image/avif"), "png"); + assert_eq!(clipboard_image_extension(""), "png"); +} + +/// Web: read an image from the system clipboard via the async Clipboard API +/// (`navigator.clipboard.read()`), used when a paste found no text. Returns +/// the registry name (`pasted-YYYYMMDD-HHMMSS.`) plus the image bytes, +/// or `None` when the clipboard holds no image item or the read is denied. +/// Like `read_clipboard_text`, the Ctrl+V keypress that reaches it is a user +/// gesture, which is what makes the permission-gated read allowed at all. +#[cfg(target_arch = "wasm32")] +pub async fn read_clipboard_image() -> Option<(String, Vec)> { + use wasm_bindgen::JsCast; + + let clipboard = web_sys::window()?.navigator().clipboard(); + let items = wasm_bindgen_futures::JsFuture::from(clipboard.read()) + .await + .ok()?; + let items = js_sys::Array::from(&items); + for index in 0..items.length() { + let Ok(item) = items.get(index).dyn_into::() else { + continue; + }; + // A clipboard item can offer several flavors; take the first image + // one (browsers list at most one per item in practice). An item with + // no image flavor just moves the search to the next one. + let Some(mime_type) = item + .types() + .iter() + .filter_map(|kind| kind.as_string()) + .find(|mime| mime.starts_with("image/")) + else { + continue; + }; + let blob = wasm_bindgen_futures::JsFuture::from(item.get_type(&mime_type)) + .await + .ok()? + .dyn_into::() + .ok()?; + let buffer = wasm_bindgen_futures::JsFuture::from(blob.array_buffer()) + .await + .ok()?; + let bytes = js_sys::Uint8Array::new(&buffer).to_vec(); + if bytes.is_empty() { + return None; + } + return Some((pasted_image_name(&mime_type), bytes)); + } + None +} + +/// Registry name for a pasted clipboard image: `pasted-YYYYMMDD-HHMMSS.ext` +/// stamped from the wall clock (local time, the same clock a screenshot file +/// name uses) with the extension derived from the MIME type. +#[cfg(target_arch = "wasm32")] +fn pasted_image_name(mime_type: &str) -> String { + let now = js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(0.0)); + format!( + "pasted-{:04}{:02}{:02}-{:02}{:02}{:02}.{}", + now.get_full_year(), + now.get_month() + 1, + now.get_date(), + now.get_hours(), + now.get_minutes(), + now.get_seconds(), + clipboard_image_extension(mime_type) + ) +} + #[cfg(target_arch = "wasm32")] #[wasm_bindgen::prelude::wasm_bindgen(module = "/web/clipboard.js")] extern "C" { diff --git a/web-app.html b/web-app.html index 02009a16a..571446372 100644 --- a/web-app.html +++ b/web-app.html @@ -38,6 +38,16 @@ animation: ocs-spin 0.9s linear infinite; } @keyframes ocs-spin { to { transform: rotate(360deg); } } + /* Browser-menu print support: hidden on screen, the only thing shown on + paper. beforeprint snapshots the WebGL canvas into it (below), so the + printout is the drawing instead of a blank page — WebGL canvases do + not composite into the print surface on their own. */ + #print-snapshot { display: none; } + @media print { + body > *:not(#print-snapshot) { display: none !important; } + html, body { overflow: visible; height: auto; background: #fff; } + #print-snapshot { display: block; max-width: 100%; height: auto; } + } @@ -56,6 +66,8 @@
Open CAD Studio
Loading…
+ + +