forked from 1b-agent/opencadstudio-upstream
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:
1 parent
369e2c8b5a
commit
6e89b36d44
34 files changed
+1138
-34
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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 पृष्ठ खोला जा रहा है…
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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 ページを開いています...
|
||||
|
||||
@@ -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 페이지를 여는 중...
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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 = 打开帕特伦的页面...
|
||||
|
||||
@@ -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
@@ -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())?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(""));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,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");
|
||||
|
||||
Reference in new issue
Block a user