Skip to content

Support rearranging layers with hotkeys #271

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Jul 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions client/web/src/components/widgets/inputs/MenuBarInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ const menuEntries: MenuListEntries = [
[
{ label: "Select All", shortcut: ["Ctrl", "A"], action: async () => (await wasm).select_all_layers() },
{ label: "Deselect All", shortcut: ["Ctrl", "Alt", "A"], action: async () => (await wasm).deselect_all_layers() },
{
label: "Order",
children: [
[
{ label: "Raise To Front", shortcut: ["Ctrl", "Shift", "]"], action: async () => (await wasm).reorder_selected_layers(2147483647) },
{ label: "Raise", shortcut: ["Ctrl", "]"], action: async () => (await wasm).reorder_selected_layers(1) },
{ label: "Lower", shortcut: ["Ctrl", "["], action: async () => (await wasm).reorder_selected_layers(-1) },
{ label: "Lower to Back", shortcut: ["Ctrl", "Shift", "["], action: async () => (await wasm).reorder_selected_layers(-2147483648) },
],
],
},
],
],
},
Expand Down
10 changes: 9 additions & 1 deletion client/web/wasm/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,14 +176,22 @@ pub fn select_all_layers() -> Result<(), JsValue> {
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::SelectAllLayers)).map_err(convert_error)
}

/// Select all layers
/// Deselect all layers
#[wasm_bindgen]
pub fn deselect_all_layers() -> Result<(), JsValue> {
EDITOR_STATE
.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::DeselectAllLayers))
.map_err(convert_error)
}

/// Reorder selected layer
#[wasm_bindgen]
pub fn reorder_selected_layers(delta: i32) -> Result<(), JsValue> {
EDITOR_STATE
.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::ReorderSelectedLayers(delta)))
.map_err(convert_error)
}

/// Export the document
#[wasm_bindgen]
pub fn export_document() -> Result<(), JsValue> {
Expand Down
4 changes: 4 additions & 0 deletions client/web/wasm/src/wrappers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ pub fn translate_key(name: &str) -> Key {
"arrowdown" => KeyArrowDown,
"arrowleft" => KeyArrowLeft,
"arrowright" => KeyArrowRight,
"[" => KeyLeftBracket,
"]" => KeyRightBracket,
"{" => KeyLeftCurlyBracket,
"}" => KeyRightCurlyBracket,
_ => UnknownKey,
}
}
18 changes: 18 additions & 0 deletions core/document/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,19 @@ impl Document {
Ok(())
}

pub fn reorder_layers(&mut self, source_paths: &[Vec<LayerId>], target_path: &[LayerId]) -> Result<(), DocumentError> {
// TODO: Detect when moving between folders and handle properly

let source_layer_ids = source_paths
.iter()
.map(|x| x.last().cloned().ok_or(DocumentError::LayerNotFound))
.collect::<Result<Vec<LayerId>, DocumentError>>()?;

self.root.as_folder_mut()?.reorder_layers(source_layer_ids, *target_path.last().ok_or(DocumentError::LayerNotFound)?)?;

Ok(())
}

pub fn layer_axis_aligned_bounding_box(&self, path: &[LayerId]) -> Result<Option<[DVec2; 2]>, DocumentError> {
// TODO: Replace with functions of the transform api
if path.is_empty() {
Expand Down Expand Up @@ -393,6 +406,11 @@ impl Document {
self.mark_as_dirty(path)?;
Some(vec![DocumentResponse::DocumentChanged])
}
Operation::ReorderLayers { source_paths, target_path } => {
self.reorder_layers(source_paths, target_path)?;

Some(vec![DocumentResponse::DocumentChanged])
}
};
if !matches!(
operation,
Expand Down
217 changes: 214 additions & 3 deletions core/document/src/layers/folder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,78 @@ impl Folder {
}

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

pub fn reorder_layers(&mut self, source_ids: Vec<LayerId>, target_id: LayerId) -> Result<(), DocumentError> {
let source_pos = self.position_of_layer(source_ids[0])?;
let source_pos_end = source_pos + source_ids.len() - 1;
let target_pos = self.position_of_layer(target_id)?;

let mut last_pos = source_pos;
for layer_id in &source_ids[1..] {
let layer_pos = self.position_of_layer(*layer_id)?;
if (layer_pos as i32 - last_pos as i32).abs() > 1 {
// Selection is not contiguous
return Err(DocumentError::NonReorderableSelection);
}
last_pos = layer_pos;
}

if source_pos < target_pos {
// Moving layers up the hierarchy

// Prevent shifting past end
if source_pos_end + 1 >= self.layers.len() {
return Err(DocumentError::NonReorderableSelection);
}

fn reorder_up<T>(arr: &mut Vec<T>, source_pos: usize, source_pos_end: usize, target_pos: usize)
where
T: Clone,
{
*arr = [
&arr[0..source_pos], // Elements before selection
&arr[source_pos_end + 1..=target_pos], // Elements between selection end and target
&arr[source_pos..=source_pos_end], // Selection itself
&arr[target_pos + 1..], // Elements before target
]
.concat();
}

reorder_up(&mut self.layers, source_pos, source_pos_end, target_pos);
reorder_up(&mut self.layer_ids, source_pos, source_pos_end, target_pos);
} else {
// Moving layers down the hierarchy

// Prevent shifting past end
if source_pos == 0 {
return Err(DocumentError::NonReorderableSelection);
}

fn reorder_down<T>(arr: &mut Vec<T>, source_pos: usize, source_pos_end: usize, target_pos: usize)
where
T: Clone,
{
*arr = [
&arr[0..target_pos], // Elements before target
&arr[source_pos..=source_pos_end], // Selection itself
&arr[target_pos..source_pos], // Elements between selection and target
&arr[source_pos_end + 1..], // Elements before selection
]
.concat();
}

reorder_down(&mut self.layers, source_pos, source_pos_end, target_pos);
reorder_down(&mut self.layer_ids, source_pos, source_pos_end, target_pos);
}

Ok(())
}

/// Returns a list of layers in the folder
pub fn list_layers(&self) -> &[LayerId] {
self.layer_ids.as_slice()
Expand All @@ -79,15 +145,19 @@ impl Folder {
}

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

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

pub fn position_of_layer(&self, layer_id: LayerId) -> Result<usize, DocumentError> {
self.layer_ids.iter().position(|x| *x == layer_id).ok_or(DocumentError::LayerNotFound)
}

pub fn folder(&self, id: LayerId) -> Option<&Folder> {
match self.layer(id) {
Some(Layer {
Expand Down Expand Up @@ -143,3 +213,144 @@ impl Default for Folder {
}
}
}

#[cfg(test)]
mod test {
use glam::{DAffine2, DVec2};

use crate::layers::{style::PathStyle, Ellipse, Layer, LayerDataTypes, Line, PolyLine, Rect, Shape};

use super::Folder;

#[test]
fn reorder_layers() {
let mut folder = Folder::default();

let identity_transform = DAffine2::IDENTITY.to_cols_array();
folder.add_layer(Layer::new(LayerDataTypes::Shape(Shape::new(true, 3)), identity_transform, PathStyle::default()), 0);
folder.add_layer(Layer::new(LayerDataTypes::Rect(Rect::default()), identity_transform, PathStyle::default()), 1);
folder.add_layer(Layer::new(LayerDataTypes::Ellipse(Ellipse::default()), identity_transform, PathStyle::default()), 2);
folder.add_layer(Layer::new(LayerDataTypes::Line(Line::default()), identity_transform, PathStyle::default()), 3);
folder.add_layer(
Layer::new(LayerDataTypes::PolyLine(PolyLine::new(vec![DVec2::ZERO, DVec2::ONE])), identity_transform, PathStyle::default()),
4,
);

assert_eq!(folder.layer_ids[0], 0);
assert_eq!(folder.layer_ids[1], 1);
assert_eq!(folder.layer_ids[2], 2);
assert_eq!(folder.layer_ids[3], 3);
assert_eq!(folder.layer_ids[4], 4);

assert!(matches!(folder.layer(0).unwrap().data, LayerDataTypes::Shape(_)));
assert!(matches!(folder.layer(1).unwrap().data, LayerDataTypes::Rect(_)));
assert!(matches!(folder.layer(2).unwrap().data, LayerDataTypes::Ellipse(_)));
assert!(matches!(folder.layer(3).unwrap().data, LayerDataTypes::Line(_)));
assert!(matches!(folder.layer(4).unwrap().data, LayerDataTypes::PolyLine(_)));

assert_eq!(folder.layer_ids.len(), 5);
assert_eq!(folder.layers.len(), 5);

folder.reorder_layers(vec![0, 1], 2).unwrap();

assert_eq!(folder.layer_ids[0], 2);
// Moved layers
assert_eq!(folder.layer_ids[1], 0);
assert_eq!(folder.layer_ids[2], 1);

assert_eq!(folder.layer_ids[3], 3);
assert_eq!(folder.layer_ids[4], 4);

assert!(matches!(folder.layer(2).unwrap().data, LayerDataTypes::Ellipse(_)));
// Moved layers
assert!(matches!(folder.layer(0).unwrap().data, LayerDataTypes::Shape(_)));
assert!(matches!(folder.layer(1).unwrap().data, LayerDataTypes::Rect(_)));

assert!(matches!(folder.layer(3).unwrap().data, LayerDataTypes::Line(_)));
assert!(matches!(folder.layer(4).unwrap().data, LayerDataTypes::PolyLine(_)));

assert_eq!(folder.layer_ids.len(), 5);
assert_eq!(folder.layers.len(), 5);
}

#[test]
fn reorder_layer_to_top() {
let mut folder = Folder::default();

let identity_transform = DAffine2::IDENTITY.to_cols_array();
folder.add_layer(Layer::new(LayerDataTypes::Shape(Shape::new(true, 3)), identity_transform, PathStyle::default()), 0);
folder.add_layer(Layer::new(LayerDataTypes::Rect(Rect::default()), identity_transform, PathStyle::default()), 1);
folder.add_layer(Layer::new(LayerDataTypes::Ellipse(Ellipse::default()), identity_transform, PathStyle::default()), 2);
folder.add_layer(Layer::new(LayerDataTypes::Line(Line::default()), identity_transform, PathStyle::default()), 3);

assert_eq!(folder.layer_ids[0], 0);
assert_eq!(folder.layer_ids[1], 1);
assert_eq!(folder.layer_ids[2], 2);
assert_eq!(folder.layer_ids[3], 3);

assert!(matches!(folder.layer(0).unwrap().data, LayerDataTypes::Shape(_)));
assert!(matches!(folder.layer(1).unwrap().data, LayerDataTypes::Rect(_)));
assert!(matches!(folder.layer(2).unwrap().data, LayerDataTypes::Ellipse(_)));
assert!(matches!(folder.layer(3).unwrap().data, LayerDataTypes::Line(_)));

assert_eq!(folder.layer_ids.len(), 4);
assert_eq!(folder.layers.len(), 4);

folder.reorder_layers(vec![1], 3).unwrap();

assert_eq!(folder.layer_ids[0], 0);
assert_eq!(folder.layer_ids[1], 2);
assert_eq!(folder.layer_ids[2], 3);
// Moved layer
assert_eq!(folder.layer_ids[3], 1);

assert!(matches!(folder.layer(0).unwrap().data, LayerDataTypes::Shape(_)));
assert!(matches!(folder.layer(2).unwrap().data, LayerDataTypes::Ellipse(_)));
assert!(matches!(folder.layer(3).unwrap().data, LayerDataTypes::Line(_)));
// Moved layer
assert!(matches!(folder.layer(1).unwrap().data, LayerDataTypes::Rect(_)));

assert_eq!(folder.layer_ids.len(), 4);
assert_eq!(folder.layers.len(), 4);
}

#[test]
fn reorder_non_contiguous_selection() {
let mut folder = Folder::default();

let identity_transform = DAffine2::IDENTITY.to_cols_array();
folder.add_layer(Layer::new(LayerDataTypes::Shape(Shape::new(true, 3)), identity_transform, PathStyle::default()), 0);
folder.add_layer(Layer::new(LayerDataTypes::Rect(Rect::default()), identity_transform, PathStyle::default()), 1);
folder.add_layer(Layer::new(LayerDataTypes::Ellipse(Ellipse::default()), identity_transform, PathStyle::default()), 2);
folder.add_layer(Layer::new(LayerDataTypes::Line(Line::default()), identity_transform, PathStyle::default()), 3);

assert_eq!(folder.layer_ids[0], 0);
assert_eq!(folder.layer_ids[1], 1);
assert_eq!(folder.layer_ids[2], 2);
assert_eq!(folder.layer_ids[3], 3);

assert!(matches!(folder.layer(0).unwrap().data, LayerDataTypes::Shape(_)));
assert!(matches!(folder.layer(1).unwrap().data, LayerDataTypes::Rect(_)));
assert!(matches!(folder.layer(2).unwrap().data, LayerDataTypes::Ellipse(_)));
assert!(matches!(folder.layer(3).unwrap().data, LayerDataTypes::Line(_)));

assert_eq!(folder.layer_ids.len(), 4);
assert_eq!(folder.layers.len(), 4);

folder.reorder_layers(vec![0, 2], 3).expect_err("Non-contiguous selections can't be reordered");

// Expect identical state
assert_eq!(folder.layer_ids[0], 0);
assert_eq!(folder.layer_ids[1], 1);
assert_eq!(folder.layer_ids[2], 2);
assert_eq!(folder.layer_ids[3], 3);

assert!(matches!(folder.layer(0).unwrap().data, LayerDataTypes::Shape(_)));
assert!(matches!(folder.layer(1).unwrap().data, LayerDataTypes::Rect(_)));
assert!(matches!(folder.layer(2).unwrap().data, LayerDataTypes::Ellipse(_)));
assert!(matches!(folder.layer(3).unwrap().data, LayerDataTypes::Line(_)));

assert_eq!(folder.layer_ids.len(), 4);
assert_eq!(folder.layers.len(), 4);
}
}
1 change: 1 addition & 0 deletions core/document/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ pub enum DocumentError {
InvalidPath,
IndexOutOfBounds,
NotAFolder,
NonReorderableSelection,
}
4 changes: 4 additions & 0 deletions core/document/src/operation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,8 @@ pub enum Operation {
path: Vec<LayerId>,
color: Color,
},
ReorderLayers {
source_paths: Vec<Vec<LayerId>>,
target_path: Vec<LayerId>,
},
}
Loading