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:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:05 +03:00
1 parent 0459f20129
commit 835e8d469f
33 files changed
+1084 -29

No files matched your search

+7
View File
@@ -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",
+4
View File
@@ -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__ رسمًا مفتوحًا.
+4
View File
@@ -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__ отворени чертежа.
+4
View File
@@ -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(ů).
+4
View File
@@ -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).
+4
View File
@@ -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__ ανοιχτά σχέδια.
+4
View File
@@ -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).
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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).
+4
View File
@@ -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__ ड्रॉइंग खुली थीं।
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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__ 件の図面が開いていました。
+4
View File
@@ -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__ 도면이 열려 있었습니다.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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__ чертежа(ей).
+4
View File
@@ -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ı.
+4
View File
@@ -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__ 个打开的绘图。
+4
View File
@@ -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__ 個開啟的繪圖。
+31
View File
@@ -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 {
+51
View File
@@ -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,
+69
View File
@@ -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;
+9
View File
@@ -22,6 +22,15 @@ use iced::{mouse, Point, Task};
impl OpenCADStudio {
pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task<Message> {
// Every entry here is Save-As semantics (the SAVEAS command, the
// recovery save-as, the native failure redirects): the user is
// choosing a destination, so on the web the FS Access picker must
// open even when the tab already holds a handle (plain Save sets
// the flag back in save_with_default_format).
#[cfg(target_arch = "wasm32")]
{
self.save_plain_reuse_fs_handle = false;
}
// Default the format dropdown to the loaded file's own format — its
// DWG-vs-DXF kind (from the extension) and its version (from the parsed
// document) — so Save-As round-trips the format instead of silently
+257 -26
View File
@@ -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
View File
@@ -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)
+377
View File
@@ -0,0 +1,377 @@
//! In-place web saves through the File System Access API.
//!
//! The legacy web save serialized the drawing and handed the bytes to a
//! browser download — every SAVE/SAVEAS produced a fresh "drawing (1).dwg"
//! in the Downloads folder, and Ctrl+S could never update the file the user
//! is working on. Where the browser exposes `showSaveFilePicker` (Chromium
//! desktop; Firefox and Safari do not implement it), the picker replaces
//! the download as the save dialog: it returns a `FileSystemFileHandle`
//! whose `createWritable()` writes the real file in place, and — once
//! picked — the handle keeps its read/write permission for the rest of the
//! session, so later plain saves write through it with no picker at all.
//!
//! Gesture timing drives the whole design: `showSaveFilePicker` may only be
//! called while the page holds transient user activation (the click on the
//! Save button), but the serialization that produces the bytes runs as a
//! thumbnail capture + worker round-trip that can far outlast that window.
//! The handle is therefore acquired in the save-confirm handler — the
//! gesture moment, before any serialize task starts — and the later write
//! goes through the retained handle, which needs no activation.
//!
//! `web-sys` 0.3.85 does ship `Window::show_save_file_picker_with_options`,
//! but every File System Access *picker* binding sits behind the
//! unstable-API cfg (`--cfg=web_sys_unstable_apis`), which this project's
//! wasm target does not enable (see `.cargo/config.toml` — turning it on
//! would change build flags for every crate in the graph). The picker is
//! therefore invoked dynamically: `Reflect::get(window,
//! "showSaveFilePicker")` + `Function::call` with a plain `js_sys::Object`
//! of options. Only the *handle* half goes through typed web-sys
//! (`FileSystemFileHandle`/`createWritable` are stable features the OPFS
//! code in `io::web_recent` already uses).
//!
//! Split like `io::web_session`: the routing decision and error
//! classification below are pure and compile (and test) on the native host;
//! everything touching the browser is wasm-only.
/// How a web save should reach the disk, decided at the save-confirm
/// gesture (see `save_route`).
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaveRoute {
/// Plain Save on a tab whose retained handle matches the target type:
/// write through the handle — no picker, no download.
WriteInPlace,
/// File System Access is available but no (matching) handle is held:
/// open `showSaveFilePicker` now, inside the user-activation window.
PickHandle,
/// No File System Access (Firefox/Safari): the legacy download flow.
Download,
}
/// The save-routing decision the web confirm path makes at gesture time.
///
/// `handle_ext` is the extension the tab's retained handle was picked for
/// (`None` when the tab holds no handle). Only a *plain* Save (Save/QSAVE
/// and the save-before-close flow) may reuse it: Save As must re-pick, and
/// a handle picked for `.dwg` must not receive DXF bytes (the file name
/// would lie about its contents).
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub fn save_route(
supported: bool,
plain_save: bool,
handle_ext: Option<&str>,
target_ext: &str,
) -> SaveRoute {
if !supported {
return SaveRoute::Download;
}
match handle_ext {
Some(handle_ext) if plain_save && exts_match(handle_ext, target_ext) => {
SaveRoute::WriteInPlace
}
_ => SaveRoute::PickHandle,
}
}
/// Case-insensitive extension comparison that tolerates a leading dot on
/// either side, so registry keys and format-table extensions ("dwg") never
/// mismatch on ".DWG".
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // via save_route's tests
fn exts_match(a: &str, b: &str) -> bool {
let strip = |ext: &str| ext.trim_start_matches('.').to_ascii_lowercase();
strip(a) == strip(b)
}
/// `showSaveFilePicker` rejects with a `DOMException` named "AbortError"
/// when the user dismisses the dialog. That name check is the whole cancel
/// classification — kept pure so it compiles and tests on every target.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub(crate) fn is_cancel_error_name(name: &str) -> bool {
name == "AbortError"
}
/// The picker's file-type filter for a CAD extension: the MIME key Chrome's
/// own examples use, with the extension list doing the actual filtering.
#[cfg(target_arch = "wasm32")]
fn accept_mime(ext: &str) -> &'static str {
if ext.eq_ignore_ascii_case("dxf") {
"application/dxf"
} else {
"application/acad"
}
}
#[cfg(target_arch = "wasm32")]
mod browser {
use std::cell::RefCell;
use std::collections::HashMap;
use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture;
/// A retained save destination: the browser file handle behind one
/// tab's in-place saves, plus the extension it was picked for.
///
/// The handle's permission persists for the session once picked, so
/// writing through it needs no user gesture — which is what lets the
/// write happen long after the click that opened the picker.
#[derive(Clone, Debug)]
struct SaveHandle {
/// Extension ("dwg"/"dxf") the picker filtered for. A later plain
/// save may only reuse the handle while its target type matches.
ext: String,
name: String,
handle: web_sys::FileSystemFileHandle,
}
thread_local! {
/// One in-place destination per tab, keyed by tab id. The registry —
/// not an App field or a Message payload — owns the handles because
/// iced requires `Message: Send` even on wasm, and a JsValue-backed
/// handle is not Send. (Same pattern as web_recent's OPEN_STORES.)
/// Closing a tab, and a failed write, drop its entry; Save As
/// replaces it.
static SAVE_HANDLES: RefCell<HashMap<u64, SaveHandle>> =
RefCell::new(HashMap::new());
}
/// The extension the tab's retained handle was picked for, if any —
/// the `handle_ext` input of `save_route`.
pub fn handle_ext(tab_id: u64) -> Option<String> {
SAVE_HANDLES.with(|handles| {
handles
.borrow()
.get(&tab_id)
.map(|handle| handle.ext.clone())
})
}
/// The retained destination's real name (the user may have renamed it
/// inside the picker) — what save bookkeeping should use.
pub fn handle_name(tab_id: u64) -> Option<String> {
SAVE_HANDLES.with(|handles| {
handles.borrow().get(&tab_id).map(|handle| handle.name.clone())
})
}
/// Forget the tab's retained destination (tab closed, or its handle
/// stopped working — the next save re-picks).
pub fn drop_handle(tab_id: u64) {
SAVE_HANDLES.with(|handles| {
handles.borrow_mut().remove(&tab_id);
});
}
/// Feature-detect `showSaveFilePicker` — never assume it: Firefox and
/// Safari keep the download flow precisely because this returns false.
pub fn supported() -> bool {
web_sys::window().is_some_and(|window| {
js_sys::Reflect::has(window.as_ref(), &JsValue::from_str("showSaveFilePicker"))
.unwrap_or(false)
})
}
/// Open `showSaveFilePicker` for `tab_id` and retain the chosen
/// destination in the registry.
///
/// MUST be called while the page holds transient user activation (the
/// Save click); the thumbnail capture + worker serialization that
/// follow can outlast that window, which is why the handle is picked
/// up front and only written to later.
///
/// * `Ok(Some(name))` — destination chosen and retained; `name` is its
/// real (possibly renamed) file name.
/// * `Ok(None)` — user dismissed the picker: cancel the save (the
/// picker IS the save dialog on this path, same as desktop cancel).
/// * `Err(message)` — the picker itself failed (blocked, no gesture,
/// …); the caller falls back to the legacy download flow.
pub async fn pick_save_handle(
tab_id: u64,
suggested_name: &str,
ext: &str,
) -> Result<Option<String>, String> {
let window = web_sys::window().ok_or_else(|| "browser window unavailable".to_string())?;
// Dynamic call — see the module docs for why the typed web-sys
// binding (`show_save_file_picker_with_options`) is not used.
let picker = js_sys::Reflect::get(window.as_ref(), &JsValue::from_str("showSaveFilePicker"))
.ok()
.and_then(|picker| picker.dyn_into::<js_sys::Function>().ok())
.ok_or_else(|| "showSaveFilePicker unavailable".to_string())?;
let options = picker_options(suggested_name, ext);
let promise = picker
.call1(window.as_ref(), &options)
.map_err(crate::io::web_recent::js_error)?
.dyn_into::<js_sys::Promise>()
.map_err(crate::io::web_recent::js_error)?;
match JsFuture::from(promise).await {
Ok(value) => {
let handle = value
.dyn_into::<web_sys::FileSystemFileHandle>()
.map_err(crate::io::web_recent::js_error)?;
let name = handle.name();
SAVE_HANDLES.with(|handles| {
handles.borrow_mut().insert(
tab_id,
SaveHandle {
ext: ext.trim_start_matches('.').to_ascii_lowercase(),
name: name.clone(),
handle,
},
);
});
Ok(Some(name))
}
// AbortError = the user dismissed the dialog; anything else is a
// real failure the caller should report/fall back on.
Err(error) => {
let cancelled = js_sys::Reflect::get(&error, &JsValue::from_str("name"))
.ok()
.and_then(|name| name.as_string())
.is_some_and(|name| super::is_cancel_error_name(&name));
if cancelled {
Ok(None)
} else {
Err(crate::io::web_recent::js_error(error))
}
}
}
}
/// Write `bytes` through the tab's retained handle: `createWritable` →
/// write → close, the OPFS pattern from `io::web_recent::write_entry`.
/// A failed write still aborts the stream so the exclusive lock is
/// released — otherwise every later write to the same handle would fail
/// the same way for the rest of the session.
pub async fn write_handle(tab_id: u64, bytes: &[u8]) -> Result<(), String> {
let handle = SAVE_HANDLES.with(|handles| {
handles.borrow().get(&tab_id).map(|save| save.handle.clone())
});
let Some(handle) = handle else {
return Err("no retained save destination for this drawing".to_string());
};
let writable = JsFuture::from(handle.create_writable())
.await
.map_err(crate::io::web_recent::js_error)?
.dyn_into::<web_sys::FileSystemWritableFileStream>()
.map_err(crate::io::web_recent::js_error)?;
if let Err(error) = write_and_close(&writable, bytes).await {
let _ = JsFuture::from(writable.abort()).await;
return Err(error);
}
Ok(())
}
/// The createWritable write+close pair from `io::web_recent`'s OPFS
/// writer — same failure contract (either promise rejecting is an
/// error), kept local so the abort-on-failure dance above owns the
/// stream lifecycle in one place.
async fn write_and_close(
writable: &web_sys::FileSystemWritableFileStream,
bytes: &[u8],
) -> Result<(), String> {
let write = writable
.write_with_u8_array(bytes)
.map_err(crate::io::web_recent::js_error)?;
JsFuture::from(write)
.await
.map_err(crate::io::web_recent::js_error)?;
JsFuture::from(writable.close())
.await
.map_err(crate::io::web_recent::js_error)?;
Ok(())
}
/// Build the `SaveFilePickerOptions`-shaped object dynamically (the
/// typed `web_sys::SaveFilePickerOptions` shares the picker's
/// unstable-API cfg, see module docs): `suggestedName` plus a types
/// filter restricted to the target extension.
fn picker_options(suggested_name: &str, ext: &str) -> js_sys::Object {
let set = |object: &js_sys::Object, key: &str, value: &JsValue| {
let _ = js_sys::Reflect::set(object, &JsValue::from_str(key), value);
};
let accept = js_sys::Object::new();
let extensions = js_sys::Array::new();
extensions.push(&JsValue::from_str(&format!(".{}", ext.trim_start_matches('.'))));
set(&accept, super::accept_mime(ext), &extensions);
let type_entry = js_sys::Object::new();
set(
&type_entry,
"description",
&JsValue::from_str(if ext.eq_ignore_ascii_case("dxf") {
"DXF Drawing"
} else {
"DWG Drawing"
}),
);
set(&type_entry, "accept", &accept);
let types = js_sys::Array::new();
types.push(&type_entry);
let options = js_sys::Object::new();
set(&options, "suggestedName", &JsValue::from_str(suggested_name));
set(&options, "types", &types);
options
}
}
#[cfg(target_arch = "wasm32")]
pub use browser::{
drop_handle, handle_ext, handle_name, pick_save_handle, supported, write_handle,
};
#[cfg(test)]
mod tests {
use super::{is_cancel_error_name, save_route, SaveRoute};
/// Firefox/Safari: no showSaveFilePicker — every save stays a download,
/// whatever handle state the tab happens to hold.
#[test]
fn unsupported_browsers_always_download() {
assert_eq!(save_route(false, true, Some("dwg"), "dwg"), SaveRoute::Download);
assert_eq!(save_route(false, false, None, "dxf"), SaveRoute::Download);
}
/// The whole point: plain Save on a tab with a matching handle writes
/// in place — no picker, no download.
#[test]
fn plain_save_with_matching_handle_writes_in_place() {
assert_eq!(save_route(true, true, Some("dwg"), "dwg"), SaveRoute::WriteInPlace);
assert_eq!(save_route(true, true, Some("dxf"), "dxf"), SaveRoute::WriteInPlace);
}
/// Extension comparison is case-insensitive and dot-tolerant — the
/// registry stores the normalized form, the format table's is bare.
#[test]
fn handle_ext_match_ignores_case_and_dot() {
assert_eq!(save_route(true, true, Some(".DWG"), "dwg"), SaveRoute::WriteInPlace);
assert_eq!(save_route(true, true, Some("dwg"), ".DWG"), SaveRoute::WriteInPlace);
}
/// A plain Save targeting the other format must re-pick: writing DXF
/// bytes through a .dwg handle would corrupt the file's identity.
#[test]
fn mismatched_format_re_picks() {
assert_eq!(save_route(true, true, Some("dwg"), "dxf"), SaveRoute::PickHandle);
assert_eq!(save_route(true, true, Some("dxf"), "dwg"), SaveRoute::PickHandle);
}
/// Save As never reuses the handle — the user asked for a destination.
#[test]
fn save_as_with_existing_handle_re_picks() {
assert_eq!(save_route(true, false, Some("dwg"), "dwg"), SaveRoute::PickHandle);
}
/// No handle yet (first save, or the previous write dropped it): pick.
#[test]
fn missing_handle_picks() {
assert_eq!(save_route(true, true, None, "dwg"), SaveRoute::PickHandle);
assert_eq!(save_route(true, false, None, "dwg"), SaveRoute::PickHandle);
}
/// Only the picker's AbortError means "user cancelled".
#[test]
fn only_abort_error_is_a_cancel() {
assert!(is_cancel_error_name("AbortError"));
assert!(!is_cancel_error_name("SecurityError"));
assert!(!is_cancel_error_name("TypeError"));
assert!(!is_cancel_error_name(""));
}
}
+4
View File
@@ -5,6 +5,10 @@
#[cfg(not(target_arch = "wasm32"))]
pub mod edit_lock;
// Same split as web_session/web_autosave: the save-routing decision (and
// its tests) is pure and compiles everywhere; the showSaveFilePicker half
// inside is wasm-only.
pub(crate) mod fs_access;
pub mod file_association;
pub mod font_repo;
pub mod line_read;
+2
View File
@@ -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
View File
@@ -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
View File
@@ -38,6 +38,16 @@
animation: ocs-spin 0.9s linear infinite;
}
@keyframes ocs-spin { to { transform: rotate(360deg); } }
/* Browser-menu print support: hidden on screen, the only thing shown on
paper. beforeprint snapshots the WebGL canvas into it (below), so the
printout is the drawing instead of a blank page — WebGL canvases do
not composite into the print surface on their own. */
#print-snapshot { display: none; }
@media print {
body > *:not(#print-snapshot) { display: none !important; }
html, body { overflow: visible; height: auto; background: #fff; }
#print-snapshot { display: block; max-width: 100%; height: auto; }
}
</style>
<link data-trunk rel="rust" data-bin="OpenCADStudio" />
<link data-trunk rel="copy-dir" href="web/fonts" />
@@ -56,6 +66,8 @@
<div class="title">Open <span>CAD</span> Studio</div>
<div class="sub">Loading…</div>
</div>
<!-- Print-only copy of the canvas; filled by beforeprint below. -->
<img id="print-snapshot" alt="" />
<script src="ocs-control.js" defer></script>
<script>
// Offline support: register the service worker. It is copied next to
@@ -72,6 +84,32 @@
});
})();
</script>
<script>
// Print support. The drawing lives in a WebGL canvas, which browsers
// rasterize as blank on the print surface. beforeprint therefore copies
// it onto a 2D canvas (the same drawImage readback the Rust side's
// sys::capture_canvas uses — no Rust state involved, the canvas is
// located from the DOM) and shows the snapshot in the print-only
// <img>; afterprint drops it again to free the PNG.
(() => {
const snapshot = document.getElementById("print-snapshot");
if (!snapshot) return;
window.addEventListener("beforeprint", () => {
const canvas = document.querySelector("canvas");
if (!canvas || !canvas.width || !canvas.height) return;
const copy = document.createElement("canvas");
copy.width = canvas.width;
copy.height = canvas.height;
const context = copy.getContext("2d");
if (!context) return;
context.drawImage(canvas, 0, 0);
snapshot.src = copy.toDataURL("image/png");
});
window.addEventListener("afterprint", () => {
snapshot.removeAttribute("src");
});
})();
</script>
<script>
(() => {
const splash = document.getElementById("loading");