mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-12-23 10:11:54 +00:00
* Refactor ViewportPosition from u32 (UVec2) to f64 (DVec2) * Fix pseudo_hash call * Replace hash function with proper function for uuid generation * Cargo fmt Co-authored-by: Dennis Kobert <dennis@kobert.dev>
537 lines
18 KiB
Rust
537 lines
18 KiB
Rust
pub use super::layer_panel::*;
|
|
use crate::{
|
|
consts::{FILE_EXPORT_SUFFIX, FILE_SAVE_SUFFIX},
|
|
frontend::layer_panel::*,
|
|
EditorError,
|
|
};
|
|
use glam::{DAffine2, DVec2};
|
|
use graphene::{document::Document as InternalDocument, DocumentError, LayerId};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
|
|
use crate::input::InputPreprocessor;
|
|
use crate::message_prelude::*;
|
|
use graphene::layers::BlendMode;
|
|
use graphene::{DocumentResponse, Operation as DocumentOperation};
|
|
use log::warn;
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
use super::movement_handler::{MovementMessage, MovementMessageHandler};
|
|
|
|
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize, Hash)]
|
|
pub enum FlipAxis {
|
|
X,
|
|
Y,
|
|
}
|
|
|
|
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize, Hash)]
|
|
pub enum AlignAxis {
|
|
X,
|
|
Y,
|
|
}
|
|
|
|
#[derive(PartialEq, Clone, Debug, Serialize, Deserialize, Hash)]
|
|
pub enum AlignAggregate {
|
|
Min,
|
|
Max,
|
|
Center,
|
|
Average,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct DocumentMessageHandler {
|
|
pub document: InternalDocument,
|
|
pub document_backup: Option<InternalDocument>,
|
|
pub name: String,
|
|
pub layer_data: HashMap<Vec<LayerId>, LayerData>,
|
|
movement_handler: MovementMessageHandler,
|
|
}
|
|
|
|
impl Default for DocumentMessageHandler {
|
|
fn default() -> Self {
|
|
Self {
|
|
document: InternalDocument::default(),
|
|
document_backup: None,
|
|
name: String::from("Untitled Document"),
|
|
layer_data: vec![(vec![], LayerData::new(true))].into_iter().collect(),
|
|
movement_handler: MovementMessageHandler::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[impl_message(Message, DocumentsMessage, Document)]
|
|
#[derive(PartialEq, Clone, Debug)]
|
|
pub enum DocumentMessage {
|
|
#[child]
|
|
Movement(MovementMessage),
|
|
DispatchOperation(Box<DocumentOperation>),
|
|
SelectLayers(Vec<Vec<LayerId>>),
|
|
SelectAllLayers,
|
|
DeselectAllLayers,
|
|
DeleteLayer(Vec<LayerId>),
|
|
DeleteSelectedLayers,
|
|
DuplicateSelectedLayers,
|
|
SetBlendModeForSelectedLayers(BlendMode),
|
|
SetOpacityForSelectedLayers(f64),
|
|
AddFolder(Vec<LayerId>),
|
|
RenameLayer(Vec<LayerId>, String),
|
|
ToggleLayerVisibility(Vec<LayerId>),
|
|
FlipSelectedLayers(FlipAxis),
|
|
ToggleLayerExpansion(Vec<LayerId>),
|
|
FolderChanged(Vec<LayerId>),
|
|
StartTransaction,
|
|
RollbackTransaction,
|
|
AbortTransaction,
|
|
CommitTransaction,
|
|
ExportDocument,
|
|
SaveDocument,
|
|
RenderDocument,
|
|
Undo,
|
|
NudgeSelectedLayers(f64, f64),
|
|
AlignSelectedLayers(AlignAxis, AlignAggregate),
|
|
MoveSelectedLayersTo {
|
|
path: Vec<LayerId>,
|
|
insert_index: isize,
|
|
},
|
|
ReorderSelectedLayers(i32), // relative_position,
|
|
}
|
|
|
|
impl From<DocumentOperation> for DocumentMessage {
|
|
fn from(operation: DocumentOperation) -> DocumentMessage {
|
|
Self::DispatchOperation(Box::new(operation))
|
|
}
|
|
}
|
|
impl From<DocumentOperation> for Message {
|
|
fn from(operation: DocumentOperation) -> Message {
|
|
DocumentMessage::DispatchOperation(Box::new(operation)).into()
|
|
}
|
|
}
|
|
|
|
impl DocumentMessageHandler {
|
|
pub fn active_document(&self) -> &DocumentMessageHandler {
|
|
self
|
|
}
|
|
pub fn active_document_mut(&mut self) -> &mut DocumentMessageHandler {
|
|
self
|
|
}
|
|
fn filter_document_responses(&self, document_responses: &mut Vec<DocumentResponse>) -> bool {
|
|
let len = document_responses.len();
|
|
document_responses.retain(|response| !matches!(response, DocumentResponse::DocumentChanged));
|
|
document_responses.len() != len
|
|
}
|
|
pub fn handle_folder_changed(&mut self, path: Vec<LayerId>) -> Option<Message> {
|
|
let _ = self.document.render_root();
|
|
self.layer_data(&path).expanded.then(|| {
|
|
let children = self.layer_panel(path.as_slice()).expect("The provided Path was not valid");
|
|
FrontendMessage::ExpandFolder { path, children }.into()
|
|
})
|
|
}
|
|
fn clear_selection(&mut self) {
|
|
self.layer_data.values_mut().for_each(|layer_data| layer_data.selected = false);
|
|
}
|
|
fn select_layer(&mut self, path: &[LayerId]) -> Option<Message> {
|
|
self.layer_data(path).selected = true;
|
|
// TODO: Add deduplication
|
|
(!path.is_empty()).then(|| self.handle_folder_changed(path[..path.len() - 1].to_vec())).flatten()
|
|
}
|
|
pub fn layerdata(&self, path: &[LayerId]) -> &LayerData {
|
|
self.layer_data.get(path).expect("Layerdata does not exist")
|
|
}
|
|
pub fn layerdata_mut(&mut self, path: &[LayerId]) -> &mut LayerData {
|
|
self.layer_data.entry(path.to_vec()).or_insert_with(|| LayerData::new(true))
|
|
}
|
|
|
|
pub fn selected_layers(&self) -> impl Iterator<Item = &Vec<LayerId>> {
|
|
self.layer_data.iter().filter_map(|(path, data)| data.selected.then(|| path))
|
|
}
|
|
|
|
/// Returns the paths to all layers in order, optionally including only selected or non-selected layers.
|
|
fn layers_sorted(&self, selected: Option<bool>) -> Vec<Vec<LayerId>> {
|
|
// Compute the indices for each layer to be able to sort them
|
|
let mut layers_with_indices: Vec<(Vec<LayerId>, Vec<usize>)> = self
|
|
|
|
.layer_data
|
|
.iter()
|
|
// 'path.len() > 0' filters out root layer since it has no indices
|
|
.filter_map(|(path, data)| (!path.is_empty() && (data.selected == selected.unwrap_or(data.selected))).then(|| path.clone()))
|
|
.filter_map(|path| {
|
|
// Currently it is possible that layer_data contains layers that are don't actually exist (has been partially fixed in #281)
|
|
// and thus indices_for_path can return an error. We currently skip these layers and log a warning.
|
|
// Once this problem is solved this code can be simplified
|
|
match self.document.indices_for_path(&path) {
|
|
Err(err) => {
|
|
warn!("layers_sorted: Could not get indices for the layer {:?}: {:?}", path, err);
|
|
None
|
|
}
|
|
Ok(indices) => Some((path, indices)),
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
layers_with_indices.sort_by_key(|(_, indices)| indices.clone());
|
|
layers_with_indices.into_iter().map(|(path, _)| path).collect()
|
|
}
|
|
|
|
/// Returns the paths to all layers in order
|
|
pub fn all_layers_sorted(&self) -> Vec<Vec<LayerId>> {
|
|
self.layers_sorted(None)
|
|
}
|
|
|
|
/// Returns the paths to all selected layers in order
|
|
pub fn selected_layers_sorted(&self) -> Vec<Vec<LayerId>> {
|
|
self.layers_sorted(Some(true))
|
|
}
|
|
|
|
/// Returns the paths to all non_selected layers in order
|
|
#[allow(dead_code)] // used for test cases
|
|
pub fn non_selected_layers_sorted(&self) -> Vec<Vec<LayerId>> {
|
|
self.layers_sorted(Some(false))
|
|
}
|
|
pub fn with_name(name: String) -> Self {
|
|
Self {
|
|
document: InternalDocument::default(),
|
|
document_backup: None,
|
|
name,
|
|
layer_data: vec![(vec![], LayerData::new(true))].into_iter().collect(),
|
|
movement_handler: MovementMessageHandler::default(),
|
|
}
|
|
}
|
|
pub fn with_name_and_content(name: String, serialized_content: String) -> Result<Self, EditorError> {
|
|
let mut document = Self::with_name(name);
|
|
let internal_document = InternalDocument::with_content(&serialized_content);
|
|
match internal_document {
|
|
Ok(handle) => {
|
|
document.document = handle;
|
|
Ok(document)
|
|
}
|
|
Err(DocumentError::InvalidFile(msg)) => Err(EditorError::Document(msg)),
|
|
_ => Err(EditorError::Document(String::from("Failed to open file"))),
|
|
}
|
|
}
|
|
|
|
pub fn layer_data(&mut self, path: &[LayerId]) -> &mut LayerData {
|
|
layer_data(&mut self.layer_data, path)
|
|
}
|
|
|
|
pub fn backup(&mut self) {
|
|
self.document_backup = Some(self.document.clone())
|
|
}
|
|
|
|
pub fn rollback(&mut self) -> Result<(), EditorError> {
|
|
self.backup();
|
|
self.reset()
|
|
}
|
|
|
|
pub fn reset(&mut self) -> Result<(), EditorError> {
|
|
match self.document_backup.take() {
|
|
Some(backup) => {
|
|
self.document = backup;
|
|
Ok(())
|
|
}
|
|
None => Err(EditorError::NoTransactionInProgress),
|
|
}
|
|
}
|
|
|
|
pub fn layer_panel_entry(&mut self, path: Vec<LayerId>) -> Result<LayerPanelEntry, EditorError> {
|
|
self.document.render_root();
|
|
let data: LayerData = *layer_data(&mut self.layer_data, &path);
|
|
let layer = self.document.layer(&path)?;
|
|
let entry = layer_panel_entry(&data, self.document.multiply_transforms(&path).unwrap(), layer, path);
|
|
Ok(entry)
|
|
}
|
|
|
|
/// Returns a list of `LayerPanelEntry`s intended for display purposes. These don't contain
|
|
/// any actual data, but rather attributes such as visibility and names of the layers.
|
|
pub fn layer_panel(&mut self, path: &[LayerId]) -> Result<Vec<LayerPanelEntry>, EditorError> {
|
|
let folder = self.document.folder(path)?;
|
|
let paths: Vec<Vec<LayerId>> = folder.layer_ids.iter().map(|id| [path, &[*id]].concat()).collect();
|
|
let data: Vec<LayerData> = paths.iter().map(|path| *layer_data(&mut self.layer_data, path)).collect();
|
|
let folder = self.document.folder(path)?;
|
|
let entries = folder
|
|
.layers()
|
|
.iter()
|
|
.zip(paths.iter().zip(data))
|
|
.rev()
|
|
.map(|(layer, (path, data))| {
|
|
layer_panel_entry(
|
|
&data,
|
|
self.document.generate_transform_across_scope(path, Some(self.document.root.transform.inverse())).unwrap(),
|
|
layer,
|
|
path.to_vec(),
|
|
)
|
|
})
|
|
.collect();
|
|
Ok(entries)
|
|
}
|
|
}
|
|
|
|
impl MessageHandler<DocumentMessage, &InputPreprocessor> for DocumentMessageHandler {
|
|
fn process_action(&mut self, message: DocumentMessage, ipp: &InputPreprocessor, responses: &mut VecDeque<Message>) {
|
|
use DocumentMessage::*;
|
|
match message {
|
|
Movement(message) => self.movement_handler.process_action(message, (layer_data(&mut self.layer_data, &[]), &self.document, ipp), responses),
|
|
DeleteLayer(path) => responses.push_back(DocumentOperation::DeleteLayer { path }.into()),
|
|
AddFolder(path) => responses.push_back(DocumentOperation::AddFolder { path }.into()),
|
|
StartTransaction => self.backup(),
|
|
RollbackTransaction => {
|
|
self.rollback().unwrap_or_else(|e| log::warn!("{}", e));
|
|
responses.extend([DocumentMessage::RenderDocument.into(), self.handle_folder_changed(vec![]).unwrap()]);
|
|
}
|
|
AbortTransaction => {
|
|
self.reset().unwrap_or_else(|e| log::warn!("{}", e));
|
|
responses.extend([DocumentMessage::RenderDocument.into(), self.handle_folder_changed(vec![]).unwrap()]);
|
|
}
|
|
CommitTransaction => self.document_backup = None,
|
|
ExportDocument => {
|
|
let bbox = self.document.visible_layers_bounding_box().unwrap_or([DVec2::ZERO, ipp.viewport_size]);
|
|
let size = bbox[1] - bbox[0];
|
|
let name = match self.name.ends_with(FILE_SAVE_SUFFIX) {
|
|
true => self.name.clone().replace(FILE_SAVE_SUFFIX, FILE_EXPORT_SUFFIX),
|
|
false => self.name.clone() + FILE_EXPORT_SUFFIX,
|
|
};
|
|
responses.push_back(
|
|
FrontendMessage::ExportDocument {
|
|
document: format!(
|
|
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{} {} {} {}">{}{}</svg>"#,
|
|
bbox[0].x,
|
|
bbox[0].y,
|
|
size.x,
|
|
size.y,
|
|
"\n",
|
|
self.document.render_root()
|
|
),
|
|
name,
|
|
}
|
|
.into(),
|
|
)
|
|
}
|
|
SaveDocument => {
|
|
let name = match self.name.ends_with(FILE_SAVE_SUFFIX) {
|
|
true => self.name.clone(),
|
|
false => self.name.clone() + FILE_SAVE_SUFFIX,
|
|
};
|
|
responses.push_back(
|
|
FrontendMessage::SaveDocument {
|
|
document: self.document.serialize_document(),
|
|
name,
|
|
}
|
|
.into(),
|
|
)
|
|
}
|
|
SetBlendModeForSelectedLayers(blend_mode) => {
|
|
let active_document = self;
|
|
|
|
for path in active_document.layer_data.iter().filter_map(|(path, data)| data.selected.then(|| path.clone())) {
|
|
responses.push_back(DocumentOperation::SetLayerBlendMode { path, blend_mode }.into());
|
|
}
|
|
}
|
|
SetOpacityForSelectedLayers(opacity) => {
|
|
let opacity = opacity.clamp(0., 1.);
|
|
|
|
for path in self.selected_layers().cloned() {
|
|
responses.push_back(DocumentOperation::SetLayerOpacity { path, opacity }.into());
|
|
}
|
|
}
|
|
ToggleLayerVisibility(path) => {
|
|
responses.push_back(DocumentOperation::ToggleVisibility { path }.into());
|
|
}
|
|
ToggleLayerExpansion(path) => {
|
|
self.layer_data(&path).expanded ^= true;
|
|
responses.extend(self.handle_folder_changed(path));
|
|
}
|
|
DeleteSelectedLayers => {
|
|
for path in self.selected_layers().cloned() {
|
|
responses.push_back(DocumentOperation::DeleteLayer { path }.into())
|
|
}
|
|
}
|
|
DuplicateSelectedLayers => {
|
|
for path in self.selected_layers_sorted() {
|
|
responses.push_back(DocumentOperation::DuplicateLayer { path }.into())
|
|
}
|
|
}
|
|
SelectLayers(paths) => {
|
|
self.clear_selection();
|
|
for path in paths {
|
|
responses.extend(self.select_layer(&path));
|
|
}
|
|
// TODO: Correctly update layer panel in clear_selection instead of here
|
|
responses.extend(self.handle_folder_changed(Vec::new()));
|
|
}
|
|
SelectAllLayers => {
|
|
let all_layer_paths = self.layer_data.keys().filter(|path| !path.is_empty()).cloned().collect::<Vec<_>>();
|
|
for path in all_layer_paths {
|
|
responses.extend(self.select_layer(&path));
|
|
}
|
|
}
|
|
DeselectAllLayers => {
|
|
self.clear_selection();
|
|
let children = self.layer_panel(&[]).expect("The provided Path was not valid");
|
|
responses.push_back(FrontendMessage::ExpandFolder { path: vec![], children }.into());
|
|
}
|
|
Undo => {
|
|
// this is a temporary fix and will be addressed by #123
|
|
if let Some(id) = self.document.root.as_folder().unwrap().list_layers().last() {
|
|
responses.push_back(DocumentOperation::DeleteLayer { path: vec![*id] }.into())
|
|
}
|
|
}
|
|
FolderChanged(path) => responses.extend(self.handle_folder_changed(path)),
|
|
DispatchOperation(op) => match self.document.handle_operation(&op) {
|
|
Ok(Some(mut document_responses)) => {
|
|
let canvas_dirty = self.filter_document_responses(&mut document_responses);
|
|
responses.extend(
|
|
document_responses
|
|
.into_iter()
|
|
.map(|response| match response {
|
|
DocumentResponse::FolderChanged { path } => self.handle_folder_changed(path),
|
|
DocumentResponse::DeletedLayer { path } => {
|
|
self.layer_data.remove(&path);
|
|
None
|
|
}
|
|
DocumentResponse::LayerChanged { path } => Some(
|
|
FrontendMessage::UpdateLayer {
|
|
path: path.clone(),
|
|
data: self.layer_panel_entry(path).unwrap(),
|
|
}
|
|
.into(),
|
|
),
|
|
DocumentResponse::CreatedLayer { path } => self.select_layer(&path),
|
|
DocumentResponse::DocumentChanged => unreachable!(),
|
|
})
|
|
.flatten(),
|
|
);
|
|
if canvas_dirty {
|
|
responses.push_back(RenderDocument.into())
|
|
}
|
|
}
|
|
Err(e) => log::error!("DocumentError: {:?}", e),
|
|
Ok(_) => (),
|
|
},
|
|
RenderDocument => responses.push_back(
|
|
FrontendMessage::UpdateCanvas {
|
|
document: self.document.render_root(),
|
|
}
|
|
.into(),
|
|
),
|
|
NudgeSelectedLayers(x, y) => {
|
|
for path in self.selected_layers().cloned() {
|
|
let operation = DocumentOperation::TransformLayerInViewport {
|
|
path,
|
|
transform: DAffine2::from_translation((x, y).into()).to_cols_array(),
|
|
};
|
|
responses.push_back(operation.into());
|
|
}
|
|
}
|
|
MoveSelectedLayersTo { path, insert_index } => {
|
|
responses.push_back(DocumentsMessage::CopySelectedLayers.into());
|
|
responses.push_back(DocumentMessage::DeleteSelectedLayers.into());
|
|
responses.push_back(DocumentsMessage::PasteLayers { path, insert_index }.into());
|
|
}
|
|
ReorderSelectedLayers(relative_position) => {
|
|
let all_layer_paths = self.all_layers_sorted();
|
|
let selected_layers = self.selected_layers_sorted();
|
|
if let Some(pivot) = match relative_position.signum() {
|
|
-1 => selected_layers.first(),
|
|
1 => selected_layers.last(),
|
|
_ => unreachable!(),
|
|
} {
|
|
if let Some(pos) = all_layer_paths.iter().position(|path| path == pivot) {
|
|
let max = all_layer_paths.len() as i64 - 1;
|
|
let insert_pos = (pos as i64 + relative_position as i64).clamp(0, max) as usize;
|
|
let insert = all_layer_paths.get(insert_pos);
|
|
if let Some(insert_path) = insert {
|
|
let (id, path) = insert_path.split_last().expect("Can't move the root folder");
|
|
if let Some(folder) = self.document.layer(path).ok().map(|layer| layer.as_folder().ok()).flatten() {
|
|
let selected: Vec<_> = selected_layers
|
|
.iter()
|
|
.filter(|layer| layer.starts_with(path) && layer.len() == path.len() + 1)
|
|
.map(|x| x.last().unwrap())
|
|
.collect();
|
|
let non_selected: Vec<_> = folder.layer_ids.iter().filter(|id| selected.iter().all(|x| x != id)).collect();
|
|
let offset = if relative_position < 0 || non_selected.is_empty() { 0 } else { 1 };
|
|
let fallback = offset * (non_selected.len());
|
|
let insert_index = non_selected.iter().position(|x| *x == id).map(|x| x + offset).unwrap_or(fallback) as isize;
|
|
responses.push_back(DocumentMessage::MoveSelectedLayersTo { path: path.to_vec(), insert_index }.into())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
FlipSelectedLayers(axis) => {
|
|
let scale = match axis {
|
|
FlipAxis::X => DVec2::new(-1., 1.),
|
|
FlipAxis::Y => DVec2::new(1., -1.),
|
|
};
|
|
if let Some([min, max]) = self.document.combined_viewport_bounding_box(self.selected_layers().map(|x| x.as_slice())) {
|
|
let center = (max + min) / 2.;
|
|
let bbox_trans = DAffine2::from_translation(-center);
|
|
for path in self.selected_layers() {
|
|
responses.push_back(
|
|
DocumentOperation::TransformLayerInScope {
|
|
path: path.clone(),
|
|
transform: DAffine2::from_scale(scale).to_cols_array(),
|
|
scope: bbox_trans.to_cols_array(),
|
|
}
|
|
.into(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
AlignSelectedLayers(axis, aggregate) => {
|
|
let (paths, boxes): (Vec<_>, Vec<_>) = self.selected_layers().filter_map(|path| self.document.viewport_bounding_box(path).ok()?.map(|b| (path, b))).unzip();
|
|
|
|
let axis = match axis {
|
|
AlignAxis::X => DVec2::X,
|
|
AlignAxis::Y => DVec2::Y,
|
|
};
|
|
let lerp = |bbox: &[DVec2; 2]| bbox[0].lerp(bbox[1], 0.5);
|
|
if let Some(combined_box) = self.document.combined_viewport_bounding_box(self.selected_layers().map(|x| x.as_slice())) {
|
|
let aggregated = match aggregate {
|
|
AlignAggregate::Min => combined_box[0],
|
|
AlignAggregate::Max => combined_box[1],
|
|
AlignAggregate::Center => lerp(&combined_box),
|
|
AlignAggregate::Average => boxes.iter().map(|b| lerp(b)).reduce(|a, b| a + b).map(|b| b / boxes.len() as f64).unwrap(),
|
|
};
|
|
for (path, bbox) in paths.into_iter().zip(boxes) {
|
|
let center = match aggregate {
|
|
AlignAggregate::Min => bbox[0],
|
|
AlignAggregate::Max => bbox[1],
|
|
_ => lerp(&bbox),
|
|
};
|
|
let translation = (aggregated - center) * axis;
|
|
responses.push_back(
|
|
DocumentOperation::TransformLayerInViewport {
|
|
path: path.clone(),
|
|
transform: DAffine2::from_translation(translation).to_cols_array(),
|
|
}
|
|
.into(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
RenameLayer(path, name) => responses.push_back(DocumentOperation::RenameLayer { path, name }.into()),
|
|
}
|
|
}
|
|
fn actions(&self) -> ActionList {
|
|
let mut common = actions!(DocumentMessageDiscriminant;
|
|
Undo,
|
|
SelectAllLayers,
|
|
DeselectAllLayers,
|
|
RenderDocument,
|
|
ExportDocument,
|
|
SaveDocument,
|
|
);
|
|
|
|
if self.layer_data.values().any(|data| data.selected) {
|
|
let select = actions!(DocumentMessageDiscriminant;
|
|
DeleteSelectedLayers,
|
|
DuplicateSelectedLayers,
|
|
NudgeSelectedLayers,
|
|
ReorderSelectedLayers,
|
|
);
|
|
common.extend(select);
|
|
}
|
|
common.extend(self.movement_handler.actions());
|
|
common
|
|
}
|
|
}
|