diff --git a/Cargo.toml b/Cargo.toml index 4a068c28e..8163cebea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -142,6 +142,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", @@ -164,6 +167,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 88fdeb4c0..b8fb5f6a5 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = تلك الكائنات clipboard = + .pasted-image = تم لصق الصورة "{ $name }": { $width }×{ $height } px + .image-unsupported = تعذّر فك ترميز الصورة الملصقة "{ $name }". .no-supported-content = لا تحتوي الحافظة على نص أو كائنات رسم مدعومة. .unavailable = حافظة النظام غير متاحة. .occupied = حافظة النظام قيد الاستخدام حاليًا. حاول مرة أخرى. @@ -2412,6 +2414,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT الموضع [استبدال/بادئة/لاحقة] <استبدال>: file = + .in-place-save-failed-downloaded = فشل الحفظ في الموقع (__ocs_fmt_0__)؛ تم تنزيل نسخة + .save-picker-failed-downloading = فشل اختيار وجهة الحفظ: __ocs_fmt_0__؛ يتم تنزيل نسخة .restore-all = استعادة الكل .restoring-drawing-s = جارٍ استعادة __ocs_fmt_0__ رسم… .session-had-open-drawing-s = كانت الجلسة تضم __ocs_fmt_0__ رسمًا مفتوحًا. diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index e734713bd..a9e9a069f 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = тези обекти clipboard = + .pasted-image = Вставено изображение "{ $name }": { $width }×{ $height } px + .image-unsupported = Вставеното изображение "{ $name }" не може да бъде декодирано. .no-supported-content = Клипбордът не съдържа поддържан текст или чертожни обекти. .unavailable = Системният клипборд не е наличен. .occupied = Системният клипборд в момента се използва. Опитай отново. @@ -2399,6 +2401,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT поставяне [Замяна/Префикс/Суфикс] <Замяна>: file = + .in-place-save-failed-downloaded = Записът на място се провали (__ocs_fmt_0__); изтеглено е копие + .save-picker-failed-downloading = Изборът на място за запис се провали: __ocs_fmt_0__; изтегля се копие .restore-all = Възстанови всички .restoring-drawing-s = Възстановяване на __ocs_fmt_0__ чертеж(а)… .session-had-open-drawing-s = Сесията е имала __ocs_fmt_0__ отворени чертежа. diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 2fd02801b..c810ba3cd 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = dané objekty clipboard = + .pasted-image = Vložen obrázek "{ $name }": { $width }×{ $height } px + .image-unsupported = Vložený obrázek "{ $name }" se nepodařilo dekódovat. .no-supported-content = Schránka neobsahuje podporovaný text ani objekty výkresu. .unavailable = Systémová schránka není dostupná. .occupied = Systémová schránka se právě používá. Zkuste to znovu. @@ -2399,6 +2401,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = Umístění TCOUNT [přepsat/předpona/přípona] : file = + .in-place-save-failed-downloaded = Uložení na místo selhalo (__ocs_fmt_0__); kopie byla stažena + .save-picker-failed-downloading = Výběr cíle uložení selhal: __ocs_fmt_0__; stahuje se kopie .restore-all = Obnovit vše .restoring-drawing-s = Obnovování __ocs_fmt_0__ výkres(ů)… .session-had-open-drawing-s = Relace měla otevřeno __ocs_fmt_0__ výkres(ů). diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 18939b6ba..e70049592 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = diese Objekte clipboard = + .pasted-image = Bild "{ $name }" eingefügt: { $width }×{ $height } px + .image-unsupported = Das eingefügte Bild "{ $name }" konnte nicht dekodiert werden. .no-supported-content = Die Zwischenablage enthält keinen unterstützten Text und keine Zeichnungsobjekte. .unavailable = Die Systemzwischenablage ist nicht verfügbar. .occupied = Die Systemzwischenablage wird gerade verwendet. Versuchen Sie es erneut. @@ -2400,6 +2402,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Platzierung [Überschreiben/Präfix/Suffix] <Überschreiben>: file = + .in-place-save-failed-downloaded = Direktes Speichern fehlgeschlagen (__ocs_fmt_0__); eine Kopie wurde heruntergeladen + .save-picker-failed-downloading = Speicherziel-Auswahl fehlgeschlagen: __ocs_fmt_0__; eine Kopie wird heruntergeladen .restore-all = Alle wiederherstellen .restoring-drawing-s = __ocs_fmt_0__ Zeichnung(en) werden wiederhergestellt… .session-had-open-drawing-s = Die Sitzung hatte __ocs_fmt_0__ geöffnete Zeichnung(en). diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 67f8deb20..940d717a6 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = αυτά τα αντικείμενα clipboard = + .pasted-image = Επικολλήθηκε η εικόνα "{ $name }": { $width }×{ $height } px + .image-unsupported = Δεν ήταν δυνατή η αποκωδικοποίηση της επικολλημένης εικόνας "{ $name }". .no-supported-content = Το πρόχειρο δεν περιέχει υποστηριζόμενο κείμενο ή αντικείμενα σχεδίου. .unavailable = Το πρόχειρο του συστήματος δεν είναι διαθέσιμο. .occupied = Το πρόχειρο του συστήματος χρησιμοποιείται αυτή τη στιγμή. Δοκιμάστε ξανά. @@ -2399,6 +2401,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT τοποθέτηση [Αντικατάσταση/Πρόθεμα/Κατάληξη] <Αντικατάσταση>: file = + .in-place-save-failed-downloaded = Απέτυχε η αποθήκευση επί τόπου (__ocs_fmt_0__)· λήφθηκε αντίγραφο + .save-picker-failed-downloading = Απέτυχε η επιλογή προορισμού αποθήκευσης: __ocs_fmt_0__· γίνεται λήψη αντιγράφου .restore-all = Επαναφορά όλων .restoring-drawing-s = Επαναφορά __ocs_fmt_0__ σχέδιο(α)… .session-had-open-drawing-s = Η συνεδρία είχε __ocs_fmt_0__ ανοιχτά σχέδια. diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 6d9791fa9..078294c99 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = those objects clipboard = + .pasted-image = Pasted image "{ $name }": { $width }×{ $height } px + .image-unsupported = The pasted image "{ $name }" could not be decoded. .no-supported-content = Clipboard does not contain supported text or drawing objects. .unavailable = System clipboard is unavailable. .occupied = System clipboard is currently in use. Try again. @@ -2403,6 +2405,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT placement [Overwrite/Prefix/Suffix] : file = + .in-place-save-failed-downloaded = In-place save failed (__ocs_fmt_0__); a copy was downloaded instead + .save-picker-failed-downloading = Save destination picker failed: __ocs_fmt_0__; downloading a copy instead .restore-all = Restore all .restoring-drawing-s = Restoring __ocs_fmt_0__ drawing(s)… .session-had-open-drawing-s = Session had __ocs_fmt_0__ open drawing(s). diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index a7af6436f..69fe1bab4 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = esos objetos clipboard = + .pasted-image = Imagen "{ $name }" pegada: { $width }×{ $height } px + .image-unsupported = No se pudo decodificar la imagen pegada "{ $name }". .no-supported-content = El portapapeles no contiene texto ni objetos de dibujo compatibles. .unavailable = El portapapeles del sistema no está disponible. .occupied = El portapapeles del sistema está en uso. Inténtelo de nuevo. @@ -2400,6 +2402,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT colocación [Sobrescribir/Prefijo/Sufijo] : file = + .in-place-save-failed-downloaded = Error al guardar en el sitio (__ocs_fmt_0__); se descargó una copia + .save-picker-failed-downloading = Falló la selección del destino de guardado: __ocs_fmt_0__; descargando una copia .restore-all = Restaurar todo .restoring-drawing-s = Restaurando __ocs_fmt_0__ dibujos… .session-had-open-drawing-s = La sesión tenía __ocs_fmt_0__ dibujos abiertos. diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 6dbea0f9c..84168b0e4 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = kyseiset objektit clipboard = + .pasted-image = Liitetty kuva "{ $name }": { $width }×{ $height } px + .image-unsupported = Liitettyä kuvaa "{ $name }" ei voitu purkaa. .no-supported-content = Leikepöydällä ei ole tuettua tekstiä tai piirustusobjekteja. .unavailable = Järjestelmän leikepöytä ei ole käytettävissä. .occupied = Järjestelmän leikepöytä on parhaillaan käytössä. Yritä uudelleen. @@ -2399,6 +2401,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT-sijoittelu [Korvaa/etuliite/liite] : file = + .in-place-save-failed-downloaded = Tallennus paikallaan epäonnistui (__ocs_fmt_0__); kopio ladattiin + .save-picker-failed-downloading = Tallennuskohteen valinta epäonnistui: __ocs_fmt_0__; ladataan kopio .restore-all = Palauta kaikki .restoring-drawing-s = Palautetaan __ocs_fmt_0__ piirustusta… .session-had-open-drawing-s = Istunnossa oli __ocs_fmt_0__ avointa piirustusta. diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 2383822d8..05685b4f8 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = ces objets clipboard = + .pasted-image = Image collée "{ $name }" : { $width }×{ $height } px + .image-unsupported = L'image collée "{ $name }" n'a pas pu être décodée. .no-supported-content = Le presse-papiers ne contient aucun texte ni objet de dessin pris en charge. .unavailable = Le presse-papiers système n’est pas disponible. .occupied = Le presse-papiers système est en cours d’utilisation. Réessayez. @@ -2400,6 +2402,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Position [Remplacer/Préfixe/Suffixe] : file = + .in-place-save-failed-downloaded = Échec de l’enregistrement local (__ocs_fmt_0__) ; une copie a été téléchargée + .save-picker-failed-downloading = Échec du sélecteur de destination : __ocs_fmt_0__ ; téléchargement d’une copie .restore-all = Tout restaurer .restoring-drawing-s = Restauration de __ocs_fmt_0__ dessin(s)… .session-had-open-drawing-s = La session avait __ocs_fmt_0__ dessin(s) ouvert(s). diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 23ef882d5..b4e74c1fe 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = उन ऑब्जेक्ट को clipboard = + .pasted-image = छवि "{ $name }" पेस्ट की गई: { $width }×{ $height } px + .image-unsupported = पेस्ट की गई छवि "{ $name }" को डिकोड नहीं किया जा सका। .no-supported-content = क्लिपबोर्ड में समर्थित टेक्स्ट या ड्रॉइंग ऑब्जेक्ट नहीं है। .unavailable = सिस्टम क्लिपबोर्ड उपलब्ध नहीं है। .occupied = सिस्टम क्लिपबोर्ड अभी उपयोग में है। फिर प्रयास करें। @@ -2388,6 +2390,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT स्थान [अधिलेखित/उपसर्ग/प्रत्यय] <अधिलेखित>: file = + .in-place-save-failed-downloaded = इन-प्लेस सेव विफल (__ocs_fmt_0__); प्रतिलिपि डाउनलोड की गई + .save-picker-failed-downloading = सेव गंतव्य चुनना विफल: __ocs_fmt_0__; प्रतिलिपि डाउनलोड हो रही है .restore-all = सभी पुनर्स्थापित करें .restoring-drawing-s = __ocs_fmt_0__ ड्रॉइंग पुनर्स्थापित की जा रही हैं… .session-had-open-drawing-s = सत्र में __ocs_fmt_0__ ड्रॉइंग खुली थीं। diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index c2d2672a7..46577f21b 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = azokat az objektumokat clipboard = + .pasted-image = "{ $name }" kép beillesztve: { $width }×{ $height } px + .image-unsupported = A beillesztett "{ $name }" kép nem dekódolható. .no-supported-content = A vágólap nem tartalmaz támogatott szöveget vagy rajzobjektumot. .unavailable = A rendszer vágólapja nem érhető el. .occupied = A rendszer vágólapja jelenleg használatban van. Próbálja újra. @@ -2412,6 +2414,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT elhelyezés [Overwrite (Felülírás) / Prefix (Előtag) / Suffix (Utótag)] : file = + .in-place-save-failed-downloaded = A helyben mentés nem sikerült (__ocs_fmt_0__); másolat lett letöltve + .save-picker-failed-downloading = A mentési cél kiválasztása nem sikerült: __ocs_fmt_0__; másolat letöltése .restore-all = Összes visszaállítása .restoring-drawing-s = __ocs_fmt_0__ rajz(ok) visszaállítása… .session-had-open-drawing-s = A munkamenetben __ocs_fmt_0__ rajz volt megnyitva. diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 7a774b884..db1789554 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = quegli oggetti clipboard = + .pasted-image = Immagine "{ $name }" incollata: { $width }×{ $height } px + .image-unsupported = Impossibile decodificare l'immagine incollata "{ $name }". .no-supported-content = Gli Appunti non contengono testo o oggetti di disegno supportati. .unavailable = Gli Appunti di sistema non sono disponibili. .occupied = Gli Appunti di sistema sono attualmente in uso. Riprova. @@ -2399,6 +2401,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = Posizionamento TCOUNT [Sovrascrivi/Prefisso/Suffisso] : file = + .in-place-save-failed-downloaded = Salvataggio sul posto non riuscito (__ocs_fmt_0__); è stata scaricata una copia + .save-picker-failed-downloading = Selezione della destinazione non riuscita: __ocs_fmt_0__; scarico una copia .restore-all = Ripristina tutto .restoring-drawing-s = Ripristino di __ocs_fmt_0__ disegno/i… .session-had-open-drawing-s = La sessione aveva __ocs_fmt_0__ disegni aperti. diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index a82fcc7e4..57b79570e 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = それらのオブジェクト clipboard = + .pasted-image = 画像 "{ $name }" を貼り付けました: { $width }×{ $height } px + .image-unsupported = 貼り付けた画像 "{ $name }" をデコードできませんでした。 .no-supported-content = クリップボードに対応するテキストまたは図面オブジェクトがありません。 .unavailable = システムのクリップボードを使用できません。 .occupied = システムのクリップボードは現在使用中です。もう一度お試しください。 @@ -2390,6 +2392,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 配置 [Overwrite/Prefix/Suffix] : file = + .in-place-save-failed-downloaded = その場での保存に失敗しました(__ocs_fmt_0__)。代わりにコピーをダウンロードしました + .save-picker-failed-downloading = 保存先の選択に失敗しました: __ocs_fmt_0__。代わりにコピーをダウンロードしています .restore-all = すべて復元 .restoring-drawing-s = __ocs_fmt_0__ 件の図面を復元しています… .session-had-open-drawing-s = セッションでは __ocs_fmt_0__ 件の図面が開いていました。 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 4b1639631..5b449d554 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = 해당 객체들 clipboard = + .pasted-image = 이미지 "{ $name }" 붙여넣음: { $width }×{ $height } px + .image-unsupported = 붙여넣은 이미지 "{ $name }"을(를) 디코딩할 수 없습니다. .no-supported-content = 클립보드에 지원되는 텍스트나 도면 객체가 없습니다. .unavailable = 시스템 클립보드를 사용할 수 없습니다. .occupied = 시스템 클립보드가 현재 사용 중입니다. 다시 시도하세요. @@ -2399,6 +2401,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 배치 [덮어쓰기/접두사/접미사] : file = + .in-place-save-failed-downloaded = 제자리 저장 실패(__ocs_fmt_0__); 대신 사본을 내려받았습니다 + .save-picker-failed-downloading = 저장 대상 선택 실패: __ocs_fmt_0__; 대신 사본을 내려받는 중 .restore-all = 모두 복원 .restoring-drawing-s = __ocs_fmt_0__ 도면을 복원하는 중… .session-had-open-drawing-s = 세션에서 __ocs_fmt_0__ 도면이 열려 있었습니다. diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 3f2869168..ca3ea813a 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = die objecten clipboard = + .pasted-image = Afbeelding "{ $name }" geplakt: { $width }×{ $height } px + .image-unsupported = De geplakte afbeelding "{ $name }" kon niet worden gedecodeerd. .no-supported-content = Het klembord bevat geen ondersteunde tekst of tekenobjecten. .unavailable = Het systeemklembord is niet beschikbaar. .occupied = Het systeemklembord is momenteel in gebruik. Probeer het opnieuw. @@ -2400,6 +2402,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Plaatsing [Overschrijven/Voorvoegsel/Achtervoegsel] : file = + .in-place-save-failed-downloaded = Ter plekke opslaan mislukt (__ocs_fmt_0__); een kopie is gedownload + .save-picker-failed-downloading = Kiezen van opslagbestemming mislukt: __ocs_fmt_0__; een kopie wordt gedownload .restore-all = Alles herstellen .restoring-drawing-s = __ocs_fmt_0__ tekening(en) worden hersteld… .session-had-open-drawing-s = De sessie had __ocs_fmt_0__ tekening(en) open. diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 2a9252bcb..d311089d5 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = te obiekty clipboard = + .pasted-image = Wklejono obraz "{ $name }": { $width }×{ $height } px + .image-unsupported = Nie udało się zdekodować wklejonego obrazu "{ $name }". .no-supported-content = Schowek nie zawiera obsługiwanego tekstu ani obiektów rysunku. .unavailable = Schowek systemowy jest niedostępny. .occupied = Schowek systemowy jest obecnie używany. Spróbuj ponownie. @@ -2399,6 +2401,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = Umieszczenie TCOUNT [Nadpisanie/Przedrostek/Sufiks] : file = + .in-place-save-failed-downloaded = Zapis w miejscu nie powiódł się (__ocs_fmt_0__); pobrano kopię + .save-picker-failed-downloading = Wybór miejsca zapisu nie powiódł się: __ocs_fmt_0__; pobieranie kopii .restore-all = Przywróć wszystkie .restoring-drawing-s = Przywracanie __ocs_fmt_0__ rysunków… .session-had-open-drawing-s = Sesja miała __ocs_fmt_0__ otwartych rysunków. diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 39e6705f7..e8a15137e 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = esses objetos clipboard = + .pasted-image = Imagem "{ $name }" colada: { $width }×{ $height } px + .image-unsupported = Não foi possível decodificar a imagem colada "{ $name }". .no-supported-content = A área de transferência não contém texto nem objetos de desenho compatíveis. .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. @@ -2400,6 +2402,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT posicionamento [Sobrescrever/Prefixo/Sufixo] : file = + .in-place-save-failed-downloaded = A gravação no local falhou (__ocs_fmt_0__); foi descarregada uma cópia + .save-picker-failed-downloading = A escolha do destino de gravação falhou: __ocs_fmt_0__; a descarregar uma cópia .restore-all = Restaurar tudo .restoring-drawing-s = Restaurando __ocs_fmt_0__ desenhos… .session-had-open-drawing-s = A sessão tinha __ocs_fmt_0__ desenhos abertos. diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index a7e1b4e7e..9a86d7a05 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -168,6 +168,8 @@ modal = .those-objects = эти объекты clipboard = + .pasted-image = Вставлено изображение "{ $name }": { $width }×{ $height } px + .image-unsupported = Не удалось декодировать вставленное изображение "{ $name }". .no-supported-content = Буфер обмена не содержит поддерживаемого текста или объектов чертежа. .unavailable = Системный буфер обмена недоступен. .occupied = Системный буфер обмена сейчас используется. Повторите попытку. @@ -2403,6 +2405,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Размещение [Заменить/Префикс/Суффикс] <Заменить>: file = + .in-place-save-failed-downloaded = Не удалось сохранить в файл (__ocs_fmt_0__); копия загружена + .save-picker-failed-downloading = Не удалось выбрать место сохранения: __ocs_fmt_0__; загружается копия .restore-all = Восстановить все .restoring-drawing-s = Восстановление __ocs_fmt_0__ чертежа(ей)… .session-had-open-drawing-s = В сессии было открыто __ocs_fmt_0__ чертежа(ей). diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 24cdc64ac..72a65ba74 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -166,6 +166,8 @@ modal = .those-objects = bu nesneleri clipboard = + .pasted-image = "{ $name }" görseli yapıştırıldı: { $width }×{ $height } px + .image-unsupported = Yapıştırılan "{ $name }" görseli çözümlenemedi. .no-supported-content = Panoda desteklenen metin veya çizim nesnesi yok. .unavailable = Sistem panosuna erişilemiyor. .occupied = Sistem panosu şu anda kullanılıyor. Yeniden deneyin. @@ -2384,6 +2386,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Yerleştirme [Üzerine yaz/Önek/Sonek] <Üzerine yaz>: file = + .in-place-save-failed-downloaded = Yerine kayıt başarısız (__ocs_fmt_0__); bir kopya indirildi + .save-picker-failed-downloading = Kayıt hedefi seçimi başarısız: __ocs_fmt_0__; bir kopya indiriliyor .restore-all = Tümünü geri yükle .restoring-drawing-s = __ocs_fmt_0__ çizim geri yükleniyor… .session-had-open-drawing-s = Oturumda __ocs_fmt_0__ açık çizim vardı. diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 96cd99051..6154c895d 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -167,6 +167,8 @@ modal = .those-objects = 这些对象 clipboard = + .pasted-image = 已粘贴图像 "{ $name }":{ $width }×{ $height } px + .image-unsupported = 无法解码粘贴的图像 "{ $name }"。 .no-supported-content = 剪贴板中没有受支持的文本或图形对象。 .unavailable = 系统剪贴板不可用。 .occupied = 系统剪贴板当前正被使用,请重试。 @@ -2387,6 +2389,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 放置方式 [覆盖/前缀/后缀] <覆盖>: file = + .in-place-save-failed-downloaded = 就地保存失败(__ocs_fmt_0__);已下载副本 + .save-picker-failed-downloading = 选择保存目标失败:__ocs_fmt_0__;正在下载副本 .restore-all = 全部恢复 .restoring-drawing-s = 正在恢复 __ocs_fmt_0__ 绘图… .session-had-open-drawing-s = 会话中有 __ocs_fmt_0__ 个打开的绘图。 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index ffa1109f3..c289025d3 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -167,6 +167,8 @@ modal = .those-objects = 這些物件 clipboard = + .pasted-image = 已貼上影像 "{ $name }":{ $width }×{ $height } px + .image-unsupported = 無法解碼貼上的影像 "{ $name }"。 .no-supported-content = 剪貼簿中沒有受支援的文字或圖形物件。 .unavailable = 系統剪貼簿不可用。 .occupied = 系統剪貼簿目前正被使用,請重試。 @@ -2399,6 +2401,8 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 放置方式 [覆蓋/前綴/後綴] <覆蓋>: file = + .in-place-save-failed-downloaded = 就地儲存失敗(__ocs_fmt_0__);已下載副本 + .save-picker-failed-downloading = 選擇儲存目標失敗:__ocs_fmt_0__;正在下載副本 .restore-all = 全部恢復 .restoring-drawing-s = 正在恢復 __ocs_fmt_0__ 繪圖… .session-had-open-drawing-s = 工作階段中有 __ocs_fmt_0__ 個開啟的繪圖。 diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 48d9c7ed4..d75c85bc3 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -1541,9 +1541,27 @@ impl OpenCADStudio { } } + #[cfg(not(target_arch = "wasm32"))] image .save_with_format(&path, image::ImageFormat::Png) .map_err(|e| e.to_string())?; + // 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. + #[cfg(target_arch = "wasm32")] + { + 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 = path + .rsplit(['/', '\\']) + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("capture.png") + .to_string(); + crate::sys::download_bytes(&download, &png); + } let doc_unit = crate::app::properties::insunits_name( self.tabs[self.active_tab] @@ -1624,6 +1642,19 @@ impl OpenCADStudio { self.control.capture_frame_pending = false; return Task::none(); }; + // Web: iced's window screenshot needs a synchronous GPU map wasm + // cannot provide; the canvas readback (the save-thumbnail technique) + // only sees the drawing right after a presented frame, before the + // browser clears the WebGL buffer — so wait for a real frame. + #[cfg(target_arch = "wasm32")] + { + if !redrew { + return Task::none(); + } + self.control.capture_frame_pending = false; + self.control.capture_after_redraw = true; + return Task::done(Message::ControlScreenshot(path, crate::sys::capture_canvas())); + } self.control.capture_frame_pending = false; self.control.capture_after_redraw = redrew; let Some(window) = self.main_window else { diff --git a/src/app/mod.rs b/src/app/mod.rs index e918409ba..e15238efc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -915,6 +915,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")] @@ -2277,6 +2285,12 @@ pub enum Message { PasteShortcutResolved(PasteFocus), /// 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, @@ -2715,6 +2729,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: codec::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: codec::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`. @@ -4548,6 +4597,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 9ba516246..19eda0861 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -195,6 +195,13 @@ impl OpenCADStudio { // 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) = closing_tab_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). @@ -2095,6 +2102,68 @@ impl OpenCADStudio { } } + /// 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 = codec::entities::RasterImage::with_size( + &name, + codec::types::Vector3::new(origin.x, origin.y, origin.z), + pixels_w as f64, + pixels_h as f64, + width, + height, + ); + image.flags = codec::entities::ImageDisplayFlags::SHOW_IMAGE + | codec::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( + codec::EntityType::RasterImage(image), + )) + } + pub(super) fn on_qselect_open(&mut self) -> Task { let i = self.active_tab; self.tabs[i].scene.selection.borrow_mut().menu.open_at = None; diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index f6691cacf..8a2f6c965 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 d762a2303..49753b1d0 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2026,7 +2026,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(); } // Missing-on-open: neutral command-line notice plus a flag @@ -2765,10 +2765,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, @@ -2789,9 +2791,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: codec::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: codec::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()) @@ -3610,6 +3714,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 = @@ -3708,7 +3820,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(); @@ -3718,30 +3832,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: codec::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: codec::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 9cb0e0006..f9593c4cf 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6810,9 +6810,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 @@ -6832,6 +6847,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) { @@ -10275,6 +10303,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/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 a25434e25..445035485 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 font_repo; pub mod line_read; diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 936d189c9..6d26598c6 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -2703,6 +2703,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "SAVEALL: saved {saved} drawing(s){}." => Some(("file", "saveall-saved-saved-drawing-s")), "SAVEALL: {} already has a save running" => Some(("file", "saveall-already-has-a-save-running")), "SAVEALL: {} failed: {e}" => Some(("file", "saveall-failed-e")), + "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")), "Restore all" => Some(("file", "restore-all")), "Restoring {} drawing(s)…" => Some(("file", "restoring-drawing-s")), "Session had {count} open drawing(s)." => Some(("file", "session-had-open-drawing-s")), diff --git a/src/sys.rs b/src/sys.rs index 71364ce2a..904a26c8d 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -311,6 +311,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 3c380d6df..049a1e75e 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…
+ + +