mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
feat(ui): Pages panel duplicates pages by Alt-drag and the New Page button; Edit page size
Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (0:42,
2:06) holds Option and drags a page in the Pages panel to copy it,
drags a page onto the New Page button to duplicate it ("click and drag
onto my plus sign I can duplicate that"), and resizes pages; InDesign's
panel also has an Edit page size button. DesignCraft's panel only moved
pages on drag, had no Edit page size button, and duplicating a page gave
the copy the size of the page before it (not its own) without its
liquid rule or guides. A page of its own size also drew its thumbnail
at the document's scale, over the panel's footer.
- Pages panel: Alt-drag copies the page to where it's dropped; a page
dropped on Create new page is duplicated at the end of the document;
Edit page size lists the presets (in the page's orientation) and
Custom Page Size…; InDesign's tooltips (Create new page, Delete
selected pages).
- Thumbnails share one scale (the document's page size, smaller when a
taller page would pass 1.8 times the usual height); rows fit their
spread's tallest page.
- engine: layout.pages.duplicateSpread copies the pages' size, margins,
columns, guides and liquid rule, takes `atEnd`, and returns the copy's
first page.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e862e0a613
commit
3939b825d3
11 files changed
+205
-21
No files matched your search
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-14** · Pages panel: Alt-drag copies a page, a page dropped on Create new page is duplicated at the end, Edit page size offers the presets and Custom Page Size…; duplicated pages keep their own size, rule and guides; thumbnails of pages of their own size stay in their rows. No readiness percentage change.
|
||||
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
|
||||
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
|
||||
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
|
||||
|
||||
@@ -135,21 +135,44 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
})
|
||||
}),
|
||||
cmd!("layout.pages.duplicateSpread", "Duplicate Spread", ["Layout", "Pages"], None, "{spread}", has_doc, |s, p| {
|
||||
let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
s.edit(|d, _| {
|
||||
let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?;
|
||||
let after = d.first_page_of_spread(si) + sp.pages.len() - 1;
|
||||
let parent = sp.pages.first().and_then(|p| p.parent);
|
||||
d.insert_pages(Some(after), sp.pages.len(), parent)?;
|
||||
// Copy items onto the new spread(s) at the same positions.
|
||||
let src = d.clone();
|
||||
let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect();
|
||||
let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?;
|
||||
super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?;
|
||||
ok()
|
||||
})
|
||||
}),
|
||||
cmd!(
|
||||
"layout.pages.duplicateSpread",
|
||||
"Duplicate Spread",
|
||||
["Layout", "Pages"],
|
||||
None,
|
||||
"{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let at_end = p.get("atEnd").and_then(Value::as_bool).unwrap_or(false);
|
||||
s.edit(|d, _| {
|
||||
let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?;
|
||||
let after = if at_end { d.page_count() - 1 } else { d.first_page_of_spread(si) + sp.pages.len() - 1 };
|
||||
let parent = sp.pages.first().and_then(|p| p.parent);
|
||||
d.insert_pages(Some(after), sp.pages.len(), parent)?;
|
||||
// The copies are the pages they copy: size, margins, columns, guides, liquid rule.
|
||||
for (k, src) in sp.pages.iter().enumerate() {
|
||||
let Some((nsi, npi)) = d.page_loc(after + 1 + k) else { continue };
|
||||
let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]);
|
||||
let pg = &mut nsp.pages[npi];
|
||||
let dx = pg.x - src.x;
|
||||
(pg.width, pg.height, pg.margins, pg.columns, pg.liquid) =
|
||||
(src.width, src.height, src.margins, src.columns.clone(), src.liquid);
|
||||
pg.guides = src.guides.clone();
|
||||
for g in pg.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) {
|
||||
g.position += dx;
|
||||
}
|
||||
nsp.relayout();
|
||||
}
|
||||
// Copy items onto the new spread(s) at the same positions.
|
||||
let src = d.clone();
|
||||
let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect();
|
||||
let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?;
|
||||
super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?;
|
||||
Ok(json!({"page": after + 1}))
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"layout.pages.applyParent",
|
||||
"Apply Parent to Pages…",
|
||||
|
||||
@@ -569,3 +569,29 @@ fn commands_take_non_object_params_without_panicking() {
|
||||
assert!(r.is_ok(), "{p}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicating_a_page_keeps_its_own_size_rule_guides_and_objects() {
|
||||
// Chris Cicchino's Page Tool video (2:06): the letterhead page is duplicated, then resized.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 252, "height": 144, "facingPages": false})).unwrap();
|
||||
s.execute("layout.pages.insert", &json!({"count": 1})).unwrap();
|
||||
s.execute("layout.pageSize", &json!({"pages": [2], "width": 612, "height": 792})).unwrap();
|
||||
s.execute("liquid.pageRule", &json!({"rule": "scale", "pages": [2]})).unwrap();
|
||||
s.execute("guide.add", &json!({"spread": 1, "page": 0, "orientation": "vertical", "position": 300})).unwrap();
|
||||
s.execute("frame.create", &json!({"spread": 1, "rect": [100, 100, 200, 200]})).unwrap();
|
||||
let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 1})).unwrap();
|
||||
assert_eq!(r["page"], 2);
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let (si, pi) = d.page_loc(2).unwrap();
|
||||
let pg = &d.spreads[si].pages[pi];
|
||||
assert_eq!((pg.width, pg.height, pg.liquid), (612.0, 792.0, designcraft_doc::LiquidRule::Scale));
|
||||
assert_eq!(pg.guides.len(), 1);
|
||||
assert_eq!(d.spreads[si].items.len(), 1);
|
||||
// At the end of the document (the New Page button): the business card, not the last page's size.
|
||||
let r = s.execute("layout.pages.duplicateSpread", &json!({"spread": 0, "atEnd": true})).unwrap();
|
||||
assert_eq!(r["page"], 3);
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let (si, pi) = d.page_loc(3).unwrap();
|
||||
assert_eq!((d.spreads[si].pages[pi].width, d.spreads[si].pages[pi].height), (252.0, 144.0));
|
||||
}
|
||||
@@ -612,6 +612,22 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
("Edit Page", ["Edit Page", "Edit Page", "Edit Page", "Edit Page", "编辑页面"]),
|
||||
("Import File", ["Import File", "Import File", "Import File", "Import File", "导入文件"]),
|
||||
("Insert Page", ["Insert Page", "Insert Page", "Insert Page", "Insert Page", "插入页面"]),
|
||||
(
|
||||
"Delete selected pages",
|
||||
["Ausgewählte Seiten löschen", "Supprimer les pages sélectionnées", "Eliminar páginas seleccionadas", "選択したページを削除", "删除选定页面"],
|
||||
),
|
||||
("Create new page", ["Neue Seite erstellen", "Créer une page", "Crear página nueva", "新規ページを作成", "新建页面"]),
|
||||
("Edit page size", ["Seitenformat bearbeiten", "Modifier le format de page", "Editar tamaño de página", "ページサイズを編集", "编辑页面大小"]),
|
||||
(
|
||||
"Custom Page Size…",
|
||||
[
|
||||
"Benutzerdefiniertes Seitenformat …",
|
||||
"Format de page personnalisé…",
|
||||
"Tamaño de página personalizado…",
|
||||
"カスタムページサイズ…",
|
||||
"自定页面大小…",
|
||||
],
|
||||
),
|
||||
("Hide Bar", ["Hide Bar", "Hide Bar", "Hide Bar", "Hide Bar", "隐藏栏"]),
|
||||
("Properties Panel", ["Properties Panel", "Properties Panel", "Properties Panel", "Properties Panel", "属性面板"]),
|
||||
("New Workspace…", ["New Workspace…", "New Workspace…", "New Workspace…", "New Workspace…", "新建工作区…"]),
|
||||
|
||||
@@ -270,6 +270,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Quick Actions", "الإجراءات السريعة"),
|
||||
("Import File", "استيراد ملف"),
|
||||
("Insert Page", "إدراج صفحة"),
|
||||
("Delete selected pages", "حذف الصفحات المحددة"),
|
||||
("Create new page", "إنشاء صفحة جديدة"),
|
||||
("Edit page size", "تحرير حجم الصفحة"),
|
||||
("Custom Page Size…", "حجم صفحة مخصص…"),
|
||||
("Edit Page", "تحرير الصفحة"),
|
||||
("Adjust Layout", "ضبط التخطيط"),
|
||||
("Smart Guides", "أدلة ذكية"),
|
||||
|
||||
@@ -363,6 +363,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Edit Page", "Modifica pagina"),
|
||||
("Import File", "Importa file"),
|
||||
("Insert Page", "Inserisci pagina"),
|
||||
("Delete selected pages", "Elimina pagine selezionate"),
|
||||
("Create new page", "Crea nuova pagina"),
|
||||
("Edit page size", "Modifica dimensioni pagina"),
|
||||
("Custom Page Size…", "Dimensioni pagina personalizzate…"),
|
||||
("Hide Bar", "Nascondi barra"),
|
||||
("Properties Panel", "Pannello Proprietà"),
|
||||
("New Workspace…", "Nuova area di lavoro…"),
|
||||
|
||||
@@ -365,6 +365,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Edit Page", "ページを編集"),
|
||||
("Import File", "ファイルを読み込み"),
|
||||
("Insert Page", "ページを挿入"),
|
||||
("Delete selected pages", "選択したページを削除"),
|
||||
("Create new page", "新規ページを作成"),
|
||||
("Edit page size", "ページサイズを編集"),
|
||||
("Custom Page Size…", "カスタムページサイズ…"),
|
||||
("Hide Bar", "バーを隠す"),
|
||||
("Properties Panel", "プロパティパネル"),
|
||||
("New Workspace…", "新規ワークスペース…"),
|
||||
|
||||
@@ -271,6 +271,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Quick Actions", "Ações rápidas"),
|
||||
("Import File", "Importar arquivo"),
|
||||
("Insert Page", "Inserir página"),
|
||||
("Delete selected pages", "Excluir páginas selecionadas"),
|
||||
("Create new page", "Criar nova página"),
|
||||
("Edit page size", "Editar tamanho da página"),
|
||||
("Custom Page Size…", "Tamanho de página personalizado…"),
|
||||
("Edit Page", "Editar página"),
|
||||
("Adjust Layout", "Ajustar layout"),
|
||||
("Smart Guides", "Guias inteligentes"),
|
||||
|
||||
@@ -366,6 +366,10 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Edit Page", "Редагувати сторінку"),
|
||||
("Import File", "Імпортувати файл"),
|
||||
("Insert Page", "Вставити сторінку"),
|
||||
("Delete selected pages", "Видалити вибрані сторінки"),
|
||||
("Create new page", "Створити нову сторінку"),
|
||||
("Edit page size", "Змінити розмір сторінки"),
|
||||
("Custom Page Size…", "Власний розмір сторінки…"),
|
||||
("Hide Bar", "Сховати панель"),
|
||||
("Properties Panel", "Панель властивостей"),
|
||||
("New Workspace…", "Нове робоче середовище…"),
|
||||
|
||||
@@ -114,11 +114,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let cur_spread = doc.page_loc(cur).map(|l| l.0);
|
||||
let center = ui.available_width() / 2.0;
|
||||
let dragging = egui::DragAndDrop::has_any_payload(ui.ctx());
|
||||
let tallest = doc.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.height)).fold(doc.settings.page_height, f64::max).max(1.0);
|
||||
for (si, sp) in doc.spreads.iter().enumerate() {
|
||||
let first = doc.first_page_of_spread(si);
|
||||
let scale = THUMB_H / doc.settings.page_height.max(1.0) as f32;
|
||||
// One scale for the whole document (pages of their own size show in proportion): the
|
||||
// document's page size at the usual height, unless a taller page would pass 1.8 times it.
|
||||
// Each row is as tall as its spread's tallest page.
|
||||
let scale = (THUMB_H / doc.settings.page_height.max(1.0) as f32).min(1.8 * THUMB_H / tallest as f32);
|
||||
let row_h = sp.pages.iter().map(|p| p.height as f32 * scale).fold(0.0, f32::max).max(THUMB_H * 0.25);
|
||||
let spine = sp.spine_x() as f32;
|
||||
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + THUMB_H + 22.0), Sense::hover());
|
||||
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_h + 22.0), Sense::hover());
|
||||
let top = row.min.y + 8.0;
|
||||
// Marker above the current spread.
|
||||
if cur_spread == Some(si) && !editing_parents {
|
||||
@@ -197,15 +202,55 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete Page")).clicked() {
|
||||
if icons::button(ui, "trash", 20.0, false, crate::i18n::tr(&app.ui.language, "Delete selected pages")).clicked() {
|
||||
let _ = app.run("layout.pages.delete", json!({"pages": [cur]}));
|
||||
}
|
||||
if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "Insert Page")).clicked() {
|
||||
// Create new page; a page dropped on it is duplicated at the end of the document.
|
||||
let plus = icons::button(ui, "plus", 20.0, dragging, crate::i18n::tr(&app.ui.language, "Create new page"));
|
||||
if let Some(payload) = plus.dnd_release_payload::<PageDrag>() {
|
||||
if let PageDrag::Page(from) = *payload
|
||||
&& let Some((si, _)) = doc.page_loc(from)
|
||||
{
|
||||
let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si, "atEnd": true}));
|
||||
}
|
||||
} else if plus.clicked() {
|
||||
let _ = app.run("layout.pages.insert", json!({"after": cur, "count": 1}));
|
||||
}
|
||||
// Edit page size: InDesign's presets for the current page, or Custom Page Size….
|
||||
let size = icons::button(ui, "panel-pages", 20.0, false, crate::i18n::tr(&app.ui.language, "Edit page size"));
|
||||
egui::Popup::menu(&size).show(|ui| {
|
||||
let Some((si, pi)) = doc.page_loc(cur) else { return };
|
||||
let pg = &doc.spreads[si].pages[pi];
|
||||
let landscape = pg.width > pg.height;
|
||||
let current = crate::panels::properties::preset_name(pg.width, pg.height);
|
||||
for (name, w, h) in crate::panels::properties::PAGE_PRESETS {
|
||||
let (w, h) = if landscape { (h, w) } else { (w, h) };
|
||||
if ui.selectable_label(current == *name, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, name))).clicked() {
|
||||
let _ = app.run("layout.pageSize", json!({"pages": [cur + 1], "width": w, "height": h}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Custom Page Size…"))).clicked() {
|
||||
app.ui.dialog =
|
||||
Some(crate::dialogs::Dialog::new("cmd:layout.pageSize", json!({"pages": [cur + 1], "width": pg.width, "height": pg.height})));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
// Alt-drag (InDesign's Option-drag) copies the page to where it's dropped.
|
||||
let copy = ui.input(|i| i.modifiers.alt);
|
||||
match drop {
|
||||
Some((PageDrag::Page(from), to)) if copy => {
|
||||
if let Some((si, _)) = doc.page_loc(from)
|
||||
&& let Ok(r) = app.run("layout.pages.duplicateSpread", json!({"spread": si}))
|
||||
&& let Some(new) = r["page"].as_u64().map(|p| p as usize)
|
||||
&& new != to
|
||||
{
|
||||
let _ = app.run("layout.pages.move", json!({"from": new, "to": to}));
|
||||
}
|
||||
}
|
||||
Some((PageDrag::Page(from), to)) if from != to => {
|
||||
let _ = app.run("layout.pages.move", json!({"from": from, "to": to}));
|
||||
}
|
||||
@@ -289,3 +334,56 @@ fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) {
|
||||
painter.rect(r, 2.0, t.panel_darker, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
painter.galley(r.min + vec2(5.0, 3.0), g, t.text_strong);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn harness(app: DesignApp) -> Harness<'static, DesignApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(280.0, 520.0)).build_ui_state(
|
||||
|ui, app: &mut DesignApp| {
|
||||
let key = egui::Id::new("test_fonts");
|
||||
if ui.data(|d| d.get_temp::<bool>(key)).is_none() {
|
||||
crate::theme::install_fonts(ui.ctx(), "");
|
||||
ui.data_mut(|d| d.insert_temp(key, true));
|
||||
return;
|
||||
}
|
||||
show(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// Release a dragged page over `at`.
|
||||
fn drop_page(h: &mut Harness<'_, DesignApp>, page: usize, at: egui::Pos2, alt: bool) {
|
||||
let modifiers = egui::Modifiers { alt, ..Default::default() };
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers });
|
||||
egui::DragAndDrop::set_payload(&h.ctx, PageDrag::Page(page));
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
h.run_steps(2);
|
||||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_dropped_on_create_new_page_is_duplicated_at_the_end() {
|
||||
// Chris Cicchino's Page Tool video (2:06): "click and drag onto my plus sign I can duplicate that".
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 252, "height": 144, "facingPages": false})).unwrap();
|
||||
app.run("layout.pages.insert", json!({"count": 1})).unwrap();
|
||||
app.run("layout.pageSize", json!({"pages": [2], "width": 612, "height": 792})).unwrap();
|
||||
let mut h = harness(app);
|
||||
let plus = h.get_by_label("Create new page").rect().center();
|
||||
drop_page(&mut h, 1, plus, false);
|
||||
let d = h.state().session.doc().unwrap().doc.clone();
|
||||
assert_eq!(d.page_count(), 3);
|
||||
let (si, pi) = d.page_loc(2).unwrap();
|
||||
assert_eq!(d.spreads[si].pages[pi].width, 612.0, "a copy of the letterhead");
|
||||
let _ = h.get_by_label("Edit page size");
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
// ---------------------------------------------------------------- No Selection
|
||||
|
||||
/// Page-size presets for the Document dropdown (portrait, points).
|
||||
const PAGE_PRESETS: &[(&str, f64, f64)] = &[
|
||||
pub(crate) const PAGE_PRESETS: &[(&str, f64, f64)] = &[
|
||||
("Letter", 612.0, 792.0),
|
||||
("Legal", 612.0, 1008.0),
|
||||
("Tabloid", 792.0, 1224.0),
|
||||
@@ -173,7 +173,7 @@ const PAGE_PRESETS: &[(&str, f64, f64)] = &[
|
||||
("B5", 498.898, 708.661),
|
||||
];
|
||||
|
||||
fn preset_name(w: f64, h: f64) -> &'static str {
|
||||
pub(crate) fn preset_name(w: f64, h: f64) -> &'static str {
|
||||
let (a, b) = (w.min(h), w.max(h));
|
||||
PAGE_PRESETS.iter().find(|p| (p.1 - a).abs() < 0.6 && (p.2 - b).abs() < 0.6).map(|p| p.0).unwrap_or("Custom")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user