feat: in-place save (File System Access), image paste, print wiring, web capture

The last high-severity web gap plus a small integration bundle.

In-place save: with the File System Access API (Chromium/Edge), Save
now works like desktop. Plain Save on a tab with a retained handle
writes straight through — no picker, no download, no "(1)" copies.
First save or format change opens showSaveFilePicker AT the confirm
gesture (the worker serialization that follows can outlast the
transient-activation window; retained handles need no activation to
write). Picker cancel cancels the save like desktop; picker or write
failure falls back to the legacy download with a note; Firefox/Safari
keep the download flow untouched (feature-detected). Handles are
keyed by tab id in a thread-local registry (Message must be Send —
JsValue cannot ride in messages or App state); closing a tab drops
its handle; Save As re-picks. Dirty clears only on successful
persistence, same race guard as before.

Image paste: Ctrl+V of a screenshot inserts it as an image reference
— the clipboard reader falls back to image/* items when neither the
entity clipboard nor text has anything (precedence preserved), the
bytes enter the image source registry as pasted-<timestamp>.<ext>,
and attach reuses the IMAGE command's model-creation path verbatim.

Browser print: beforeprint snapshots the WebGL canvas into a print-
only <img> (the capture_canvas technique), so the browser's own print
menu produces the drawing instead of a blank page.

Automation capture on web: the capture op no longer calls the
synchronous GPU-map screenshot (dead on wasm); it waits one presented
frame and reads via capture_canvas — the same workaround the save-
thumbnail path uses — delivering the PNG as a download.
This commit is contained in:
Afonsop committed 2026-09-21 11:17:23 +01:00
1 parent 369e2c8b5a
commit 6e89b36d44
34 files changed
+1138 -34

No files matched your search

+7
View File
@@ -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",
+4
View File
@@ -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...
+4
View File
@@ -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...
+4
View File
@@ -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...
+4
View File
@@ -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...
+4
View File
@@ -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...
+4
View File
@@ -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...
+4
View File
@@ -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...
+4
View File
@@ -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...
+4
View File
@@ -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...
+4
View File
@@ -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 पृष्ठ खोला जा रहा है…
+4
View File
@@ -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...
+4
View File
@@ -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...
+4
View File
@@ -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 ページを開いています...
+4
View File
@@ -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 페이지를 여는 중...
+4
View File
@@ -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...
+4
View File
@@ -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...
+4
View File
@@ -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...
+4
View File
@@ -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...
+4
View File
@@ -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...
+4
View File
@@ -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 = 打开帕特伦的页面...
+4
View File
@@ -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 = 開啟帕特倫的頁面...
+63 -5
View File
@@ -113,6 +113,13 @@ pub(super) struct State {
serial: u64,
events: VecDeque<Value>,
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<String>,
}
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<Message> {
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())?;
+57
View File
@@ -724,6 +724,14 @@ pub(super) struct OpenCADStudio {
pending_native_thumbnail_save: Option<PendingNativeThumbnailSave>,
#[cfg(target_arch = "wasm32")]
pending_web_thumbnail_save: Option<PendingWebThumbnailSave>,
/// 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<Message>),
ControlTaskDone(String),
ControlScreenshot(String, Option<iced::window::Screenshot>),
/// 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<u8>)>),
/// 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<Vec<u8>, 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<Option<String>, 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<u8>,
},
/// 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,
+69
View File
@@ -117,6 +117,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
// 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<Message> {
}
}
/// 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<u8>) -> Task<Message> {
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<Message> {
let i = self.active_tab;
self.tabs[i].scene.selection.borrow_mut().context_menu = None;
+9
View File
@@ -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<Message> {
// 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
+257 -26
View File
@@ -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<u8>,
) -> Task<Message> {
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<u8>,
) -> Task<Message> {
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<Message> {
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<Message> {
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<Option<String>, String>,
) -> Task<Message> {
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)
}
}
}
+67 -3
View File
@@ -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)
+12
View File
@@ -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,
+377
View File
@@ -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<HashMap<u64, SaveHandle>> =
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<String> {
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<String> {
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<Option<String>, 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::<js_sys::Function>().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::<js_sys::Promise>()
.map_err(crate::io::web_recent::js_error)?;
match JsFuture::from(promise).await {
Ok(value) => {
let handle = value
.dyn_into::<web_sys::FileSystemFileHandle>()
.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::<web_sys::FileSystemWritableFileStream>()
.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(""));
}
}
+4
View File
@@ -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;
+2
View File
@@ -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")),
+92
View File
@@ -242,6 +242,98 @@ pub async fn read_clipboard_text() -> Option<String> {
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.<ext>`) 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<u8>)> {
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::<web_sys::ClipboardItem>() 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::<web_sys::Blob>()
.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" {
+38
View File
@@ -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; }
}
</style>
<link data-trunk rel="rust" data-bin="OpenCADStudio" />
<link data-trunk rel="copy-dir" href="web/fonts" />
@@ -56,6 +66,8 @@
<div class="title">Open <span>CAD</span> Studio</div>
<div class="sub">Loading…</div>
</div>
<!-- Print-only copy of the canvas; filled by beforeprint below. -->
<img id="print-snapshot" alt="" />
<script src="ocs-control.js" defer></script>
<script>
// Offline support: register the service worker. It is copied next to
@@ -72,6 +84,32 @@
});
})();
</script>
<script>
// Print support. The drawing lives in a WebGL canvas, which browsers
// rasterize as blank on the print surface. beforeprint therefore copies
// it onto a 2D canvas (the same drawImage readback the Rust side's
// sys::capture_canvas uses — no Rust state involved, the canvas is
// located from the DOM) and shows the snapshot in the print-only
// <img>; afterprint drops it again to free the PNG.
(() => {
const snapshot = document.getElementById("print-snapshot");
if (!snapshot) return;
window.addEventListener("beforeprint", () => {
const canvas = document.querySelector("canvas");
if (!canvas || !canvas.width || !canvas.height) return;
const copy = document.createElement("canvas");
copy.width = canvas.width;
copy.height = canvas.height;
const context = copy.getContext("2d");
if (!context) return;
context.drawImage(canvas, 0, 0);
snapshot.src = copy.toDataURL("image/png");
});
window.addEventListener("afterprint", () => {
snapshot.removeAttribute("src");
});
})();
</script>
<script>
(() => {
const splash = document.getElementById("loading");