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); main's frame-parked
capture waits for a presented frame and reads via capture_canvas — the
same workaround the save-thumbnail path uses — delivering the PNG as a
download.
(cherry picked from commit 6e89b36d)
Ported onto main (#1717 review): the web capture rides main's existing
frame-parked capture (one ControlCaptureFrame path, ignoring the timer
tick on wasm) instead of a second parked-capture state; closing a tab
drops its file handle inside main's on_tab_close.
This commit is contained in:
1 parent
0459f20129
commit
835e8d469f
33 files changed
+1084
-29
No files matched your search
@@ -142,6 +142,9 @@ web-sys = { version = "=0.3.85", features = [
|
||||
"File",
|
||||
"FileSystemDirectoryHandle",
|
||||
"FileSystemFileHandle",
|
||||
# FileSystemFileHandle derefs to FileSystemHandle for the `name()`
|
||||
# getter the in-place save (io::fs_access) reads off a picked handle.
|
||||
"FileSystemHandle",
|
||||
"FileSystemGetDirectoryOptions",
|
||||
"FileSystemGetFileOptions",
|
||||
"FileSystemWritableFileStream",
|
||||
@@ -164,6 +167,10 @@ web-sys = { version = "=0.3.85", features = [
|
||||
"MessageEvent",
|
||||
"ErrorEvent",
|
||||
"Clipboard",
|
||||
# The web image paste reads `navigator.clipboard.read()` items
|
||||
# (sys::read_clipboard_image): their `types()`/`getType()` surface lives
|
||||
# on ClipboardItem.
|
||||
"ClipboardItem",
|
||||
"KeyboardEvent",
|
||||
"KeyboardEventInit",
|
||||
"EventInit",
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = تلك الكائنات
|
||||
|
||||
clipboard =
|
||||
.pasted-image = تم لصق الصورة "{ $name }": { $width }×{ $height } px
|
||||
.image-unsupported = تعذّر فك ترميز الصورة الملصقة "{ $name }".
|
||||
.no-supported-content = لا تحتوي الحافظة على نص أو كائنات رسم مدعومة.
|
||||
.unavailable = حافظة النظام غير متاحة.
|
||||
.occupied = حافظة النظام قيد الاستخدام حاليًا. حاول مرة أخرى.
|
||||
@@ -2412,6 +2414,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT الموضع [استبدال/بادئة/لاحقة] <استبدال>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = فشل الحفظ في الموقع (__ocs_fmt_0__)؛ تم تنزيل نسخة
|
||||
.save-picker-failed-downloading = فشل اختيار وجهة الحفظ: __ocs_fmt_0__؛ يتم تنزيل نسخة
|
||||
.restore-all = استعادة الكل
|
||||
.restoring-drawing-s = جارٍ استعادة __ocs_fmt_0__ رسم…
|
||||
.session-had-open-drawing-s = كانت الجلسة تضم __ocs_fmt_0__ رسمًا مفتوحًا.
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = тези обекти
|
||||
|
||||
clipboard =
|
||||
.pasted-image = Вставено изображение "{ $name }": { $width }×{ $height } px
|
||||
.image-unsupported = Вставеното изображение "{ $name }" не може да бъде декодирано.
|
||||
.no-supported-content = Клипбордът не съдържа поддържан текст или чертожни обекти.
|
||||
.unavailable = Системният клипборд не е наличен.
|
||||
.occupied = Системният клипборд в момента се използва. Опитай отново.
|
||||
@@ -2399,6 +2401,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT поставяне [Замяна/Префикс/Суфикс] <Замяна>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = Записът на място се провали (__ocs_fmt_0__); изтеглено е копие
|
||||
.save-picker-failed-downloading = Изборът на място за запис се провали: __ocs_fmt_0__; изтегля се копие
|
||||
.restore-all = Възстанови всички
|
||||
.restoring-drawing-s = Възстановяване на __ocs_fmt_0__ чертеж(а)…
|
||||
.session-had-open-drawing-s = Сесията е имала __ocs_fmt_0__ отворени чертежа.
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = dané objekty
|
||||
|
||||
clipboard =
|
||||
.pasted-image = Vložen obrázek "{ $name }": { $width }×{ $height } px
|
||||
.image-unsupported = Vložený obrázek "{ $name }" se nepodařilo dekódovat.
|
||||
.no-supported-content = Schránka neobsahuje podporovaný text ani objekty výkresu.
|
||||
.unavailable = Systémová schránka není dostupná.
|
||||
.occupied = Systémová schránka se právě používá. Zkuste to znovu.
|
||||
@@ -2399,6 +2401,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = Umístění TCOUNT [přepsat/předpona/přípona] <Overwrite>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = Uložení na místo selhalo (__ocs_fmt_0__); kopie byla stažena
|
||||
.save-picker-failed-downloading = Výběr cíle uložení selhal: __ocs_fmt_0__; stahuje se kopie
|
||||
.restore-all = Obnovit vše
|
||||
.restoring-drawing-s = Obnovování __ocs_fmt_0__ výkres(ů)…
|
||||
.session-had-open-drawing-s = Relace měla otevřeno __ocs_fmt_0__ výkres(ů).
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = diese Objekte
|
||||
|
||||
clipboard =
|
||||
.pasted-image = Bild "{ $name }" eingefügt: { $width }×{ $height } px
|
||||
.image-unsupported = Das eingefügte Bild "{ $name }" konnte nicht dekodiert werden.
|
||||
.no-supported-content = Die Zwischenablage enthält keinen unterstützten Text und keine Zeichnungsobjekte.
|
||||
.unavailable = Die Systemzwischenablage ist nicht verfügbar.
|
||||
.occupied = Die Systemzwischenablage wird gerade verwendet. Versuchen Sie es erneut.
|
||||
@@ -2400,6 +2402,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Platzierung [Überschreiben/Präfix/Suffix] <Überschreiben>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = Direktes Speichern fehlgeschlagen (__ocs_fmt_0__); eine Kopie wurde heruntergeladen
|
||||
.save-picker-failed-downloading = Speicherziel-Auswahl fehlgeschlagen: __ocs_fmt_0__; eine Kopie wird heruntergeladen
|
||||
.restore-all = Alle wiederherstellen
|
||||
.restoring-drawing-s = __ocs_fmt_0__ Zeichnung(en) werden wiederhergestellt…
|
||||
.session-had-open-drawing-s = Die Sitzung hatte __ocs_fmt_0__ geöffnete Zeichnung(en).
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = αυτά τα αντικείμενα
|
||||
|
||||
clipboard =
|
||||
.pasted-image = Επικολλήθηκε η εικόνα "{ $name }": { $width }×{ $height } px
|
||||
.image-unsupported = Δεν ήταν δυνατή η αποκωδικοποίηση της επικολλημένης εικόνας "{ $name }".
|
||||
.no-supported-content = Το πρόχειρο δεν περιέχει υποστηριζόμενο κείμενο ή αντικείμενα σχεδίου.
|
||||
.unavailable = Το πρόχειρο του συστήματος δεν είναι διαθέσιμο.
|
||||
.occupied = Το πρόχειρο του συστήματος χρησιμοποιείται αυτή τη στιγμή. Δοκιμάστε ξανά.
|
||||
@@ -2399,6 +2401,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT τοποθέτηση [Αντικατάσταση/Πρόθεμα/Κατάληξη] <Αντικατάσταση>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = Απέτυχε η αποθήκευση επί τόπου (__ocs_fmt_0__)· λήφθηκε αντίγραφο
|
||||
.save-picker-failed-downloading = Απέτυχε η επιλογή προορισμού αποθήκευσης: __ocs_fmt_0__· γίνεται λήψη αντιγράφου
|
||||
.restore-all = Επαναφορά όλων
|
||||
.restoring-drawing-s = Επαναφορά __ocs_fmt_0__ σχέδιο(α)…
|
||||
.session-had-open-drawing-s = Η συνεδρία είχε __ocs_fmt_0__ ανοιχτά σχέδια.
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = those objects
|
||||
|
||||
clipboard =
|
||||
.pasted-image = Pasted image "{ $name }": { $width }×{ $height } px
|
||||
.image-unsupported = The pasted image "{ $name }" could not be decoded.
|
||||
.no-supported-content = Clipboard does not contain supported text or drawing objects.
|
||||
.unavailable = System clipboard is unavailable.
|
||||
.occupied = System clipboard is currently in use. Try again.
|
||||
@@ -2403,6 +2405,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT placement [Overwrite/Prefix/Suffix] <Overwrite>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = In-place save failed (__ocs_fmt_0__); a copy was downloaded instead
|
||||
.save-picker-failed-downloading = Save destination picker failed: __ocs_fmt_0__; downloading a copy instead
|
||||
.restore-all = Restore all
|
||||
.restoring-drawing-s = Restoring __ocs_fmt_0__ drawing(s)…
|
||||
.session-had-open-drawing-s = Session had __ocs_fmt_0__ open drawing(s).
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = esos objetos
|
||||
|
||||
clipboard =
|
||||
.pasted-image = Imagen "{ $name }" pegada: { $width }×{ $height } px
|
||||
.image-unsupported = No se pudo decodificar la imagen pegada "{ $name }".
|
||||
.no-supported-content = El portapapeles no contiene texto ni objetos de dibujo compatibles.
|
||||
.unavailable = El portapapeles del sistema no está disponible.
|
||||
.occupied = El portapapeles del sistema está en uso. Inténtelo de nuevo.
|
||||
@@ -2400,6 +2402,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT colocación [Sobrescribir/Prefijo/Sufijo] <Sobrescribir>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = Error al guardar en el sitio (__ocs_fmt_0__); se descargó una copia
|
||||
.save-picker-failed-downloading = Falló la selección del destino de guardado: __ocs_fmt_0__; descargando una copia
|
||||
.restore-all = Restaurar todo
|
||||
.restoring-drawing-s = Restaurando __ocs_fmt_0__ dibujos…
|
||||
.session-had-open-drawing-s = La sesión tenía __ocs_fmt_0__ dibujos abiertos.
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = kyseiset objektit
|
||||
|
||||
clipboard =
|
||||
.pasted-image = Liitetty kuva "{ $name }": { $width }×{ $height } px
|
||||
.image-unsupported = Liitettyä kuvaa "{ $name }" ei voitu purkaa.
|
||||
.no-supported-content = Leikepöydällä ei ole tuettua tekstiä tai piirustusobjekteja.
|
||||
.unavailable = Järjestelmän leikepöytä ei ole käytettävissä.
|
||||
.occupied = Järjestelmän leikepöytä on parhaillaan käytössä. Yritä uudelleen.
|
||||
@@ -2399,6 +2401,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT-sijoittelu [Korvaa/etuliite/liite] <Overwrite>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = Tallennus paikallaan epäonnistui (__ocs_fmt_0__); kopio ladattiin
|
||||
.save-picker-failed-downloading = Tallennuskohteen valinta epäonnistui: __ocs_fmt_0__; ladataan kopio
|
||||
.restore-all = Palauta kaikki
|
||||
.restoring-drawing-s = Palautetaan __ocs_fmt_0__ piirustusta…
|
||||
.session-had-open-drawing-s = Istunnossa oli __ocs_fmt_0__ avointa piirustusta.
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = ces objets
|
||||
|
||||
clipboard =
|
||||
.pasted-image = Image collée "{ $name }" : { $width }×{ $height } px
|
||||
.image-unsupported = L'image collée "{ $name }" n'a pas pu être décodée.
|
||||
.no-supported-content = Le presse-papiers ne contient aucun texte ni objet de dessin pris en charge.
|
||||
.unavailable = Le presse-papiers système n’est pas disponible.
|
||||
.occupied = Le presse-papiers système est en cours d’utilisation. Réessayez.
|
||||
@@ -2400,6 +2402,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Position [Remplacer/Préfixe/Suffixe] <Remplacer> :
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = Échec de l’enregistrement local (__ocs_fmt_0__) ; une copie a été téléchargée
|
||||
.save-picker-failed-downloading = Échec du sélecteur de destination : __ocs_fmt_0__ ; téléchargement d’une copie
|
||||
.restore-all = Tout restaurer
|
||||
.restoring-drawing-s = Restauration de __ocs_fmt_0__ dessin(s)…
|
||||
.session-had-open-drawing-s = La session avait __ocs_fmt_0__ dessin(s) ouvert(s).
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = उन ऑब्जेक्ट को
|
||||
|
||||
clipboard =
|
||||
.pasted-image = छवि "{ $name }" पेस्ट की गई: { $width }×{ $height } px
|
||||
.image-unsupported = पेस्ट की गई छवि "{ $name }" को डिकोड नहीं किया जा सका।
|
||||
.no-supported-content = क्लिपबोर्ड में समर्थित टेक्स्ट या ड्रॉइंग ऑब्जेक्ट नहीं है।
|
||||
.unavailable = सिस्टम क्लिपबोर्ड उपलब्ध नहीं है।
|
||||
.occupied = सिस्टम क्लिपबोर्ड अभी उपयोग में है। फिर प्रयास करें।
|
||||
@@ -2388,6 +2390,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT स्थान [अधिलेखित/उपसर्ग/प्रत्यय] <अधिलेखित>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = इन-प्लेस सेव विफल (__ocs_fmt_0__); प्रतिलिपि डाउनलोड की गई
|
||||
.save-picker-failed-downloading = सेव गंतव्य चुनना विफल: __ocs_fmt_0__; प्रतिलिपि डाउनलोड हो रही है
|
||||
.restore-all = सभी पुनर्स्थापित करें
|
||||
.restoring-drawing-s = __ocs_fmt_0__ ड्रॉइंग पुनर्स्थापित की जा रही हैं…
|
||||
.session-had-open-drawing-s = सत्र में __ocs_fmt_0__ ड्रॉइंग खुली थीं।
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = azokat az objektumokat
|
||||
|
||||
clipboard =
|
||||
.pasted-image = "{ $name }" kép beillesztve: { $width }×{ $height } px
|
||||
.image-unsupported = A beillesztett "{ $name }" kép nem dekódolható.
|
||||
.no-supported-content = A vágólap nem tartalmaz támogatott szöveget vagy rajzobjektumot.
|
||||
.unavailable = A rendszer vágólapja nem érhető el.
|
||||
.occupied = A rendszer vágólapja jelenleg használatban van. Próbálja újra.
|
||||
@@ -2412,6 +2414,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT elhelyezés [Overwrite (Felülírás) / Prefix (Előtag) / Suffix (Utótag)] <Overwrite>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = A helyben mentés nem sikerült (__ocs_fmt_0__); másolat lett letöltve
|
||||
.save-picker-failed-downloading = A mentési cél kiválasztása nem sikerült: __ocs_fmt_0__; másolat letöltése
|
||||
.restore-all = Összes visszaállítása
|
||||
.restoring-drawing-s = __ocs_fmt_0__ rajz(ok) visszaállítása…
|
||||
.session-had-open-drawing-s = A munkamenetben __ocs_fmt_0__ rajz volt megnyitva.
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = quegli oggetti
|
||||
|
||||
clipboard =
|
||||
.pasted-image = Immagine "{ $name }" incollata: { $width }×{ $height } px
|
||||
.image-unsupported = Impossibile decodificare l'immagine incollata "{ $name }".
|
||||
.no-supported-content = Gli Appunti non contengono testo o oggetti di disegno supportati.
|
||||
.unavailable = Gli Appunti di sistema non sono disponibili.
|
||||
.occupied = Gli Appunti di sistema sono attualmente in uso. Riprova.
|
||||
@@ -2399,6 +2401,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = Posizionamento TCOUNT [Sovrascrivi/Prefisso/Suffisso] <Overwrite>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = Salvataggio sul posto non riuscito (__ocs_fmt_0__); è stata scaricata una copia
|
||||
.save-picker-failed-downloading = Selezione della destinazione non riuscita: __ocs_fmt_0__; scarico una copia
|
||||
.restore-all = Ripristina tutto
|
||||
.restoring-drawing-s = Ripristino di __ocs_fmt_0__ disegno/i…
|
||||
.session-had-open-drawing-s = La sessione aveva __ocs_fmt_0__ disegni aperti.
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = それらのオブジェクト
|
||||
|
||||
clipboard =
|
||||
.pasted-image = 画像 "{ $name }" を貼り付けました: { $width }×{ $height } px
|
||||
.image-unsupported = 貼り付けた画像 "{ $name }" をデコードできませんでした。
|
||||
.no-supported-content = クリップボードに対応するテキストまたは図面オブジェクトがありません。
|
||||
.unavailable = システムのクリップボードを使用できません。
|
||||
.occupied = システムのクリップボードは現在使用中です。もう一度お試しください。
|
||||
@@ -2390,6 +2392,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 配置 [Overwrite/Prefix/Suffix] <Overwrite>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = その場での保存に失敗しました(__ocs_fmt_0__)。代わりにコピーをダウンロードしました
|
||||
.save-picker-failed-downloading = 保存先の選択に失敗しました: __ocs_fmt_0__。代わりにコピーをダウンロードしています
|
||||
.restore-all = すべて復元
|
||||
.restoring-drawing-s = __ocs_fmt_0__ 件の図面を復元しています…
|
||||
.session-had-open-drawing-s = セッションでは __ocs_fmt_0__ 件の図面が開いていました。
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = 해당 객체들
|
||||
|
||||
clipboard =
|
||||
.pasted-image = 이미지 "{ $name }" 붙여넣음: { $width }×{ $height } px
|
||||
.image-unsupported = 붙여넣은 이미지 "{ $name }"을(를) 디코딩할 수 없습니다.
|
||||
.no-supported-content = 클립보드에 지원되는 텍스트나 도면 객체가 없습니다.
|
||||
.unavailable = 시스템 클립보드를 사용할 수 없습니다.
|
||||
.occupied = 시스템 클립보드가 현재 사용 중입니다. 다시 시도하세요.
|
||||
@@ -2399,6 +2401,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 배치 [덮어쓰기/접두사/접미사] <Overwrite>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = 제자리 저장 실패(__ocs_fmt_0__); 대신 사본을 내려받았습니다
|
||||
.save-picker-failed-downloading = 저장 대상 선택 실패: __ocs_fmt_0__; 대신 사본을 내려받는 중
|
||||
.restore-all = 모두 복원
|
||||
.restoring-drawing-s = __ocs_fmt_0__ 도면을 복원하는 중…
|
||||
.session-had-open-drawing-s = 세션에서 __ocs_fmt_0__ 도면이 열려 있었습니다.
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = die objecten
|
||||
|
||||
clipboard =
|
||||
.pasted-image = Afbeelding "{ $name }" geplakt: { $width }×{ $height } px
|
||||
.image-unsupported = De geplakte afbeelding "{ $name }" kon niet worden gedecodeerd.
|
||||
.no-supported-content = Het klembord bevat geen ondersteunde tekst of tekenobjecten.
|
||||
.unavailable = Het systeemklembord is niet beschikbaar.
|
||||
.occupied = Het systeemklembord is momenteel in gebruik. Probeer het opnieuw.
|
||||
@@ -2400,6 +2402,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Plaatsing [Overschrijven/Voorvoegsel/Achtervoegsel] <Overschrijven>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = Ter plekke opslaan mislukt (__ocs_fmt_0__); een kopie is gedownload
|
||||
.save-picker-failed-downloading = Kiezen van opslagbestemming mislukt: __ocs_fmt_0__; een kopie wordt gedownload
|
||||
.restore-all = Alles herstellen
|
||||
.restoring-drawing-s = __ocs_fmt_0__ tekening(en) worden hersteld…
|
||||
.session-had-open-drawing-s = De sessie had __ocs_fmt_0__ tekening(en) open.
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = te obiekty
|
||||
|
||||
clipboard =
|
||||
.pasted-image = Wklejono obraz "{ $name }": { $width }×{ $height } px
|
||||
.image-unsupported = Nie udało się zdekodować wklejonego obrazu "{ $name }".
|
||||
.no-supported-content = Schowek nie zawiera obsługiwanego tekstu ani obiektów rysunku.
|
||||
.unavailable = Schowek systemowy jest niedostępny.
|
||||
.occupied = Schowek systemowy jest obecnie używany. Spróbuj ponownie.
|
||||
@@ -2399,6 +2401,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = Umieszczenie TCOUNT [Nadpisanie/Przedrostek/Sufiks] <Overwrite>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = Zapis w miejscu nie powiódł się (__ocs_fmt_0__); pobrano kopię
|
||||
.save-picker-failed-downloading = Wybór miejsca zapisu nie powiódł się: __ocs_fmt_0__; pobieranie kopii
|
||||
.restore-all = Przywróć wszystkie
|
||||
.restoring-drawing-s = Przywracanie __ocs_fmt_0__ rysunków…
|
||||
.session-had-open-drawing-s = Sesja miała __ocs_fmt_0__ otwartych rysunków.
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = esses objetos
|
||||
|
||||
clipboard =
|
||||
.pasted-image = Imagem "{ $name }" colada: { $width }×{ $height } px
|
||||
.image-unsupported = Não foi possível decodificar a imagem colada "{ $name }".
|
||||
.no-supported-content = A área de transferência não contém texto nem objetos de desenho compatíveis.
|
||||
.unavailable = A área de transferência do sistema não está disponível.
|
||||
.occupied = A área de transferência do sistema está em uso. Tente novamente.
|
||||
@@ -2400,6 +2402,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT posicionamento [Sobrescrever/Prefixo/Sufixo] <Sobrescrever>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = A gravação no local falhou (__ocs_fmt_0__); foi descarregada uma cópia
|
||||
.save-picker-failed-downloading = A escolha do destino de gravação falhou: __ocs_fmt_0__; a descarregar uma cópia
|
||||
.restore-all = Restaurar tudo
|
||||
.restoring-drawing-s = Restaurando __ocs_fmt_0__ desenhos…
|
||||
.session-had-open-drawing-s = A sessão tinha __ocs_fmt_0__ desenhos abertos.
|
||||
|
||||
@@ -168,6 +168,8 @@ modal =
|
||||
.those-objects = эти объекты
|
||||
|
||||
clipboard =
|
||||
.pasted-image = Вставлено изображение "{ $name }": { $width }×{ $height } px
|
||||
.image-unsupported = Не удалось декодировать вставленное изображение "{ $name }".
|
||||
.no-supported-content = Буфер обмена не содержит поддерживаемого текста или объектов чертежа.
|
||||
.unavailable = Системный буфер обмена недоступен.
|
||||
.occupied = Системный буфер обмена сейчас используется. Повторите попытку.
|
||||
@@ -2403,6 +2405,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Размещение [Заменить/Префикс/Суффикс] <Заменить>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = Не удалось сохранить в файл (__ocs_fmt_0__); копия загружена
|
||||
.save-picker-failed-downloading = Не удалось выбрать место сохранения: __ocs_fmt_0__; загружается копия
|
||||
.restore-all = Восстановить все
|
||||
.restoring-drawing-s = Восстановление __ocs_fmt_0__ чертежа(ей)…
|
||||
.session-had-open-drawing-s = В сессии было открыто __ocs_fmt_0__ чертежа(ей).
|
||||
|
||||
@@ -166,6 +166,8 @@ modal =
|
||||
.those-objects = bu nesneleri
|
||||
|
||||
clipboard =
|
||||
.pasted-image = "{ $name }" görseli yapıştırıldı: { $width }×{ $height } px
|
||||
.image-unsupported = Yapıştırılan "{ $name }" görseli çözümlenemedi.
|
||||
.no-supported-content = Panoda desteklenen metin veya çizim nesnesi yok.
|
||||
.unavailable = Sistem panosuna erişilemiyor.
|
||||
.occupied = Sistem panosu şu anda kullanılıyor. Yeniden deneyin.
|
||||
@@ -2384,6 +2386,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Yerleştirme [Üzerine yaz/Önek/Sonek] <Üzerine yaz>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = Yerine kayıt başarısız (__ocs_fmt_0__); bir kopya indirildi
|
||||
.save-picker-failed-downloading = Kayıt hedefi seçimi başarısız: __ocs_fmt_0__; bir kopya indiriliyor
|
||||
.restore-all = Tümünü geri yükle
|
||||
.restoring-drawing-s = __ocs_fmt_0__ çizim geri yükleniyor…
|
||||
.session-had-open-drawing-s = Oturumda __ocs_fmt_0__ açık çizim vardı.
|
||||
|
||||
@@ -167,6 +167,8 @@ modal =
|
||||
.those-objects = 这些对象
|
||||
|
||||
clipboard =
|
||||
.pasted-image = 已粘贴图像 "{ $name }":{ $width }×{ $height } px
|
||||
.image-unsupported = 无法解码粘贴的图像 "{ $name }"。
|
||||
.no-supported-content = 剪贴板中没有受支持的文本或图形对象。
|
||||
.unavailable = 系统剪贴板不可用。
|
||||
.occupied = 系统剪贴板当前正被使用,请重试。
|
||||
@@ -2387,6 +2389,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 放置方式 [覆盖/前缀/后缀] <覆盖>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = 就地保存失败(__ocs_fmt_0__);已下载副本
|
||||
.save-picker-failed-downloading = 选择保存目标失败:__ocs_fmt_0__;正在下载副本
|
||||
.restore-all = 全部恢复
|
||||
.restoring-drawing-s = 正在恢复 __ocs_fmt_0__ 绘图…
|
||||
.session-had-open-drawing-s = 会话中有 __ocs_fmt_0__ 个打开的绘图。
|
||||
|
||||
@@ -167,6 +167,8 @@ modal =
|
||||
.those-objects = 這些物件
|
||||
|
||||
clipboard =
|
||||
.pasted-image = 已貼上影像 "{ $name }":{ $width }×{ $height } px
|
||||
.image-unsupported = 無法解碼貼上的影像 "{ $name }"。
|
||||
.no-supported-content = 剪貼簿中沒有受支援的文字或圖形物件。
|
||||
.unavailable = 系統剪貼簿不可用。
|
||||
.occupied = 系統剪貼簿目前正被使用,請重試。
|
||||
@@ -2399,6 +2401,8 @@ command =
|
||||
.tcount-placement-overwrite-prefix-suffix-overw = TCOUNT 放置方式 [覆蓋/前綴/後綴] <覆蓋>:
|
||||
|
||||
file =
|
||||
.in-place-save-failed-downloaded = 就地儲存失敗(__ocs_fmt_0__);已下載副本
|
||||
.save-picker-failed-downloading = 選擇儲存目標失敗:__ocs_fmt_0__;正在下載副本
|
||||
.restore-all = 全部恢復
|
||||
.restoring-drawing-s = 正在恢復 __ocs_fmt_0__ 繪圖…
|
||||
.session-had-open-drawing-s = 工作階段中有 __ocs_fmt_0__ 個開啟的繪圖。
|
||||
|
||||
@@ -1541,9 +1541,27 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
image
|
||||
.save_with_format(&path, image::ImageFormat::Png)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// No filesystem behind a web "path": encode the PNG in memory and
|
||||
// hand it to the browser as a download named after the request's
|
||||
// file name — the delivery every other web output uses.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let mut png = Vec::new();
|
||||
image
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let download = path
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or("capture.png")
|
||||
.to_string();
|
||||
crate::sys::download_bytes(&download, &png);
|
||||
}
|
||||
|
||||
let doc_unit = crate::app::properties::insunits_name(
|
||||
self.tabs[self.active_tab]
|
||||
@@ -1624,6 +1642,19 @@ impl OpenCADStudio {
|
||||
self.control.capture_frame_pending = false;
|
||||
return Task::none();
|
||||
};
|
||||
// Web: iced's window screenshot needs a synchronous GPU map wasm
|
||||
// cannot provide; the canvas readback (the save-thumbnail technique)
|
||||
// only sees the drawing right after a presented frame, before the
|
||||
// browser clears the WebGL buffer — so wait for a real frame.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if !redrew {
|
||||
return Task::none();
|
||||
}
|
||||
self.control.capture_frame_pending = false;
|
||||
self.control.capture_after_redraw = true;
|
||||
return Task::done(Message::ControlScreenshot(path, crate::sys::capture_canvas()));
|
||||
}
|
||||
self.control.capture_frame_pending = false;
|
||||
self.control.capture_after_redraw = redrew;
|
||||
let Some(window) = self.main_window else {
|
||||
|
||||
@@ -915,6 +915,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")]
|
||||
@@ -2277,6 +2285,12 @@ pub enum Message {
|
||||
PasteShortcutResolved(PasteFocus),
|
||||
/// Completion of a system text clipboard read requested by PASTECLIP.
|
||||
SystemClipboardPaste(SystemClipboardText),
|
||||
/// Web: completion of the clipboard-image read the paste fallback starts
|
||||
/// when no text was found — `Some((name, bytes))` attaches the image,
|
||||
/// `None` reports the usual unsupported-content error. Native never
|
||||
/// constructs it.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
SystemClipboardPasteImage(Option<(String, Vec<u8>)>),
|
||||
/// Ctrl/Cmd+A — select all layer rows when the Layer Manager is open, or all
|
||||
/// drawing objects otherwise (#236).
|
||||
SelectAllShortcut,
|
||||
@@ -2715,6 +2729,41 @@ pub enum Message {
|
||||
camera_generation: u64,
|
||||
result: Result<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: codec::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: codec::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`.
|
||||
@@ -4548,6 +4597,8 @@ impl OpenCADStudio {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pending_web_thumbnail_save: None,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
save_plain_reuse_fs_handle: false,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_autosave_in_flight: false,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
web_autosave_warned: false,
|
||||
|
||||
@@ -195,6 +195,13 @@ impl OpenCADStudio {
|
||||
// This tab is closing for good — drop its autosave recovery copy.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = std::fs::remove_file(self.autosave_target(idx));
|
||||
// …and, on the web, its retained in-place save handle: the next tab
|
||||
// to reuse that id must start with a fresh pick instead of silently
|
||||
// writing over the closed drawing's file.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if let Some(closing_id) = closing_tab_id {
|
||||
crate::io::fs_access::drop_handle(closing_id);
|
||||
}
|
||||
// Only-tab case: when the lone non-start tab closes, fall
|
||||
// back to the Start tab if it exists; otherwise spawn a
|
||||
// fresh blank drawing (legacy behaviour).
|
||||
@@ -2095,6 +2102,68 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a clipboard image read on the web (a pasted screenshot) the way
|
||||
/// the IMAGE command's pick does: register the bytes name-keyed, decode
|
||||
/// for the file's true pixel size, then commit the entity — but placed
|
||||
/// directly at the cursor (0,0 before any pointer move) with the command's
|
||||
/// default width, skipping the pick prompts a paste should not run.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn paste_clipboard_image(&mut self, name: String, bytes: Vec<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 = codec::entities::RasterImage::with_size(
|
||||
&name,
|
||||
codec::types::Vector3::new(origin.x, origin.y, origin.z),
|
||||
pixels_w as f64,
|
||||
pixels_h as f64,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
image.flags = codec::entities::ImageDisplayFlags::SHOW_IMAGE
|
||||
| codec::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY;
|
||||
let short = std::path::Path::new(&name)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(&name)
|
||||
.to_string();
|
||||
self.command_line.push_output(
|
||||
crate::tr!(
|
||||
"clipboard",
|
||||
"pasted-image",
|
||||
name = short,
|
||||
width = pixels_w,
|
||||
height = pixels_h
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
self.apply_cmd_result(crate::command::CmdResult::CommitAndExit(
|
||||
codec::EntityType::RasterImage(image),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn on_qselect_open(&mut self) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].scene.selection.borrow_mut().menu.open_at = 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
@@ -2026,7 +2026,7 @@ impl OpenCADStudio {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if self.web_session_focus_pending && self.web_autosave_restore_all_remaining == 0 {
|
||||
self.web_session_focus_pending = false;
|
||||
self.select_remembered_session_tab();
|
||||
let _ = self.select_remembered_session_tab();
|
||||
}
|
||||
|
||||
// Missing-on-open: neutral command-line notice plus a flag
|
||||
@@ -2765,10 +2765,12 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
/// Web SAVE completion: the serializer worker (or inline fallback) has the
|
||||
/// drawing bytes. The download and tab bookkeeping that used to run
|
||||
/// synchronously inside `on_web_save_screenshot`, unchanged in semantics —
|
||||
/// except the dirty flag is only cleared when the serialized snapshot is
|
||||
/// still the tab's current state.
|
||||
/// drawing bytes. When the tab holds a File System Access handle the
|
||||
/// bytes go to it in place (createWritable needs no user gesture, so the
|
||||
/// write may happen long after the pick); completion continues in
|
||||
/// `on_web_save_written`. Otherwise the legacy download fires — the only
|
||||
/// artifact of the save, launched even if the tab was closed while
|
||||
/// serialization was in flight.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn on_web_save_serialized(
|
||||
&mut self,
|
||||
@@ -2789,9 +2791,111 @@ impl OpenCADStudio {
|
||||
return self.finish_web_save(false);
|
||||
}
|
||||
};
|
||||
// In-place write through the handle picked at the confirm gesture
|
||||
// (or an earlier one). The bookkeeping runs at write completion
|
||||
// (WebSaveWritten) so a failed write cannot clear the dirty flag;
|
||||
// the bytes ride back with the completion so the OPFS recent store
|
||||
// gets the same copy the download path uses.
|
||||
if let Some(picked_name) = crate::io::fs_access::handle_name(tab_id) {
|
||||
let download_name = picked_name.clone();
|
||||
return Task::perform(
|
||||
async move {
|
||||
let result = if let Err(error) =
|
||||
crate::io::fs_access::write_handle(tab_id, &bytes).await
|
||||
{
|
||||
// Keep the save alive: the drawing still lands
|
||||
// somewhere via the legacy download (which needs no
|
||||
// activation), and the error travels with the
|
||||
// completion message for the command-line note.
|
||||
crate::sys::download_bytes(&download_name, &bytes);
|
||||
Err(error)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
(result, bytes)
|
||||
},
|
||||
move |(result, bytes)| Message::WebSaveWritten {
|
||||
tab_id,
|
||||
filename: picked_name,
|
||||
autosave_key,
|
||||
version,
|
||||
geometry_epoch,
|
||||
edit_revision,
|
||||
camera_generation,
|
||||
result,
|
||||
bytes,
|
||||
},
|
||||
);
|
||||
}
|
||||
// The download is the only artifact of the save — fire it even if the
|
||||
// tab was closed while serialization was in flight.
|
||||
crate::sys::download_bytes(&filename, &bytes);
|
||||
self.finalize_web_save(
|
||||
tab_id,
|
||||
filename,
|
||||
autosave_key,
|
||||
version,
|
||||
geometry_epoch,
|
||||
edit_revision,
|
||||
camera_generation,
|
||||
bytes,
|
||||
)
|
||||
}
|
||||
|
||||
/// Web SAVE in-place completion: the retained handle's write settled.
|
||||
/// Success keeps the handle for the next plain Save; failure drops it
|
||||
/// (the task already downloaded a copy — note that in the command line,
|
||||
/// and re-pick on the next save instead of failing the same way again).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn on_web_save_written(
|
||||
&mut self,
|
||||
tab_id: u64,
|
||||
filename: String,
|
||||
autosave_key: String,
|
||||
version: codec::DxfVersion,
|
||||
geometry_epoch: u64,
|
||||
edit_revision: u64,
|
||||
camera_generation: u64,
|
||||
result: Result<(), String>,
|
||||
bytes: Vec<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: codec::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())
|
||||
@@ -3610,6 +3714,14 @@ impl OpenCADStudio {
|
||||
/// save-before-close flow — the version picker is reserved for Save As.
|
||||
pub(in crate::app) fn save_with_default_format(&mut self, tab_idx: usize) -> Task<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 =
|
||||
@@ -3708,7 +3820,9 @@ impl OpenCADStudio {
|
||||
);
|
||||
Task::batch([close, pick])
|
||||
}
|
||||
// Web: no filesystem — serialize and hand the browser a download.
|
||||
// Web: no filesystem path — the destination is either a retained
|
||||
// File System Access handle (in-place write), a fresh
|
||||
// showSaveFilePicker pick, or the legacy download.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let close = self.close_save_dialog_window();
|
||||
@@ -3718,30 +3832,147 @@ impl OpenCADStudio {
|
||||
self.sync_solid_models_for_save(i);
|
||||
self.tabs[i].scene.sync_native_parametric_graph();
|
||||
let tab_id = self.tabs[i].id;
|
||||
let bounds =
|
||||
crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID);
|
||||
let Some(bounds) = bounds else {
|
||||
return Task::batch([
|
||||
close,
|
||||
Task::done(Message::WebSaveScreenshot {
|
||||
tab_id,
|
||||
filename,
|
||||
ext: ext.to_string(),
|
||||
version,
|
||||
bounds: None,
|
||||
screenshot: None,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
self.pending_web_thumbnail_save = Some(crate::app::PendingWebThumbnailSave {
|
||||
// File System Access routing, decided HERE: showSaveFilePicker
|
||||
// may only run while the page holds the Save click's transient
|
||||
// user activation, and the thumbnail capture + worker
|
||||
// serialization that follow can outlast that window. So the
|
||||
// handle is secured at this gesture moment (or reused from a
|
||||
// previous pick — retained handles need no activation to write)
|
||||
// and only the write happens later.
|
||||
let route = crate::io::fs_access::save_route(
|
||||
crate::io::fs_access::supported(),
|
||||
self.save_plain_reuse_fs_handle,
|
||||
crate::io::fs_access::handle_ext(tab_id).as_deref(),
|
||||
ext,
|
||||
);
|
||||
match route {
|
||||
crate::io::fs_access::SaveRoute::WriteInPlace => {
|
||||
// Plain Save on a matching handle — THE point: Ctrl+S
|
||||
// updates the picked file, no dialog at all. The real
|
||||
// name (the user may have renamed it in the picker)
|
||||
// drives the save bookkeeping.
|
||||
let picked_name =
|
||||
crate::io::fs_access::handle_name(tab_id).unwrap_or(filename);
|
||||
Task::batch([
|
||||
close,
|
||||
self.web_save_capture(tab_id, picked_name, ext.to_string(), version),
|
||||
])
|
||||
}
|
||||
crate::io::fs_access::SaveRoute::PickHandle => {
|
||||
// The picker IS the save dialog here: it opens inside
|
||||
// the click's activation window, before any serialize
|
||||
// task starts. Cancelling it cancels the save (same as
|
||||
// a native save-dialog cancel); a picker failure falls
|
||||
// back to the legacy download.
|
||||
let suggested = filename;
|
||||
let pick_suggested = suggested.clone();
|
||||
Task::batch([
|
||||
close,
|
||||
Task::perform(
|
||||
async move {
|
||||
crate::io::fs_access::pick_save_handle(
|
||||
tab_id,
|
||||
&pick_suggested,
|
||||
ext,
|
||||
)
|
||||
.await
|
||||
},
|
||||
move |picked| Message::WebSaveHandlePicked {
|
||||
tab_id,
|
||||
filename: suggested,
|
||||
ext: ext.to_string(),
|
||||
version,
|
||||
picked,
|
||||
},
|
||||
),
|
||||
])
|
||||
}
|
||||
// Firefox/Safari (no showSaveFilePicker): untouched legacy
|
||||
// download flow.
|
||||
crate::io::fs_access::SaveRoute::Download => {
|
||||
Task::batch([
|
||||
close,
|
||||
self.web_save_capture(tab_id, filename, ext.to_string(), version),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Web save, destination-known half shared by the legacy download path
|
||||
/// and both File System Access paths: queue the viewport thumbnail
|
||||
/// capture (or skip straight to the serializer when the bounds are
|
||||
/// unavailable), which continues in `on_web_save_screenshot`.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn web_save_capture(
|
||||
&mut self,
|
||||
tab_id: u64,
|
||||
filename: String,
|
||||
ext: String,
|
||||
version: codec::DxfVersion,
|
||||
) -> Task<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: codec::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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+63
-3
@@ -6810,9 +6810,24 @@ impl OpenCADStudio {
|
||||
self.focus_cmd_input()
|
||||
}
|
||||
Text::EmptyOrUnsupported => {
|
||||
self.command_line
|
||||
.push_error(crate::tr!("clipboard", "no-supported-content").as_ref());
|
||||
Task::none()
|
||||
// Web: no text — the clipboard may still hold an IMAGE
|
||||
// (a pasted screenshot). Read it through the async
|
||||
// Clipboard API and attach it the way the IMAGE
|
||||
// command's pick does; only when that also finds
|
||||
// nothing does the unsupported-content error surface.
|
||||
// Native has no image route off the sync clipboard.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Task::perform(
|
||||
crate::sys::read_clipboard_image(),
|
||||
Message::SystemClipboardPasteImage,
|
||||
);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.command_line.push_error(
|
||||
crate::tr!("clipboard", "no-supported-content").as_ref(),
|
||||
);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
Text::Unavailable => {
|
||||
self.command_line
|
||||
@@ -6832,6 +6847,19 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
// Web: the clipboard-image fallback of a text-less paste either
|
||||
// attaches the image or falls back to the same error the text
|
||||
// path reports when nothing readable was on the clipboard.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::SystemClipboardPasteImage(image) => match image {
|
||||
Some((name, bytes)) => self.paste_clipboard_image(name, bytes),
|
||||
None => {
|
||||
self.command_line
|
||||
.push_error(crate::tr!("clipboard", "no-supported-content").as_ref());
|
||||
Task::none()
|
||||
}
|
||||
},
|
||||
|
||||
Message::SelectAllShortcut => {
|
||||
let i = self.active_tab;
|
||||
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
|
||||
@@ -10275,6 +10303,38 @@ impl OpenCADStudio {
|
||||
result,
|
||||
),
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::WebSaveHandlePicked {
|
||||
tab_id,
|
||||
filename,
|
||||
ext,
|
||||
version,
|
||||
picked,
|
||||
} => self.on_web_save_handle_picked(tab_id, filename, ext, version, picked),
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::WebSaveWritten {
|
||||
tab_id,
|
||||
filename,
|
||||
autosave_key,
|
||||
version,
|
||||
geometry_epoch,
|
||||
edit_revision,
|
||||
camera_generation,
|
||||
result,
|
||||
bytes,
|
||||
} => self.on_web_save_written(
|
||||
tab_id,
|
||||
filename,
|
||||
autosave_key,
|
||||
version,
|
||||
geometry_epoch,
|
||||
edit_revision,
|
||||
camera_generation,
|
||||
result,
|
||||
bytes,
|
||||
),
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::WebSaveallSerialized { skipped, results } => {
|
||||
self.on_web_saveall_serialized(skipped, results)
|
||||
|
||||
@@ -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 font_repo;
|
||||
pub mod line_read;
|
||||
|
||||
@@ -2703,6 +2703,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
|
||||
"SAVEALL: saved {saved} drawing(s){}." => Some(("file", "saveall-saved-saved-drawing-s")),
|
||||
"SAVEALL: {} already has a save running" => Some(("file", "saveall-already-has-a-save-running")),
|
||||
"SAVEALL: {} failed: {e}" => Some(("file", "saveall-failed-e")),
|
||||
"Save destination picker failed: {error}; downloading a copy instead" => Some(("file", "save-picker-failed-downloading")),
|
||||
"In-place save failed ({error}); a copy was downloaded instead" => Some(("file", "in-place-save-failed-downloaded")),
|
||||
"Restore all" => Some(("file", "restore-all")),
|
||||
"Restoring {} drawing(s)…" => Some(("file", "restoring-drawing-s")),
|
||||
"Session had {count} open drawing(s)." => Some(("file", "session-had-open-drawing-s")),
|
||||
|
||||
+92
@@ -311,6 +311,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