this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: image rect selection, drag, resize, and z-order

- Click to select a sidecar image rect (blue border, grab cursor, × delete, edge handles)
- Click away (SVG background) to deselect
- Drag selected rect body to move it; drag edge handles to resize
- Newly drawn image rects auto-select after creation
- Selected rect always renders on top of intersecting rects
- Eat post-drag click via capture-phase one-shot listener to prevent spurious deselection on fast drags or when releasing over another rect
- Edge handle onclick stops propagation so drag-end clicks don't deselect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+281 -7
+281 -7
crates/tala/src/main.rs
··· 288 288 image_boxes: Vec<[f64; 4]>, 289 289 } 290 290 291 + #[derive(Clone, Copy, PartialEq, Debug)] 292 + enum DragEdge { 293 + Top, 294 + Bottom, 295 + Left, 296 + Right, 297 + } 298 + 299 + 300 + fn active_card_key(source: &str, active_idx: usize) -> String { 301 + let segs = make_segments(source); 302 + let ai = active_idx.min(segs.len().saturating_sub(1)); 303 + segs[..=ai] 304 + .iter() 305 + .filter(|s| s.kind == SegKind::Card) 306 + .count() 307 + .saturating_sub(1) 308 + .to_string() 309 + } 310 + 311 + fn commit_sidecar_rects(rects: Vec<RectEntry>, card_key: &str) { 312 + let typ_path = cards_path(); 313 + if let Ok(mut sc) = Sidecar::load_or_empty_for(&typ_path) { 314 + if let Some(CardSchedule::Cloze { rects: r, .. }) = sc.cards.get_mut(card_key) { 315 + *r = rects; 316 + let _ = sc.save_for(&typ_path); 317 + } 318 + } 319 + } 320 + 321 + fn delete_sidecar_rect(idx: usize, source: &str, active_idx: usize) -> Option<Vec<RectEntry>> { 322 + let card_key = active_card_key(source, active_idx); 323 + let typ_path = cards_path(); 324 + let mut sc = Sidecar::load_or_empty_for(&typ_path).ok()?; 325 + if let Some(CardSchedule::Cloze { rects, .. }) = sc.cards.get_mut(&card_key) { 326 + if idx < rects.len() { 327 + rects.remove(idx); 328 + let updated = rects.clone(); 329 + sc.save_for(&typ_path).ok()?; 330 + return Some(updated); 331 + } 332 + } 333 + None 334 + } 335 + 336 + async fn edge_drag_task( 337 + rect_idx: usize, 338 + edge: DragEdge, 339 + img_box: [f64; 4], 340 + mut rects_sig: Signal<Vec<RectEntry>>, 341 + mut drag_active: Signal<bool>, 342 + source: Signal<String>, 343 + active_idx: Signal<usize>, 344 + ) { 345 + let [bx, by, bw, bh] = img_box; 346 + let mut eval = document::eval( 347 + r#" 348 + var svg = document.querySelector('.card-row.active .cloze-overlay'); 349 + var bb = svg.getBoundingClientRect(); 350 + function onMove(e) { 351 + dioxus.send([0.0, (e.clientX-bb.left)/bb.width, (e.clientY-bb.top)/bb.height]); 352 + } 353 + function onUp() { 354 + document.removeEventListener('mousemove', onMove); 355 + document.removeEventListener('mouseup', onUp); 356 + document.addEventListener('click', function(e){ e.stopPropagation(); }, { capture: true, once: true }); 357 + dioxus.send([1.0, 0.0, 0.0]); 358 + } 359 + document.addEventListener('mousemove', onMove); 360 + document.addEventListener('mouseup', onUp); 361 + "#, 362 + ); 363 + loop { 364 + match eval.recv::<[f64; 3]>().await { 365 + Ok([kind, nx, ny]) => { 366 + if kind < 0.5 { 367 + let local_x = ((nx - bx) / bw) as f32; 368 + let local_y = ((ny - by) / bh) as f32; 369 + let mut rects = rects_sig.read().clone(); 370 + if let Some(r) = rects.get_mut(rect_idx) { 371 + match edge { 372 + DragEdge::Top => { 373 + let bot = r.rect[1] + r.rect[3]; 374 + r.rect[1] = local_y.clamp(0.0, bot - 0.01); 375 + r.rect[3] = bot - r.rect[1]; 376 + } 377 + DragEdge::Bottom => { 378 + r.rect[3] = (local_y - r.rect[1]).max(0.01); 379 + } 380 + DragEdge::Left => { 381 + let right = r.rect[0] + r.rect[2]; 382 + r.rect[0] = local_x.clamp(0.0, right - 0.01); 383 + r.rect[2] = right - r.rect[0]; 384 + } 385 + DragEdge::Right => { 386 + r.rect[2] = (local_x - r.rect[0]).max(0.01); 387 + } 388 + } 389 + rects_sig.set(rects); 390 + } 391 + } else { 392 + drag_active.set(false); 393 + let cur = source.peek().clone(); 394 + let card_key = active_card_key(&cur, *active_idx.peek()); 395 + commit_sidecar_rects(rects_sig.read().clone(), &card_key); 396 + break; 397 + } 398 + } 399 + Err(_) => { 400 + drag_active.set(false); 401 + break; 402 + } 403 + } 404 + } 405 + } 406 + 407 + async fn move_drag_task( 408 + rect_idx: usize, 409 + img_box: [f64; 4], 410 + mut rects_sig: Signal<Vec<RectEntry>>, 411 + mut drag_active: Signal<bool>, 412 + source: Signal<String>, 413 + active_idx: Signal<usize>, 414 + ) { 415 + let [_bx, _by, bw, bh] = img_box; 416 + let mut eval = document::eval( 417 + r#" 418 + var svg = document.querySelector('.card-row.active .cloze-overlay'); 419 + var bb = svg.getBoundingClientRect(); 420 + function onMove(e) { 421 + dioxus.send([0.0, (e.clientX-bb.left)/bb.width, (e.clientY-bb.top)/bb.height]); 422 + } 423 + function onUp() { 424 + document.removeEventListener('mousemove', onMove); 425 + document.removeEventListener('mouseup', onUp); 426 + document.addEventListener('click', function(e){ e.stopPropagation(); }, { capture: true, once: true }); 427 + dioxus.send([1.0, 0.0, 0.0]); 428 + } 429 + document.addEventListener('mousemove', onMove); 430 + document.addEventListener('mouseup', onUp); 431 + "#, 432 + ); 433 + let mut last: Option<(f64, f64)> = None; 434 + loop { 435 + match eval.recv::<[f64; 3]>().await { 436 + Ok([kind, nx, ny]) => { 437 + if kind < 0.5 { 438 + if let Some((lx, ly)) = last { 439 + let dx = ((nx - lx) / bw) as f32; 440 + let dy = ((ny - ly) / bh) as f32; 441 + let mut rects = rects_sig.read().clone(); 442 + if let Some(r) = rects.get_mut(rect_idx) { 443 + r.rect[0] = (r.rect[0] + dx).clamp(0.0, 1.0 - r.rect[2]); 444 + r.rect[1] = (r.rect[1] + dy).clamp(0.0, 1.0 - r.rect[3]); 445 + rects_sig.set(rects); 446 + } 447 + } 448 + last = Some((nx, ny)); 449 + } else { 450 + drag_active.set(false); 451 + let cur = source.peek().clone(); 452 + let card_key = active_card_key(&cur, *active_idx.peek()); 453 + commit_sidecar_rects(rects_sig.read().clone(), &card_key); 454 + break; 455 + } 456 + } 457 + Err(_) => { 458 + drag_active.set(false); 459 + break; 460 + } 461 + } 462 + } 463 + } 464 + 291 465 #[component] 292 466 fn Editor() -> Element { 293 467 // ── Source & save ───────────────────────────────────────────────────────── ··· 342 516 let mut drawn_boxes = use_signal(Vec::<[f64; 4]>::new); 343 517 let cap_dims = use_signal(|| [1.0f64, 1.0, 1.0]); 344 518 let mut insert_error = use_signal(|| Option::<String>::None); 519 + let mut selected_rect_sig = use_signal(|| Option::<usize>::None); 520 + let mut edge_drag_active = use_signal(|| false); 345 521 346 522 // Update glyph signals when the active card or its render result changes. 347 523 use_effect(move || { ··· 496 672 let active_img_boxes = image_boxes_sig.read().clone(); 497 673 let active_sidecar_rects = sidecar_rects_sig.read().clone(); 498 674 let active_img_names = extract_img_names(&active_fragment); 675 + let selected_rect = *selected_rect_sig.read(); 499 676 500 677 rsx! { 501 678 div { id: "editor", ··· 627 804 svg { 628 805 class: "cloze-overlay", 629 806 view_box: "{vb}", 807 + onclick: move |_| { selected_rect_sig.set(None); }, 630 808 for rect in &card_blank_rects { 631 809 rect { 632 810 x: "{rect[0] * iw}", y: "{rect[1] * ih}", ··· 637 815 } 638 816 } 639 817 if is_active { 640 - for sr in &active_sidecar_rects { 818 + // Render selected rect last so it paints above intersecting rects. 819 + for sri in (0..active_sidecar_rects.len()).filter(|&i| Some(i) != selected_rect).chain(selected_rect.into_iter().filter(|&i| i < active_sidecar_rects.len())) { 641 820 { 821 + let sr = active_sidecar_rects[sri].clone(); 642 822 let img_idx = active_img_names.iter() 643 823 .position(|n| n == &sr.src) 644 824 .unwrap_or(0); ··· 647 827 let py = (ib[1] + sr.rect[1] as f64 * ib[3]) * ih; 648 828 let pw = sr.rect[2] as f64 * ib[2] * iw; 649 829 let ph = sr.rect[3] as f64 * ib[3] * ih; 830 + let is_sel = selected_rect == Some(sri); 831 + let [bx, by, bw, bh] = ib; 832 + let rid = sr.id.clone(); 650 833 rsx! { 651 - rect { 652 - x: "{px}", y: "{py}", 653 - width: "{pw}", height: "{ph}", 654 - fill: "rgba(255,220,50,0.35)", 655 - stroke: "rgb(200,150,0)", 656 - stroke_width: "1.5", rx: "3", 834 + g { key: "{rid}", 835 + style: "pointer-events: all;", 836 + rect { 837 + x: "{px}", y: "{py}", 838 + width: "{pw}", height: "{ph}", 839 + fill: "rgba(255,220,50,0.35)", 840 + stroke: if is_sel { "rgb(60,120,220)" } else { "rgb(200,150,0)" }, 841 + stroke_width: if is_sel { "2.5" } else { "1.5" }, 842 + rx: "3", 843 + style: if is_sel { "cursor: grab;" } else { "cursor: pointer;" }, 844 + onclick: move |e| { 845 + e.stop_propagation(); 846 + selected_rect_sig.set(Some(sri)); 847 + }, 848 + onmousedown: move |e| { 849 + if is_sel { 850 + e.stop_propagation(); 851 + edge_drag_active.set(true); 852 + spawn(move_drag_task(sri, [bx, by, bw, bh], sidecar_rects_sig, edge_drag_active, source, active_idx)); 853 + } 854 + }, 855 + } 856 + if is_sel { 857 + text { 858 + x: "{px + pw - 2.0}", y: "{py + 13.0}", 859 + font_size: "13", 860 + fill: "rgb(180,30,30)", 861 + text_anchor: "end", 862 + style: "cursor: pointer; user-select: none; font-weight: bold;", 863 + onclick: move |e| { 864 + e.stop_propagation(); 865 + if let Some(updated) = delete_sidecar_rect(sri, &source.peek(), *active_idx.peek()) { 866 + sidecar_rects_sig.set(updated); 867 + } 868 + selected_rect_sig.set(None); 869 + }, 870 + "×" 871 + } 872 + // Top edge handle 873 + rect { 874 + x: "{px + pw * 0.1}", y: "{py - 5.0}", 875 + width: "{pw * 0.8}", height: "10", 876 + fill: "rgba(70,130,220,0.2)", 877 + stroke: "rgb(70,130,220)", stroke_width: "1", 878 + rx: "2", style: "cursor: ns-resize;", 879 + onclick: move |e| { e.stop_propagation(); }, 880 + onmousedown: move |e| { 881 + e.stop_propagation(); 882 + edge_drag_active.set(true); 883 + spawn(edge_drag_task(sri, DragEdge::Top, [bx, by, bw, bh], sidecar_rects_sig, edge_drag_active, source, active_idx)); 884 + }, 885 + } 886 + // Bottom edge handle 887 + rect { 888 + x: "{px + pw * 0.1}", y: "{py + ph - 5.0}", 889 + width: "{pw * 0.8}", height: "10", 890 + fill: "rgba(70,130,220,0.2)", 891 + stroke: "rgb(70,130,220)", stroke_width: "1", 892 + rx: "2", style: "cursor: ns-resize;", 893 + onclick: move |e| { e.stop_propagation(); }, 894 + onmousedown: move |e| { 895 + e.stop_propagation(); 896 + edge_drag_active.set(true); 897 + spawn(edge_drag_task(sri, DragEdge::Bottom, [bx, by, bw, bh], sidecar_rects_sig, edge_drag_active, source, active_idx)); 898 + }, 899 + } 900 + // Left edge handle 901 + rect { 902 + x: "{px - 5.0}", y: "{py + ph * 0.1}", 903 + width: "10", height: "{ph * 0.8}", 904 + fill: "rgba(70,130,220,0.2)", 905 + stroke: "rgb(70,130,220)", stroke_width: "1", 906 + rx: "2", style: "cursor: ew-resize;", 907 + onclick: move |e| { e.stop_propagation(); }, 908 + onmousedown: move |e| { 909 + e.stop_propagation(); 910 + edge_drag_active.set(true); 911 + spawn(edge_drag_task(sri, DragEdge::Left, [bx, by, bw, bh], sidecar_rects_sig, edge_drag_active, source, active_idx)); 912 + }, 913 + } 914 + // Right edge handle 915 + rect { 916 + x: "{px + pw - 5.0}", y: "{py + ph * 0.1}", 917 + width: "10", height: "{ph * 0.8}", 918 + fill: "rgba(70,130,220,0.2)", 919 + stroke: "rgb(70,130,220)", stroke_width: "1", 920 + rx: "2", style: "cursor: ew-resize;", 921 + onclick: move |e| { e.stop_propagation(); }, 922 + onmousedown: move |e| { 923 + e.stop_propagation(); 924 + edge_drag_active.set(true); 925 + spawn(edge_drag_task(sri, DragEdge::Right, [bx, by, bw, bh], sidecar_rects_sig, edge_drag_active, source, active_idx)); 926 + }, 927 + } 928 + } 657 929 } 658 930 } 659 931 } ··· 812 1084 }; 813 1085 if let Some(rects) = updated_rects { 814 1086 let _ = sidecar.save_for(&typ_path); 1087 + let new_idx = rects.len().saturating_sub(1); 815 1088 sidecar_rects_sig.set(rects); 1089 + selected_rect_sig.set(Some(new_idx)); 816 1090 } 817 1091 } 818 1092 insert_error.set(None);