Save and Export ask before replacing a file that an added extension names

After the desktop save panel closes, the app adds the file type's
extension to a name typed without it (or with one the save does not
write). The panel asked only about the name as typed, so on Linux
typing `card` with `card.vectorcraft` in the folder wrote over
`card.vectorcraft` without a question. On Linux the save panel is the
XDG desktop portal, and its GTK and GNOME file choosers return the
name as typed. Windows and macOS add the extension themselves only
when the panel lists file types, and the panels of Document Info's
Save, the presets dialogs' Export…, the CSS Properties export, the
swatch and graphic style library saves and the Libraries panel's
export list none.

When the added extension names a file that exists, Save, Save As, Save
a Copy, Save as Template, the exports and the other writes through a
save panel now ask "“card.vectorcraft” already exists. Replace it?"
first (dialog `replaceFile`). OK runs the write again with the same
answer and replaces the file. Cancel writes nothing and shows the
dialog it was asked over again. The host reports whether a file exists
through a new `Services::exists`.

Docs: docs/control-protocol.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-17 15:14:42 +07:00
committed by bflatastic
parent 92a801a04f
commit f526671534
29 changed files with 351 additions and 21 deletions
+1
View File
@@ -529,6 +529,7 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
no_cursor_theme: wayland && cursor_theme::theme_missing(),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
// Background Save and Export write from a worker thread.
write_shared: Some(std::sync::Arc::new(write_file)),
// Native saves stream into the file as they are encoded.
+22
View File
@@ -41,6 +41,28 @@ pub const MESSAGE: &str = "message";
pub(super) const MESSAGE_SPEC: DialogSpec = DialogSpec { ok: None, ..SPEC };
/// The dialog kind of the question whether to replace a file that a save panel's name names once
/// the app gave it an extension ([`ask_replace`]). Fields: `message`, `detail`, `path` (the file).
pub const REPLACE: &str = "replaceFile";
pub(super) const REPLACE_SPEC: DialogSpec = DialogSpec { confirm: |app, d| crate::picks::replace(app, &d.str("path")), ..SPEC };
/// Ask whether to replace the file at `path`, which the name `typed` in a save panel became when
/// the app gave it an extension, over the dialog open now (Cancel shows it again). OK runs
/// [`crate::picks::replace`]; the Enter that closed the save panel does not press it.
pub(crate) fn ask_replace(app: &mut VectorcraftApp, path: &str, typed: &str) {
let name = vectorcraft_engine::cmd::fileio::file_name(path);
let typed = vectorcraft_engine::cmd::fileio::file_name(typed);
let message = crate::i18n::fmt(tl!("“{name}” already exists. Replace it?"), &[("name", &name)]);
let detail = crate::i18n::fmt(
tl!("The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents."),
&[("typed", &typed)],
);
let under = app.ui.dialog.take().and_then(|d| serde_json::to_value(d).ok());
let fields = json!({"message": message, "detail": detail, "path": path, super::PARENT: under, super::AFTER_PANEL: true});
app.ui.dialog = Some(Dialog::new(REPLACE, fields));
}
/// Most messages kept for later while a dialog is open ([`tell`]).
const MAX_WAITING: usize = 16;
+1
View File
@@ -240,6 +240,7 @@ registry! {
SwatchOptions: [swatch_options::KIND] => swatch_options::SPEC,
Confirm: [confirm::KIND] => confirm::SPEC,
Message: [confirm::MESSAGE] => confirm::MESSAGE_SPEC,
Replace: [confirm::REPLACE] => confirm::REPLACE_SPEC,
NewSwatch: [new_swatch::KIND] => new_swatch::SPEC,
NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC,
GraphData: [graph_data::KIND] => graph_data::SPEC,
+2
View File
@@ -2448,6 +2448,8 @@
Revert to the saved version of “{name}”? Връщане към запазената версия на „{name}“?
Replace “{name}”, the file this document was opened from? Замяна на „{name}“, файла, от който беше отворен този документ?
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Отварянето му пропусна това, което VectorCraft все още не може да прочете ({what}), така че замяната му го губи завинаги. Запазете под друго име, за да го запазите.
“{name}” already exists. Replace it? „{name}“ вече съществува. Да бъде ли заменен?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Името в панела за запазване беше „{typed}“, а VectorCraft добави разширението на типа файл. Замяната на файла презаписва съдържанието му.
Revolve… Завъртане…
RGB Color RGB цвят
Right Вдясно
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3656,6 +3656,8 @@ effect Regular Pravidelný
Revert to the saved version of “{name}”? Vrátit se k uložené verzi dokumentu „{name}“?
Replace “{name}”, the file this document was opened from? Nahradit „{name}“, soubor, ze kterého byl tento dokument otevřen?
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Při otevírání bylo vynecháno, co VectorCraft zatím neumí přečíst ({what}), takže nahrazením se to nenávratně ztratí. Chcete-li to zachovat, uložte dokument pod jiným názvem.
“{name}” already exists. Replace it? „{name}“ již existuje. Nahradit?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Název v panelu ukládání byl „{typed}“ a VectorCraft přidal příponu typu souboru. Nahrazením souboru se přepíše jeho obsah.
Saved {path} Uloženo: {path}
Saved {path} with {} note(s): {first} Uloženo: {path}, poznámky ({}): {first}
Saving Ukládání
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2460,6 +2460,8 @@ mark-style Japanese Japanisch
Revert to the saved version of “{name}”? Zur gespeicherten Version von „{name}“ zurückkehren?
Replace “{name}”, the file this document was opened from? „{name}“ ersetzen, die Datei, aus der dieses Dokument geöffnet wurde?
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Beim Öffnen wurde ausgelassen, was VectorCraft noch nicht lesen kann ({what}); beim Ersetzen geht das endgültig verloren. Speichern Sie unter einem anderen Namen, um es zu behalten.
“{name}” already exists. Replace it? „{name}“ ist bereits vorhanden. Ersetzen?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Der Name im Speichern-Dialog war „{typed}“, und VectorCraft hat die Erweiterung des Dateityps angefügt. Beim Ersetzen wird der Inhalt der Datei überschrieben.
Revolve… Kreiseln…
RGB Color RGB-Farbe
Right Rechts
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2981,6 +2981,8 @@ theme Appearance Εμφάνιση
Revert to the saved version of “{name}”? Επαναφορά στην αποθηκευμένη έκδοση του «{name}»;
Replace “{name}”, the file this document was opened from? Να αντικατασταθεί το «{name}», το αρχικό αρχείο του εγγράφου;
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Κατά το άνοιγμα παραλείφθηκαν στοιχεία που το VectorCraft δεν μπορεί ακόμη να διαβάσει ({what}). Αν αντικαταστήσετε το αρχείο, θα χαθούν οριστικά. Αποθηκεύστε με άλλο όνομα για να τα διατηρήσετε.
“{name}” already exists. Replace it? Το «{name}» υπάρχει ήδη. Να αντικατασταθεί;
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Το όνομα στο παράθυρο αποθήκευσης ήταν «{typed}» και το VectorCraft πρόσθεσε την επέκταση του τύπου αρχείου. Αν αντικαταστήσετε το αρχείο, τα περιεχόμενά του θα χαθούν.
Revolve… Περιστροφή γύρω από άξονα…
Right Grid: Δεξί πλέγμα:
Right in Front Δεξιά εμπρός
Can't render this file because it contains an unexpected character in line 1902 and column 19.
+2
View File
@@ -2494,6 +2494,8 @@
Revert to the saved version of “{name}”? ¿Revertir a la versión guardada de “{name}”?
Replace “{name}”, the file this document was opened from? ¿Reemplazar “{name}”, el archivo desde el que se abrió este documento?
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Al abrirlo quedó fuera lo que VectorCraft aún no puede leer ({what}), así que reemplazarlo lo pierde para siempre. Guárdelo con otro nombre para conservarlo.
“{name}” already exists. Replace it? “{name}” ya existe. ¿Reemplazarlo?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. El nombre en el panel de guardado era “{typed}”, y VectorCraft añadió la extensión del tipo de archivo. Reemplazar el archivo sobrescribe su contenido.
Revolve… Revolución…
RGB Color Color RGB
Right Derecha
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3589,6 +3589,8 @@ effect Regular Normaali
Revert to the saved version of “{name}”? Palautetaanko tiedoston ”{name}” tallennettu versio?
Replace “{name}”, the file this document was opened from? Korvataanko ”{name}”, tiedosto, josta tämä asiakirja avattiin?
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Avattaessa jätettiin pois se, mitä VectorCraft ei vielä osaa lukea ({what}), joten korvaaminen hävittää sen pysyvästi. Säilytä se tallentamalla toisella nimellä.
“{name}” already exists. Replace it? ”{name}” on jo olemassa. Korvataanko se?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Tallennusikkunan nimi oli ”{typed}”, ja VectorCraft lisäsi tiedostotyypin tunnisteen. Tiedoston korvaaminen kirjoittaa sen sisällön yli.
Saved {path} Tallennettu {path}
Saved {path} with {} note(s): {first} Tallennettu {path}, huomautuksia {}: {first}
Saving Tallennetaan
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2491,6 +2491,8 @@
Revert to the saved version of “{name}”? Revenir à la version enregistrée de « {name} » ?
Replace “{name}”, the file this document was opened from? Remplacer « {name} », le fichier à partir duquel ce document a été ouvert ?
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Son ouverture a laissé de côté ce que VectorCraft ne sait pas encore lire ({what}) : le remplacer le perd définitivement. Enregistrez sous un autre nom pour le conserver.
“{name}” already exists. Replace it? « {name} » existe déjà. Le remplacer ?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Le nom dans la fenêtre d’enregistrement était « {typed} », puis VectorCraft a ajouté l’extension du type de fichier. Remplacer le fichier écrase son contenu.
Revolve… Révolution…
RGB Color Couleur RVB
Right Droite
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2468,6 +2468,8 @@ file-filter Templates Sablonok
Open the URL in the web browser URL megnyitása a böngészőben
Opened with {} note(s): {} Megnyitva, megjegyzések: {}. {}
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Megnyitáskor kimaradt, amit a VectorCraft még nem tud beolvasni ({what}), ezért a fájl felülírásával ez végleg elvész. A megtartásához mentsd más néven.
“{name}” already exists. Replace it? „{name}” már létezik. Felülírod?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. A mentési panelen megadott név ez volt: „{typed}”, a VectorCraft pedig hozzáadta a fájltípus kiterjesztését. A fájl felülírásakor a tartalma elvész.
Open… Megnyitás…
Optical Margin Alignment Optikai margóigazítás
Optimize for fast web view Optimalizálás gyors webes megtekintéshez
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2511,6 +2511,8 @@ mark-style Japanese Giapponese
Revert to the saved version of “{name}”? Ripristinare la versione salvata di “{name}”?
Replace “{name}”, the file this document was opened from? Sostituire “{name}”, il file da cui è stato aperto questo documento?
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Aprendolo è rimasto fuori ciò che VectorCraft non sa ancora leggere ({what}), quindi sostituirlo lo perde per sempre. Salva con un altro nome per conservarlo.
“{name}” already exists. Replace it? “{name}” esiste già. Sostituirlo?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Il nome nel pannello di salvataggio era “{typed}”, poi VectorCraft ha aggiunto l'estensione del tipo di file. Sostituire il file ne sovrascrive il contenuto.
Revolve… Rivoluzione…
RGB Color Colore RGB
Right Destra
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2451,6 +2451,8 @@ mark-style Japanese 日本式
Revert to the saved version of “{name}”? 「{name}」を保存済みの状態に戻しますか?
Replace “{name}”, the file this document was opened from? このドキュメントの開き元のファイル「{name}」を置き換えますか?
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. 開いたときに VectorCraft がまだ読み込めない内容({what})が除外されたため、置き換えるとそれは完全に失われます。保持するには別の名前で保存してください。
“{name}” already exists. Replace it? 「{name}」は既に存在します。置き換えますか?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. 保存パネルの名前は「{typed}」で、VectorCraft がファイル形式の拡張子を付加しました。ファイルを置き換えると内容が上書きされます。
Right Grid: 右グリッド:
Right Indent 右インデント
Right Plane 右面
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2445,6 +2445,8 @@ mark-style Japanese 일본식
Revert to the saved version of “{name}”? “{name}”을(를) 저장된 버전으로 되돌리시겠습니까?
Replace “{name}”, the file this document was opened from? 이 문서를 연 원본 파일 “{name}”을(를) 바꾸시겠습니까?
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. 파일을 열 때 VectorCraft가 아직 읽지 못하는 내용({what})이 제외되어, 덮어쓰면 해당 내용이 영구적으로 사라집니다. 보존하려면 다른 이름으로 저장하세요.
“{name}” already exists. Replace it? “{name}”이(가) 이미 있습니다. 바꾸시겠습니까?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. 저장 패널의 이름은 “{typed}”였고, VectorCraft가 파일 형식의 확장자를 붙였습니다. 파일을 바꾸면 내용을 덮어씁니다.
Right Grid: 오른쪽 격자:
Right Indent 오른쪽 들여쓰기
Right Plane 오른쪽 평면
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3489,6 +3489,8 @@ effect Regular Normalny
Revert to the saved version of “{name}”? Przywrócić zapisaną wersję „{name}”?
Replace “{name}”, the file this document was opened from? Zastąpić „{name}”, plik, z którego otwarto ten dokument?
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Przy otwieraniu pominięto to, czego VectorCraft jeszcze nie odczytuje ({what}), więc zastąpienie pliku utraci to bezpowrotnie. Zapisz pod inną nazwą, aby to zachować.
“{name}” already exists. Replace it? „{name}” już istnieje. Zastąpić?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Nazwa w oknie zapisu to „{typed}”, a VectorCraft dodał rozszerzenie typu pliku. Zastąpienie pliku nadpisze jego zawartość.
Saved {path} Zapisano {path}
Saved {path} with {} note(s): {first} Zapisano {path}, uwagi ({}): {first}
Saving Zapisywanie
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2071,6 +2071,8 @@
Revert to the saved version of “{name}”? Reverter para a versão salva de “{name}”?
Replace “{name}”, the file this document was opened from? Substituir “{name}”, o arquivo do qual este documento foi aberto?
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Ao abri-lo, ficou de fora o que o VectorCraft ainda não consegue ler ({what}), então substituí-lo perde isso para sempre. Salve com outro nome para mantê-lo.
“{name}” already exists. Replace it? “{name}” já existe. Substituir?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. O nome no painel de salvamento era “{typed}”, e o VectorCraft adicionou a extensão do tipo de arquivo. Substituir o arquivo sobrescreve o conteúdo.
Revolve… Revolver…
Right Grid: Grade Direita:
Right Indent Recuo Direito
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2507,6 +2507,8 @@ mark-style Japanese Японский
Revert to the saved version of “{name}”? Вернуться к сохранённой версии «{name}»?
Replace “{name}”, the file this document was opened from? Заменить «{name}» — файл, из которого открыт этот документ?
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. При открытии было пропущено то, что VectorCraft пока не умеет читать ({what}), поэтому замена файла потеряет это навсегда. Чтобы сохранить это, сохраните документ под другим именем.
“{name}” already exists. Replace it? «{name}» уже существует. Заменить?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Имя в окне сохранения было «{typed}», а VectorCraft добавил расширение типа файла. Замена файла перезапишет его содержимое.
Revolve… Вращение…
RGB Color Цвет RGB
Right Справа
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2448,6 +2448,8 @@
Revert to the saved version of “{name}”? Vill du återgå till den sparade versionen av ”{name}”?
Replace “{name}”, the file this document was opened from? Vill du ersätta ”{name}”, filen som dokumentet öppnades från?
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. När filen öppnades utelämnades det som VectorCraft inte kan läsa än ({what}), så om du ersätter den förloras det för gott. Spara under ett annat namn för att behålla det.
“{name}” already exists. Replace it? ”{name}” finns redan. Vill du ersätta den?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Namnet i sparpanelen var ”{typed}”, och VectorCraft lade till filtypens filändelse. Om du ersätter filen skrivs innehållet över.
Revolve… Rotera runt axel…
RGB Color RGB-färg
Right Höger
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -2503,6 +2503,8 @@ mark-style Japanese Японський
Revert to the saved version of “{name}”? Повернутися до збереженої версії «{name}»?
Replace “{name}”, the file this document was opened from? Замінити «{name}» — файл, з якого відкрито цей документ?
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. При відкритті було пропущено те, що VectorCraft поки що не вміє читати ({what}), тому заміна файлу втратить це назавжди. Щоб зберегти це, збережіть документ під іншим ім’ям.
“{name}” already exists. Replace it? «{name}» уже існує. Замінити?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Назва у вікні збереження була «{typed}», а VectorCraft додав розширення типу файлу. Заміна файлу перезапише його вміст.
Revolve… Обертання…
RGB Color Колір RGB
Right Праворуч
Can't render this file because it contains an unexpected character in line 721 and column 40.
+2
View File
@@ -2265,6 +2265,8 @@ file-filter Templates Mẫu
Open the URL in the web browser Mở URL trong trình duyệt web
Opened with {} note(s): {} Mở với {} ghi chú: {}
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Khi mở, những phần VectorCraft chưa đọc được ({what}) đã bị bỏ qua, nên thay thế sẽ mất chúng vĩnh viễn. Hãy lưu với tên khác để giữ lại.
“{name}” already exists. Replace it? “{name}” đã tồn tại. Thay thế tệp này?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Tên trong bảng lưu là “{typed}”, và VectorCraft đã thêm phần mở rộng của loại tệp. Thay thế tệp sẽ ghi đè nội dung của tệp.
Open… Mở…
Optical Margin Alignment Căn lề quang học
Optimize for fast web view Tối ưu để xem web nhanh
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
2265
2266
2267
2268
2269
2270
2271
2272
+2
View File
@@ -2403,6 +2403,8 @@ mark-style Japanese 日式
Revert to the saved version of “{name}”? 要恢复为“{name}”已存储的版本吗?
Replace “{name}”, the file this document was opened from? 要替换“{name}”(打开此文档的源文件)吗?
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. 打开时略去了 VectorCraft 尚无法读取的内容({what}),替换该文件会永久丢失这些内容。要保留它们,请以其他名称存储。
“{name}” already exists. Replace it? “{name}”已存在。要替换它吗?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. 存储面板中的名称是“{typed}”,VectorCraft 随后添加了文件类型的扩展名。替换该文件会覆盖其内容。
Revolve… 回转…
RGB Color RGB 颜色
Right 右
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2485,6 +2485,8 @@ mark-style Japanese 日式
Revert to the saved version of “{name}”? 要回復為「{name}」已儲存的版本嗎?
Replace “{name}”, the file this document was opened from? 要取代「{name}」(開啟此文件的來源檔案)嗎?
Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. 開啟時略過了 VectorCraft 尚無法讀取的內容({what}),取代該檔案會永久遺失這些內容。若要保留,請以其他名稱儲存。
“{name}” already exists. Replace it? 「{name}」已存在。要取代它嗎?
The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. 儲存面板中的名稱是「{typed}」,VectorCraft 隨後加上了檔案類型的副檔名。取代該檔案會覆寫其內容。
Revolve… 迴轉…
RGB Color RGB 色彩
Right 右
Can't render this file because it contains an unexpected character in line 6 and column 47.
+19 -4
View File
@@ -304,11 +304,25 @@ pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option<String>, name:
/// save panel's choice, given the suggested name's extension when it names no file type (a name
/// typed without one).
fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String> {
let ext = fileio::extension(&pick.name);
pick_finished(app, pick, |picked| with_extension(picked, &ext, |_| true))
}
/// The suggested name made into a path by `finish` on the web (a download), else the save panel's
/// choice made into one. When `finish` changed the choice (an extension added) and a file is
/// there, which the panel did not ask about, a question asks before replacing it
/// ([`crate::picks::ask_to_replace`]) and this answers "cancelled"; the question's OK runs what
/// asked again.
fn pick_finished(app: &mut VectorcraftApp, pick: &FilePick, finish: impl Fn(&str) -> String) -> Result<String, String> {
if is_web(app) {
return Ok(pick.name.clone());
return Ok(finish(&pick.name));
}
let picked = crate::picks::save(app, pick).ok_or("cancelled")?;
Ok(with_extension(&picked, &fileio::extension(&pick.name), |_| true))
let path = finish(&picked);
if path != picked && crate::picks::ask_to_replace(app, crate::picks::PickRequest::Save(pick.clone()), &picked, &path) {
return Err("cancelled".into());
}
Ok(path)
}
/// `path` as typed in a save panel: kept when it ends in `ext` or its extension names a format
@@ -402,12 +416,13 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo
SaveMode::Template => fileio::template_filters(first.format.id),
_ => fileio::save_filters(first.format.id),
};
let picked = pick_path(app, &FilePick { name: first.name.clone(), folder: first.folder.clone(), filters })?;
let pick = FilePick { name: first.name.clone(), folder: first.folder.clone(), filters };
let picked = pick_finished(app, &pick, |picked| with_save_extension(picked, first.format, mode))?;
// The picked file type wins over a `format` param.
let mut q = if p.is_object() { p.clone() } else { json!({}) };
if let Some(o) = q.as_object_mut() {
o.remove("format");
o.insert("path".into(), json!(with_save_extension(&picked, first.format, mode)));
o.insert("path".into(), json!(picked));
}
let chosen = plan(app, mode, &acknowledged(&q))?;
if let Some(r) = chosen.path.as_deref().and_then(|path| ask_before_losing(app, path, save_command(mode), &q)) {
+5
View File
@@ -192,6 +192,7 @@ pub type PickOpen = Box<dyn FnMut(&FilePick) -> Option<String>>;
pub type PickSave = Box<dyn FnMut(&FilePick) -> Option<String>>;
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
pub type ExistsFn = Box<dyn Fn(&str) -> bool>;
pub type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Starts a browser download of the bytes under a file name; an error when it couldn't be started
/// (the save or export then fails as a refused write does: #1693).
@@ -212,6 +213,10 @@ pub struct Services {
pub pick_save: Option<PickSave>,
pub read: Option<ReadFn>,
pub write: Option<WriteFn>,
/// Is there a file or folder at a path (desktop)? The app asks before replacing a file that a
/// save panel's name names once the app gives it an extension ([`picks::ask_to_replace`]).
/// Without it, no file counts as there.
pub exists: Option<ExistsFn>,
/// Files that arrived asynchronously (web open / drops): (name, bytes).
pub inbox: Option<Inbox>,
/// Web: trigger a browser download instead of writing a path.
+91 -14
View File
@@ -98,6 +98,22 @@ pub(crate) struct Picks {
answer: Option<(PickRequest, PickAnswer)>,
/// Another dialog was refused while this one is open ([`busy`]).
refused: bool,
/// A save dialog's answer waiting for the question whether to replace the file it names
/// ([`ask_to_replace`]).
replacing: Option<Replacing>,
/// The file that question's OK allows to be replaced while what asked runs again.
replace_ok: Option<String>,
}
/// A save dialog's answer that names, once the app gave it an extension, a file that is there.
struct Replacing {
request: PickRequest,
answer: PickAnswer,
/// The file it names.
path: String,
entry: Entry,
/// The active document when it was asked for ([`vectorcraft_engine::DocState::uid`]).
doc: Option<u64>,
}
impl Picks {
@@ -116,6 +132,7 @@ impl Picks {
pub(crate) fn recover(&mut self) {
self.entry = None;
self.answer = None;
self.replace_ok = None;
}
/// The kind of dialog open off the UI thread (`ui.inspect`'s `fileDialog`).
@@ -161,12 +178,21 @@ pub(crate) fn button(app: &mut VectorcraftApp, f: impl Fn(&mut VectorcraftApp) +
/// A dialog body's action `f` on `d` (the open dialog's copy) that asks for a file dialog: shown
/// off the UI thread, its answer runs `f` again on the open dialog, when it is still of that kind.
/// A question whether to replace a file that `f` asks ([`ask_to_replace`]) takes `d`'s place, with
/// `d` under it.
pub(crate) fn in_dialog(
app: &mut VectorcraftApp,
d: &mut Dialog,
f: impl Fn(&mut VectorcraftApp, &mut Dialog) -> Result<(), String> + 'static,
) -> Result<(), String> {
let f = Rc::new(f);
let f = Rc::new(move |app: &mut VectorcraftApp, d: &mut Dialog| {
let r = f(app, d);
if let Some(mut question) = app.ui.dialog.take_if(|q| q.kind == crate::dialogs::confirm::REPLACE) {
question.fields.insert(crate::dialogs::PARENT.into(), serde_json::to_value(&*d).unwrap_or_default());
*d = question;
}
r
});
let (again, kind) = (f.clone(), d.kind.clone());
let entry = move || {
Entry::Call(Rc::new(move |app: &mut VectorcraftApp| {
@@ -274,6 +300,11 @@ fn ask(app: &mut VectorcraftApp, request: PickRequest) -> Option<PickAnswer> {
/// Each frame: when the dialog shown off the UI thread answers with paths, run its entry again
/// with them. While it is open, look again shortly.
pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) {
// Clear the "cancelled" that a save leaves in the status bar when it stops to ask whether to
// replace a file ([`ask_to_replace`]).
if app.ui.status == "cancelled" && app.ui.dialog.as_ref().is_some_and(|d| d.kind == crate::dialogs::confirm::REPLACE) {
app.ui.status.clear();
}
let Some(w) = &app.picks.waiting else { return };
let answer = match w.answer.try_recv() {
Ok(answer) => answer,
@@ -297,19 +328,8 @@ pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) {
if answer.is_empty() {
return;
}
// On the document it was asked for: another may be active by now (opened from the Finder, by
// an agent), or it may be gone.
if let Some(uid) = doc {
let Some(i) = app.session.documents().iter().position(|d| d.uid == uid) else {
app.status(tl!("The document was closed while its file dialog was open"));
return;
};
if app.session.active_index() != Some(i)
&& let Err(e) = app.run("document.activate", serde_json::json!({ "index": i }))
{
app.status(e);
return;
}
if !on_document(app, doc) {
return;
}
app.picks.answer = Some((request, answer));
rerun(app, entry);
@@ -317,6 +337,63 @@ pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) {
app.picks.answer = None;
}
/// Make the document an entry asked for (`doc`, by its uid) active again before it runs again:
/// another may be active by now (opened from the Finder, by an agent), or it may be gone → false
/// when it can't run (the status bar says why).
fn on_document(app: &mut VectorcraftApp, doc: Option<u64>) -> bool {
let Some(uid) = doc else { return true };
let Some(i) = app.session.documents().iter().position(|d| d.uid == uid) else {
app.status(tl!("The document was closed while its file dialog was open"));
return false;
};
if app.session.active_index() != Some(i)
&& let Err(e) = app.run("document.activate", serde_json::json!({ "index": i }))
{
app.status(e);
return false;
}
true
}
/// `picked`, a save dialog's answer to `request`, names `path`, another file, once the app gave it
/// an extension: the dialog checked only `picked` for an existing file, not `path`. When a file is
/// at `path`, ask before replacing it, over the dialog open now → true while the question is open
/// (nothing is written meanwhile). OK runs what asked again with the same answer, and `path` is
/// written then ([`replace`]); Cancel writes nothing. Nothing is asked while that OK runs what
/// asked, or outside an entry, which nothing could run again.
pub(crate) fn ask_to_replace(app: &mut VectorcraftApp, request: PickRequest, picked: &str, path: &str) -> bool {
if app.picks.replace_ok.as_deref() == Some(path) || !app.services.exists.as_ref().is_some_and(|exists| exists(path)) {
return false;
}
let Some(entry) = app.picks.entry.clone() else { return false };
let doc = entry.document(app);
app.picks.replacing = Some(Replacing { request, answer: vec![picked.to_string()], path: path.to_string(), entry, doc });
crate::dialogs::confirm::ask_replace(app, path, picked);
true
}
/// The OK of the question whether to replace the file at `path` ([`ask_to_replace`]): the dialog
/// it was asked over shows again, and what asked runs again with the same answer, on the document
/// it asked for, replacing the file.
pub(crate) fn replace(app: &mut VectorcraftApp, path: &str) -> Result<Value, String> {
app.ui.dialog = app.ui.dialog.take().and_then(|d| crate::dialogs::parent(&d));
let Some(Replacing { request, answer, path, entry, doc }) = app.picks.replacing.take().filter(|r| r.path == path) else {
return Ok(Value::Null);
};
if !on_document(app, doc) {
return Ok(Value::Null);
}
app.picks.answer = Some((request, answer));
app.picks.replace_ok = Some(path);
// A dialog it asks for next runs what asked again, not this question.
let outer = app.picks.entry.replace(entry.clone());
rerun(app, entry);
app.picks.entry = outer;
app.picks.answer = None;
app.picks.replace_ok = None;
Ok(Value::Null)
}
/// Run `entry` again.
fn rerun(app: &mut VectorcraftApp, entry: Entry) {
let r = match entry {
+52
View File
@@ -369,6 +369,58 @@ fn preset_dialogs_export_asks_off_the_ui_thread() {
}
}
/// A sheet's answer that names a file that is there once given its extension asks before replacing
/// it, with no "cancelled" in the status bar, and the question's OK writes it without another sheet.
#[test]
fn a_sheets_answer_given_its_extension_asks_before_replacing() {
let (mut app, shown, written) = app();
app.services.exists = Some(Box::new(|p: &str| p == "/art/card.vectorcraft"));
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
assert!(app.run("file.save", json!({})).is_err());
shown.answer(&["/art/card"]);
poll(&mut app);
assert_eq!(dialog_kind(&app), Some(crate::dialogs::confirm::REPLACE));
assert!(written.borrow().is_empty());
poll(&mut app);
assert_eq!(app.ui.status, "");
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(*written.borrow(), ["/art/card.vectorcraft"]);
assert!(shown.requests().is_empty(), "{:?}", shown.requests());
}
/// Export… in a presets dialog whose save dialog shows in line (Windows): a name that is there
/// once given its extension asks over the presets dialog; Cancel shows it again and writes
/// nothing, and OK writes the preset and shows it again.
#[test]
fn a_preset_dialogs_export_asks_before_replacing_over_it() {
let written = Written::default();
let w = written.clone();
let services = Services {
pick_save: Some(Box::new(|_: &FilePick| Some("/presets/mine".to_string()))),
write: Some(Box::new(move |path: &str, _: &[u8]| {
w.borrow_mut().push(path.to_string());
Ok(())
})),
exists: Some(Box::new(|_: &str| true)),
..Default::default()
};
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
app.run("pdf.preset.save", json!({"name": "Mine"})).unwrap();
app.run("ui.pdfPresetsDialog", json!({"selected": "Mine"})).unwrap();
let kind = dialog_kind(&app).unwrap().to_string();
click(&mut app, "Export…", dialogs);
assert_eq!(dialog_kind(&app), Some(crate::dialogs::confirm::REPLACE));
crate::dialogs::cancel(&mut app);
assert_eq!(dialog_kind(&app), Some(kind.as_str()));
assert!(written.borrow().is_empty());
click(&mut app, "Export…", dialogs);
crate::dialogs::confirm(&mut app).unwrap();
let path = format!("/presets/mine.{}", vectorcraft_engine::cmd::pdfcmds::PRESET_FORMAT);
assert_eq!(*written.borrow(), [path], "{}", app.ui.status);
assert_eq!(dialog_kind(&app), Some(kind.as_str()));
}
/// Save As answers on the document that asked for it: the one made active meanwhile isn't saved,
/// and the one that asked is active again.
#[test]
+120 -2
View File
@@ -1,5 +1,6 @@
//! A name typed in a save panel without an extension gets the chosen format's: Save, Save As,
//! Save a Copy, Export, Export Selection and other writes through a save panel.
//! Save a Copy, Export, Export Selection and other writes through a save panel. When the name
//! then names a file that is there, which the panel did not ask about, the app asks first.
use std::cell::RefCell;
use std::rc::Rc;
@@ -7,7 +8,7 @@ use std::rc::Rc;
use serde_json::json;
use vectorcraft_engine::Session;
use crate::{FilePick, Services, VectorcraftApp, io};
use crate::{FilePick, Services, VectorcraftApp, dialogs, io};
type Written = Rc<RefCell<Vec<String>>>;
@@ -59,6 +60,123 @@ fn exports_add_the_format_extension_to_a_bare_name() {
assert_eq!(last(&w), "/tmp/art.txt");
}
/// An app with a document whose save panel answers `typed` in a folder of its own named after
/// `tag`, where files are written and found → (the app, the folder).
fn on_disk(tag: &str, typed: &str) -> (VectorcraftApp, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("vc-saveext-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let picked = dir.join(typed).to_string_lossy().into_owned();
let services = Services {
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
pick_save: Some(Box::new(move |_: &FilePick| Some(picked.clone()))),
..Default::default()
};
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
(app, dir)
}
/// `card` typed in the save panel becomes `card.vectorcraft`, a file the panel did not ask about:
/// Save asks before replacing it, Cancel leaves it as it was, and OK replaces it.
#[test]
fn a_name_given_its_extension_asks_before_replacing_a_file_there() {
let (mut app, dir) = on_disk("ask", "card");
let card = dir.join("card.vectorcraft");
let path = card.to_string_lossy().into_owned();
std::fs::write(&card, b"the old card").unwrap();
assert!(app.run("file.save", json!({})).is_err(), "nothing is saved while it asks");
let d = app.ui.dialog.clone().expect("Save asks before replacing card.vectorcraft");
assert_eq!((d.kind.as_str(), d.str("path")), (dialogs::confirm::REPLACE, path.clone()));
assert!(d.str("message").contains("card.vectorcraft") && d.str("detail").contains("card"), "{d:?}");
dialogs::cancel(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!(std::fs::read(&card).unwrap(), b"the old card", "Cancel leaves the file as it was");
assert_eq!(app.session.active().unwrap().path, None);
assert!(app.run("file.save", json!({})).is_err());
dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_ne!(std::fs::read(&card).unwrap(), b"the old card", "OK replaces it");
assert_eq!(app.session.active().unwrap().path.as_deref(), Some(path.as_str()));
let _ = std::fs::remove_dir_all(&dir);
}
/// The Enter that closed the save panel, still held as the question opens, does not replace the
/// file; Enter pressed again after it is let go does.
#[test]
fn the_enter_that_closed_the_save_panel_does_not_replace_the_file() {
let (mut app, dir) = on_disk("enter", "card");
let card = dir.join("card.vectorcraft");
std::fs::write(&card, b"the old card").unwrap();
assert!(app.run("file.save", json!({})).is_err());
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let enter = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
};
frame(&mut app, vec![enter(true)]);
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::REPLACE));
assert_eq!(std::fs::read(&card).unwrap(), b"the old card");
frame(&mut app, vec![enter(false)]);
frame(&mut app, vec![enter(true)]);
assert!(app.ui.dialog.is_none());
assert_ne!(std::fs::read(&card).unwrap(), b"the old card");
let _ = std::fs::remove_dir_all(&dir);
}
/// A name given its extension writes at once when no file is there, and a name typed with its
/// extension writes at once too (the save panel checked that file itself).
#[test]
fn a_name_given_its_extension_writes_at_once_when_no_file_is_there() {
let (mut app, dir) = on_disk("fresh", "fresh");
app.run("file.save", json!({})).unwrap();
assert!(app.ui.dialog.is_none());
assert!(dir.join("fresh.vectorcraft").exists());
let _ = std::fs::remove_dir_all(&dir);
let (mut app, dir) = on_disk("typed", "card.vectorcraft");
std::fs::write(dir.join("card.vectorcraft"), b"the old card").unwrap();
app.run("file.save", json!({})).unwrap();
assert!(app.ui.dialog.is_none());
assert_ne!(std::fs::read(dir.join("card.vectorcraft")).unwrap(), b"the old card");
let _ = std::fs::remove_dir_all(&dir);
}
/// Save a Copy, Export Selection, Export for Office and Export As's OK ask the same way before
/// replacing a file that a name given its extension names, and the question's OK writes it.
#[test]
fn every_write_through_a_save_panel_asks_before_replacing() {
let cases = [
("file.saveCopy", json!({"format": "svg", "svg": {}}), "/tmp/out.svg"),
("document.exportSelection", json!({}), "/tmp/out.png"),
("document.exportForOffice", json!({"ppi": 150}), "/tmp/out.png"),
];
for (cmd, params, path) in cases {
let (mut app, w) = saving_app("/tmp/out");
app.services.exists = Some(Box::new(|_: &str| true));
app.run("select.all", json!({})).unwrap();
assert!(app.run(cmd, params).is_err(), "{cmd}");
assert!(w.borrow().is_empty(), "{cmd}: nothing is written while it asks");
let d = app.ui.dialog.clone().expect(cmd);
assert_eq!((d.kind.as_str(), d.str("path")), (dialogs::confirm::REPLACE, path.to_string()), "{cmd}");
dialogs::confirm(&mut app).unwrap();
assert_eq!(last(&w), path, "{cmd}");
}
// Export As: the question's OK picks the file again, and PNG Options follows.
let (mut app, w) = saving_app("/tmp/out");
app.services.exists = Some(Box::new(|_: &str| true));
app.run("file.exportAs", json!({"format": "png"})).unwrap();
assert!(dialogs::confirm(&mut app).is_err());
let open = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.str("path")));
assert_eq!(open(&app), Some((dialogs::confirm::REPLACE.to_string(), "/tmp/out.png".to_string())));
dialogs::confirm(&mut app).unwrap();
assert_eq!(open(&app), Some(("pngOptions".to_string(), "/tmp/out.png".to_string())));
assert!(w.borrow().is_empty());
}
#[test]
fn typed_extensions_are_kept() {
let (mut app, w) = saving_app("/tmp/art.svg");
+1 -1
View File
@@ -109,7 +109,7 @@ the two can still win that race.
| `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `<path stem>-<artboard>.<ext>`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards |
| `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on |
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab |
| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it; when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab |
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`), plug-ins (`.wasm`) and shortcut sets (`.kys`, in Import Shortcut Set) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
| `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` |
+1
View File
@@ -623,6 +623,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source.
A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests_import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them.
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604).
A name typed in the desktop save panel without the file type's extension gets it, and when a file of that name exists, which the panel did not ask about, Save, Save As, Save a Copy, Save as Template, the exports and every other write through a save panel ask before replacing it (dialog `replaceFile`; Cancel writes nothing).
- macOS: the save sheet of Save As, Save a Copy, Save as Template and the first Save of a new document has a Format menu that lists each format the save can write, with the suggested file's format selected. Choosing a format changes the extension in the name field, and a name typed without an extension gets the chosen format's. A name typed with another listed extension is saved in that format. The menu is set up before the sheet opens; a save panel with one file type is shown by rfd as before (#866, `apps/vectorcraft/src/mac_save_format.rs`).
- Save as Template writes a PDF-compatible `.ait` as well as a `.vctemplate`, which stays the default: the save panel and the web build's Save as Template dialog list both, and New from Template opens the `.ait` as an untitled document with its layers and art (`file.saveAsTemplate {format: "ait"}`, or a path ending in `.ait`).
Save as Template always writes a file named with its template type's extension: `file.saveAsTemplate` saves a path ending in another format's extension (`card.png`) as `card.vctemplate` (or `card.ait`), the desktop save panel adds `.vctemplate` to a name typed with a Save As format's extension (`card.pdf.vctemplate`), and `file.saveAs` and `document.save` refuse `ait` with an error that names `file.saveAsTemplate`.