Group Selection for source editor

This commit is contained in:
euzu
2025-11-11 15:55:04 +01:00
parent 35330a24cc
commit f15a8edf58
8 changed files with 243 additions and 66 deletions
-1
View File
@@ -520,5 +520,4 @@
"keys": ["Accept"],
"path": "m18 7-1.41-1.41-6.34 6.34 1.41 1.41zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41 6 19l1.41-1.41L1.83 12z"
}
]
+3 -1
View File
@@ -363,8 +363,10 @@
--source-editor-line-color: #ffffff;
--source-editor-pending-line-color: yellow;
}
--selection-area-border-color: rgba(0, 120, 215, 0.9);
--selection-area-background-color: rgba(0, 120, 215, 0.12);
}
body[data-theme="bright"] {
--primary-color: rgba(10, 120, 255, 0.95);
@@ -79,6 +79,13 @@
&__connections {
}
&__selection-rect {
border: 1px dashed var(--selection-area-border-color);
background: var(--selection-area-background-color);
z-index: 9999;
pointer-events: none;
}
}
.graph-paper-advanced {
@@ -94,6 +101,11 @@
cursor: grabbing;
}
.selection_mode * {
cursor: crosshair !important;
}
.clickable {
cursor: pointer;
}
@@ -194,7 +194,7 @@ $port-size: 10px;
justify-content: flex-end;
width: 100%;
&-label {
color: var(--text-color);
color: var(--modest-text-color);
font-size: 0.7rem;
font-weight: bold;
width: 50%;
@@ -4,6 +4,11 @@ use serde::{Deserialize, Serialize};
use yew::{Callback, UseStateHandle};
use shared::model::{ConfigInputDto, ConfigTargetDto, InputType, TargetOutputDto};
pub const BLOCK_WIDTH: f32 = 100.0;
pub const BLOCK_HEIGHT: f32 = 50.0;
pub const BLOCK_HEADER_HEIGHT: f32 = 12.0;
pub const BLOCK_PORT_HEIGHT: f32 = 10.0;
// ----------------- Data Models -----------------
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
pub enum BlockType {
@@ -98,13 +103,50 @@ pub struct Block {
pub instance: BlockInstance,
}
impl Block {
/// Liefert die Bounding-Box des Blocks unter Berücksichtigung der Canvas-Verschiebung.
pub fn bounds(&self, canvas_offset: (f32, f32)) -> (f32, f32, f32, f32) {
let (ox, oy) = canvas_offset;
let bx = self.position.0 + ox;
let by = self.position.1 + oy;
let b_left = bx;
let b_top = by;
let b_right = bx + BLOCK_WIDTH;
let b_bottom = by + (BLOCK_HEIGHT + BLOCK_HEADER_HEIGHT + BLOCK_PORT_HEIGHT);
(b_left, b_top, b_right, b_bottom)
}
/// Prüft, ob der Block von einem Auswahlrechteck getroffen wird.
pub fn intersects_rect(
&self,
rect_start: (f32, f32),
rect_end: (f32, f32),
canvas_offset: (f32, f32),
) -> bool {
let (b_left, b_top, b_right, b_bottom) = self.bounds(canvas_offset);
// Rechteck normalisieren
let (x1, y1) = rect_start;
let (x2, y2) = rect_end;
let r_left = x1.min(x2);
let r_top = y1.min(y2);
let r_right = x1.max(x2);
let r_bottom = y1.max(y2);
b_right >= r_left &&
b_left <= r_right &&
b_bottom >= r_top &&
b_top <= r_bottom
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Connection {
pub from: BlockId,
pub to: BlockId,
}
#[derive(Clone, Copy, PartialEq)]
pub enum PortStatus {
Valid,
@@ -112,8 +154,6 @@ pub enum PortStatus {
Inactive
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BlockInstance {
Input(Rc<ConfigInputDto>),
@@ -2,14 +2,11 @@ use std::rc::Rc;
use web_sys::{HtmlElement, MouseEvent};
use yew::prelude::*;
use shared::model::{ConfigInputDto, ConfigTargetDto, HdHomeRunTargetOutputDto, M3uTargetOutputDto, StrmTargetOutputDto, TargetOutputDto, XtreamTargetOutputDto};
use crate::app::components::{can_connect, Block, BlockId, BlockInstance, BlockType, BlockView, Connection, EditMode, InputRow, PortStatus, SourceEditorContext, SourceEditorForm, SourceEditorSidebar};
use crate::app::components::{can_connect, Block, BlockId, BlockInstance, BlockType, BlockView, Connection, EditMode, InputRow, PortStatus, SourceEditorContext, SourceEditorForm, SourceEditorSidebar, BLOCK_HEADER_HEIGHT, BLOCK_HEIGHT, BLOCK_PORT_HEIGHT, BLOCK_WIDTH};
use crate::app::{PlaylistContext};
use crate::app::components::source_editor::layout::layout;
const BLOCK_WIDTH: f32 = 100.0;
const BLOCK_HEIGHT: f32 = 50.0;
const BLOCK_HEADER_HEIGHT: f32 = 12.0;
const BLOCK_PORT_HEIGHT: f32 = 10.0;
const BLOCK_MIDDLE_Y: f32 = (BLOCK_HEIGHT + BLOCK_HEADER_HEIGHT + BLOCK_PORT_HEIGHT) / 2.0;
fn create_instance(block_type: BlockType) -> BlockInstance {
@@ -27,6 +24,7 @@ fn create_instance(block_type: BlockType) -> BlockInstance {
}
}
// ----------------- Component -----------------
#[function_component]
pub fn SourceEditor() -> Html {
@@ -45,6 +43,14 @@ pub fn SourceEditor() -> Html {
let dragging_block = use_state(|| None);
let drag_offset = use_state(|| (0.0f32, 0.0f32));
let sidebar_drag_offset = use_state(|| (0.0f32, 0.0f32));
let dragging_group = use_state(|| Vec::<BlockId>::new());
// Selection / marquee states (neu)
let selection_rect = use_state(|| None as Option<(f32, f32, f32, f32)>); // x,y,w,h relative to canvas
let selection_start = use_state(|| (0.0f32, 0.0f32));
let selected_blocks = use_state(|| Vec::<BlockId>::new());
let group_initial_positions = use_state(|| Vec::<(BlockId, (f32, f32))>::new());
let group_anchor_mouse = use_state(|| (0.0f32, 0.0f32));
// Pending line for live connection
let pending_line = use_state(|| None);
@@ -54,6 +60,7 @@ pub fn SourceEditor() -> Html {
let delete_mode = use_state(|| false);
let cursor_grabbing = use_state(|| false);
let is_selecting = use_state(|| false);
{
let playlists = playlist_ctx.clone();
@@ -95,14 +102,10 @@ pub fn SourceEditor() -> Html {
};
gen_blocks.push(block);
input_ids.iter().for_each(|input_id| gen_connections.push(Connection { from: *input_id, to: target_id }));
// Test connections
// gen_connections.push(Connection {from: 1, to: 14});
// gen_connections.push(Connection {from: 17, to: 2});
for output in &target_config.output {
let (block_instance, block_type) = match output {
let (block_instance, block_type) = match output {
TargetOutputDto::Xtream(dto) => (BlockInstance::Output(Rc::new(TargetOutputDto::Xtream(dto.clone()))), BlockType::OutputXtream),
TargetOutputDto::M3u(dto) => (BlockInstance::Output(Rc::new(TargetOutputDto::M3u(dto.clone()))), BlockType::OutputM3u),
TargetOutputDto::Strm(dto) => (BlockInstance::Output(Rc::new(TargetOutputDto::Strm(dto.clone()))), BlockType::OutputStrm),
@@ -133,11 +136,30 @@ pub fn SourceEditor() -> Html {
});
}
let handle_layout = {
let blocks_clone = blocks.clone();
let connections_clone = connections.clone();
Callback::from(move |_| {
let mut new_blocks = (*blocks_clone).clone();
let new_connections = (*connections_clone).clone();
layout(&mut *new_blocks, &new_connections);
blocks_clone.set(new_blocks);
connections_clone.set(new_connections);
})
};
// ----------------- Drag Start from Sidebar -----------------
let handle_drag_start = {
let sidebar_drag_offset = sidebar_drag_offset.clone();
let cursor_grabbing = cursor_grabbing.clone();
let is_selecting = is_selecting.clone();
let selected_blocks = selected_blocks.clone();
let selection_rect = selection_rect.clone();
Callback::from(move |e: DragEvent| {
is_selecting.set(false);
selected_blocks.set(Vec::new());
selection_rect.set(None);
if let Some(target) = e.target_dyn_into::<HtmlElement>() {
let block_type = target.get_attribute("data-block-type").unwrap_or_default();
e.data_transfer().unwrap().set_data("text/plain", &block_type).unwrap();
@@ -149,7 +171,7 @@ pub fn SourceEditor() -> Html {
cursor_grabbing.set(true);
}
}
)};
)};
// ----------------- Drop on Canvas -----------------
let handle_drop = {
@@ -229,45 +251,32 @@ pub fn SourceEditor() -> Html {
current_blocks.get(from_id as usize -1),
current_blocks.get(to_id as usize -1),
) {
// ✅ Check connection rules before adding
// Check connection rules before adding
if can_connect(from_block, to_block, &connections, &blocks) {
let mut current_connections = (*connections).clone();
current_connections.push(Connection { from: from_id, to: to_id });
connections.set(current_connections);
//} else {
// web_sys::console::log_1(
// &format!(
// "Connection from {:?} to {:?} not allowed",
// from_block.block_type, to_block.block_type
// )
// .into(),
// );
}
}
}
}
// // Snap pending line end to target port
// if let Some(to_block) = (*blocks).iter().find(|b| b.id == to_id) {
// let x = to_block.position.0;
// let y = to_block.position.1 + BLOCK_MIDDLE_Y;
// if let Some(((from_x, from_y), _)) = *pending_line {
// pending_line.set(Some(((from_x, from_y), (x, y))));
// }
// }
pending_connection.set(None);
pending_line.set(None);
})
};
// ----------------- Drag block logic -----------------
// ----------------- Drag block logic (angepasst für group-drag) -----------------
let handle_block_mouse_down = {
let dragging_block = dragging_block.clone();
let dragging_group = dragging_group.clone();
let drag_offset = drag_offset.clone();
let canvas_ref = canvas_ref.clone();
let blocks = blocks.clone();
let cursor_grabbing = cursor_grabbing.clone();
let selected_blocks = selected_blocks.clone();
let group_initial_positions = group_initial_positions.clone();
let group_anchor_mouse = group_anchor_mouse.clone();
Callback::from(move |(block_id, e): (BlockId, MouseEvent)| {
e.prevent_default();
@@ -277,6 +286,25 @@ pub fn SourceEditor() -> Html {
let mouse_x = e.client_x() as f32 - rect.left() as f32;
let mouse_y = e.client_y() as f32 - rect.top() as f32;
if let Some(block) = (*blocks).get(block_id as usize -1) {
// if block is not in selection list, than only select this block
if !(*selected_blocks).contains(&block_id) {
selected_blocks.set(vec![block_id]);
}
// For Group-Drag: save all blocks
let mut initials = vec![];
let mut group_ids = vec![];
for id in selected_blocks.iter() {
if let Some(b) = (*blocks).get(*id as usize -1) {
initials.push((*id, b.position));
group_ids.push(*id);
}
}
dragging_group.set(group_ids);
group_initial_positions.set(initials);
group_anchor_mouse.set((mouse_x, mouse_y));
// Single drag offset
drag_offset.set((mouse_x - block.position.0, mouse_y - block.position.1));
dragging_block.set(Some(block_id));
}
@@ -284,25 +312,51 @@ pub fn SourceEditor() -> Html {
})
};
// ----------------- Canvas mouse down (start panning if clicking the canvas itself) -----------------
// ----------------- Canvas mouse down (start panning or marquee selection) -----------------
let handle_canvas_mouse_down = {
let is_panning = is_panning.clone();
let is_selecting = is_selecting.clone();
let pan_start = pan_start.clone();
let canvas_ref = canvas_ref.clone();
let cursor_grabbing = cursor_grabbing.clone();
let selection_rect = selection_rect.clone();
let selection_start = selection_start.clone();
let selected_blocks = selected_blocks.clone();
Callback::from(move |e: MouseEvent| {
// Start panning with right mouse button if the click target is exactly the canvas element.
if e.button() == 2 {
if let Some(target) = e.target_dyn_into::<web_sys::Element>() {
if let Some(canvas) = canvas_ref.cast::<web_sys::Element>() {
let tag = target.tag_name().to_lowercase();
// accept both the canvas <div> itself and its <svg> background as valid pan targets
if target.is_same_node(Some(&canvas)) || tag == "svg" {
e.prevent_default();
let mouse_button = e.button();
if mouse_button != 0 && mouse_button != 2 {
return;
}
if let Some(target) = e.target_dyn_into::<web_sys::Element>() {
if let Some(canvas) = canvas_ref.cast::<web_sys::Element>() {
let tag = target.tag_name().to_lowercase();
if target.is_same_node(Some(&canvas)) || tag == "svg" {
e.prevent_default();
if e.button() == 0 { // left button
if *is_selecting {
is_selecting.set(false);
} else {
// selection area mode
is_selecting.set(true);
if let Some(rect_el) = canvas_ref.cast::<HtmlElement>() {
let rect = rect_el.get_bounding_client_rect();
let mouse_x = e.client_x() as f32 - rect.left() as f32;
let mouse_y = e.client_y() as f32 - rect.top() as f32;
selection_start.set((mouse_x, mouse_y));
selection_rect.set(Some((mouse_x, mouse_y, 0.0, 0.0)));
selected_blocks.set(vec![]);
}
}
} else if e.button() == 2 { // right button
is_selecting.set(false);
selected_blocks.set(vec![]);
// Right button panning
cursor_grabbing.set(true);
is_panning.set(true);
pan_start.set((e.client_x() as f32, e.client_y() as f32));
return;
}
}
}
@@ -310,26 +364,32 @@ pub fn SourceEditor() -> Html {
})
};
// ----------------- Mouse move for both pending line, block drag, and canvas panning -----------------
// ----------------- Mouse move for pending line, block drag, canvas panning, marquee update -----------------
let handle_canvas_mouse_move = {
let pending_line = pending_line.clone();
let dragging_block = dragging_block.clone();
let drag_offset = drag_offset.clone();
let dragging_group = dragging_group.clone();
let blocks = blocks.clone();
let canvas_ref = canvas_ref.clone();
let is_panning = is_panning.clone();
let is_selecting = is_selecting.clone();
let pan_start = pan_start.clone();
let canvas_offset = canvas_offset.clone();
let selection_rect = selection_rect.clone();
let selection_start = selection_start.clone();
let selected_blocks = selected_blocks.clone();
let group_initial_positions = group_initial_positions.clone();
let group_anchor_mouse = group_anchor_mouse.clone();
Callback::from(move |e: MouseEvent| {
// If currently panning the canvas, update the canvas offset and exit early.
// Panning (right mouse)
if *is_panning {
let (start_x, start_y) = *pan_start;
let dx = e.client_x() as f32 - start_x;
let dy = e.client_y() as f32 - start_y;
let (ox, oy) = *canvas_offset;
canvas_offset.set((ox + dx, oy + dy));
// Update pan start for smooth continuous panning
pan_start.set((e.client_x() as f32, e.client_y() as f32));
return;
}
@@ -339,7 +399,7 @@ pub fn SourceEditor() -> Html {
let mouse_x = e.client_x() as f32 - rect.left() as f32;
let mouse_y = e.client_y() as f32 - rect.top() as f32;
// Update pending line (snap to nearest port if close)
// Pending line snap
if let Some(((from_x, from_y), _)) = *pending_line {
let mut snapped = (mouse_x, mouse_y);
let (ox, oy) = *canvas_offset;
@@ -356,29 +416,68 @@ pub fn SourceEditor() -> Html {
pending_line.set(Some(((from_x, from_y), snapped)));
}
// Update dragging block
if *is_selecting {
// compute normalized rect
let (start_x, start_y) = *selection_start;
let x = start_x.min(mouse_x);
let y = start_y.min(mouse_y);
let w = (mouse_x - start_x).abs();
let h = (mouse_y - start_y).abs();
selection_rect.set(Some((x, y, w, h)));
// Update selected_blocks: block intersects rect?
let sel = (*blocks).iter().filter(|b| b.intersects_rect((x, y), (x + w, y + h), *canvas_offset))
.map(|b| b.id).collect();
selected_blocks.set(sel);
}
// Update dragging block (Single or Group)
if let Some(block_id) = *dragging_block {
let (offset_x, offset_y) = *drag_offset;
let mut current_blocks = (*blocks).clone();
if let Some(block) = current_blocks.get_mut(block_id as usize -1) {
block.position = (mouse_x - offset_x, mouse_y - offset_y);
// If the dragged block is member of a selection -> move group
if (*dragging_group).contains(&block_id) && !(*group_initial_positions).is_empty() {
let (anchor_x, anchor_y) = *group_anchor_mouse;
let dx = mouse_x - anchor_x;
let dy = mouse_y - anchor_y;
let mut current_blocks = (*blocks).clone();
for (id, (ix, iy)) in (*group_initial_positions).iter() {
if let Some(b) = current_blocks.get_mut(*id as usize -1) {
b.position = (ix + dx, iy + dy);
}
}
blocks.set(current_blocks);
} else {
// Single drag block
let (offset_x, offset_y) = *drag_offset;
let mut current_blocks = (*blocks).clone();
if let Some(block) = current_blocks.get_mut(block_id as usize -1) {
block.position = (mouse_x - offset_x, mouse_y - offset_y);
}
blocks.set(current_blocks);
}
blocks.set(current_blocks);
}
}
})
};
let handle_canvas_mouse_up = {
let dragging_block = dragging_block.clone();
let dragging_group = dragging_group.clone();
let is_panning = is_panning.clone();
let is_selecting = is_selecting.clone();
let cursor_grabbing = cursor_grabbing.clone();
let selection_rect = selection_rect.clone();
let group_initial_positions = group_initial_positions.clone();
Callback::from(move |_e: MouseEvent| {
// Stop any block dragging and stop panning
dragging_block.set(None);
dragging_group.set(vec![]);
is_panning.set(false);
cursor_grabbing.set(false);
// End selection rectangle (leave selected_blocks as-is but hide rect)
is_selecting.set(false);
selection_rect.set(None);
group_initial_positions.set(vec![]);
})
};
@@ -398,10 +497,13 @@ pub fn SourceEditor() -> Html {
Callback::from(move |_| delete_mode.set(!*delete_mode))
};
// Deleting a Block means updating the following block ids,
// because a BlockId is the index in the blocks list.
let handle_delete_block = {
let blocks = blocks.clone();
let connections = connections.clone();
let next_id = next_id.clone();
let selected_blocks = selected_blocks.clone();
Callback::from(move |block_id: BlockId| {
let mut current_blocks = (*blocks).clone();
current_blocks.retain(|b| b.id != block_id);
@@ -415,7 +517,7 @@ pub fn SourceEditor() -> Html {
}
}
// Schritt 2: Connections anpassen
// udpate connection ids
for conn in &mut current_connections {
if conn.from >= block_id {
conn.from -= 1;
@@ -429,6 +531,9 @@ pub fn SourceEditor() -> Html {
next_id.set(max_id+1);
blocks.set(current_blocks);
connections.set(current_connections);
// Ausgewählte Blocks ggf. aufräumen
selected_blocks.set((*selected_blocks).iter().filter(|&&id| id != block_id).cloned().collect());
})
};
@@ -461,9 +566,9 @@ pub fn SourceEditor() -> Html {
Callback::<(BlockId, BlockInstance)>::from(move |(block_id, instance): (BlockId, BlockInstance)| {
let mut current_blocks = (*blocks).clone();
if let Some(block) = current_blocks.get_mut(block_id as usize -1) {
block.instance = instance;
}
blocks.set(current_blocks);
block.instance = instance;
}
blocks.set(current_blocks);
})
};
@@ -489,6 +594,7 @@ pub fn SourceEditor() -> Html {
EditMode::Active(ref b) => b.id,
};
let grabbed = *cursor_grabbing;
let selection_mode = *is_selecting;
// ----------------- Render -----------------
html! {
<ContextProvider<SourceEditorContext> context={editor_context}>
@@ -498,12 +604,13 @@ pub fn SourceEditor() -> Html {
delete_mode={*delete_mode}
on_drag_start={handle_drag_start.clone()}
on_toggle_delete={handle_toggle_delete_mode.clone()}
on_layout={handle_layout.clone()}
/>
// Canvas
<div class="tp__source-editor__canvas-wrapper">
<div
ref={canvas_ref.clone()}
class={classes!("tp__source-editor__canvas", "graph-paper-advanced", if grabbed {"grabbed"} else {""})}
class={classes!("tp__source-editor__canvas", "graph-paper-advanced", if grabbed {"grabbed"} else {""}, if selection_mode {"selection_mode"} else {""})}
ondrop={handle_drop.clone()}
ondragend={handle_drag_end.clone()}
ondragover={handle_drag_over.clone()}
@@ -513,7 +620,7 @@ pub fn SourceEditor() -> Html {
oncontextmenu={handle_canvas_right_click.clone()}>
// SVG for connections
<svg class={classes!("tp__source-editor__connections", if grabbed {"grabbed"} else {""})}>
<svg class={classes!("tp__source-editor__connections", if grabbed {"grabbed"} else {""}, if selection_mode {"selection_mode"} else {""})}>
{ for (*connections).iter().filter_map(|c| {
let from_block = (*blocks).iter().find(|b| b.id == c.from)?;
let to_block = (*blocks).iter().find(|b| b.id == c.to)?;
@@ -555,7 +662,7 @@ pub fn SourceEditor() -> Html {
})
}) }
// Pending line (straight, yellow)
// Pending line straight
{ if let Some(((x1, y1), (x2, y2))) = *pending_line {
html! {
<line
@@ -568,17 +675,30 @@ pub fn SourceEditor() -> Html {
} else { html!{} } }
</svg>
// Render blocks
// Selection rectangle overlay
{
if let Some((x, y, w, h)) = *selection_rect {
let style = format!("position:absolute; left:{}px; top:{}px; width:{}px; height:{}px; pointer-events:none;", x, y, w, h);
html! {
<div class="tp__source-editor__selection-rect" style={style}></div>
}
} else {
html! {}
}
}
// Render blocks with canvas offset
{ for (*blocks).iter().map(|b|{
let port_status = get_port_status(b);
let (ox, oy) = *canvas_offset; // Apply virtual offset to each block
let mut shifted_block = b.clone();
let block_id = shifted_block.id;
shifted_block.position = (b.position.0 + ox, b.position.1 + oy);
let is_block_selected = (*selected_blocks).contains(&block_id);
html! {
<BlockView
block={shifted_block}
selected={selected_block_id == block_id}
selected={selected_block_id == block_id || is_block_selected}
delete_mode={*delete_mode}
delete_block={handle_delete_block.clone()}
port_status={port_status}
@@ -595,3 +715,4 @@ pub fn SourceEditor() -> Html {
</ContextProvider<SourceEditorContext>>
}
}
@@ -246,6 +246,6 @@ pub fn layout(blocks: &mut [Block], connections: &[Connection]) {
let block = &mut blocks[*block_id as usize -1];
block.position = (CANVAS_OFFSET, final_y);
last_input_y = final_y + BLOCK_HEIGHT + Y_GAP;
last_input_y = final_y + BLOCK_HEIGHT + Y_GAP*2.0;
}
}
@@ -39,6 +39,8 @@ pub struct SourceEditorSidebarProps {
pub on_toggle_delete: Callback<(String, MouseEvent)>,
#[prop_or_default]
pub on_drag_start: Callback<DragEvent>,
#[prop_or_default]
pub on_layout: Callback<(String, MouseEvent)>,
}
#[function_component]
@@ -49,6 +51,7 @@ pub fn SourceEditorSidebar(props: &SourceEditorSidebarProps) -> Html {
// Sidebar
<div class="tp__source-editor__sidebar">
<div class="tp__source-editor__sidebar-actions">
<IconButton name="layout" icon="Nodes" onclick={props.on_layout.clone()} />
// Delete mode toggle button
<IconButton class={if props.delete_mode {"tp__source-editor__sidebar-actions-active"} else {""} } name="toggle_delete" icon="Delete" onclick={props.on_toggle_delete.clone()} />
</div>