this repo has no description
1
fork

Configure Feed

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

fix: scrollable card list, source-above-preview, focus on keyboard nav

- Move body flex layout to #main (Dioxus desktop mount point) so
#editor has a bounded height to flex against
- Add min-height:0 to .card-list and flex-shrink:0 to .card-row so
overflow-y:auto triggers instead of squishing cards
- Move textarea above preview within each card row so re-renders don't
shift the editing surface
- Focus textarea in onmounted JS so arrow-key boundary navigation
places the cursor in the adjacent card's textarea

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

+294 -84
+38 -2
crates/tala/assets/main.css
··· 8 8 margin: 0; 9 9 height: 100vh; 10 10 overflow: hidden; 11 + } 12 + 13 + /* Dioxus desktop mounts into <div id="main"> — make it fill the viewport. */ 14 + #main { 15 + height: 100vh; 11 16 display: flex; 12 17 flex-direction: column; 13 18 } ··· 99 104 /* ── Card list ────────────────────────────────────────────────────────────── */ 100 105 .card-list { 101 106 flex: 1; 107 + min-height: 0; 102 108 overflow-y: auto; 103 109 display: flex; 104 110 flex-direction: column; 105 - gap: 12px; 111 + gap: 24px; 106 112 padding: 16px; 107 113 background: #13151c; 108 114 } ··· 113 119 overflow: hidden; 114 120 cursor: pointer; 115 121 background: #13151c; 122 + flex-shrink: 0; 116 123 } 117 124 118 125 .card-row.active { ··· 120 127 cursor: default; 121 128 } 122 129 130 + .card-actions { 131 + display: flex; 132 + justify-content: flex-end; 133 + padding: 2px 6px; 134 + background: #0f1116; 135 + border-bottom: 1px solid #1e2130; 136 + } 137 + 138 + .btn-delete { 139 + background: none; 140 + border: none; 141 + color: #3a3f55; 142 + font-size: 16px; 143 + line-height: 1; 144 + padding: 0 4px; 145 + cursor: pointer; 146 + } 147 + 148 + .btn-delete:hover { color: #e06c75; } 149 + 150 + .text-seg-preview { 151 + margin: 0; 152 + padding: 12px 16px; 153 + font-family: 'JetBrains Mono', 'Fira Code', monospace; 154 + font-size: 13px; 155 + color: #555c70; 156 + white-space: pre-wrap; 157 + word-break: break-word; 158 + } 159 + 123 160 .card-preview-wrap { 124 161 position: relative; 125 162 display: inline-block; 126 163 line-height: 0; 127 - width: 100%; 128 164 } 129 165 130 166 .card-source {
+256 -82
crates/tala/src/main.rs
··· 174 174 Error(String), 175 175 } 176 176 177 + /// A segment of the source file: either a fully-parsed card or unparsed text. 178 + #[derive(Clone)] 179 + enum Seg { 180 + Card { card_idx: usize, start: usize, end: usize }, 181 + Text { start: usize, end: usize }, 182 + } 183 + 184 + impl Seg { 185 + fn start(&self) -> usize { 186 + match self { Seg::Card { start, .. } | Seg::Text { start, .. } => *start } 187 + } 188 + fn end(&self) -> usize { 189 + match self { Seg::Card { end, .. } | Seg::Text { end, .. } => *end } 190 + } 191 + } 192 + 193 + fn make_segments(source: &str, cards: &[tala_format::CardEntry]) -> Vec<Seg> { 194 + let mut segs = Vec::new(); 195 + let mut pos = 0usize; 196 + for (ci, card) in cards.iter().enumerate() { 197 + let cs = card.span.start.saturating_sub(1); 198 + let ce = card.span.end; 199 + if pos < cs && source[pos..cs].bytes().any(|b| !b.is_ascii_whitespace()) { 200 + segs.push(Seg::Text { start: pos, end: cs }); 201 + } 202 + segs.push(Seg::Card { card_idx: ci, start: cs, end: ce }); 203 + pos = ce; 204 + } 205 + let tail = source.len(); 206 + if pos < tail && source[pos..tail].bytes().any(|b| !b.is_ascii_whitespace()) { 207 + segs.push(Seg::Text { start: pos, end: tail }); 208 + } 209 + segs 210 + } 211 + 177 212 #[derive(Clone)] 178 213 struct PreviewData { 179 214 b64: String, ··· 210 245 // ── Multi-card state ─────────────────────────────────────────────────────── 211 246 let mut active_idx = use_signal(|| 0usize); 212 247 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 248 + let mut active_card_start = use_signal(|| 0usize); // byte offset of active segment in full source 214 249 let mut active_card_end = use_signal(|| 0usize); 250 + let mut n_segs_sig = use_signal(|| 0usize); 215 251 216 252 // Per-card render resource: re-runs whenever source changes (300ms debounce). 217 253 let _render = use_resource(move || async move { ··· 238 274 let mut glyph_map_sig = use_signal(Vec::<([f64; 4], std::ops::Range<usize>, bool)>::new); 239 275 let mut math_spans_sig = use_signal(Vec::<std::ops::Range<usize>>::new); 240 276 241 - // Keep active card bounds and glyph signals in sync whenever source, 242 - // active_idx, or previews change. 277 + // Keep active segment bounds, glyph signals, and n_segs in sync. 243 278 use_effect(move || { 244 279 let text = source.read().clone(); 245 280 let idx = *active_idx.read(); 246 281 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); 282 + let segs = make_segments(&text, &cards); 283 + n_segs_sig.set(segs.len()); 284 + if let Some(seg) = segs.get(idx) { 285 + active_card_start.set(seg.start()); 286 + active_card_end.set(seg.end()); 250 287 } 251 288 let pvs = previews.read(); 252 - if let Some(Ok(data)) = pvs.get(idx) { 253 - blank_rects_sig.set(data.blank_rects.clone()); 254 - glyph_map_sig.set(data.glyph_map.clone()); 255 - math_spans_sig.set(data.math_spans.clone()); 289 + match segs.get(idx) { 290 + Some(Seg::Card { card_idx, .. }) => { 291 + if let Some(Ok(data)) = pvs.get(*card_idx) { 292 + blank_rects_sig.set(data.blank_rects.clone()); 293 + glyph_map_sig.set(data.glyph_map.clone()); 294 + math_spans_sig.set(data.math_spans.clone()); 295 + } 296 + } 297 + _ => { 298 + blank_rects_sig.set(Vec::new()); 299 + glyph_map_sig.set(Vec::new()); 300 + math_spans_sig.set(Vec::new()); 301 + } 256 302 } 257 303 }); 258 304 ··· 264 310 let cap_dims = use_signal(|| [1.0f64, 1.0, 1.0]); 265 311 let mut insert_error = use_signal(|| Option::<String>::None); 266 312 267 - // ── oninput: splice active card fragment back into full source ──────────── 313 + // ── Shared keyboard nav: Up/Down at textarea boundary → switch segment ────── 314 + let on_keydown = move |e: Event<KeyboardData>| { 315 + let key = e.data().key().to_string(); 316 + if key == "ArrowUp" || key == "ArrowDown" { 317 + let going_up = key == "ArrowUp"; 318 + let cur_idx = *active_idx.read(); 319 + let n = *n_segs_sig.read(); 320 + let mut ai = active_idx; 321 + spawn(async move { 322 + let js = if going_up { 323 + "var t=document.querySelector('.card-row.active textarea');\ 324 + dioxus.send(t?!t.value.slice(0,t.selectionStart).includes('\\n'):false);" 325 + } else { 326 + "var t=document.querySelector('.card-row.active textarea');\ 327 + dioxus.send(t?!t.value.slice(t.selectionStart).includes('\\n'):false);" 328 + }; 329 + if let Ok(at_boundary) = document::eval(js).recv::<bool>().await { 330 + if at_boundary { 331 + let new_i = if going_up { 332 + cur_idx.saturating_sub(1) 333 + } else { 334 + (cur_idx + 1).min(n.saturating_sub(1)) 335 + }; 336 + if new_i != cur_idx { 337 + ai.set(new_i); 338 + document::eval( 339 + "setTimeout(()=>document.querySelector('.card-row.active textarea')?.focus(),0)" 340 + ).await.ok(); 341 + } 342 + } 343 + } 344 + }); 345 + } 346 + }; 347 + 348 + // ── oninput: splice active segment fragment back into full source ───────── 268 349 let on_input = move |e: FormEvent| { 269 350 let new_frag = e.value(); 351 + if new_frag.trim().is_empty() { 352 + // Don't splice empty content — card would vanish from parse_cards. 353 + // Deletion is an explicit action via the delete button. 354 + return; 355 + } 270 356 let cs = *active_card_start.read(); 271 357 let ce = *active_card_end.read(); 272 358 let cur = source.read().clone(); ··· 279 365 // ── Snapshot values for RSX (avoids holding multiple ReadGuards) ────────── 280 366 let text_snap = source.read().clone(); 281 367 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() 368 + let segs_snap = make_segments(&text_snap, &cards_snap); 369 + let n_segs = segs_snap.len(); 370 + let active_i = (*active_idx.read()).min(n_segs.saturating_sub(1)); 371 + let active_is_cloze = match segs_snap.get(active_i) { 372 + Some(Seg::Card { card_idx, .. }) => matches!( 373 + cards_snap.get(*card_idx).map(|c| &c.kind), 374 + Some(CardKind::Cloze { .. }) 375 + ), 376 + _ => false, 291 377 }; 378 + let pvs_snap = previews.read().clone(); 379 + // Compute bounds directly from segs_snap -- never read the lagging signals here. 380 + let (active_cs, active_ce) = segs_snap.get(active_i) 381 + .map(|s| (s.start(), s.end())) 382 + .unwrap_or((0, 0)); 383 + let active_fragment = text_snap[active_cs..active_ce.min(text_snap.len())].to_string(); 292 384 let in_draw = *draw_mode.read(); 293 385 let ds = *drag_start.read(); 294 386 let dc = *drag_current.read(); ··· 299 391 div { id: "editor", 300 392 // ── Toolbar ─────────────────────────────────────────────────────── 301 393 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" } 394 + if active_is_cloze { 395 + button { 396 + class: if in_draw { "btn active" } else { "btn" }, 397 + onclick: move |_| { 398 + let m = *draw_mode.read(); 399 + draw_mode.set(!m); 400 + drag_start.set(None); 401 + drag_current.set(None); 402 + drawn_boxes.write().clear(); 403 + insert_error.set(None); 404 + }, 405 + if in_draw { "Exit Draw" } else { "Draw Cloze" } 406 + } 313 407 } 314 408 match &*save_status.read() { 315 409 SaveStatus::Clean => rsx! {}, ··· 323 417 } 324 418 // ── Card list ───────────────────────────────────────────────────── 325 419 div { class: "card-list", 326 - if n_cards == 0 { 420 + if n_segs == 0 { 327 421 p { class: "status", "No cards yet — start with #cloze[...] or #card[...][...]" } 328 422 } 329 423 { 330 - cards_snap.iter().enumerate().map(|(i, _card)| { 331 - let card_result = pvs_snap.get(i).cloned(); 332 - let is_active = i == active_i; 424 + segs_snap.iter().enumerate().map(|(i, seg)| { 425 + let is_active = i == active_i; 426 + let frag_for_mount = active_fragment.clone(); 427 + let row_class = if is_active { "card-row active" } else { "card-row" }; 428 + 429 + match seg { 430 + // ── Text segment (unparsed / mid-edit) ──────────────── 431 + Seg::Text { start, end } => { 432 + let preview_text = text_snap[*start..*end].to_string(); 433 + rsx! { 434 + div { 435 + key: "t{i}", 436 + class: row_class, 437 + onclick: move |_| { 438 + if !is_active { 439 + active_idx.set(i); 440 + spawn(async move { 441 + document::eval( 442 + "setTimeout(()=>document.querySelector('.card-row.active textarea')?.focus(),0)" 443 + ).await.ok(); 444 + }); 445 + } 446 + }, 447 + div { class: "card-actions", 448 + button { class: "btn-delete", title: "Delete segment", 449 + onclick: move |e| { 450 + e.stop_propagation(); 451 + let text = source.read().clone(); 452 + let cards = tala_format::parse_cards(&text); 453 + let segs = make_segments(&text, &cards); 454 + if let Some(seg) = segs.get(i) { 455 + let (cs, ce) = (seg.start(), seg.end()); 456 + source.set(format!("{}{}", &text[..cs], &text[ce..])); 457 + save_status.set(SaveStatus::Dirty); 458 + } 459 + }, 460 + "×" 461 + } 462 + } 463 + if is_active { 464 + textarea { 465 + class: "card-source", 466 + spellcheck: "false", 467 + onmounted: { 468 + let frag = frag_for_mount; 469 + move |_| { 470 + let b64 = base64::engine::general_purpose::STANDARD.encode(&frag); 471 + spawn(async move { 472 + document::eval(&format!( 473 + "var t=document.querySelector('.card-row.active textarea');\ 474 + if(t){{t.value=atob('{}');t.focus();}}", b64 475 + )).await.ok(); 476 + }); 477 + } 478 + }, 479 + oninput: on_input, 480 + onkeydown: on_keydown.clone(), 481 + } 482 + } else { 483 + pre { class: "text-seg-preview", "{preview_text}" } 484 + } 485 + } 486 + } 487 + } 488 + // ── Card segment ────────────────────────────────────── 489 + Seg::Card { card_idx, .. } => { 490 + let card_idx = *card_idx; 491 + let card_result: Option<Result<PreviewData, String>> = pvs_snap.get(card_idx).cloned(); 333 492 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 493 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 494 let vb = format!("0 0 {} {}", iw as u32, ih as u32); ··· 337 496 .and_then(|r| r.as_ref().ok()) 338 497 .map(|d| format!("data:image/png;base64,{}", d.b64)) 339 498 .unwrap_or_default(); 340 - // Yellow blank rects: use live signal for active card, snapshot for others. 341 499 let card_blank_rects: Vec<[f64; 4]> = if is_active { 342 500 blank_rects.clone() 343 501 } else { ··· 350 508 351 509 rsx! { 352 510 div { 353 - key: "{i}", 354 - class: if is_active { "card-row active" } else { "card-row" }, 511 + key: "c{i}", 512 + class: row_class, 355 513 onclick: move |_| { 356 514 if !is_active { 357 515 active_idx.set(i); ··· 362 520 }); 363 521 } 364 522 }, 523 + // ── Card actions bar ────────────────────────── 524 + div { class: "card-actions", 525 + button { 526 + class: "btn-delete", 527 + title: "Delete card", 528 + onclick: move |e| { 529 + e.stop_propagation(); 530 + let text = source.read().clone(); 531 + let cards = tala_format::parse_cards(&text); 532 + if let Some(card) = cards.get(card_idx as usize) { 533 + let cs = card.span.start.saturating_sub(1); 534 + let ce = card.span.end; 535 + let ce = if ce < text.len() && text.as_bytes()[ce] == b'\n' { ce + 1 } else { ce }; 536 + source.set(format!("{}{}", &text[..cs], &text[ce..])); 537 + save_status.set(SaveStatus::Dirty); 538 + } 539 + }, 540 + "×" 541 + } 542 + } 543 + // ── Source textarea (active card only) ──────── 544 + if is_active { 545 + textarea { 546 + class: "card-source", 547 + spellcheck: "false", 548 + onmounted: { 549 + let frag = frag_for_mount.clone(); 550 + move |_| { 551 + let b64 = base64::engine::general_purpose::STANDARD 552 + .encode(&frag); 553 + spawn(async move { 554 + document::eval(&format!( 555 + "var t=document.querySelector('.card-row.active textarea');\ 556 + if(t){{t.value=atob('{}');t.focus();}}", b64 557 + )).await.ok(); 558 + }); 559 + } 560 + }, 561 + oninput: on_input, 562 + onkeydown: on_keydown.clone(), 563 + } 564 + } 365 565 // ── Preview ─────────────────────────────────── 366 566 if let Some(err) = render_err { 367 567 pre { class: "render-error", "{err}" } ··· 414 614 } 415 615 } 416 616 } 417 - // Draw-capture div (active card in draw mode only) 418 - if is_active && in_draw { 617 + // Draw-capture div (active cloze card in draw mode only) 618 + if is_active && in_draw && active_is_cloze { 419 619 div { 420 620 class: "draw-capture", 421 621 onmousedown: move |e| { ··· 519 719 ); 520 720 source.set(format!("{}{}{}", &cur[..cs], new_frag, &cur[ce..])); 521 721 insert_error.set(None); 722 + let b64 = base64::engine::general_purpose::STANDARD.encode(&new_frag); 723 + spawn(async move { 724 + document::eval(&format!( 725 + "var t=document.querySelector('.card-row.active textarea');\ 726 + if(t)t.value=atob('{}');", b64 727 + )).await.ok(); 728 + }); 522 729 } 523 730 } 524 731 return; ··· 531 738 let new_frag = insert_blank_wrap(&fragment, min_start, max_end); 532 739 source.set(format!("{}{}{}", &cur[..cs], new_frag, &cur[ce..])); 533 740 insert_error.set(None); 741 + let b64 = base64::engine::general_purpose::STANDARD.encode(&new_frag); 742 + spawn(async move { 743 + document::eval(&format!( 744 + "var t=document.querySelector('.card-row.active textarea');\ 745 + if(t)t.value=atob('{}');", b64 746 + )).await.ok(); 747 + }); 534 748 }, 535 749 } 536 750 } 537 751 } 538 752 } 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 - }); 577 - } 578 - }, 579 - } 580 - } 581 753 } 582 754 } 755 + } // end Seg::Card arm 756 + } // end match seg 583 757 }) 584 758 } 585 759 }