this repo has no description
1
fork

Configure Feed

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

feat: multi-card stacked editor

Replace single-textarea + split layout with a vertically stacked card
list. Each card row shows its rendered preview at all times; the active
card also shows a source textarea below its preview.

- Per-card rendering: extract each card fragment, render independently
via a single use_resource that re-runs on source change (300ms debounce)
- Active card state: click to focus; Up/Down at textarea boundary moves
to adjacent card (JS cursor-position check before nav)
- oninput splices the active card fragment back into the full source
string so save logic and parse positions stay consistent
- Draw mode: draw-capture overlay only on active card; blank insertion
operates on the card fragment then splices into full source
- PreviewData now derives Clone; DIVIDER_DRAG_JS removed
- CSS: #editor is now flex-column; card-list replaces preview-pane;
card-row / card-row.active / card-source / card-preview-wrap rules added

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

+383 -296
+43 -43
crates/tala/assets/main.css
··· 76 76 /* ── Editor ──────────────────────────────────────────────────────────────── */ 77 77 #editor { 78 78 display: flex; 79 - flex-direction: row; 79 + flex-direction: column; 80 80 flex: 1; 81 81 min-height: 0; 82 82 } 83 83 84 - #source-pane { 85 - width: var(--left-w, 50%); 86 - min-width: 0; 84 + .preview-toolbar { 87 85 display: flex; 88 - flex-direction: column; 89 - } 90 - 91 - #divider { 92 - width: 5px; 86 + align-items: center; 87 + gap: 8px; 88 + padding: 8px 16px; 93 89 flex-shrink: 0; 94 - background: #1e2130; 95 - cursor: col-resize; 96 - transition: background 0.15s; 90 + background: #0a0c11; 91 + border-bottom: 1px solid #1e2130; 97 92 } 98 93 99 - #divider:hover { background: #4a5080; } 100 - 101 - #card-source { 102 - flex: 1; 103 - background: #1a1d24; 104 - color: #d4d8e8; 105 - font-family: 'JetBrains Mono', 'Fira Code', monospace; 106 - font-size: 14px; 107 - line-height: 1.6; 108 - padding: 16px; 109 - border: none; 110 - resize: none; 111 - outline: none; 112 - min-height: 10rem; 94 + .insert-error { 95 + color: #e06c75; 96 + font-size: 12px; 113 97 } 114 98 115 - #preview-pane { 99 + /* ── Card list ────────────────────────────────────────────────────────────── */ 100 + .card-list { 116 101 flex: 1; 102 + overflow-y: auto; 117 103 display: flex; 118 104 flex-direction: column; 119 - align-items: center; 105 + gap: 12px; 106 + padding: 16px; 120 107 background: #13151c; 121 - padding: 24px; 122 - overflow: auto; 123 108 } 124 109 125 - .preview-toolbar { 126 - display: flex; 127 - align-items: center; 128 - gap: 8px; 129 - padding-bottom: 8px; 130 - flex-shrink: 0; 131 - align-self: flex-start; 110 + .card-row { 111 + border: 1px solid #1e2130; 112 + border-radius: 6px; 113 + overflow: hidden; 114 + cursor: pointer; 115 + background: #13151c; 132 116 } 133 117 134 - .insert-error { 135 - color: #e06c75; 136 - font-size: 12px; 118 + .card-row.active { 119 + border-color: #4a5080; 120 + cursor: default; 137 121 } 138 122 139 - .preview-container { 123 + .card-preview-wrap { 140 124 position: relative; 141 125 display: inline-block; 142 126 line-height: 0; 127 + width: 100%; 128 + } 129 + 130 + .card-source { 131 + width: 100%; 132 + background: #1a1d24; 133 + color: #d4d8e8; 134 + font-family: 'JetBrains Mono', 'Fira Code', monospace; 135 + font-size: 14px; 136 + line-height: 1.6; 137 + padding: 16px; 138 + border: none; 139 + border-top: 1px solid #1e2130; 140 + resize: none; 141 + outline: none; 142 + box-sizing: border-box; 143 + min-height: 6rem; 144 + display: block; 143 145 } 144 146 145 147 .cloze-overlay { ··· 174 176 .card-preview { 175 177 display: block; 176 178 max-width: 100%; 177 - border-radius: 4px; 178 - box-shadow: 0 4px 24px rgba(0,0,0,0.5); 179 179 } 180 180 181 181 .status {
+340 -253
crates/tala/src/main.rs
··· 81 81 })(); 82 82 "#; 83 83 84 - const DIVIDER_DRAG_JS: &str = r#" 85 - (function() { 86 - var editor = document.getElementById('editor'); 87 - function onMove(e) { 88 - var rect = editor.getBoundingClientRect(); 89 - var pct = (e.clientX - rect.left) / rect.width * 100; 90 - pct = Math.min(Math.max(pct, 15), 85); 91 - editor.style.setProperty('--left-w', pct + '%'); 92 - } 93 - function onUp() { 94 - document.removeEventListener('mousemove', onMove); 95 - document.removeEventListener('mouseup', onUp); 96 - document.body.style.userSelect = ''; 97 - document.body.style.cursor = ''; 98 - } 99 - document.body.style.userSelect = 'none'; 100 - document.body.style.cursor = 'col-resize'; 101 - document.addEventListener('mousemove', onMove); 102 - document.addEventListener('mouseup', onUp); 103 - })(); 104 - "#; 105 - 106 84 #[derive(Debug, Clone, Routable, PartialEq)] 107 85 #[rustfmt::skip] 108 86 enum Route { ··· 196 174 Error(String), 197 175 } 198 176 177 + #[derive(Clone)] 199 178 struct PreviewData { 200 179 b64: String, 201 180 img_w: u32, ··· 210 189 211 190 #[component] 212 191 fn Editor() -> Element { 213 - // Load source from cards.typ (empty string if file doesn't exist yet). 192 + // ── Source & save ───────────────────────────────────────────────────────── 214 193 let mut source = use_signal(|| { 215 194 std::fs::read_to_string(cards_path()).unwrap_or_default() 216 195 }); 217 - 218 - // Track save state: None = clean, Some(text) = pending save. 219 196 let mut save_status = use_signal(|| SaveStatus::Clean); 220 197 221 - // Auto-save: debounce 1s after last edit, write to disk. 198 + // Auto-save: debounce 1s after last edit. 222 199 let _saver = use_resource(move || async move { 223 200 let text = source.read().clone(); 224 201 tokio::time::sleep(Duration::from_millis(1000)).await; ··· 226 203 match tokio::task::spawn_blocking(move || std::fs::write(&path, &text)).await { 227 204 Ok(Ok(())) => save_status.set(SaveStatus::Saved), 228 205 Ok(Err(e)) => save_status.set(SaveStatus::Error(e.to_string())), 229 - Err(e) => save_status.set(SaveStatus::Error(e.to_string())), 206 + Err(e) => save_status.set(SaveStatus::Error(e.to_string())), 230 207 } 231 208 }); 232 209 233 - // Mark dirty on every edit. 234 - let on_input = move |e: FormEvent| { 235 - source.set(e.value()); 236 - save_status.set(SaveStatus::Dirty); 237 - }; 210 + // ── Multi-card state ─────────────────────────────────────────────────────── 211 + let mut active_idx = use_signal(|| 0usize); 212 + let mut previews = use_signal(Vec::<Result<PreviewData, String>>::new); 213 + let mut active_card_start = use_signal(|| 0usize); // byte offset of active card in full source 214 + let mut active_card_end = use_signal(|| 0usize); 238 215 239 - let dir = card_dir().clone(); 240 - let preview = use_resource(move || { 241 - let dir = dir.clone(); 242 - async move { 243 - let text = source.read().clone(); 244 - tokio::time::sleep(Duration::from_millis(300)).await; 245 - tokio::task::spawn_blocking(move || render_preview(&text, &dir)) 246 - .await 247 - .unwrap_or_else(|e| Err(e.to_string())) 248 - } 216 + // Per-card render resource: re-runs whenever source changes (300ms debounce). 217 + let _render = use_resource(move || async move { 218 + let text = source.read().clone(); 219 + tokio::time::sleep(Duration::from_millis(300)).await; 220 + let dir = card_dir(); 221 + let results = tokio::task::spawn_blocking(move || { 222 + tala_format::parse_cards(&text) 223 + .iter() 224 + .map(|card| { 225 + let start = card.span.start.saturating_sub(1); 226 + let fragment = text[start..card.span.end].to_string(); 227 + render_preview(&fragment, &dir) 228 + }) 229 + .collect::<Vec<_>>() 230 + }) 231 + .await 232 + .unwrap_or_default(); 233 + previews.set(results); 249 234 }); 250 235 251 - // Blank rects, glyph map, and math spans synced from latest preview (needed in event closures). 236 + // ── Active card glyph signals (for draw mode) ───────────────────────────── 252 237 let mut blank_rects_sig = use_signal(Vec::<[f64; 4]>::new); 253 - let mut glyph_map_sig = 254 - use_signal(Vec::<([f64; 4], std::ops::Range<usize>, bool)>::new); 255 - let mut math_spans_sig = use_signal(Vec::<std::ops::Range<usize>>::new); 238 + let mut glyph_map_sig = use_signal(Vec::<([f64; 4], std::ops::Range<usize>, bool)>::new); 239 + let mut math_spans_sig = use_signal(Vec::<std::ops::Range<usize>>::new); 240 + 241 + // Keep active card bounds and glyph signals in sync whenever source, 242 + // active_idx, or previews change. 256 243 use_effect(move || { 257 - if let Some(Ok(data)) = &*preview.read() { 244 + let text = source.read().clone(); 245 + let idx = *active_idx.read(); 246 + let cards = tala_format::parse_cards(&text); 247 + if let Some(card) = cards.get(idx) { 248 + active_card_start.set(card.span.start.saturating_sub(1)); 249 + active_card_end.set(card.span.end); 250 + } 251 + let pvs = previews.read(); 252 + if let Some(Ok(data)) = pvs.get(idx) { 258 253 blank_rects_sig.set(data.blank_rects.clone()); 259 254 glyph_map_sig.set(data.glyph_map.clone()); 260 255 math_spans_sig.set(data.math_spans.clone()); 261 256 } 262 257 }); 263 258 264 - // Draw mode state. 265 - let mut draw_mode = use_signal(|| false); 266 - let mut drag_start = use_signal(|| Option::<(f64, f64)>::None); 259 + // ── Draw mode state ──────────────────────────────────────────────────────── 260 + let mut draw_mode = use_signal(|| false); 261 + let mut drag_start = use_signal(|| Option::<(f64, f64)>::None); 267 262 let mut drag_current = use_signal(|| Option::<(f64, f64)>::None); 268 - // Blue error boxes: drawn boxes that didn't map to insertable text. 269 - let mut drawn_boxes = use_signal(Vec::<[f64; 4]>::new); 270 - // Draw-capture div dims + zoom: [offsetWidth, offsetHeight, cssZoom]. 271 - let cap_dims = use_signal(|| [1.0f64, 1.0, 1.0]); 272 - // Insertion error shown near the toolbar. 263 + let mut drawn_boxes = use_signal(Vec::<[f64; 4]>::new); 264 + let cap_dims = use_signal(|| [1.0f64, 1.0, 1.0]); 273 265 let mut insert_error = use_signal(|| Option::<String>::None); 274 266 267 + // ── oninput: splice active card fragment back into full source ──────────── 268 + let on_input = move |e: FormEvent| { 269 + let new_frag = e.value(); 270 + let cs = *active_card_start.read(); 271 + let ce = *active_card_end.read(); 272 + let cur = source.read().clone(); 273 + if ce <= cur.len() { 274 + source.set(format!("{}{}{}", &cur[..cs], new_frag, &cur[ce..])); 275 + } 276 + save_status.set(SaveStatus::Dirty); 277 + }; 278 + 279 + // ── Snapshot values for RSX (avoids holding multiple ReadGuards) ────────── 280 + let text_snap = source.read().clone(); 281 + let cards_snap = tala_format::parse_cards(&text_snap); 282 + let n_cards = cards_snap.len(); 283 + let active_i = (*active_idx.read()).min(n_cards.saturating_sub(1)); 284 + let pvs_snap = previews.read().clone(); 285 + let active_cs = *active_card_start.read(); 286 + let active_ce = *active_card_end.read(); 287 + let active_fragment = if active_ce <= text_snap.len() && active_cs <= active_ce { 288 + text_snap[active_cs..active_ce].to_string() 289 + } else { 290 + String::new() 291 + }; 292 + let in_draw = *draw_mode.read(); 293 + let ds = *drag_start.read(); 294 + let dc = *drag_current.read(); 295 + let drawn = drawn_boxes.read().clone(); 296 + let blank_rects = blank_rects_sig.read().clone(); 297 + 275 298 rsx! { 276 299 div { id: "editor", 277 - div { id: "source-pane", 278 - textarea { 279 - id: "card-source", 280 - spellcheck: "false", 281 - value: "{source}", 282 - oninput: on_input, 300 + // ── Toolbar ─────────────────────────────────────────────────────── 301 + div { class: "preview-toolbar", 302 + button { 303 + class: if in_draw { "btn active" } else { "btn" }, 304 + onclick: move |_| { 305 + let m = *draw_mode.read(); 306 + draw_mode.set(!m); 307 + drag_start.set(None); 308 + drag_current.set(None); 309 + drawn_boxes.write().clear(); 310 + insert_error.set(None); 311 + }, 312 + if in_draw { "Exit Draw" } else { "Draw Cloze" } 313 + } 314 + match &*save_status.read() { 315 + SaveStatus::Clean => rsx! {}, 316 + SaveStatus::Dirty => rsx! { span { class: "save-status dirty", "Unsaved" } }, 317 + SaveStatus::Saved => rsx! { span { class: "save-status saved", "Saved" } }, 318 + SaveStatus::Error(e) => rsx! { span { class: "save-status error", "Save error: {e}" } }, 319 + } 320 + if let Some(err) = &*insert_error.read() { 321 + span { class: "insert-error", "{err}" } 283 322 } 284 323 } 285 - div { 286 - id: "divider", 287 - onmousedown: move |_| { document::eval(DIVIDER_DRAG_JS); }, 288 - } 289 - div { id: "preview-pane", 290 - div { class: "preview-toolbar", 291 - button { 292 - class: if *draw_mode.read() { "btn active" } else { "btn" }, 293 - onclick: move |_| { 294 - let m = *draw_mode.read(); 295 - draw_mode.set(!m); 296 - drag_start.set(None); 297 - drag_current.set(None); 298 - drawn_boxes.write().clear(); 299 - insert_error.set(None); 300 - }, 301 - if *draw_mode.read() { "Exit Draw" } else { "Draw Cloze" } 302 - } 303 - match &*save_status.read() { 304 - SaveStatus::Clean => rsx! {}, 305 - SaveStatus::Dirty => rsx! { span { class: "save-status dirty", "Unsaved" } }, 306 - SaveStatus::Saved => rsx! { span { class: "save-status saved", "Saved" } }, 307 - SaveStatus::Error(e) => rsx! { span { class: "save-status error", "Save error: {e}" } }, 308 - } 309 - if let Some(err) = &*insert_error.read() { 310 - span { class: "insert-error", "{err}" } 311 - } 324 + // ── Card list ───────────────────────────────────────────────────── 325 + div { class: "card-list", 326 + if n_cards == 0 { 327 + p { class: "status", "No cards yet — start with #cloze[...] or #card[...][...]" } 312 328 } 313 - match &*preview.read() { 314 - None => rsx! { span { class: "status", "Rendering…" } }, 315 - Some(Err(msg)) => rsx! { pre { class: "render-error", "{msg}" } }, 316 - Some(Ok(data)) => { 317 - let src = format!("data:image/png;base64,{}", data.b64); 318 - let vb = format!("0 0 {} {}", data.img_w, data.img_h); 319 - let iw = data.img_w as f64; 320 - let ih = data.img_h as f64; 321 - let blank_rects = data.blank_rects.clone(); 322 - let drawn = drawn_boxes.read().clone(); 323 - let ds = *drag_start.read(); 324 - let dc = *drag_current.read(); 325 - let in_draw = *draw_mode.read(); 329 + { 330 + cards_snap.iter().enumerate().map(|(i, _card)| { 331 + let card_result = pvs_snap.get(i).cloned(); 332 + let is_active = i == active_i; 333 + let iw = card_result.as_ref().and_then(|r| r.as_ref().ok()).map(|d| d.img_w as f64).unwrap_or(1.0); 334 + let ih = card_result.as_ref().and_then(|r| r.as_ref().ok()).map(|d| d.img_h as f64).unwrap_or(1.0); 335 + let vb = format!("0 0 {} {}", iw as u32, ih as u32); 336 + let b64_src = card_result.as_ref() 337 + .and_then(|r| r.as_ref().ok()) 338 + .map(|d| format!("data:image/png;base64,{}", d.b64)) 339 + .unwrap_or_default(); 340 + // Yellow blank rects: use live signal for active card, snapshot for others. 341 + let card_blank_rects: Vec<[f64; 4]> = if is_active { 342 + blank_rects.clone() 343 + } else { 344 + card_result.as_ref() 345 + .and_then(|r| r.as_ref().ok()) 346 + .map(|d| d.blank_rects.clone()) 347 + .unwrap_or_default() 348 + }; 349 + let render_err = card_result.and_then(|r| r.err()); 350 + 326 351 rsx! { 327 - div { class: "preview-container", 328 - img { class: "card-preview", src: "{src}" } 329 - svg { 330 - class: "cloze-overlay", 331 - view_box: "{vb}", 332 - // Existing blank overlays 333 - for rect in &blank_rects { 334 - rect { 335 - x: "{rect[0] * iw}", 336 - y: "{rect[1] * ih}", 337 - width: "{rect[2] * iw}", 338 - height: "{rect[3] * ih}", 339 - fill: "rgba(255,220,50,0.35)", 340 - stroke: "rgb(200,150,0)", 341 - stroke_width: "1.5", 342 - rx: "3", 343 - } 344 - } 345 - // Error boxes (drawn but no text found / math) 346 - for rect in &drawn { 347 - rect { 348 - x: "{rect[0] * iw}", 349 - y: "{rect[1] * ih}", 350 - width: "{rect[2] * iw}", 351 - height: "{rect[3] * ih}", 352 - fill: "rgba(255,100,100,0.2)", 353 - stroke: "rgb(200,60,60)", 354 - stroke_width: "1.5", 355 - rx: "3", 356 - } 352 + div { 353 + key: "{i}", 354 + class: if is_active { "card-row active" } else { "card-row" }, 355 + onclick: move |_| { 356 + if !is_active { 357 + active_idx.set(i); 358 + spawn(async move { 359 + document::eval( 360 + "setTimeout(()=>document.querySelector('.card-row.active textarea')?.focus(),0)" 361 + ).await.ok(); 362 + }); 357 363 } 358 - // Live drag preview 359 - if let (Some((sx, sy)), Some((cx, cy))) = (ds, dc) { 360 - { 361 - let rx = sx.min(cx) * iw; 362 - let ry = sy.min(cy) * ih; 363 - let rw = (cx - sx).abs() * iw; 364 - let rh = (cy - sy).abs() * ih; 365 - rsx! { 364 + }, 365 + // ── Preview ─────────────────────────────────── 366 + if let Some(err) = render_err { 367 + pre { class: "render-error", "{err}" } 368 + } else if b64_src.is_empty() { 369 + span { class: "status", "Rendering…" } 370 + } else { 371 + div { class: "card-preview-wrap", 372 + img { class: "card-preview", src: "{b64_src}" } 373 + svg { 374 + class: "cloze-overlay", 375 + view_box: "{vb}", 376 + // Yellow blank highlights (all cards) 377 + for rect in &card_blank_rects { 366 378 rect { 367 - x: "{rx}", y: "{ry}", 368 - width: "{rw}", height: "{rh}", 369 - fill: "rgba(100,180,255,0.1)", 370 - stroke: "rgb(70,130,220)", 371 - stroke_width: "1.5", 372 - stroke_dasharray: "4 2", 373 - rx: "3", 379 + x: "{rect[0] * iw}", y: "{rect[1] * ih}", 380 + width: "{rect[2] * iw}", height: "{rect[3] * ih}", 381 + fill: "rgba(255,220,50,0.35)", 382 + stroke: "rgb(200,150,0)", 383 + stroke_width: "1.5", rx: "3", 374 384 } 375 385 } 376 - } 377 - } 378 - } 379 - if in_draw { 380 - div { 381 - class: "draw-capture", 382 - onmousedown: move |e| { 383 - // element_coordinates() = offsetX/Y (element-local px). 384 - // In webkit2gtk with CSS zoom on <html>, offsetX/Y are 385 - // scaled by zoom while offsetWidth/Height are not. 386 - // We read the current zoom and divide it out. 387 - let ox = e.data().element_coordinates().x; 388 - let oy = e.data().element_coordinates().y; 389 - let mut ds = drag_start; 390 - let mut dc = drag_current; 391 - let mut cd = cap_dims; 392 - spawn(async move { 393 - let mut eval = document::eval(r#" 394 - var el = document.querySelector('.draw-capture'); 395 - var z = parseFloat(document.documentElement.style.zoom || '1'); 396 - dioxus.send([el ? el.offsetWidth : 1, el ? el.offsetHeight : 1, z]); 397 - "#); 398 - if let Ok(val) = eval.recv::<[f64; 3]>().await { 399 - cd.set(val); 400 - let (nx, ny) = normalize_draw_coords(ox, oy, val); 401 - ds.set(Some((nx, ny))); 402 - dc.set(Some((nx, ny))); 386 + // Draw overlays (active card only) 387 + if is_active { 388 + for rect in &drawn { 389 + rect { 390 + x: "{rect[0] * iw}", y: "{rect[1] * ih}", 391 + width: "{rect[2] * iw}", height: "{rect[3] * ih}", 392 + fill: "rgba(255,100,100,0.2)", 393 + stroke: "rgb(200,60,60)", 394 + stroke_width: "1.5", rx: "3", 395 + } 403 396 } 404 - }); 405 - }, 406 - onmousemove: move |e| { 407 - if drag_start.read().is_some() { 408 - let (nx, ny) = normalize_draw_coords( 409 - e.data().element_coordinates().x, 410 - e.data().element_coordinates().y, 411 - *cap_dims.read(), 412 - ); 413 - drag_current.set(Some((nx, ny))); 397 + if let (Some((sx, sy)), Some((cx, cy))) = (ds, dc) { 398 + { 399 + let rx = sx.min(cx) * iw; 400 + let ry = sy.min(cy) * ih; 401 + let rw = (cx - sx).abs() * iw; 402 + let rh = (cy - sy).abs() * ih; 403 + rsx! { 404 + rect { 405 + x: "{rx}", y: "{ry}", 406 + width: "{rw}", height: "{rh}", 407 + fill: "rgba(100,180,255,0.1)", 408 + stroke: "rgb(70,130,220)", 409 + stroke_width: "1.5", 410 + stroke_dasharray: "4 2", rx: "3", 411 + } 412 + } 413 + } 414 + } 414 415 } 415 - }, 416 - onmouseleave: move |_| { 417 - drag_start.set(None); 418 - drag_current.set(None); 419 - }, 420 - onmouseup: move |e| { 421 - let Some((sx, sy)) = *drag_start.read() else { return; }; 422 - let (nx, ny) = normalize_draw_coords( 423 - e.data().element_coordinates().x, 424 - e.data().element_coordinates().y, 425 - *cap_dims.read(), 426 - ); 427 - let rx = sx.min(nx); 428 - let ry = sy.min(ny); 429 - let rw = (nx - sx).abs(); 430 - let rh = (ny - sy).abs(); 431 - drag_start.set(None); 432 - drag_current.set(None); 433 - if rw * rh < 0.001 { return; } 434 - let drawn_rect = [rx, ry, rw, rh]; 416 + } 417 + // Draw-capture div (active card in draw mode only) 418 + if is_active && in_draw { 419 + div { 420 + class: "draw-capture", 421 + onmousedown: move |e| { 422 + let ox = e.data().element_coordinates().x; 423 + let oy = e.data().element_coordinates().y; 424 + let mut ds = drag_start; 425 + let mut dc = drag_current; 426 + let mut cd = cap_dims; 427 + spawn(async move { 428 + let mut eval = document::eval(r#" 429 + var el = document.querySelector('.draw-capture'); 430 + var z = parseFloat(document.documentElement.style.zoom || '1'); 431 + dioxus.send([el ? el.offsetWidth : 1, el ? el.offsetHeight : 1, z]); 432 + "#); 433 + if let Ok(val) = eval.recv::<[f64; 3]>().await { 434 + cd.set(val); 435 + let (nx, ny) = normalize_draw_coords(ox, oy, val); 436 + ds.set(Some((nx, ny))); 437 + dc.set(Some((nx, ny))); 438 + } 439 + }); 440 + }, 441 + onmousemove: move |e| { 442 + if drag_start.read().is_some() { 443 + let (nx, ny) = normalize_draw_coords( 444 + e.data().element_coordinates().x, 445 + e.data().element_coordinates().y, 446 + *cap_dims.read(), 447 + ); 448 + drag_current.set(Some((nx, ny))); 449 + } 450 + }, 451 + onmouseleave: move |_| { 452 + drag_start.set(None); 453 + drag_current.set(None); 454 + }, 455 + onmouseup: move |e| { 456 + let Some((sx, sy)) = *drag_start.read() else { return; }; 457 + let (nx, ny) = normalize_draw_coords( 458 + e.data().element_coordinates().x, 459 + e.data().element_coordinates().y, 460 + *cap_dims.read(), 461 + ); 462 + let rx = sx.min(nx); 463 + let ry = sy.min(ny); 464 + let rw = (nx - sx).abs(); 465 + let rh = (ny - sy).abs(); 466 + drag_start.set(None); 467 + drag_current.set(None); 468 + if rw * rh < 0.001 { return; } 469 + let drawn_rect = [rx, ry, rw, rh]; 435 470 436 - // Find glyphs whose rects overlap the drawn box. 437 - let glyphs = glyph_map_sig.read().clone(); 438 - let hits: Vec<_> = glyphs 439 - .iter() 440 - .filter(|(gr, _, _)| rects_overlap(drawn_rect, *gr)) 441 - .cloned() 442 - .collect(); 471 + let glyphs = glyph_map_sig.read().clone(); 472 + let hits: Vec<_> = glyphs 473 + .iter() 474 + .filter(|(gr, _, _)| rects_overlap(drawn_rect, *gr)) 475 + .cloned() 476 + .collect(); 443 477 444 - if hits.is_empty() { 445 - insert_error.set(Some("No text found in drawn region.".into())); 446 - drawn_boxes.write().push(drawn_rect); 447 - return; 448 - } 449 - let all_math = hits.iter().all(|(_, _, m)| *m); 450 - let any_math = hits.iter().any(|(_, _, m)| *m); 451 - let min_start = hits.iter().map(|(_, r, _)| r.start).min().unwrap(); 452 - let max_end = hits.iter().map(|(_, r, _)| r.end ).max().unwrap(); 453 - if all_math { 454 - let spans = math_spans_sig.read().clone(); 455 - let eq = spans.iter().find(|ms| ms.start <= min_start && max_end <= ms.end).cloned(); 456 - match eq { 457 - None => { 458 - insert_error.set(Some("Could not locate equation boundary.".into())); 478 + if hits.is_empty() { 479 + insert_error.set(Some("No text found in drawn region.".into())); 459 480 drawn_boxes.write().push(drawn_rect); 481 + return; 460 482 } 461 - Some(eq) => { 462 - let cur = source.read().clone(); 463 - let open_len = if cur[eq.start..].starts_with("$ ") { 2 } else { 1 }; 464 - let close_len = if cur[..eq.end].ends_with(" $") { 2 } else { 1 }; 465 - let content_start = eq.start + open_len; 466 - let content_end = eq.end - close_len; 467 - let (exp_start, exp_end) = expand_math_selection( 468 - &cur[content_start..content_end], 469 - min_start - content_start, 470 - max_end - content_start, 471 - ); 472 - source.set(insert_blank_wrap_math( 473 - &cur, 474 - content_start + exp_start, 475 - content_start + exp_end, 476 - eq.start, eq.end, 477 - )); 478 - insert_error.set(None); 483 + let all_math = hits.iter().all(|(_, _, m)| *m); 484 + let any_math = hits.iter().any(|(_, _, m)| *m); 485 + let min_start = hits.iter().map(|(_, r, _)| r.start).min().unwrap(); 486 + let max_end = hits.iter().map(|(_, r, _)| r.end ).max().unwrap(); 487 + 488 + // Operate on the active card's fragment, then splice back. 489 + let cs = *active_card_start.read(); 490 + let ce = *active_card_end.read(); 491 + let cur = source.read().clone(); 492 + let fragment = cur[cs..ce].to_string(); 493 + 494 + if all_math { 495 + let spans = math_spans_sig.read().clone(); 496 + let eq = spans.iter() 497 + .find(|ms| ms.start <= min_start && max_end <= ms.end) 498 + .cloned(); 499 + match eq { 500 + None => { 501 + insert_error.set(Some("Could not locate equation boundary.".into())); 502 + drawn_boxes.write().push(drawn_rect); 503 + } 504 + Some(eq) => { 505 + let open_len = if fragment[eq.start..].starts_with("$ ") { 2 } else { 1 }; 506 + let close_len = if fragment[..eq.end].ends_with(" $") { 2 } else { 1 }; 507 + let content_start = eq.start + open_len; 508 + let content_end = eq.end - close_len; 509 + let (exp_start, exp_end) = expand_math_selection( 510 + &fragment[content_start..content_end], 511 + min_start - content_start, 512 + max_end - content_start, 513 + ); 514 + let new_frag = insert_blank_wrap_math( 515 + &fragment, 516 + content_start + exp_start, 517 + content_start + exp_end, 518 + eq.start, eq.end, 519 + ); 520 + source.set(format!("{}{}{}", &cur[..cs], new_frag, &cur[ce..])); 521 + insert_error.set(None); 522 + } 523 + } 524 + return; 479 525 } 480 - } 481 - return; 526 + if any_math { 527 + insert_error.set(Some("Selection spans math and text — draw over one type only.".into())); 528 + drawn_boxes.write().push(drawn_rect); 529 + return; 530 + } 531 + let new_frag = insert_blank_wrap(&fragment, min_start, max_end); 532 + source.set(format!("{}{}{}", &cur[..cs], new_frag, &cur[ce..])); 533 + insert_error.set(None); 534 + }, 482 535 } 483 - if any_math { 484 - insert_error.set(Some("Selection spans math and text — draw over one type only.".into())); 485 - drawn_boxes.write().push(drawn_rect); 486 - return; 536 + } 537 + } 538 + } 539 + // ── Source textarea (active card only) ──────── 540 + if is_active { 541 + textarea { 542 + class: "card-source", 543 + spellcheck: "false", 544 + value: "{active_fragment}", 545 + oninput: on_input, 546 + onkeydown: move |e| { 547 + let key = e.data().key().to_string(); 548 + if key == "ArrowUp" || key == "ArrowDown" { 549 + let going_up = key == "ArrowUp"; 550 + let cur_idx = *active_idx.read(); 551 + let n = previews.read().len(); 552 + let mut ai = active_idx; 553 + spawn(async move { 554 + let js = if going_up { 555 + "var t=document.querySelector('.card-row.active textarea');\ 556 + dioxus.send(t?!t.value.slice(0,t.selectionStart).includes('\\n'):false);" 557 + } else { 558 + "var t=document.querySelector('.card-row.active textarea');\ 559 + dioxus.send(t?!t.value.slice(t.selectionStart).includes('\\n'):false);" 560 + }; 561 + if let Ok(at_boundary) = document::eval(js).recv::<bool>().await { 562 + if at_boundary { 563 + let new_i = if going_up { 564 + cur_idx.saturating_sub(1) 565 + } else { 566 + (cur_idx + 1).min(n.saturating_sub(1)) 567 + }; 568 + if new_i != cur_idx { 569 + ai.set(new_i); 570 + document::eval( 571 + "setTimeout(()=>document.querySelector('.card-row.active textarea')?.focus(),0)" 572 + ).await.ok(); 573 + } 574 + } 575 + } 576 + }); 487 577 } 488 - let cur = source.read().clone(); 489 - source.set(insert_blank_wrap(&cur, min_start, max_end)); 490 - insert_error.set(None); 491 578 }, 492 579 } 493 580 } 494 581 } 495 582 } 496 - } 583 + }) 497 584 } 498 585 } 499 586 }