this repo has no description
1
fork

Configure Feed

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

feat: replace img_cloze with image rects in cloze cards

Remove CardKind::ImgCloze as a separate card type. Images now live
inside regular #cloze card bodies via #img("name") or native #image().
Drawn rectangles over image elements are stored in the sidecar under
CardSchedule::Cloze { rects: Vec<RectEntry> } where RectEntry gains a
src field identifying which image the rect belongs to.

tala-typst: collect image element bounding boxes (image_boxes) from the
rendered frame tree and return them in RenderResult.

tala/main.rs: dispatch draw-mode mouseup to sidecar when the drawn rect
overlaps an image box rather than text; display saved rects as yellow
SVG overlays on the active card using image-local → page-space mapping.

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

+212 -54
+1
Cargo.lock
··· 7890 7890 "image", 7891 7891 "rfd 0.15.4", 7892 7892 "tala-format", 7893 + "tala-srs", 7893 7894 "tala-typst", 7894 7895 "tokio", 7895 7896 ]
+4 -14
crates/tala-cli/src/main.rs
··· 107 107 .iter() 108 108 .filter(|c| matches!(c.kind, CardKind::Cloze { .. })) 109 109 .count(); 110 - let n_img = df 111 - .cards 112 - .iter() 113 - .filter(|c| matches!(c.kind, CardKind::ImgCloze { .. })) 114 - .count(); 115 - 116 110 println!("{}", df.typ_path.display()); 117 111 println!( 118 - " {} cards (front/back: {n_fb} cloze: {n_cloze} img-cloze: {n_img})", 112 + " {} cards (front/back: {n_fb} cloze: {n_cloze})", 119 113 df.cards.len() 120 114 ); 121 115 ··· 185 179 } 186 180 (CardKind::Cloze { blanks, .. }, sched) => { 187 181 let blank_schedules = match sched { 188 - Some(CardSchedule::Cloze { blanks: b }) => b.clone(), 182 + Some(CardSchedule::Cloze { blanks: b, .. }) => b.clone(), 189 183 _ => std::collections::HashMap::new(), 190 184 }; 191 185 for blank in blanks { ··· 201 195 }); 202 196 } 203 197 } 204 - } 205 - (CardKind::ImgCloze { .. }, _) => { 206 - // ImgCloze review requires the UI; skip in CLI. 207 198 } 208 199 } 209 200 } ··· 326 317 .entry(card_id.to_owned()) 327 318 .or_insert(CardSchedule::Cloze { 328 319 blanks: std::collections::HashMap::new(), 320 + rects: Vec::new(), 329 321 }); 330 - if let CardSchedule::Cloze { blanks } = entry { 322 + if let CardSchedule::Cloze { blanks, .. } = entry { 331 323 blanks.insert(sub_id.to_owned(), sched); 332 324 } 333 325 } 334 - CardKind::ImgCloze { .. } => {} 335 326 } 336 327 } 337 328 ··· 339 330 match kind { 340 331 CardKind::FrontBack { .. } => println!(" [front/back — render with tala-ui]"), 341 332 CardKind::Cloze { .. } => println!(" [cloze — render with tala-ui]"), 342 - CardKind::ImgCloze { src } => println!(" [img-cloze: {src}]"), 343 333 } 344 334 } 345 335
+2 -31
crates/tala-format/src/lib.rs
··· 25 25 body_span: Range<usize>, 26 26 blanks: Vec<BlankEntry>, 27 27 }, 28 - ImgCloze { 29 - src: String, 30 - }, 31 28 } 32 29 33 30 #[derive(Debug, Clone)] ··· 78 75 match name.as_str() { 79 76 "card" => parse_front_back(node, span), 80 77 "cloze" => parse_cloze(node, span), 81 - "img_cloze" => parse_img_cloze(node, span), 82 78 _ => None, 83 79 } 84 80 } ··· 224 220 }) 225 221 } 226 222 227 - fn parse_img_cloze(node: &LinkedNode<'_>, span: Range<usize>) -> Option<CardEntry> { 228 - let args = args_of(node)?; 229 - let (named, tags, _) = collect_args(&args); 230 - 231 - let src = named.get("src")?.clone(); 232 - 233 - Some(CardEntry { 234 - tags, 235 - span, 236 - kind: CardKind::ImgCloze { src }, 237 - }) 238 - } 239 223 240 224 // ── Blank extraction ────────────────────────────────────────────────────────── 241 225 ··· 287 271 #cloze[ 288 272 The resonant frequency is where #blank[inductance (ESL)] and #blank[capacitance (C)] cancel. 289 273 ] 290 - 291 - #img_cloze(src: "capacitor-photo") 292 274 "#; 293 275 294 276 #[test] 295 277 fn card_count() { 296 278 let cards = parse_cards(SAMPLE); 297 - assert_eq!(cards.len(), 4); 279 + assert_eq!(cards.len(), 3); 298 280 } 299 281 300 282 #[test] ··· 318 300 } 319 301 320 302 #[test] 321 - fn img_cloze_src() { 322 - let cards = parse_cards(SAMPLE); 323 - let CardKind::ImgCloze { src } = &cards[3].kind else { 324 - panic!() 325 - }; 326 - assert_eq!(src, "capacitor-photo"); 327 - } 328 - 329 - #[test] 330 303 fn tags_parsed() { 331 304 let cards = parse_cards(SAMPLE); 332 305 assert_eq!(cards[0].tags, vec!["circuits", "passive"]); ··· 346 319 for (i, card) in cards.iter().enumerate() { 347 320 let text = &SAMPLE[card.span.clone()]; 348 321 assert!( 349 - text.starts_with("card") 350 - || text.starts_with("cloze") 351 - || text.starts_with("img_cloze"), 322 + text.starts_with("card") || text.starts_with("cloze"), 352 323 "card {i} span text: {:?}", 353 324 &text[..20.min(text.len())] 354 325 );
+6 -3
crates/tala-srs/src/lib.rs
··· 27 27 Cloze { 28 28 /// Indexed by blank ID: "b0", "b1", … 29 29 blanks: HashMap<String, Schedule>, 30 - }, 31 - ImgCloze { 30 + /// Image region overlays drawn via Draw Cloze mode. 31 + #[serde(default, skip_serializing_if = "Vec::is_empty")] 32 32 rects: Vec<RectEntry>, 33 33 }, 34 34 } ··· 36 36 #[derive(Debug, Clone, Serialize, Deserialize)] 37 37 pub struct RectEntry { 38 38 pub id: String, 39 - /// Normalised [x, y, w, h] in [0, 1] relative to image dimensions. 39 + /// Image source name (matches the `#img("name")` call in the card body). 40 + pub src: String, 41 + /// Normalised [x, y, w, h] in [0, 1] relative to the image's own dimensions. 40 42 pub rect: [f32; 4], 41 43 pub schedule: Schedule, 42 44 } ··· 324 326 ); 325 327 m 326 328 }, 329 + rects: vec![], 327 330 }, 328 331 ); 329 332 s
+36 -3
crates/tala-typst/src/lib.rs
··· 36 36 pub glyph_map: Vec<([f32; 4], Range<usize>, bool)>, 37 37 /// Fragment-relative byte ranges of every `Equation` node in the fragment. 38 38 pub math_spans: Vec<Range<usize>>, 39 + /// Pixel-space [x, y, w, h] for each image element, in document order. 40 + pub image_boxes: Vec<[f32; 4]>, 39 41 } 40 42 41 43 // ── Entry point ─────────────────────────────────────────────────────────────── ··· 91 93 ) 92 94 }; 93 95 96 + let image_boxes = collect_image_boxes(&page.frame, 2.0, 0.0, 0.0); 94 97 let pixmap = typst_render_page(page, 2.0); // 2 px/pt ≈ 144 dpi 95 98 96 99 Ok(RenderResult { ··· 100 103 blank_boxes, 101 104 glyph_map, 102 105 math_spans, 106 + image_boxes, 103 107 }) 104 108 } 105 109 ··· 213 217 } 214 218 } 215 219 220 + // ── Image boxes ─────────────────────────────────────────────────────────────── 221 + 222 + /// Walk the frame tree and return pixel-space [x, y, w, h] for each image 223 + /// element, in document (layout) order. 224 + fn collect_image_boxes( 225 + frame: &typst::layout::Frame, 226 + scale: f32, 227 + ox: f32, 228 + oy: f32, 229 + ) -> Vec<[f32; 4]> { 230 + let mut boxes = Vec::new(); 231 + for (pos, item) in frame.items() { 232 + let ax = ox + pos.x.to_pt() as f32; 233 + let ay = oy + pos.y.to_pt() as f32; 234 + match item { 235 + typst::layout::FrameItem::Group(g) => { 236 + boxes.extend(collect_image_boxes(&g.frame, scale, ax, ay)); 237 + } 238 + typst::layout::FrameItem::Image(_, size, _) => { 239 + boxes.push([ 240 + ax * scale, 241 + ay * scale, 242 + size.x.to_pt() as f32 * scale, 243 + size.y.to_pt() as f32 * scale, 244 + ]); 245 + } 246 + _ => {} 247 + } 248 + } 249 + boxes 250 + } 251 + 216 252 // ── Glyph map (all fragment glyphs with source ranges and math flag) ────────── 217 253 218 254 /// Collect byte ranges of all `Equation` nodes in `source` that originate from ··· 366 402 } 367 403 #let blank(..args) = args.pos().at(0, default: []) 368 404 #let cloze(tags: (), ..args) = args.pos().at(0, default: []) 369 - #let img_cloze(src: "") = image("images/" + src) 370 405 #let img(name) = image("images/" + name) 371 406 "#; 372 407 ··· 376 411 #let card(dir: "fwd", tags: (), ..args) = args.pos().at(0, default: []) 377 412 #let blank(..args) = args.pos().at(0, default: []) 378 413 #let cloze(tags: (), ..args) = args.pos().at(0, default: []) 379 - #let img_cloze(src: "") = image("images/" + src) 380 414 #let img(name) = image("images/" + name) 381 415 "#; 382 416 ··· 393 427 } 394 428 #let blank(..args) = box(width: 4em, stroke: (bottom: 0.5pt), inset: (bottom: 2pt))[] 395 429 #let cloze(tags: (), ..args) = args.pos().at(0, default: []) 396 - #let img_cloze(src: "") = image("images/" + src) 397 430 #let img(name) = image("images/" + name) 398 431 "#; 399 432
+1
crates/tala/Cargo.toml
··· 10 10 dioxus = { version = "0.7.1", features = ["router"] } 11 11 tala-typst = { path = "../tala-typst" } 12 12 tala-format = { path = "../tala-format" } 13 + tala-srs = { path = "../tala-srs" } 13 14 image = { workspace = true } 14 15 base64 = "0.22" 15 16 tokio = { version = "1", features = ["time"] }
+162 -3
crates/tala/src/main.rs
··· 5 5 use dioxus::prelude::*; 6 6 use image::RgbaImage; 7 7 use tala_format::CardKind; 8 + use tala_srs::{CardSchedule, RectEntry, Schedule, Sidecar}; 8 9 9 10 const FAVICON: Asset = asset!("/assets/favicon.ico"); 10 11 const MAIN_CSS: Asset = asset!("/assets/main.css"); ··· 200 201 || s.starts_with("#card(") 201 202 || s.starts_with("#cloze[") 202 203 || s.starts_with("#cloze(") 203 - || s.starts_with("#img_cloze(") 204 + } 205 + 206 + /// Extract image source arguments in document order from a fragment. 207 + /// Handles both `#img("name")` (custom shorthand) and `#image("path")` (native typst). 208 + fn extract_img_names(source: &str) -> Vec<String> { 209 + let mut names = Vec::new(); 210 + let mut rest = source; 211 + while !rest.is_empty() { 212 + // Find the next #img( or #image( occurrence. 213 + let img_pos = rest.find("#img(\""); 214 + let image_pos = rest.find("#image(\""); 215 + let (pos, skip) = match (img_pos, image_pos) { 216 + (Some(a), Some(b)) => if a <= b { (a, 6) } else { (b, 8) }, 217 + (Some(a), None) => (a, 6), 218 + (None, Some(b)) => (b, 8), 219 + (None, None) => break, 220 + }; 221 + rest = &rest[pos + skip..]; 222 + if let Some(end) = rest.find('"') { 223 + names.push(rest[..end].to_string()); 224 + rest = &rest[end + 1..]; 225 + } 226 + } 227 + names 204 228 } 205 229 206 230 /// Split source into paragraph segments (blank-line separated) and classify each. ··· 260 284 glyph_map: Vec<([f64; 4], std::ops::Range<usize>, bool)>, 261 285 /// Fragment-relative byte ranges of every equation in the source. 262 286 math_spans: Vec<std::ops::Range<usize>>, 287 + /// Normalized [x, y, w, h] in [0.0, 1.0] for each image element, in document order. 288 + image_boxes: Vec<[f64; 4]>, 263 289 } 264 290 265 291 #[component] ··· 308 334 let mut blank_rects_sig = use_signal(Vec::<[f64; 4]>::new); 309 335 let mut glyph_map_sig = use_signal(Vec::<([f64; 4], std::ops::Range<usize>, bool)>::new); 310 336 let mut math_spans_sig = use_signal(Vec::<std::ops::Range<usize>>::new); 337 + let mut image_boxes_sig = use_signal(Vec::<[f64; 4]>::new); 338 + let mut sidecar_rects_sig = use_signal(Vec::<RectEntry>::new); 311 339 let mut draw_mode = use_signal(|| false); 312 340 let mut drag_start = use_signal(|| Option::<(f64, f64)>::None); 313 341 let mut drag_current = use_signal(|| Option::<(f64, f64)>::None); ··· 322 350 let text = source.peek().clone(); // no subscription 323 351 let segs = make_segments(&text); 324 352 let ai = idx.min(segs.len().saturating_sub(1)); 353 + let card_idx = segs[..=ai] 354 + .iter() 355 + .filter(|s| s.kind == SegKind::Card) 356 + .count() 357 + .saturating_sub(1); 325 358 match segs.get(ai) { 326 359 Some(Seg { 327 360 kind: SegKind::Card, ··· 331 364 blank_rects_sig.set(data.blank_rects.clone()); 332 365 glyph_map_sig.set(data.glyph_map.clone()); 333 366 math_spans_sig.set(data.math_spans.clone()); 367 + image_boxes_sig.set(data.image_boxes.clone()); 334 368 } 369 + // Load saved image rects for this card from sidecar. 370 + let rects = Sidecar::load_or_empty_for(&cards_path()) 371 + .ok() 372 + .and_then(|sc| { 373 + if let Some(CardSchedule::Cloze { rects, .. }) = 374 + sc.cards.get(&card_idx.to_string()) 375 + { 376 + Some(rects.clone()) 377 + } else { 378 + None 379 + } 380 + }) 381 + .unwrap_or_default(); 382 + sidecar_rects_sig.set(rects); 335 383 } 336 384 _ => { 337 385 blank_rects_sig.set(Vec::new()); 338 386 glyph_map_sig.set(Vec::new()); 339 387 math_spans_sig.set(Vec::new()); 388 + image_boxes_sig.set(Vec::new()); 389 + sidecar_rects_sig.set(Vec::new()); 340 390 } 341 391 } 342 392 }); ··· 443 493 let dc = *drag_current.read(); 444 494 let drawn = drawn_boxes.read().clone(); 445 495 let blank_rects = blank_rects_sig.read().clone(); 496 + let active_img_boxes = image_boxes_sig.read().clone(); 497 + let active_sidecar_rects = sidecar_rects_sig.read().clone(); 498 + let active_img_names = extract_img_names(&active_fragment); 446 499 447 500 rsx! { 448 501 div { id: "editor", ··· 584 637 } 585 638 } 586 639 if is_active { 640 + for sr in &active_sidecar_rects { 641 + { 642 + let img_idx = active_img_names.iter() 643 + .position(|n| n == &sr.src) 644 + .unwrap_or(0); 645 + let ib = active_img_boxes.get(img_idx).copied().unwrap_or([0.0, 0.0, 1.0, 1.0]); 646 + let px = (ib[0] + sr.rect[0] as f64 * ib[2]) * iw; 647 + let py = (ib[1] + sr.rect[1] as f64 * ib[3]) * ih; 648 + let pw = sr.rect[2] as f64 * ib[2] * iw; 649 + let ph = sr.rect[3] as f64 * ib[3] * ih; 650 + 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", 657 + } 658 + } 659 + } 660 + } 661 + } 662 + if is_active { 587 663 for rect in &drawn { 588 664 rect { 589 665 x: "{rect[0] * iw}", y: "{rect[1] * ih}", ··· 674 750 .collect(); 675 751 676 752 if hits.is_empty() { 677 - insert_error.set(Some("No text found in drawn region.".into())); 678 - drawn_boxes.write().push(drawn_rect); 753 + // Check if the rect overlaps an image element. 754 + let img_boxes = image_boxes_sig.read().clone(); 755 + let img_hit = img_boxes 756 + .iter() 757 + .enumerate() 758 + .find(|(_, ib)| rects_overlap(drawn_rect, **ib)); 759 + if let Some((img_idx, img_box)) = img_hit { 760 + let cur = source.read().clone(); 761 + let segs = make_segments(&cur); 762 + let ai = (*active_idx.read()).min(segs.len().saturating_sub(1)); 763 + let card_key = { 764 + let card_idx = segs[..=ai] 765 + .iter() 766 + .filter(|s| s.kind == SegKind::Card) 767 + .count() 768 + .saturating_sub(1); 769 + card_idx.to_string() 770 + }; 771 + let fragment = segs.get(ai).map(|s| &cur[s.start..s.end]).unwrap_or(""); 772 + let src = extract_img_names(fragment) 773 + .into_iter() 774 + .nth(img_idx) 775 + .unwrap_or_default(); 776 + // Convert drawn_rect (page-normalized) to image-local [0,1]. 777 + let [ix, iy, iw, ih] = *img_box; 778 + let local_rect = [ 779 + ((drawn_rect[0] - ix) / iw).clamp(0.0, 1.0) as f32, 780 + ((drawn_rect[1] - iy) / ih).clamp(0.0, 1.0) as f32, 781 + (drawn_rect[2] / iw).clamp(0.0, 1.0) as f32, 782 + (drawn_rect[3] / ih).clamp(0.0, 1.0) as f32, 783 + ]; 784 + let typ_path = cards_path(); 785 + if let Ok(mut sidecar) = Sidecar::load_or_empty_for(&typ_path) { 786 + // Scope the mutable borrow of sidecar.cards so 787 + // save_for can borrow sidecar immutably afterward. 788 + let updated_rects = { 789 + let entry = sidecar 790 + .cards 791 + .entry(card_key) 792 + .or_insert(CardSchedule::Cloze { 793 + blanks: std::collections::HashMap::new(), 794 + rects: Vec::new(), 795 + }); 796 + if let CardSchedule::Cloze { rects, .. } = entry { 797 + let rect_id = format!("r{}", rects.len()); 798 + rects.push(RectEntry { 799 + id: rect_id, 800 + src, 801 + rect: local_rect, 802 + schedule: Schedule { 803 + due: tala_srs::today_str(), 804 + stability: 0.0, 805 + difficulty: 0.0, 806 + }, 807 + }); 808 + Some(rects.clone()) 809 + } else { 810 + None 811 + } 812 + }; 813 + if let Some(rects) = updated_rects { 814 + let _ = sidecar.save_for(&typ_path); 815 + sidecar_rects_sig.set(rects); 816 + } 817 + } 818 + insert_error.set(None); 819 + drawn_boxes.write().clear(); 820 + } else { 821 + insert_error.set(Some("No text or image found in drawn region.".into())); 822 + drawn_boxes.write().push(drawn_rect); 823 + } 679 824 return; 680 825 } 681 826 let all_math = hits.iter().all(|(_, _, m)| *m); ··· 1129 1274 }) 1130 1275 .collect(); 1131 1276 1277 + let image_boxes = result 1278 + .image_boxes 1279 + .iter() 1280 + .map(|b| { 1281 + [ 1282 + b[0] as f64 / iw, 1283 + b[1] as f64 / ih, 1284 + b[2] as f64 / iw, 1285 + b[3] as f64 / ih, 1286 + ] 1287 + }) 1288 + .collect(); 1289 + 1132 1290 Ok(PreviewData { 1133 1291 b64: base64::engine::general_purpose::STANDARD.encode(buf.into_inner()), 1134 1292 img_w: result.width, ··· 1136 1294 blank_rects, 1137 1295 glyph_map, 1138 1296 math_spans: result.math_spans, 1297 + image_boxes, 1139 1298 }) 1140 1299 }