Skip to content

Commit a448b36

Browse files
ajweeksTrueDoctor
andauthored
Support rearranging layers with hotkeys (#271)
* Support moving single layers * Fix "Move layer to top/bottom" keybinds * Rename things named "move" to "reorder" Fix formatting * Combine sorted layer helper functions * Use integer consts for moving layers to front/back * Fix merge mistake * Fix some clippy lints * Fix panic * Remove "get" prefix from functions * Bring layer menu items out to sub-menu * Support moving multiple layers at a time * Add comment explaining odd keybinding * Add reordering tests * Add negative test * Add new error type * Add layer position helper, clean up tests * Make position helper return Result * Clean up slice iteration * Simplify source_layer_ids computation Co-authored-by: Dennis Kobert <[email protected]>
1 parent 255cdea commit a448b36

File tree

10 files changed

+319
-13
lines changed

10 files changed

+319
-13
lines changed

client/web/src/components/widgets/inputs/MenuBarInput.vue

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,17 @@ const menuEntries: MenuListEntries = [
126126
[
127127
{ label: "Select All", shortcut: ["Ctrl", "A"], action: async () => (await wasm).select_all_layers() },
128128
{ label: "Deselect All", shortcut: ["Ctrl", "Alt", "A"], action: async () => (await wasm).deselect_all_layers() },
129+
{
130+
label: "Order",
131+
children: [
132+
[
133+
{ label: "Raise To Front", shortcut: ["Ctrl", "Shift", "]"], action: async () => (await wasm).reorder_selected_layers(2147483647) },
134+
{ label: "Raise", shortcut: ["Ctrl", "]"], action: async () => (await wasm).reorder_selected_layers(1) },
135+
{ label: "Lower", shortcut: ["Ctrl", "["], action: async () => (await wasm).reorder_selected_layers(-1) },
136+
{ label: "Lower to Back", shortcut: ["Ctrl", "Shift", "["], action: async () => (await wasm).reorder_selected_layers(-2147483648) },
137+
],
138+
],
139+
},
129140
],
130141
],
131142
},

client/web/wasm/src/document.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,14 +176,22 @@ pub fn select_all_layers() -> Result<(), JsValue> {
176176
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::SelectAllLayers)).map_err(convert_error)
177177
}
178178

179-
/// Select all layers
179+
/// Deselect all layers
180180
#[wasm_bindgen]
181181
pub fn deselect_all_layers() -> Result<(), JsValue> {
182182
EDITOR_STATE
183183
.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::DeselectAllLayers))
184184
.map_err(convert_error)
185185
}
186186

187+
/// Reorder selected layer
188+
#[wasm_bindgen]
189+
pub fn reorder_selected_layers(delta: i32) -> Result<(), JsValue> {
190+
EDITOR_STATE
191+
.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::ReorderSelectedLayers(delta)))
192+
.map_err(convert_error)
193+
}
194+
187195
/// Export the document
188196
#[wasm_bindgen]
189197
pub fn export_document() -> Result<(), JsValue> {

client/web/wasm/src/wrappers.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ pub fn translate_key(name: &str) -> Key {
131131
"arrowdown" => KeyArrowDown,
132132
"arrowleft" => KeyArrowLeft,
133133
"arrowright" => KeyArrowRight,
134+
"[" => KeyLeftBracket,
135+
"]" => KeyRightBracket,
136+
"{" => KeyLeftCurlyBracket,
137+
"}" => KeyRightCurlyBracket,
134138
_ => UnknownKey,
135139
}
136140
}

core/document/src/document.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,19 @@ impl Document {
226226
Ok(())
227227
}
228228

229+
pub fn reorder_layers(&mut self, source_paths: &[Vec<LayerId>], target_path: &[LayerId]) -> Result<(), DocumentError> {
230+
// TODO: Detect when moving between folders and handle properly
231+
232+
let source_layer_ids = source_paths
233+
.iter()
234+
.map(|x| x.last().cloned().ok_or(DocumentError::LayerNotFound))
235+
.collect::<Result<Vec<LayerId>, DocumentError>>()?;
236+
237+
self.root.as_folder_mut()?.reorder_layers(source_layer_ids, *target_path.last().ok_or(DocumentError::LayerNotFound)?)?;
238+
239+
Ok(())
240+
}
241+
229242
pub fn layer_axis_aligned_bounding_box(&self, path: &[LayerId]) -> Result<Option<[DVec2; 2]>, DocumentError> {
230243
// TODO: Replace with functions of the transform api
231244
if path.is_empty() {
@@ -393,6 +406,11 @@ impl Document {
393406
self.mark_as_dirty(path)?;
394407
Some(vec![DocumentResponse::DocumentChanged])
395408
}
409+
Operation::ReorderLayers { source_paths, target_path } => {
410+
self.reorder_layers(source_paths, target_path)?;
411+
412+
Some(vec![DocumentResponse::DocumentChanged])
413+
}
396414
};
397415
if !matches!(
398416
operation,

core/document/src/layers/folder.rs

Lines changed: 214 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,78 @@ impl Folder {
5959
}
6060

6161
pub fn remove_layer(&mut self, id: LayerId) -> Result<(), DocumentError> {
62-
let pos = self.layer_ids.iter().position(|x| *x == id).ok_or(DocumentError::LayerNotFound)?;
62+
let pos = self.position_of_layer(id)?;
6363
self.layers.remove(pos);
6464
self.layer_ids.remove(pos);
6565
Ok(())
6666
}
6767

68+
pub fn reorder_layers(&mut self, source_ids: Vec<LayerId>, target_id: LayerId) -> Result<(), DocumentError> {
69+
let source_pos = self.position_of_layer(source_ids[0])?;
70+
let source_pos_end = source_pos + source_ids.len() - 1;
71+
let target_pos = self.position_of_layer(target_id)?;
72+
73+
let mut last_pos = source_pos;
74+
for layer_id in &source_ids[1..] {
75+
let layer_pos = self.position_of_layer(*layer_id)?;
76+
if (layer_pos as i32 - last_pos as i32).abs() > 1 {
77+
// Selection is not contiguous
78+
return Err(DocumentError::NonReorderableSelection);
79+
}
80+
last_pos = layer_pos;
81+
}
82+
83+
if source_pos < target_pos {
84+
// Moving layers up the hierarchy
85+
86+
// Prevent shifting past end
87+
if source_pos_end + 1 >= self.layers.len() {
88+
return Err(DocumentError::NonReorderableSelection);
89+
}
90+
91+
fn reorder_up<T>(arr: &mut Vec<T>, source_pos: usize, source_pos_end: usize, target_pos: usize)
92+
where
93+
T: Clone,
94+
{
95+
*arr = [
96+
&arr[0..source_pos], // Elements before selection
97+
&arr[source_pos_end + 1..=target_pos], // Elements between selection end and target
98+
&arr[source_pos..=source_pos_end], // Selection itself
99+
&arr[target_pos + 1..], // Elements before target
100+
]
101+
.concat();
102+
}
103+
104+
reorder_up(&mut self.layers, source_pos, source_pos_end, target_pos);
105+
reorder_up(&mut self.layer_ids, source_pos, source_pos_end, target_pos);
106+
} else {
107+
// Moving layers down the hierarchy
108+
109+
// Prevent shifting past end
110+
if source_pos == 0 {
111+
return Err(DocumentError::NonReorderableSelection);
112+
}
113+
114+
fn reorder_down<T>(arr: &mut Vec<T>, source_pos: usize, source_pos_end: usize, target_pos: usize)
115+
where
116+
T: Clone,
117+
{
118+
*arr = [
119+
&arr[0..target_pos], // Elements before target
120+
&arr[source_pos..=source_pos_end], // Selection itself
121+
&arr[target_pos..source_pos], // Elements between selection and target
122+
&arr[source_pos_end + 1..], // Elements before selection
123+
]
124+
.concat();
125+
}
126+
127+
reorder_down(&mut self.layers, source_pos, source_pos_end, target_pos);
128+
reorder_down(&mut self.layer_ids, source_pos, source_pos_end, target_pos);
129+
}
130+
131+
Ok(())
132+
}
133+
68134
/// Returns a list of layers in the folder
69135
pub fn list_layers(&self) -> &[LayerId] {
70136
self.layer_ids.as_slice()
@@ -79,15 +145,19 @@ impl Folder {
79145
}
80146

81147
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
82-
let pos = self.layer_ids.iter().position(|x| *x == id)?;
148+
let pos = self.position_of_layer(id).ok()?;
83149
Some(&self.layers[pos])
84150
}
85151

86152
pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut Layer> {
87-
let pos = self.layer_ids.iter().position(|x| *x == id)?;
153+
let pos = self.position_of_layer(id).ok()?;
88154
Some(&mut self.layers[pos])
89155
}
90156

157+
pub fn position_of_layer(&self, layer_id: LayerId) -> Result<usize, DocumentError> {
158+
self.layer_ids.iter().position(|x| *x == layer_id).ok_or(DocumentError::LayerNotFound)
159+
}
160+
91161
pub fn folder(&self, id: LayerId) -> Option<&Folder> {
92162
match self.layer(id) {
93163
Some(Layer {
@@ -143,3 +213,144 @@ impl Default for Folder {
143213
}
144214
}
145215
}
216+
217+
#[cfg(test)]
218+
mod test {
219+
use glam::{DAffine2, DVec2};
220+
221+
use crate::layers::{style::PathStyle, Ellipse, Layer, LayerDataTypes, Line, PolyLine, Rect, Shape};
222+
223+
use super::Folder;
224+
225+
#[test]
226+
fn reorder_layers() {
227+
let mut folder = Folder::default();
228+
229+
let identity_transform = DAffine2::IDENTITY.to_cols_array();
230+
folder.add_layer(Layer::new(LayerDataTypes::Shape(Shape::new(true, 3)), identity_transform, PathStyle::default()), 0);
231+
folder.add_layer(Layer::new(LayerDataTypes::Rect(Rect::default()), identity_transform, PathStyle::default()), 1);
232+
folder.add_layer(Layer::new(LayerDataTypes::Ellipse(Ellipse::default()), identity_transform, PathStyle::default()), 2);
233+
folder.add_layer(Layer::new(LayerDataTypes::Line(Line::default()), identity_transform, PathStyle::default()), 3);
234+
folder.add_layer(
235+
Layer::new(LayerDataTypes::PolyLine(PolyLine::new(vec![DVec2::ZERO, DVec2::ONE])), identity_transform, PathStyle::default()),
236+
4,
237+
);
238+
239+
assert_eq!(folder.layer_ids[0], 0);
240+
assert_eq!(folder.layer_ids[1], 1);
241+
assert_eq!(folder.layer_ids[2], 2);
242+
assert_eq!(folder.layer_ids[3], 3);
243+
assert_eq!(folder.layer_ids[4], 4);
244+
245+
assert!(matches!(folder.layer(0).unwrap().data, LayerDataTypes::Shape(_)));
246+
assert!(matches!(folder.layer(1).unwrap().data, LayerDataTypes::Rect(_)));
247+
assert!(matches!(folder.layer(2).unwrap().data, LayerDataTypes::Ellipse(_)));
248+
assert!(matches!(folder.layer(3).unwrap().data, LayerDataTypes::Line(_)));
249+
assert!(matches!(folder.layer(4).unwrap().data, LayerDataTypes::PolyLine(_)));
250+
251+
assert_eq!(folder.layer_ids.len(), 5);
252+
assert_eq!(folder.layers.len(), 5);
253+
254+
folder.reorder_layers(vec![0, 1], 2).unwrap();
255+
256+
assert_eq!(folder.layer_ids[0], 2);
257+
// Moved layers
258+
assert_eq!(folder.layer_ids[1], 0);
259+
assert_eq!(folder.layer_ids[2], 1);
260+
261+
assert_eq!(folder.layer_ids[3], 3);
262+
assert_eq!(folder.layer_ids[4], 4);
263+
264+
assert!(matches!(folder.layer(2).unwrap().data, LayerDataTypes::Ellipse(_)));
265+
// Moved layers
266+
assert!(matches!(folder.layer(0).unwrap().data, LayerDataTypes::Shape(_)));
267+
assert!(matches!(folder.layer(1).unwrap().data, LayerDataTypes::Rect(_)));
268+
269+
assert!(matches!(folder.layer(3).unwrap().data, LayerDataTypes::Line(_)));
270+
assert!(matches!(folder.layer(4).unwrap().data, LayerDataTypes::PolyLine(_)));
271+
272+
assert_eq!(folder.layer_ids.len(), 5);
273+
assert_eq!(folder.layers.len(), 5);
274+
}
275+
276+
#[test]
277+
fn reorder_layer_to_top() {
278+
let mut folder = Folder::default();
279+
280+
let identity_transform = DAffine2::IDENTITY.to_cols_array();
281+
folder.add_layer(Layer::new(LayerDataTypes::Shape(Shape::new(true, 3)), identity_transform, PathStyle::default()), 0);
282+
folder.add_layer(Layer::new(LayerDataTypes::Rect(Rect::default()), identity_transform, PathStyle::default()), 1);
283+
folder.add_layer(Layer::new(LayerDataTypes::Ellipse(Ellipse::default()), identity_transform, PathStyle::default()), 2);
284+
folder.add_layer(Layer::new(LayerDataTypes::Line(Line::default()), identity_transform, PathStyle::default()), 3);
285+
286+
assert_eq!(folder.layer_ids[0], 0);
287+
assert_eq!(folder.layer_ids[1], 1);
288+
assert_eq!(folder.layer_ids[2], 2);
289+
assert_eq!(folder.layer_ids[3], 3);
290+
291+
assert!(matches!(folder.layer(0).unwrap().data, LayerDataTypes::Shape(_)));
292+
assert!(matches!(folder.layer(1).unwrap().data, LayerDataTypes::Rect(_)));
293+
assert!(matches!(folder.layer(2).unwrap().data, LayerDataTypes::Ellipse(_)));
294+
assert!(matches!(folder.layer(3).unwrap().data, LayerDataTypes::Line(_)));
295+
296+
assert_eq!(folder.layer_ids.len(), 4);
297+
assert_eq!(folder.layers.len(), 4);
298+
299+
folder.reorder_layers(vec![1], 3).unwrap();
300+
301+
assert_eq!(folder.layer_ids[0], 0);
302+
assert_eq!(folder.layer_ids[1], 2);
303+
assert_eq!(folder.layer_ids[2], 3);
304+
// Moved layer
305+
assert_eq!(folder.layer_ids[3], 1);
306+
307+
assert!(matches!(folder.layer(0).unwrap().data, LayerDataTypes::Shape(_)));
308+
assert!(matches!(folder.layer(2).unwrap().data, LayerDataTypes::Ellipse(_)));
309+
assert!(matches!(folder.layer(3).unwrap().data, LayerDataTypes::Line(_)));
310+
// Moved layer
311+
assert!(matches!(folder.layer(1).unwrap().data, LayerDataTypes::Rect(_)));
312+
313+
assert_eq!(folder.layer_ids.len(), 4);
314+
assert_eq!(folder.layers.len(), 4);
315+
}
316+
317+
#[test]
318+
fn reorder_non_contiguous_selection() {
319+
let mut folder = Folder::default();
320+
321+
let identity_transform = DAffine2::IDENTITY.to_cols_array();
322+
folder.add_layer(Layer::new(LayerDataTypes::Shape(Shape::new(true, 3)), identity_transform, PathStyle::default()), 0);
323+
folder.add_layer(Layer::new(LayerDataTypes::Rect(Rect::default()), identity_transform, PathStyle::default()), 1);
324+
folder.add_layer(Layer::new(LayerDataTypes::Ellipse(Ellipse::default()), identity_transform, PathStyle::default()), 2);
325+
folder.add_layer(Layer::new(LayerDataTypes::Line(Line::default()), identity_transform, PathStyle::default()), 3);
326+
327+
assert_eq!(folder.layer_ids[0], 0);
328+
assert_eq!(folder.layer_ids[1], 1);
329+
assert_eq!(folder.layer_ids[2], 2);
330+
assert_eq!(folder.layer_ids[3], 3);
331+
332+
assert!(matches!(folder.layer(0).unwrap().data, LayerDataTypes::Shape(_)));
333+
assert!(matches!(folder.layer(1).unwrap().data, LayerDataTypes::Rect(_)));
334+
assert!(matches!(folder.layer(2).unwrap().data, LayerDataTypes::Ellipse(_)));
335+
assert!(matches!(folder.layer(3).unwrap().data, LayerDataTypes::Line(_)));
336+
337+
assert_eq!(folder.layer_ids.len(), 4);
338+
assert_eq!(folder.layers.len(), 4);
339+
340+
folder.reorder_layers(vec![0, 2], 3).expect_err("Non-contiguous selections can't be reordered");
341+
342+
// Expect identical state
343+
assert_eq!(folder.layer_ids[0], 0);
344+
assert_eq!(folder.layer_ids[1], 1);
345+
assert_eq!(folder.layer_ids[2], 2);
346+
assert_eq!(folder.layer_ids[3], 3);
347+
348+
assert!(matches!(folder.layer(0).unwrap().data, LayerDataTypes::Shape(_)));
349+
assert!(matches!(folder.layer(1).unwrap().data, LayerDataTypes::Rect(_)));
350+
assert!(matches!(folder.layer(2).unwrap().data, LayerDataTypes::Ellipse(_)));
351+
assert!(matches!(folder.layer(3).unwrap().data, LayerDataTypes::Line(_)));
352+
353+
assert_eq!(folder.layer_ids.len(), 4);
354+
assert_eq!(folder.layers.len(), 4);
355+
}
356+
}

core/document/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ pub enum DocumentError {
1616
InvalidPath,
1717
IndexOutOfBounds,
1818
NotAFolder,
19+
NonReorderableSelection,
1920
}

core/document/src/operation.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,8 @@ pub enum Operation {
7676
path: Vec<LayerId>,
7777
color: Color,
7878
},
79+
ReorderLayers {
80+
source_paths: Vec<Vec<LayerId>>,
81+
target_path: Vec<LayerId>,
82+
},
7983
}

0 commit comments

Comments
 (0)