mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 19:56:51 -06:00
Merge pull request #1638 from marcoarnulfo/feat/layout-go-back-forward
Layout: Go Back and Go Forward through the pages viewed, with InDesign's shortcuts
This commit is contained in:
@@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-23** · Layout › Go Back and Go Forward (Cmd+Page Up / Cmd+Page Down, Ctrl on Windows and Linux) retrace the jumps between pages (Go to Page, the page and spread commands, the Pages panel) as InDesign's do, returning to where the view was on each page at the current zoom; a new jump after going back drops the pages ahead, scrolling adds no entry, and pages deleted since are skipped. No readiness percentage change.
|
||||
- **2026-10-24** · Type › Notes › Previous Note and Next Note (`note.previous`, `note.next`): as in InDesign, they move the insertion point from note to note in the story, putting the caret after the note's anchor. Each item is unavailable when no note lies in its direction in the story, or when the insertion point is in a table cell or a footnote. No readiness percentage change.
|
||||
- **2026-10-24** · Type › Notes › Convert to Note (`note.convertFromText {author?}`): as in InDesign, the selected text leaves the story and becomes the text of a new note, whose anchor takes its place; Convert Note to Text puts it back. DesignCraft refuses a selection in a table cell, or one that holds a note, footnote, endnote, anchored object, index marker, text anchor, cross-reference or table. No readiness percentage change.
|
||||
- **2026-10-23** · Effects dialog: with Use Global Light on, the Angle field (and the Bevel and Emboss one) stays editable and shows the document's global light angle; a change there sets the global light for every effect that uses it, applied with the effects in one undo step (`object.effects` takes `globalLight`). Before, the field was greyed out and the angle couldn't be changed in the dialog. No readiness percentage change.
|
||||
|
||||
@@ -238,8 +238,14 @@ pub fn current_parent_page(app: &DesignApp) -> Option<(usize, usize)> {
|
||||
Some((pi, qi))
|
||||
}
|
||||
|
||||
/// Scroll so absolute page `abs` is centred (keeps zoom).
|
||||
/// Scroll so absolute page `abs` is centred (keeps zoom). A jump Go Back can return from.
|
||||
pub fn go_to_page(app: &mut DesignApp, abs: usize) {
|
||||
note_jump(app, false);
|
||||
centre_page(app, abs);
|
||||
note_jump(app, true);
|
||||
}
|
||||
|
||||
fn centre_page(app: &mut DesignApp, abs: usize) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let layout = CanvasLayout::new(&st.doc, false);
|
||||
let Some(pr) = layout.page_rect(&st.doc, abs) else { return };
|
||||
@@ -249,6 +255,65 @@ pub fn go_to_page(app: &mut DesignApp, abs: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's pages by id, in page order.
|
||||
fn page_ids(doc: &Document) -> Vec<designcraft_doc::ids::PageId> {
|
||||
doc.spreads.iter().flat_map(|s| s.pages.iter().map(|p| p.id)).collect()
|
||||
}
|
||||
|
||||
/// The page in view and the view centre's offset from that page's centre. Parent pages have none,
|
||||
/// nor has a view still waiting to be fitted (leaving parent pages for a document page).
|
||||
pub(crate) fn page_in_view(app: &DesignApp) -> Option<(designcraft_doc::ids::PageId, DVec2)> {
|
||||
let st = app.session.active().filter(|st| !st.editing_parents)?;
|
||||
let (v, r) = (app.view().filter(|v| v.fitted)?, app.canvas_rect?);
|
||||
let abs = current_page(app)?;
|
||||
let id = st.doc.page(abs)?.id;
|
||||
let pr = CanvasLayout::new(&st.doc, false).page_rect(&st.doc, abs)?;
|
||||
let c = Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom);
|
||||
Some((id, c - pr.center()))
|
||||
}
|
||||
|
||||
/// Layout ▸ Go Back / Go Forward record jumps (Go to Page, the page and spread commands, the Pages
|
||||
/// panel), not scrolling: where the view is when a jump starts, and where the jump took it.
|
||||
fn note_jump(app: &mut DesignApp, to: bool) {
|
||||
let Some((id, offset)) = page_in_view(app) else { return };
|
||||
if let Some(h) = app.page_history_mut() {
|
||||
if to { h.visit(id, offset) } else { h.leave(id, offset) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether Layout ▸ Go Back (`step` < 0) or Go Forward has a page to go to from the view as it is.
|
||||
pub fn can_go_through_history(app: &DesignApp, step: isize) -> bool {
|
||||
let Some(st) = app.session.active().filter(|st| !st.editing_parents) else { return false };
|
||||
// A view still waiting to be fitted is refitted next frame, which would replace the destination.
|
||||
if !app.view().is_some_and(|v| v.fitted) {
|
||||
return false;
|
||||
}
|
||||
let ids = page_ids(&st.doc);
|
||||
let Some(mut h) = app.page_history().cloned() else { return false };
|
||||
if let Some((id, offset)) = page_in_view(app) {
|
||||
h.leave(id, offset);
|
||||
}
|
||||
h.can_go(step, |id| ids.contains(&id))
|
||||
}
|
||||
|
||||
/// Layout ▸ Go Back (`step` < 0) or Go Forward: show the page viewed before or after this one, where
|
||||
/// the view was on it (zoom kept).
|
||||
pub fn go_through_history(app: &mut DesignApp, step: isize) {
|
||||
if !app.view().is_some_and(|v| v.fitted) {
|
||||
return;
|
||||
}
|
||||
note_jump(app, false);
|
||||
let Some(ids) = app.session.active().filter(|st| !st.editing_parents).map(|st| page_ids(&st.doc)) else { return };
|
||||
let Some((id, offset)) = app.page_history_mut().and_then(|h| h.go(step, |id| ids.contains(&id))) else { return };
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let Some(pr) = ids.iter().position(|p| *p == id).and_then(|abs| CanvasLayout::new(&st.doc, false).page_rect(&st.doc, abs)) else { return };
|
||||
let Some(rect) = app.canvas_rect else { return };
|
||||
let c = pr.center() + offset;
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.origin = Point::new(c.x - rect.width() as f64 / 2.0 / v.zoom, c.y - rect.height() as f64 / 2.0 / v.zoom);
|
||||
}
|
||||
}
|
||||
|
||||
/// Presentation: the spread in view, centred and as large as `rect` allows.
|
||||
fn present_fit(app: &mut DesignApp, rect: Rect) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
@@ -268,10 +333,29 @@ fn present_fit(app: &mut DesignApp, rect: Rect) {
|
||||
/// Centre the spread `delta` spreads after (or before) the one in view (keeps zoom); stops at the
|
||||
/// first and last spread. Parent spreads while they are being edited.
|
||||
pub fn step_spread(app: &mut DesignApp, delta: isize) {
|
||||
// At the first or last spread only the view moves, within the spread: no jump for Go Back.
|
||||
let jump = spread_step(app, delta).is_some_and(|(cur, to)| cur != to);
|
||||
if jump {
|
||||
note_jump(app, false);
|
||||
}
|
||||
centre_spread(app, delta);
|
||||
if jump {
|
||||
note_jump(app, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot in view and the one `delta` spreads after (or before) it, stopping at the ends.
|
||||
fn spread_step(app: &DesignApp, delta: isize) -> Option<(usize, usize)> {
|
||||
let st = app.session.active()?;
|
||||
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
|
||||
let cur = current_slot(app, &layout)?;
|
||||
Some((cur, cur.saturating_add_signed(delta).min(layout.slots.len().saturating_sub(1))))
|
||||
}
|
||||
|
||||
fn centre_spread(app: &mut DesignApp, delta: isize) {
|
||||
let Some((_, to)) = spread_step(app, delta) else { return };
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
|
||||
let Some(cur) = current_slot(app, &layout) else { return };
|
||||
let to = cur.saturating_add_signed(delta).min(layout.slots.len().saturating_sub(1));
|
||||
let Some(b) = layout.slots.get(to).map(|s| s.bounds) else { return };
|
||||
let Some(rect) = app.canvas_rect else { return };
|
||||
if let Some(v) = app.view_mut() {
|
||||
|
||||
@@ -200,6 +200,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
("Previous Page", ["Vorherige Seite", "Page précédente", "Página anterior", "前のページ", "上一页"]),
|
||||
("Next Page", ["Nächste Seite", "Page suivante", "Página siguiente", "次のページ", "下一页"]),
|
||||
("Last Page", ["Letzte Seite", "Dernière page", "Última página", "最後のページ", "最后一页"]),
|
||||
("Go Back", ["Zurück gehen", "Revenir", "Retroceder", "前に戻る", "后退"]),
|
||||
("Go Forward", ["Vorwärts gehen", "Avancer", "Avanzar", "次に進む", "前进"]),
|
||||
(
|
||||
"Close Tab Group",
|
||||
["Registerkartengruppe schließen", "Fermer le groupe d'onglets", "Cerrar grupo de fichas", "タブグループを閉じる", "关闭选项卡组"],
|
||||
|
||||
@@ -207,6 +207,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Previous Page", "الصفحة السابقة"),
|
||||
("First Page", "الصفحة الأولى"),
|
||||
("Last Page", "الصفحة الأخيرة"),
|
||||
("Go Back", "رجوع"),
|
||||
("Go Forward", "تقدم"),
|
||||
("Font", "خط"),
|
||||
("Size", "الحجم"),
|
||||
("Character", "حرف"),
|
||||
|
||||
@@ -1483,6 +1483,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Previous Page", "Předchozí stránka"),
|
||||
("First Page", "První stránka"),
|
||||
("Last Page", "Poslední stránka"),
|
||||
("Go Back", "Přejít zpět"),
|
||||
("Go Forward", "Přejít vpřed"),
|
||||
("Object Styles", "Styly objektů"),
|
||||
("Row", "Řádek"),
|
||||
("Column", "Sloupec"),
|
||||
|
||||
@@ -1657,6 +1657,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Previous Page", "Pagina precedente"),
|
||||
("First Page", "Prima pagina"),
|
||||
("Last Page", "Ultima pagina"),
|
||||
("Go Back", "Torna indietro"),
|
||||
("Go Forward", "Vai avanti"),
|
||||
("Object Styles", "Stili oggetto"),
|
||||
("Row", "Riga"),
|
||||
("Column", "Colonna"),
|
||||
|
||||
@@ -1589,6 +1589,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Previous Page", "წინა გვერდი"),
|
||||
("First Page", "პირველი გვერდი"),
|
||||
("Last Page", "ბოლო გვერდი"),
|
||||
("Go Back", "უკან დაბრუნება"),
|
||||
("Go Forward", "წინ გადასვლა"),
|
||||
("Object Styles", "ობიექტის სტილები"),
|
||||
("Row", "სტრიქონი"),
|
||||
("Rulers", "სახაზავები"),
|
||||
|
||||
@@ -1520,6 +1520,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Previous Page", "Poprzednia strona"),
|
||||
("First Page", "Pierwsza strona"),
|
||||
("Last Page", "Ostatnia strona"),
|
||||
("Go Back", "Przejdź wstecz"),
|
||||
("Go Forward", "Przejdź dalej"),
|
||||
("Object Styles", "Style obiektów"),
|
||||
("Row", "Wiersz"),
|
||||
("Rulers", "Linijki"),
|
||||
|
||||
@@ -213,6 +213,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Previous Page", "Página anterior"),
|
||||
("First Page", "Primeira página"),
|
||||
("Last Page", "Última página"),
|
||||
("Go Back", "Voltar"),
|
||||
("Go Forward", "Avançar"),
|
||||
("Font", "Fonte"),
|
||||
("Size", "Tamanho"),
|
||||
("Character", "Caractere"),
|
||||
|
||||
@@ -1471,6 +1471,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Previous Page", "Предыдущая страница"),
|
||||
("First Page", "Первая страница"),
|
||||
("Last Page", "Последняя страница"),
|
||||
("Go Back", "Назад"),
|
||||
("Go Forward", "Вперёд"),
|
||||
("Object Styles", "Стили объектов"),
|
||||
("Row", "Строка"),
|
||||
("Column", "Столбец"),
|
||||
|
||||
@@ -1654,6 +1654,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Previous Page", "Попередня сторінка"),
|
||||
("First Page", "Перша сторінка"),
|
||||
("Last Page", "Остання сторінка"),
|
||||
("Go Back", "Назад"),
|
||||
("Go Forward", "Вперед"),
|
||||
("Object Styles", "Стилі об’єктів"),
|
||||
("Row", "Рядок"),
|
||||
("Column", "Стовпець"),
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod menus;
|
||||
mod modal_drag;
|
||||
mod new_document;
|
||||
mod object_control;
|
||||
mod page_history;
|
||||
mod panel_docking;
|
||||
pub mod panels;
|
||||
mod prefs;
|
||||
@@ -194,6 +195,12 @@ pub struct UiState {
|
||||
/// The view (by its `views` key) Presentation was entered from; leaving puts it back.
|
||||
#[serde(skip)]
|
||||
pub presentation_view: Option<(u64, View)>,
|
||||
/// The page in that view and the place on it, for Go Back after leaving Presentation.
|
||||
#[serde(skip)]
|
||||
pub presentation_page: Option<(designcraft_doc::ids::PageId, designcraft_geom::Vec2)>,
|
||||
/// That view's Go Back / Go Forward history; leaving puts it back with one jump added.
|
||||
#[serde(skip)]
|
||||
pub(crate) presentation_history: Option<page_history::PageHistory>,
|
||||
pub frame_edges: bool,
|
||||
pub rulers: bool,
|
||||
pub guides: bool,
|
||||
@@ -371,6 +378,8 @@ impl Default for UiState {
|
||||
screen_mode: ScreenMode::Normal,
|
||||
presentation_return: ScreenMode::Normal,
|
||||
presentation_view: None,
|
||||
presentation_page: None,
|
||||
presentation_history: None,
|
||||
frame_edges: true,
|
||||
rulers: true,
|
||||
guides: true,
|
||||
@@ -572,6 +581,8 @@ pub struct DesignApp {
|
||||
pub ui: UiState,
|
||||
pub services: Services,
|
||||
pub views: HashMap<u64, View>,
|
||||
/// Layout ▸ Go Back / Go Forward, by view (like `views`).
|
||||
pub(crate) page_history: HashMap<u64, page_history::PageHistory>,
|
||||
/// The canvas cache and rect of the pane in `pane` (the other pane's are in `other_pane`).
|
||||
pub canvas: CanvasCache,
|
||||
pub canvas_rect: Option<egui::Rect>,
|
||||
@@ -675,6 +686,7 @@ impl DesignApp {
|
||||
ui: UiState::default(),
|
||||
services,
|
||||
views: HashMap::new(),
|
||||
page_history: HashMap::new(),
|
||||
canvas: CanvasCache::new(),
|
||||
canvas_rect: None,
|
||||
split: false,
|
||||
@@ -739,6 +751,13 @@ impl DesignApp {
|
||||
let k = self.view_key()?;
|
||||
Some(self.views.entry(k).or_default())
|
||||
}
|
||||
pub(crate) fn page_history(&self) -> Option<&page_history::PageHistory> {
|
||||
self.page_history.get(&self.view_key()?)
|
||||
}
|
||||
pub(crate) fn page_history_mut(&mut self) -> Option<&mut page_history::PageHistory> {
|
||||
let k = self.view_key()?;
|
||||
Some(self.page_history.entry(k).or_default())
|
||||
}
|
||||
/// Make `pane` current: its canvas cache and rect move in.
|
||||
pub fn switch_pane(&mut self, pane: u8) {
|
||||
if pane == self.pane {
|
||||
|
||||
+294
-2
@@ -251,6 +251,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("view.nextSpread", "Next Spread", Some("Alt+PageDown"), "{} — centre the spread after the one in view"),
|
||||
("view.previousSpread", "Previous Spread", Some("Alt+PageUp"), "{} — centre the spread before the one in view"),
|
||||
("view.goToPage", "Go to Page…", Some("Cmd+J"), "{page: number (position) | \"name\" (section page name, or \"+n\" for a position)}"),
|
||||
("view.goBack", "Go Back", Some("Cmd+PageUp"), "{} — show the page viewed before this one in the window"),
|
||||
("view.goForward", "Go Forward", Some("Cmd+PageDown"), "{} — show the page viewed after this one (after Go Back)"),
|
||||
(
|
||||
"window.panel",
|
||||
"Show Panel",
|
||||
@@ -441,6 +443,8 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"ui:view.nextSpread",
|
||||
"ui:view.previousSpread",
|
||||
"ui:view.goToPage",
|
||||
"ui:view.goBack",
|
||||
"ui:view.goForward",
|
||||
"-",
|
||||
"cmd:layout.section",
|
||||
"cmd:toc.generate",
|
||||
@@ -1670,6 +1674,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.goBack" | "view.goForward" => {
|
||||
crate::canvas::go_through_history(app, if id == "view.goBack" { -1 } else { 1 });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.nextSpread" | "view.previousSpread" => {
|
||||
crate::canvas::step_spread(app, if id == "view.nextSpread" { 1 } else { -1 });
|
||||
Ok(Value::Null)
|
||||
@@ -2013,13 +2021,28 @@ fn set_screen_mode(app: &mut DesignApp, mode: ScreenMode) {
|
||||
app.ui.presentation_return = app.ui.screen_mode;
|
||||
// Presentation fits the spread to the screen; leaving it puts the user's view back.
|
||||
app.ui.presentation_view = app.view_key().zip(app.view().copied());
|
||||
app.ui.presentation_page = crate::canvas::page_in_view(app);
|
||||
app.ui.presentation_history = app.page_history().cloned();
|
||||
}
|
||||
if mode != ScreenMode::Presentation
|
||||
&& presenting
|
||||
&& let Some((k, v)) = app.ui.presentation_view.take()
|
||||
&& let Some(view) = app.views.get_mut(&k)
|
||||
&& app.views.contains_key(&k)
|
||||
{
|
||||
*view = v;
|
||||
// Go Back sees the presentation as one jump to the last spread presented, and putting the
|
||||
// view back as a jump from there: the spreads in between would otherwise have replaced the
|
||||
// place the view was at with the presentation's.
|
||||
let shown = app.ui.presentation_page.take().zip(crate::canvas::page_in_view(app)).filter(|_| app.view_key() == Some(k));
|
||||
let h = app.page_history.entry(k).or_default();
|
||||
*h = app.ui.presentation_history.take().unwrap_or_default();
|
||||
if let Some(((from, at), (to, offset))) = shown {
|
||||
h.leave(from, at);
|
||||
h.visit(to, offset);
|
||||
h.visit(from, at);
|
||||
}
|
||||
if let Some(view) = app.views.get_mut(&k) {
|
||||
*view = v;
|
||||
}
|
||||
}
|
||||
app.ui.screen_mode = mode;
|
||||
}
|
||||
@@ -2512,6 +2535,9 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool {
|
||||
if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) {
|
||||
return false;
|
||||
}
|
||||
if id == "view.goBack" || id == "view.goForward" {
|
||||
return crate::canvas::can_go_through_history(app, if id == "view.goBack" { -1 } else { 1 });
|
||||
}
|
||||
if ui_label(id).is_some() {
|
||||
return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.") || id.starts_with("help.");
|
||||
}
|
||||
@@ -4243,6 +4269,272 @@ mod tests {
|
||||
assert_eq!(v.zoom, zoom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_back_and_go_forward_retrace_the_pages_viewed() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
let key = |k: egui::Key, modifiers: egui::Modifiers| {
|
||||
vec![
|
||||
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
]
|
||||
};
|
||||
let page = |app: &crate::DesignApp| crate::canvas::current_page(app).unwrap();
|
||||
let can = |app: &crate::DesignApp| (menu_enabled(app, "view.goBack"), menu_enabled(app, "view.goForward"));
|
||||
frame(&mut app, vec![]);
|
||||
frame(&mut app, vec![]);
|
||||
// In the Layout menu after Go to Page.
|
||||
let layout = MENUS.iter().find(|m| m.0 == "Layout").unwrap().1;
|
||||
let at = |id: &str| layout.iter().position(|e| *e == format!("ui:{id}")).unwrap();
|
||||
assert_eq!(at("view.goBack"), at("view.goToPage") + 1);
|
||||
assert_eq!(at("view.goForward"), at("view.goBack") + 1);
|
||||
assert_eq!(can(&app), (false, false), "one page viewed: nowhere to go");
|
||||
let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
|
||||
let cmd_shift = egui::Modifiers { shift: true, ..cmd };
|
||||
let shift = egui::Modifiers::SHIFT;
|
||||
// Pages viewed: 1, 5, 7.
|
||||
app.run("view.goToPage", json!({"page": 5})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
frame(&mut app, key(egui::Key::PageDown, cmd_shift));
|
||||
assert_eq!(page(&app), 6);
|
||||
frame(&mut app, vec![]);
|
||||
assert_eq!(can(&app), (true, false));
|
||||
frame(&mut app, key(egui::Key::PageUp, cmd));
|
||||
assert_eq!(page(&app), 4, "Go Back");
|
||||
frame(&mut app, key(egui::Key::PageUp, cmd));
|
||||
assert_eq!(page(&app), 0, "Go Back again");
|
||||
frame(&mut app, vec![]);
|
||||
assert_eq!(can(&app), (false, true), "back at the first page viewed");
|
||||
frame(&mut app, key(egui::Key::PageDown, cmd));
|
||||
assert_eq!(page(&app), 4, "Go Forward");
|
||||
frame(&mut app, key(egui::Key::PageDown, cmd));
|
||||
assert_eq!(page(&app), 6, "Go Forward again");
|
||||
frame(&mut app, vec![]);
|
||||
assert_eq!(can(&app), (true, false));
|
||||
// After going back, viewing another page drops the pages ahead.
|
||||
frame(&mut app, key(egui::Key::PageUp, cmd));
|
||||
assert_eq!(page(&app), 4);
|
||||
frame(&mut app, key(egui::Key::PageDown, shift));
|
||||
assert_eq!(page(&app), 5, "Next Page");
|
||||
frame(&mut app, vec![]);
|
||||
assert_eq!(can(&app), (true, false), "nothing ahead after a new page");
|
||||
frame(&mut app, key(egui::Key::PageDown, cmd));
|
||||
assert_eq!(page(&app), 5, "Go Forward does nothing");
|
||||
frame(&mut app, key(egui::Key::PageUp, cmd));
|
||||
assert_eq!(page(&app), 4, "Go Back returns to the page before Next Page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_back_waits_for_the_view_to_be_fitted() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut crate::DesignApp| {
|
||||
let input =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
frame(&mut app);
|
||||
frame(&mut app);
|
||||
app.run("view.goToPage", json!({"page": 5})).unwrap();
|
||||
frame(&mut app);
|
||||
assert!(menu_enabled(&app, "view.goBack"));
|
||||
// Leaving parent pages through the Pages panel: the canvas refits on the next frame.
|
||||
app.view_mut().unwrap().fitted = false;
|
||||
assert!(!menu_enabled(&app, "view.goBack"), "the refit would replace where Go Back went");
|
||||
crate::canvas::go_through_history(&mut app, -1);
|
||||
frame(&mut app);
|
||||
assert!(menu_enabled(&app, "view.goBack"), "the unfitted Go Back used no history entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_spread_at_the_last_spread_is_no_jump() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
let key = |k: egui::Key, modifiers: egui::Modifiers| {
|
||||
vec![
|
||||
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
]
|
||||
};
|
||||
let page = |app: &crate::DesignApp| crate::canvas::current_page(app).unwrap();
|
||||
let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
|
||||
let alt = egui::Modifiers::ALT;
|
||||
frame(&mut app, vec![]);
|
||||
frame(&mut app, vec![]);
|
||||
// Pages 1 | 2-3 | 4-5 | 6-7: page 6 is the left page of the last spread.
|
||||
app.run("view.goToPage", json!({"page": 6})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
assert_eq!(page(&app), 5);
|
||||
// Next Spread has no spread to go to; centring the last one moves the view to its fold.
|
||||
frame(&mut app, key(egui::Key::PageDown, alt));
|
||||
frame(&mut app, key(egui::Key::PageDown, alt));
|
||||
assert_ne!(page(&app), 5, "the view moved to the right page");
|
||||
frame(&mut app, key(egui::Key::PageUp, cmd));
|
||||
assert_eq!(page(&app), 0, "Go Back returns to the page before Go to Page");
|
||||
assert!(!menu_enabled(&app, "view.goBack"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_back_returns_to_the_view_a_jump_left_and_scrolling_adds_no_entry() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
let key = |k: egui::Key, modifiers: egui::Modifiers| {
|
||||
vec![
|
||||
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
]
|
||||
};
|
||||
let spread = |app: &crate::DesignApp| {
|
||||
let st = app.session.active().unwrap();
|
||||
crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap()
|
||||
};
|
||||
let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
|
||||
let alt = egui::Modifiers::ALT;
|
||||
frame(&mut app, vec![]);
|
||||
frame(&mut app, vec![]);
|
||||
// Pages 1 | 2-3 | 4-5 | 6-7. Spread 2-3 fitted: the view centre is on the fold.
|
||||
frame(&mut app, key(egui::Key::PageDown, alt));
|
||||
app.run("view.fitSpread", json!({})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
let before = *app.view().unwrap();
|
||||
frame(&mut app, key(egui::Key::PageDown, alt));
|
||||
assert_eq!(spread(&app), 2, "Next Spread");
|
||||
frame(&mut app, key(egui::Key::PageUp, cmd));
|
||||
let v = *app.view().unwrap();
|
||||
assert!((v.origin - before.origin).hypot() < 1e-6, "Go Back shows the spread as it was: {:?} vs {:?}", v.origin, before.origin);
|
||||
assert_eq!(v.zoom, before.zoom);
|
||||
// Zoomed in after the jump: Go Back keeps the magnification in use, as InDesign's does, and
|
||||
// centres the same place.
|
||||
let centre = |app: &crate::DesignApp| {
|
||||
let (v, r) = (app.view().unwrap(), app.canvas_rect.unwrap());
|
||||
designcraft_geom::Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom)
|
||||
};
|
||||
let place = centre(&app);
|
||||
frame(&mut app, key(egui::Key::PageDown, alt));
|
||||
app.run("view.zoomIn", json!({})).unwrap();
|
||||
let zoomed = app.view().unwrap().zoom;
|
||||
assert!(zoomed > before.zoom);
|
||||
frame(&mut app, key(egui::Key::PageUp, cmd));
|
||||
assert_eq!(app.view().unwrap().zoom, zoomed, "the zoom in use is kept");
|
||||
assert!((centre(&app) - place).hypot() < 1e-6, "the same place: {:?} vs {place:?}", centre(&app));
|
||||
// Scrolling down two spreads is not a jump: Go Forward keeps its page, and Go Back skips
|
||||
// from where the scroll ended straight to the page before the first jump.
|
||||
let slots = designcraft_tools::layout::CanvasLayout::new(&app.session.active().unwrap().doc, false).slots;
|
||||
app.view_mut().unwrap().origin.y += slots[3].bounds.center().y - slots[1].bounds.center().y;
|
||||
frame(&mut app, vec![]);
|
||||
assert_eq!(spread(&app), 3);
|
||||
assert!(menu_enabled(&app, "view.goForward"), "scrolling kept the page ahead");
|
||||
frame(&mut app, key(egui::Key::PageUp, cmd));
|
||||
assert_eq!(spread(&app), 0, "Go Back");
|
||||
assert!(!menu_enabled(&app, "view.goBack"), "the scroll added no entry");
|
||||
frame(&mut app, key(egui::Key::PageDown, cmd));
|
||||
assert_eq!(spread(&app), 3, "Go Forward returns to where the scroll ended");
|
||||
frame(&mut app, key(egui::Key::PageDown, cmd));
|
||||
assert_eq!(spread(&app), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_back_after_presentation_returns_to_the_last_spread_presented() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", &json!({"pages": 7, "facingPages": true})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let frame = |app: &mut crate::DesignApp, events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
app.logic(&ui.ctx().clone());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
let key = |k: egui::Key, modifiers: egui::Modifiers| {
|
||||
vec![
|
||||
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
]
|
||||
};
|
||||
let spread = |app: &crate::DesignApp| {
|
||||
let st = app.session.active().unwrap();
|
||||
crate::canvas::current_slot(app, &designcraft_tools::layout::CanvasLayout::new(&st.doc, false)).unwrap()
|
||||
};
|
||||
let cmd = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
|
||||
frame(&mut app, vec![]);
|
||||
frame(&mut app, vec![]);
|
||||
// Scrolled off the page's centre, so the place Go Back returns to is not just the page.
|
||||
let view = app.view_mut().unwrap();
|
||||
view.origin.x += 40.0;
|
||||
view.origin.y += 60.0;
|
||||
frame(&mut app, vec![]);
|
||||
assert_eq!(spread(&app), 0);
|
||||
let before = *app.view().unwrap();
|
||||
// Presentation goes on to spreads 1 and 2; leaving it puts spread 0 back as it was.
|
||||
app.run("view.screenMode", json!({"mode": "presentation"})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
frame(&mut app, key(egui::Key::ArrowRight, egui::Modifiers::NONE));
|
||||
frame(&mut app, key(egui::Key::ArrowRight, egui::Modifiers::NONE));
|
||||
assert_eq!(spread(&app), 2);
|
||||
app.run("view.screenMode", json!({"mode": "normal"})).unwrap();
|
||||
frame(&mut app, vec![]);
|
||||
assert_eq!(spread(&app), 0);
|
||||
assert!(!menu_enabled(&app, "view.goForward"));
|
||||
frame(&mut app, key(egui::Key::PageUp, cmd));
|
||||
assert_eq!(spread(&app), 2, "Go Back shows the last spread presented");
|
||||
frame(&mut app, key(egui::Key::PageUp, cmd));
|
||||
assert_eq!(spread(&app), 0, "Go Back again, the view Presentation was entered from");
|
||||
let v = *app.view().unwrap();
|
||||
assert!((v.origin - before.origin).hypot() < 1e-6, "where it was: {:?} vs {:?}", v.origin, before.origin);
|
||||
assert!(!menu_enabled(&app, "view.goBack"), "the presentation is one jump: spread 1 isn't kept");
|
||||
frame(&mut app, key(egui::Key::PageDown, cmd));
|
||||
assert_eq!(spread(&app), 2, "Go Forward");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presentation_shows_one_spread_and_goes_through_them_with_keys_and_clicks() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -278,11 +278,13 @@ pub(crate) fn end_stale_preview(app: &mut DesignApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the views (one per pane) of a preview document that has gone.
|
||||
/// Drop the views and page histories (one per pane) of a preview document that has gone.
|
||||
fn forget_view(app: &mut DesignApp, uid: Option<u64>) {
|
||||
if let Some(uid) = uid.filter(|uid| app.session.documents().iter().all(|d| d.uid != *uid)) {
|
||||
app.views.remove(&uid);
|
||||
app.views.remove(&(uid ^ (1 << 63)));
|
||||
app.page_history.remove(&uid);
|
||||
app.page_history.remove(&(uid ^ (1 << 63)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Layout ▸ Go Back / Go Forward: the pages a window has jumped between, in the order it showed them.
|
||||
//! InDesign keeps this order for the session, like a web browser's Back and Forward.
|
||||
|
||||
use designcraft_doc::ids::PageId;
|
||||
use designcraft_geom::Vec2;
|
||||
|
||||
/// Entries kept: the oldest go first.
|
||||
const LIMIT: usize = 256;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PageHistory {
|
||||
/// Each page with where the view centre was on it, as an offset from the page's centre.
|
||||
pages: Vec<(PageId, Vec2)>,
|
||||
/// The entry in view.
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl PageHistory {
|
||||
/// A jump or Go Back starts from `page`, the view centre `offset` from the page's centre: the
|
||||
/// entry in view takes that place, wherever scrolling has moved the view since it was recorded,
|
||||
/// as a browser keeps a page's scroll position.
|
||||
pub fn leave(&mut self, page: PageId, offset: Vec2) {
|
||||
match self.pages.get_mut(self.at) {
|
||||
Some(e) => *e = (page, offset),
|
||||
None => self.visit(page, offset),
|
||||
}
|
||||
}
|
||||
|
||||
/// A jump has brought the view to `page`, its centre `offset` from the page's centre. Another page
|
||||
/// than the entry in view drops the entries ahead of it, as a browser does when a link is followed
|
||||
/// after going back.
|
||||
pub fn visit(&mut self, page: PageId, offset: Vec2) {
|
||||
if let Some(e) = self.pages.get_mut(self.at)
|
||||
&& e.0 == page
|
||||
{
|
||||
e.1 = offset;
|
||||
return;
|
||||
}
|
||||
self.pages.truncate(self.at.saturating_add(1));
|
||||
self.pages.push((page, offset));
|
||||
if self.pages.len() > LIMIT {
|
||||
self.pages.remove(0);
|
||||
}
|
||||
self.at = self.pages.len().saturating_sub(1);
|
||||
}
|
||||
|
||||
/// The entry Go Back (`step` < 0) or Go Forward goes to: the nearest one that way whose page is
|
||||
/// still in the document and isn't the page in view.
|
||||
fn target(&self, step: isize, exists: impl Fn(PageId) -> bool) -> Option<usize> {
|
||||
let current = self.pages.get(self.at).map(|e| e.0);
|
||||
let usable = |i: &usize| self.pages.get(*i).is_some_and(|&(p, _)| Some(p) != current && exists(p));
|
||||
if step < 0 { (0..self.at).rev().find(usable) } else { (self.at.saturating_add(1)..self.pages.len()).find(usable) }
|
||||
}
|
||||
|
||||
/// Whether Go Back (`step` < 0) or Go Forward has a page to go to.
|
||||
pub fn can_go(&self, step: isize, exists: impl Fn(PageId) -> bool) -> bool {
|
||||
self.target(step, exists).is_some()
|
||||
}
|
||||
|
||||
/// Go Back (`step` < 0) or Go Forward: the page to show and where the view was on it. It becomes
|
||||
/// the entry in view.
|
||||
pub fn go(&mut self, step: isize, exists: impl Fn(PageId) -> bool) -> Option<(PageId, Vec2)> {
|
||||
let i = self.target(step, exists)?;
|
||||
self.at = i;
|
||||
self.pages.get(i).copied()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ALL: fn(PageId) -> bool = |_| true;
|
||||
|
||||
fn visited(pages: &[u64]) -> PageHistory {
|
||||
let mut h = PageHistory::default();
|
||||
for &p in pages {
|
||||
h.visit(PageId(p), Vec2::ZERO);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn page(e: Option<(PageId, Vec2)>) -> Option<u64> {
|
||||
e.map(|(p, _)| p.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_back_and_forward_retrace_the_pages_in_the_order_they_were_viewed() {
|
||||
let mut h = visited(&[1, 5, 3]);
|
||||
assert!(!h.can_go(1, ALL), "nothing ahead of the last page viewed");
|
||||
assert_eq!(page(h.go(-1, ALL)), Some(5));
|
||||
assert_eq!(page(h.go(-1, ALL)), Some(1));
|
||||
assert_eq!(h.go(-1, ALL), None, "the first page viewed has nothing before it");
|
||||
assert_eq!(page(h.go(1, ALL)), Some(5));
|
||||
assert_eq!(page(h.go(1, ALL)), Some(3));
|
||||
assert_eq!(h.go(1, ALL), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn going_back_does_not_record_the_page_it_shows() {
|
||||
let mut h = visited(&[1, 5, 3]);
|
||||
let (p, offset) = h.go(-1, ALL).unwrap();
|
||||
// The next jump starts from it.
|
||||
h.leave(p, offset);
|
||||
assert_eq!(page(h.go(1, ALL)), Some(3), "Go Forward still has the page left");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_keeps_the_place_a_jump_left_it_at() {
|
||||
let mut h = visited(&[1, 5]);
|
||||
// Scrolled from page 5 to page 7, then jumped.
|
||||
h.leave(PageId(7), Vec2::new(-200.0, 30.0));
|
||||
h.visit(PageId(9), Vec2::ZERO);
|
||||
assert_eq!(h.go(-1, ALL), Some((PageId(7), Vec2::new(-200.0, 30.0))), "back to where the view was");
|
||||
assert_eq!(page(h.go(-1, ALL)), Some(1), "scrolling added no entry");
|
||||
assert_eq!(page(h.go(1, ALL)), Some(7));
|
||||
assert_eq!(page(h.go(1, ALL)), Some(9), "nor dropped the ones ahead");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewing_another_page_after_going_back_clears_go_forward() {
|
||||
let mut h = visited(&[1, 5, 3]);
|
||||
h.go(-1, ALL);
|
||||
h.visit(PageId(7), Vec2::ZERO);
|
||||
assert_eq!(page(h.go(-1, ALL)), Some(5), "the page gone back to stays behind the new one");
|
||||
assert_eq!(page(h.go(1, ALL)), Some(7));
|
||||
assert!(!h.can_go(1, ALL), "the page that was ahead is gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_pages_are_skipped() {
|
||||
let mut h = visited(&[1, 5, 3]);
|
||||
let exists = |p: PageId| p != PageId(5);
|
||||
assert_eq!(page(h.go(-1, exists)), Some(1));
|
||||
assert_eq!(page(h.go(1, exists)), Some(3));
|
||||
assert!(!visited(&[5, 3]).can_go(-1, exists), "no page left to go back to");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_history_is_bounded() {
|
||||
let mut h = visited(&(0..LIMIT as u64 + 10).collect::<Vec<_>>());
|
||||
let mut back = 0;
|
||||
while h.go(-1, ALL).is_some() {
|
||||
back += 1;
|
||||
}
|
||||
assert_eq!(back, LIMIT - 1);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -177,10 +177,10 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
- **Estimate:** 30–50 h.
|
||||
|
||||
### 16. Menu items still missing (U)
|
||||
- **Missing:** 151 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes
|
||||
- **Missing:** 149 of 482 in-scope items of InDesign 21.6's menu dump have no match (measured 2026-10-10; some exist under other labels): Type ▸ Notes
|
||||
and Track Changes submenus (the panels and commands exist), hyperlink and cross-reference
|
||||
update commands, Convert Bullets/Numbering to Text, Convert Variable to Text, Object ▸ Clear
|
||||
Effects / Clear All Transparency / Rotate 180° / Reverse Path, Layout ▸ Go Back/Forward,
|
||||
Effects / Clear All Transparency / Rotate 180° / Reverse Path,
|
||||
Window ▸ Arrange (2-up … 6-up), Trap Presets, Overlays, Edit ▸ Assign Profiles / Convert to
|
||||
Profile / Edit Original, File ▸ File Info / Open PDF / Document and Print Presets / User
|
||||
Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After,
|
||||
|
||||
@@ -204,7 +204,7 @@ table is the one summed.
|
||||
| Area | Breadth (checklist) | Ready | Hours | Main gaps |
|
||||
|---|---|---|---|---|
|
||||
| Application shell & workspace | 99% | 55% | 40–60 | context menus not opening on some systems; window controls (#333); System theme (#237); New Window/Arrange; Help menu |
|
||||
| Documents, pages, spreads | 100% | 65% | 20–35 | Layout ▸ Pages submenu items, Page Attributes / Color Label, Go Back/Forward, layout grid (CJK) |
|
||||
| Documents, pages, spreads | 100% | 65% | 20–35 | Layout ▸ Pages submenu items, Page Attributes / Color Label, layout grid (CJK) |
|
||||
| Layers | 100% | 75% | 5–10 | IDML layer order fixed 2026-10-09; Layer panel options depth |
|
||||
| Frames, shapes & paths | 100% | 60% | 25–40 | object copy/paste failures, reference point relocation (#337), Join/Reverse Path/Convert Point menu |
|
||||
| Transform | 100% | 70% | 10–20 | Rotate 180°; transform panel depth |
|
||||
|
||||
Reference in New Issue
Block a user