this repo has no description
1
fork

Configure Feed

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

refactor: split editor.rs into submodules; fix format data-loss bug

Splits crates/tala/src/editor.rs into focused submodules:
blank_wrap, draw_ops, format, image_paste, render, segments, sidecar_ops.

Fixes a data-loss bug in format_card_frag: when the user deletes the
opening `#blank[` but leaves the closing `]`, parse_card_structure finds
one balanced block ending at that orphaned `]`, leaving trailing content
(e.g. `ghi`) unaccounted for. The formatter then reconstructed the card
from only the parsed block, silently dropping the suffix. Fix: bail out
of formatting when non-whitespace content follows the last block's `]`.

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

+2120 -2109
-2109
crates/tala/src/editor.rs
··· 1 - use std::path::{Path}; 2 - use std::time::Duration; 3 - 4 - use base64::Engine as _; 5 - use dioxus::prelude::*; 6 - use image::RgbaImage; 7 - use tala_format::{CardKind}; 8 - use tala_srs::{CardSchedule, RectEntry, Schedule, Sidecar}; 9 - 10 - use crate::util::{TagMode, card_dir, cards_path, cloze_color, cloze_fill_css, cloze_stroke_css, extract_img_names}; 11 - 12 - #[derive(Clone, PartialEq)] 13 - enum SaveStatus { 14 - Clean, 15 - Dirty, 16 - Saved, 17 - Error(String), 18 - } 19 - 20 - /// Whether a paragraph segment is a card definition or plain text. 21 - #[derive(Clone, PartialEq)] 22 - enum SegKind { 23 - Card, 24 - Text, 25 - } 26 - 27 - /// A blank-line-delimited paragraph in the source file. 28 - #[derive(Clone)] 29 - struct Seg { 30 - start: usize, 31 - end: usize, 32 - kind: SegKind, 33 - } 34 - 35 - /// True if a source fragment looks like a card definition by prefix alone. 36 - fn is_card_frag(s: &str) -> bool { 37 - let s = s.trim_start(); 38 - let tail = if s.starts_with("#card") { 39 - &s[5..] 40 - } else if s.starts_with("#cloze") { 41 - &s[6..] 42 - } else { 43 - return false; 44 - }; 45 - // Allow `#card[`, `#card(`, `#card\n[` (multiline formatted), and bare `#card`. 46 - matches!( 47 - tail.as_bytes().first(), 48 - None | Some(b'[') | Some(b'(') | Some(b'\n') | Some(b'\r') 49 - ) 50 - } 51 - 52 - 53 - /// Split source into paragraph segments (blank-line separated) and classify each. 54 - /// Typst is never called here — classification is a pure prefix check. 55 - fn make_segments(source: &str) -> Vec<Seg> { 56 - let bytes = source.as_bytes(); 57 - let len = bytes.len(); 58 - let mut segs = Vec::new(); 59 - let mut seg_start = 0; 60 - let mut i = 0; 61 - while i < len { 62 - if bytes[i] == b'\n' && i + 1 < len && bytes[i + 1] == b'\n' { 63 - let chunk = &source[seg_start..i]; 64 - if !chunk.trim().is_empty() { 65 - let kind = if is_card_frag(chunk) { 66 - SegKind::Card 67 - } else { 68 - SegKind::Text 69 - }; 70 - segs.push(Seg { 71 - start: seg_start, 72 - end: i, 73 - kind, 74 - }); 75 - } 76 - while i < len && bytes[i] == b'\n' { 77 - i += 1; 78 - } 79 - seg_start = i; 80 - } else { 81 - i += 1; 82 - } 83 - } 84 - if seg_start < len && !source[seg_start..].trim().is_empty() { 85 - let kind = if is_card_frag(&source[seg_start..]) { 86 - SegKind::Card 87 - } else { 88 - SegKind::Text 89 - }; 90 - segs.push(Seg { 91 - start: seg_start, 92 - end: len, 93 - kind, 94 - }); 95 - } 96 - segs 97 - } 98 - 99 - #[derive(Clone)] 100 - struct PreviewData { 101 - b64: String, 102 - img_w: u32, 103 - img_h: u32, 104 - /// Per-blank list of normalized [x, y, w, h] rects in [0.0, 1.0], one per rendered line. 105 - blank_rects: Vec<Vec<[f64; 4]>>, 106 - /// Normalized rect, fragment-relative byte range, is_in_math for every glyph. 107 - glyph_map: Vec<([f64; 4], std::ops::Range<usize>, bool)>, 108 - /// Fragment-relative byte ranges of every equation in the source. 109 - math_spans: Vec<std::ops::Range<usize>>, 110 - /// Normalized [x, y, w, h] in [0.0, 1.0] for each image element, in document order. 111 - image_boxes: Vec<[f64; 4]>, 112 - } 113 - 114 - #[derive(Clone, Copy, PartialEq, Debug)] 115 - enum DragEdge { 116 - Top, 117 - Bottom, 118 - Left, 119 - Right, 120 - } 121 - 122 - 123 - fn active_card_key(source: &str, active_idx: usize) -> String { 124 - let segs = make_segments(source); 125 - let ai = active_idx.min(segs.len().saturating_sub(1)); 126 - let card_pos = segs[..=ai] 127 - .iter() 128 - .filter(|s| s.kind == SegKind::Card) 129 - .count() 130 - .saturating_sub(1); 131 - let cards = tala_format::parse_cards(source); 132 - cards.get(card_pos) 133 - .and_then(|c| c.id.clone()) 134 - .unwrap_or_else(|| card_pos.to_string()) 135 - } 136 - 137 - fn commit_sidecar_rects(rects: Vec<RectEntry>, card_key: &str) { 138 - let typ_path = cards_path(); 139 - if let Ok(mut sc) = Sidecar::load_or_empty_for(&typ_path) { 140 - if let Some(CardSchedule::Cloze { rects: r, .. }) = sc.cards.get_mut(card_key) { 141 - *r = rects; 142 - let _ = sc.save_for(&typ_path); 143 - } 144 - } 145 - } 146 - 147 - fn delete_sidecar_rect(idx: usize, source: &str, active_idx: usize) -> Option<Vec<RectEntry>> { 148 - let card_key = active_card_key(source, active_idx); 149 - let typ_path = cards_path(); 150 - let mut sc = Sidecar::load_or_empty_for(&typ_path).ok()?; 151 - if let Some(CardSchedule::Cloze { rects, .. }) = sc.cards.get_mut(&card_key) { 152 - if idx < rects.len() { 153 - rects.remove(idx); 154 - let updated = rects.clone(); 155 - sc.save_for(&typ_path).ok()?; 156 - return Some(updated); 157 - } 158 - } 159 - None 160 - } 161 - 162 - async fn edge_drag_task( 163 - rect_idx: usize, 164 - edge: DragEdge, 165 - img_box: [f64; 4], 166 - mut rects_sig: Signal<Vec<RectEntry>>, 167 - mut drag_active: Signal<bool>, 168 - source: Signal<String>, 169 - active_idx: Signal<usize>, 170 - ) { 171 - let [bx, by, bw, bh] = img_box; 172 - let mut eval = document::eval( 173 - r#" 174 - var svg = document.querySelector('.card-row.active .cloze-overlay'); 175 - var bb = svg.getBoundingClientRect(); 176 - function onMove(e) { 177 - dioxus.send([0.0, (e.clientX-bb.left)/bb.width, (e.clientY-bb.top)/bb.height]); 178 - } 179 - function onUp() { 180 - document.removeEventListener('mousemove', onMove); 181 - document.removeEventListener('mouseup', onUp); 182 - document.addEventListener('click', function(e){ e.stopPropagation(); }, { capture: true, once: true }); 183 - dioxus.send([1.0, 0.0, 0.0]); 184 - } 185 - document.addEventListener('mousemove', onMove); 186 - document.addEventListener('mouseup', onUp); 187 - "#, 188 - ); 189 - loop { 190 - match eval.recv::<[f64; 3]>().await { 191 - Ok([kind, nx, ny]) => { 192 - if kind < 0.5 { 193 - let local_x = ((nx - bx) / bw) as f32; 194 - let local_y = ((ny - by) / bh) as f32; 195 - let mut rects = rects_sig.read().clone(); 196 - if let Some(r) = rects.get_mut(rect_idx) { 197 - match edge { 198 - DragEdge::Top => { 199 - let bot = r.rect[1] + r.rect[3]; 200 - r.rect[1] = local_y.clamp(0.0, bot - 0.01); 201 - r.rect[3] = bot - r.rect[1]; 202 - } 203 - DragEdge::Bottom => { 204 - r.rect[3] = (local_y - r.rect[1]).max(0.01); 205 - } 206 - DragEdge::Left => { 207 - let right = r.rect[0] + r.rect[2]; 208 - r.rect[0] = local_x.clamp(0.0, right - 0.01); 209 - r.rect[2] = right - r.rect[0]; 210 - } 211 - DragEdge::Right => { 212 - r.rect[2] = (local_x - r.rect[0]).max(0.01); 213 - } 214 - } 215 - rects_sig.set(rects); 216 - } 217 - } else { 218 - drag_active.set(false); 219 - let cur = source.peek().clone(); 220 - let card_key = active_card_key(&cur, *active_idx.peek()); 221 - commit_sidecar_rects(rects_sig.read().clone(), &card_key); 222 - break; 223 - } 224 - } 225 - Err(_) => { 226 - drag_active.set(false); 227 - break; 228 - } 229 - } 230 - } 231 - } 232 - 233 - async fn move_drag_task( 234 - rect_idx: usize, 235 - img_box: [f64; 4], 236 - mut rects_sig: Signal<Vec<RectEntry>>, 237 - mut drag_active: Signal<bool>, 238 - source: Signal<String>, 239 - active_idx: Signal<usize>, 240 - ) { 241 - let [_bx, _by, bw, bh] = img_box; 242 - let mut eval = document::eval( 243 - r#" 244 - var svg = document.querySelector('.card-row.active .cloze-overlay'); 245 - var bb = svg.getBoundingClientRect(); 246 - function onMove(e) { 247 - dioxus.send([0.0, (e.clientX-bb.left)/bb.width, (e.clientY-bb.top)/bb.height]); 248 - } 249 - function onUp() { 250 - document.removeEventListener('mousemove', onMove); 251 - document.removeEventListener('mouseup', onUp); 252 - document.addEventListener('click', function(e){ e.stopPropagation(); }, { capture: true, once: true }); 253 - dioxus.send([1.0, 0.0, 0.0]); 254 - } 255 - document.addEventListener('mousemove', onMove); 256 - document.addEventListener('mouseup', onUp); 257 - "#, 258 - ); 259 - let mut last: Option<(f64, f64)> = None; 260 - loop { 261 - match eval.recv::<[f64; 3]>().await { 262 - Ok([kind, nx, ny]) => { 263 - if kind < 0.5 { 264 - if let Some((lx, ly)) = last { 265 - let dx = ((nx - lx) / bw) as f32; 266 - let dy = ((ny - ly) / bh) as f32; 267 - let mut rects = rects_sig.read().clone(); 268 - if let Some(r) = rects.get_mut(rect_idx) { 269 - r.rect[0] = (r.rect[0] + dx).clamp(0.0, 1.0 - r.rect[2]); 270 - r.rect[1] = (r.rect[1] + dy).clamp(0.0, 1.0 - r.rect[3]); 271 - rects_sig.set(rects); 272 - } 273 - } 274 - last = Some((nx, ny)); 275 - } else { 276 - drag_active.set(false); 277 - let cur = source.peek().clone(); 278 - let card_key = active_card_key(&cur, *active_idx.peek()); 279 - commit_sidecar_rects(rects_sig.read().clone(), &card_key); 280 - break; 281 - } 282 - } 283 - Err(_) => { 284 - drag_active.set(false); 285 - break; 286 - } 287 - } 288 - } 289 - } 290 - 291 - 292 - // ── Image paste helpers ─────────────────────────────────────────────────────── 293 - 294 - fn paste_image_filename() -> String { 295 - use std::time::{SystemTime, UNIX_EPOCH}; 296 - let secs = SystemTime::now() 297 - .duration_since(UNIX_EPOCH) 298 - .unwrap_or_default() 299 - .as_secs() as i64; 300 - // Date part (Gregorian, from http://howardhinnant.github.io/date_algorithms.html) 301 - let days = secs / 86400; 302 - let z = days + 719468; 303 - let era = if z >= 0 { z } else { z - 146096 } / 146097; 304 - let doe = z - era * 146097; 305 - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; 306 - let y = yoe + era * 400; 307 - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); 308 - let mp = (5 * doy + 2) / 153; 309 - let d = doy - (153 * mp + 2) / 5 + 1; 310 - let m = if mp < 10 { mp + 3 } else { mp - 9 }; 311 - let y = if m <= 2 { y + 1 } else { y }; 312 - let tod = (secs % 86400) as u32; 313 - let h = tod / 3600; 314 - let min = (tod % 3600) / 60; 315 - let s = tod % 60; 316 - format!("Pasted-image-{y:04}{m:02}{d:02}-{h:02}{min:02}{s:02}.png") 317 - } 318 - 319 - /// Reads an image from the OS clipboard and encodes it as PNG bytes. 320 - /// Intended to run on a dedicated OS thread (arboard is not Send on Linux). 321 - fn read_clipboard_png() -> Option<(Vec<u8>, String)> { 322 - let img_data = arboard::Clipboard::new().and_then(|mut c| c.get_image()).ok()?; 323 - let rgba = image::RgbaImage::from_raw( 324 - img_data.width as u32, 325 - img_data.height as u32, 326 - img_data.bytes.into_owned(), 327 - )?; 328 - let mut png_bytes: Vec<u8> = Vec::new(); 329 - image::DynamicImage::ImageRgba8(rgba) 330 - .write_to(&mut std::io::Cursor::new(&mut png_bytes), image::ImageFormat::Png) 331 - .ok()?; 332 - Some((png_bytes, paste_image_filename())) 333 - } 334 - 335 - fn apply_pasted_image( 336 - mut source: Signal<String>, 337 - active_idx: Signal<usize>, 338 - mut save_status: Signal<SaveStatus>, 339 - png_bytes: Vec<u8>, 340 - filename: String, 341 - ) { 342 - let stem = filename.trim_end_matches(".png").to_string(); 343 - if std::fs::write(card_dir().join("images").join(&filename), &png_bytes).is_err() { 344 - return; 345 - } 346 - 347 - // Append \n#img("stem") at end of active segment. 348 - let cur = source.peek().clone(); 349 - let segs = make_segments(&cur); 350 - let ai = (*active_idx.peek()).min(segs.len().saturating_sub(1)); 351 - let snippet = format!("\n#img(\"{}\")", stem); 352 - let new_src = if let Some(seg) = segs.get(ai) { 353 - format!("{}{}{}", &cur[..seg.end], snippet, &cur[seg.end..]) 354 - } else { 355 - format!("{}{}", cur, snippet) 356 - }; 357 - source.set(new_src.clone()); 358 - save_status.set(SaveStatus::Dirty); 359 - 360 - // Update textarea to reflect the new fragment. 361 - let new_segs = make_segments(&new_src); 362 - let ai2 = ai.min(new_segs.len().saturating_sub(1)); 363 - let new_frag = new_segs.get(ai2).map(|s| new_src[s.start..s.end].to_string()).unwrap_or_default(); 364 - let b64 = base64::engine::general_purpose::STANDARD.encode(&new_frag); 365 - spawn(async move { 366 - document::eval(&format!( 367 - "var t=document.querySelector('.card-row.active textarea');\ 368 - if(t)t.value=atob('{}');", b64 369 - )).await.ok(); 370 - }); 371 - } 372 - 373 - 374 - /// Convert element-local `offsetX/offsetY` to normalized [0,1] coords. 375 - /// 376 - /// `dims` is `[offsetWidth, offsetHeight, cssZoom]`. 377 - /// In webkit2gtk with CSS `zoom` on `<html>`, `offsetX/Y` are in zoomed (visual) 378 - /// pixels while `offsetWidth/Height` are in logical pixels. Dividing out the 379 - /// zoom factor before normalizing corrects this. 380 - fn normalize_draw_coords(offset_x: f64, offset_y: f64, dims: [f64; 3]) -> (f64, f64) { 381 - let [w, h, zoom] = dims; 382 - let z = zoom.max(0.1); 383 - ( 384 - (offset_x / z / w.max(1.0)).clamp(0.0, 1.0), 385 - (offset_y / z / h.max(1.0)).clamp(0.0, 1.0), 386 - ) 387 - } 388 - 389 - /// True if two normalized [x, y, w, h] rects have any overlap. 390 - fn rects_overlap(a: [f64; 4], b: [f64; 4]) -> bool { 391 - a[0] < b[0] + b[2] && a[0] + a[2] > b[0] && a[1] < b[1] + b[3] && a[1] + a[3] > b[1] 392 - } 393 - 394 - /// Wrap `source[start..end]` in `#blank[...]`, keeping surrounding whitespace outside. 395 - fn insert_blank_wrap(source: &str, start: usize, end: usize) -> String { 396 - let raw = &source[start..end]; 397 - let inner = raw.trim(); 398 - let lead = raw.len() - raw.trim_start().len(); 399 - let trail = raw.len() - raw.trim_end().len(); 400 - format!( 401 - "{}{}#blank[{}]{}{}", 402 - &source[..start], 403 - &source[start..start + lead], 404 - inner, 405 - &source[end - trail..end], 406 - &source[end..], 407 - ) 408 - } 409 - 410 - /// Given a raw glyph-based `sel_start..sel_end` within `eq_content` (the inner 411 - /// text of an equation, excluding `$` delimiters), expand the selection so it: 412 - /// 1. Extends `sel_end` to the end of any incomplete identifier token. 413 - /// 2. Includes a trailing `(...)` function-call argument if the token is followed by one. 414 - /// 3. Re-balances any unclosed `()`, `[]`, `{}` pairs. 415 - fn expand_math_selection(eq_content: &str, sel_start: usize, sel_end: usize) -> (usize, usize) { 416 - let b = eq_content.as_bytes(); 417 - let len = b.len(); 418 - let mut end = sel_end.min(len); 419 - 420 - // 1. Expand to end of identifier/number token if sel_end is mid-token. 421 - let is_alnum = |c: u8| c.is_ascii_alphanumeric() || c == b'_'; 422 - if end > 0 && end < len && is_alnum(b[end - 1]) && is_alnum(b[end]) { 423 - while end < len && is_alnum(b[end]) { 424 - end += 1; 425 - } 426 - } 427 - 428 - // 2. If immediately followed by `(`, include the matched parenthesised group. 429 - if end < len && b[end] == b'(' { 430 - end += 1; 431 - let mut depth = 1usize; 432 - while end < len && depth > 0 { 433 - match b[end] { 434 - b'(' => depth += 1, 435 - b')' => { 436 - depth -= 1; 437 - } 438 - _ => {} 439 - } 440 - end += 1; 441 - } 442 - } 443 - 444 - // 3. Balance any unclosed delimiters in sel_start..end. 445 - let (mut parens, mut brackets, mut braces) = (0i32, 0i32, 0i32); 446 - for &c in &b[sel_start..end] { 447 - match c { 448 - b'(' => parens += 1, 449 - b')' => parens -= 1, 450 - b'[' => brackets += 1, 451 - b']' => brackets -= 1, 452 - b'{' => braces += 1, 453 - b'}' => braces -= 1, 454 - _ => {} 455 - } 456 - } 457 - while end < len && (parens > 0 || brackets > 0 || braces > 0) { 458 - match b[end] { 459 - b'(' => parens += 1, 460 - b')' => parens -= 1, 461 - b'[' => brackets += 1, 462 - b']' => brackets -= 1, 463 - b'{' => braces += 1, 464 - b'}' => braces -= 1, 465 - _ => {} 466 - } 467 - end += 1; 468 - } 469 - 470 - // 4. Balance any unclosed typst string literal (`"..."`) in sel_start..end. 471 - // Quotes use the same character for open and close, so an odd count means 472 - // the selection ends inside a string. 473 - let quote_count = b[sel_start..end].iter().filter(|&&c| c == b'"').count(); 474 - if quote_count % 2 != 0 { 475 - while end < len { 476 - end += 1; 477 - if b[end - 1] == b'"' { 478 - break; 479 - } 480 - } 481 - } 482 - 483 - (sel_start, end) 484 - } 485 - 486 - /// Split an equation at `sel_start..sel_end` (fragment-relative), wrapping the 487 - /// selection in `#blank[...]`. 488 - /// 489 - /// For **display math** (`$ ... $`): the blank is inserted *inside* the single 490 - /// display math block so the equation stays on one line. The blank's content 491 - /// uses inline math `$...$`. 492 - /// `$ A = #blank[$B$] $` 493 - /// 494 - /// For **inline math** (`$...$`): the equation is split into separate inline 495 - /// spans around the blank (existing behaviour). 496 - /// `$A +$ #blank[$b$] $+ C$` 497 - /// 498 - /// `eq_start..eq_end` are the fragment-relative bounds of the full equation node 499 - /// (including the `$` delimiters). Empty left or right parts are omitted. 500 - fn insert_blank_wrap_math( 501 - source: &str, 502 - sel_start: usize, 503 - sel_end: usize, 504 - eq_start: usize, 505 - eq_end: usize, 506 - ) -> String { 507 - // Detect display math (`$ ... $`) vs inline math (`$...$`). 508 - let is_display = source[eq_start..].starts_with("$ ") 509 - && source[..eq_end].ends_with(" $"); 510 - let (open, close) = if is_display { ("$ ", " $") } else { ("$", "$") }; 511 - let content_start = eq_start + open.len(); 512 - let content_end = eq_end - close.len(); 513 - 514 - let left_inner = source[content_start..sel_start].trim_end(); 515 - let selected = source[sel_start..sel_end].trim(); 516 - let right_inner = source[sel_end..content_end].trim_start(); 517 - 518 - if is_display { 519 - // Keep everything inside one display math block. 520 - // The blank's content is inline math so it doesn't break the block. 521 - let left_sep = if left_inner.is_empty() { "" } else { " " }; 522 - let right_sep = if right_inner.is_empty() { "" } else { " " }; 523 - format!( 524 - "{}$ {}{}#blank[${}$]{}{} ${}", 525 - &source[..eq_start], 526 - left_inner, 527 - left_sep, 528 - selected, 529 - right_sep, 530 - right_inner, 531 - &source[eq_end..] 532 - ) 533 - } else { 534 - let left_part = if left_inner.is_empty() { 535 - String::new() 536 - } else { 537 - format!("{}{}{} ", open, left_inner, close) 538 - }; 539 - let blank_part = format!("#blank[{}{}{}]", open, selected, close); 540 - let right_part = if right_inner.is_empty() { 541 - String::new() 542 - } else { 543 - format!(" {}{}{}", open, right_inner, close) 544 - }; 545 - format!( 546 - "{}{}{}{}{}", 547 - &source[..eq_start], 548 - left_part, 549 - blank_part, 550 - right_part, 551 - &source[eq_end..] 552 - ) 553 - } 554 - } 555 - 556 - /// Remove whitespace between a card/cloze head and its first `[` content block. 557 - /// Typst markup mode does not attach `[...]` as a content argument when a newline 558 - /// precedes it, so `#card\n[...]` renders incorrectly. Returns the normalized 559 - /// string and the number of characters stripped (used to adjust blank spans). 560 - fn strip_head_whitespace(frag: &str) -> (String, usize) { 561 - let bytes = frag.as_bytes(); 562 - let mut paren_depth = 0i32; 563 - let mut bracket_pos = None; 564 - for (i, &b) in bytes.iter().enumerate() { 565 - match b { 566 - b'(' => paren_depth += 1, 567 - b')' => paren_depth -= 1, 568 - b'[' if paren_depth == 0 => { 569 - bracket_pos = Some(i); 570 - break; 571 - } 572 - _ => {} 573 - } 574 - } 575 - let Some(pos) = bracket_pos else { 576 - return (frag.to_string(), 0); 577 - }; 578 - let head = &frag[..pos]; 579 - let trimmed = head.trim_end(); 580 - let stripped = head.len() - trimmed.len(); 581 - if stripped == 0 { 582 - return (frag.to_string(), 0); 583 - } 584 - (format!("{}{}", trimmed, &frag[pos..]), stripped) 585 - } 586 - 587 - fn render_preview(source: &str, dir: &Path) -> Result<PreviewData, String> { 588 - let deck_dir = dir.to_path_buf(); 589 - 590 - // Collect content_spans of all blanks in document order. 591 - let cards = tala_format::parse_cards(source); 592 - let blank_spans: Vec<std::ops::Range<usize>> = cards 593 - .into_iter() 594 - .filter_map(|c| { 595 - if let CardKind::Cloze { blanks, .. } = c.kind { 596 - Some(blanks) 597 - } else { 598 - None 599 - } 600 - }) 601 - .flatten() 602 - .map(|b| b.content_span) 603 - .collect(); 604 - 605 - // Normalize `#card\n[` → `#card[` so typst attaches content blocks correctly. 606 - let (render_source, stripped) = strip_head_whitespace(source); 607 - let render_spans: Vec<std::ops::Range<usize>> = blank_spans 608 - .iter() 609 - .map(|r| r.start.saturating_sub(stripped)..r.end.saturating_sub(stripped)) 610 - .collect(); 611 - 612 - let result = tala_typst::render( 613 - &deck_dir, 614 - &render_source, 615 - tala_typst::Preamble::Authoring, 616 - &render_spans, 617 - ) 618 - .map_err(|e| e.to_string())?; 619 - 620 - // Normalize blank boxes to [0,1]. 621 - let iw = result.width as f64; 622 - let ih = result.height as f64; 623 - let blank_rects = result 624 - .blank_boxes 625 - .iter() 626 - .map(|line_rects| { 627 - line_rects 628 - .iter() 629 - .filter(|b| b[2] > 0.0) // skip zero-width (not found) 630 - .map(|b| { 631 - [ 632 - b[0] as f64 / iw, 633 - b[1] as f64 / ih, 634 - b[2] as f64 / iw, 635 - b[3] as f64 / ih, 636 - ] 637 - }) 638 - .collect::<Vec<_>>() 639 - }) 640 - .collect(); 641 - 642 - // Premultiplied → straight alpha. 643 - let straight: Vec<u8> = result 644 - .rgba 645 - .chunks_exact(4) 646 - .flat_map(|px| { 647 - let a = px[3]; 648 - if a == 0 { 649 - [0u8, 0, 0, 0] 650 - } else { 651 - let af = a as f32 / 255.0; 652 - [ 653 - (px[0] as f32 / af).min(255.0) as u8, 654 - (px[1] as f32 / af).min(255.0) as u8, 655 - (px[2] as f32 / af).min(255.0) as u8, 656 - a, 657 - ] 658 - } 659 - }) 660 - .collect(); 661 - 662 - let img = RgbaImage::from_raw(result.width, result.height, straight) 663 - .ok_or("image buffer size mismatch")?; 664 - 665 - let mut buf = std::io::Cursor::new(Vec::new()); 666 - image::DynamicImage::ImageRgba8(img) 667 - .write_to(&mut buf, image::ImageFormat::Png) 668 - .map_err(|e| e.to_string())?; 669 - 670 - let glyph_map = result 671 - .glyph_map 672 - .iter() 673 - .map(|(rect, range, in_math)| { 674 - ( 675 - [ 676 - rect[0] as f64 / iw, 677 - rect[1] as f64 / ih, 678 - rect[2] as f64 / iw, 679 - rect[3] as f64 / ih, 680 - ], 681 - range.clone(), 682 - *in_math, 683 - ) 684 - }) 685 - .collect(); 686 - 687 - let image_boxes = result 688 - .image_boxes 689 - .iter() 690 - .map(|b| { 691 - [ 692 - b[0] as f64 / iw, 693 - b[1] as f64 / ih, 694 - b[2] as f64 / iw, 695 - b[3] as f64 / ih, 696 - ] 697 - }) 698 - .collect(); 699 - 700 - Ok(PreviewData { 701 - b64: base64::engine::general_purpose::STANDARD.encode(buf.into_inner()), 702 - img_w: result.width, 703 - img_h: result.height, 704 - blank_rects, 705 - glyph_map, 706 - math_spans: result.math_spans, 707 - image_boxes, 708 - }) 709 - } 710 - 711 - // ── Card autoformat helpers ─────────────────────────────────────────────────── 712 - 713 - /// Find the head boundary and content block spans in a card fragment. 714 - /// 715 - /// Returns `(head_end, blocks)` where `head_end` is the byte index of the first 716 - /// top-level `[` and each element of `blocks` is `(content_start, content_end)`: 717 - /// `content_start` is one past `[` and `content_end` is the index of the matching `]`. 718 - fn parse_card_structure(frag: &str) -> Option<(usize, Vec<(usize, usize)>)> { 719 - let b = frag.as_bytes(); 720 - let len = b.len(); 721 - 722 - // Find head_end: first `[` not inside parentheses. 723 - let mut paren_depth = 0i32; 724 - let mut head_end = None; 725 - for i in 0..len { 726 - match b[i] { 727 - b'(' => paren_depth += 1, 728 - b')' => paren_depth -= 1, 729 - b'[' if paren_depth == 0 => { 730 - head_end = Some(i); 731 - break; 732 - } 733 - _ => {} 734 - } 735 - } 736 - let head_end = head_end?; 737 - 738 - // Collect top-level `[...]` blocks with depth tracking. 739 - let mut blocks = Vec::new(); 740 - let mut i = head_end; 741 - while i < len { 742 - if b[i] == b'[' { 743 - let block_start = i + 1; 744 - let mut depth = 1i32; 745 - i += 1; 746 - while i < len && depth > 0 { 747 - match b[i] { 748 - b'[' => depth += 1, 749 - b']' => depth -= 1, 750 - _ => {} 751 - } 752 - if depth > 0 { 753 - i += 1; 754 - } 755 - } 756 - blocks.push((block_start, i)); // content_end is index of `]` 757 - i += 1; // skip `]` 758 - } else { 759 - i += 1; 760 - } 761 - } 762 - 763 - if blocks.is_empty() { 764 - return None; 765 - } 766 - Some((head_end, blocks)) 767 - } 768 - 769 - /// Reformat a card fragment to multi-line form: 770 - /// `{head}\n[\n {content0}\n][\n {content1}\n]` 771 - /// 772 - /// Returns `None` if already in that form or not a recognised card. 773 - fn format_card_frag(frag: &str) -> Option<String> { 774 - let (head_end, ref blocks) = parse_card_structure(frag)?; 775 - let head = frag[..head_end].trim_end_matches('\n'); 776 - let mut out = head.to_string(); 777 - for &(cs, ce) in blocks { 778 - let content = frag[cs..ce].trim(); 779 - out.push_str(&format!("[\n {}\n]", content)); 780 - } 781 - if out == frag || out.as_str() == frag.trim_end_matches('\n') { 782 - return None; 783 - } 784 - Some(out) 785 - } 786 - 787 - /// Map a cursor position from the original fragment to the formatted fragment. 788 - /// 789 - /// Each block's opening `[` gains `\n ` (3 chars) after it; each closing `]` 790 - /// gains a `\n` before it; a `\n` is inserted after the head. Leading/trailing 791 - /// whitespace stripped from block content shifts the cursor back accordingly. 792 - fn map_cursor_after_format( 793 - frag: &str, 794 - cursor: usize, 795 - head_end: usize, 796 - blocks: &[(usize, usize)], 797 - ) -> usize { 798 - // Effective head end after trimming trailing newlines (mirrors format_card_frag). 799 - let trimmed_head_end = frag[..head_end].trim_end_matches('\n').len(); 800 - if cursor < trimmed_head_end { 801 - return cursor; 802 - } 803 - // Cursor in trailing head whitespace or past head: offset by the net change. 804 - // Trimmed head is trimmed_head_end chars; formatted adds one \n after it. 805 - // So everything from trimmed_head_end onward shifts by (1 - (head_end - trimmed_head_end)). 806 - let head_ws_removed = (head_end - trimmed_head_end) as i64; 807 - // +1 for the \n inserted after head 808 - let mut offset: i64 = 1 - head_ws_removed; 809 - for &(content_start, content_end) in blocks { 810 - let open_bracket = content_start - 1; 811 - if cursor <= open_bracket { 812 - break; 813 - } 814 - // Past `[`: +3 for `\n ` inserted after `[` 815 - offset += 3; 816 - if cursor <= content_start { 817 - break; 818 - } 819 - let raw = &frag[content_start..content_end]; 820 - let leading_ws = raw.len() - raw.trim_start().len(); 821 - if cursor <= content_start + leading_ws { 822 - // Cursor in leading whitespace: snap to start of trimmed content 823 - break; 824 - } 825 - offset -= leading_ws as i64; 826 - let trailing_ws = raw.len() - raw.trim_end().len(); 827 - if cursor <= content_end - trailing_ws { 828 - break; // Cursor within actual content 829 - } 830 - offset -= trailing_ws as i64; 831 - if cursor <= content_end { 832 - break; // Cursor on or before `]` 833 - } 834 - // Past `]`: +1 for `\n` inserted before `]` 835 - offset += 1; 836 - } 837 - (cursor as i64 + offset).max(0) as usize 838 - } 839 - 840 - #[component] 841 - pub fn Editor() -> Element { 842 - // ── Source & save ───────────────────────────────────────────────────────── 843 - let mut source = use_signal(|| std::fs::read_to_string(cards_path()).unwrap_or_default()); 844 - let mut save_status = use_signal(|| SaveStatus::Clean); 845 - let mut new_card_draft = use_signal(|| String::new()); 846 - 847 - // ── Tag filter ──────────────────────────────────────────────────────────── 848 - let mut editor_selected_tags: Signal<Vec<String>> = use_signal(Vec::new); 849 - let mut editor_tag_mode = use_signal(|| TagMode::Or); 850 - let editor_all_tags = use_memo(move || crate::util::collect_all_tags_from_source(&source.read())); 851 - 852 - // ── Multi-card state ─────────────────────────────────────────────────────── 853 - let mut active_idx = use_signal(|| 0usize); 854 - // Indexed by segment. None = text segment (no render attempt). 855 - let mut previews = use_signal(Vec::<Option<Result<PreviewData, String>>>::new); 856 - 857 - // Auto-save (1s debounce) + auto-format active card on save. 858 - // Sequence: read cursor -> format -> save formatted -> sync DOM -> update source. 859 - // Updating source last avoids cancelling the task before the save completes. 860 - let _saver = use_resource(move || async move { 861 - let text = source.read().clone(); 862 - let ai = *active_idx.read(); 863 - tokio::time::sleep(Duration::from_millis(1000)).await; 864 - 865 - // Check if the active card segment needs formatting. 866 - let segs = make_segments(&text); 867 - let ai = ai.min(segs.len().saturating_sub(1)); 868 - let format_result = segs.get(ai).filter(|s| s.kind == SegKind::Card).and_then(|seg| { 869 - let frag = text[seg.start..seg.end].to_string(); 870 - let formatted = format_card_frag(&frag)?; 871 - let (head_end, blocks) = parse_card_structure(&frag)?; 872 - Some((seg.start, seg.end, frag, formatted, head_end, blocks)) 873 - }); 874 - 875 - let (save_text, dom_update) = if let Some((seg_start, seg_end, frag, formatted, head_end, blocks)) = 876 - format_result 877 - { 878 - // Read cursor before any mutation. 879 - let mut eval = document::eval( 880 - "var t=document.querySelector('.card-row.active textarea');\ 881 - dioxus.send(t?t.selectionStart:0);" 882 - ); 883 - let cursor = eval.recv::<i64>().await.unwrap_or(0).max(0) as usize; 884 - let new_cursor = map_cursor_after_format(&frag, cursor, head_end, &blocks); 885 - 886 - let new_src = format!("{}{}{}", &text[..seg_start], formatted, &text[seg_end..]); 887 - (new_src, Some((ai, new_cursor))) 888 - } else { 889 - (text, None) 890 - }; 891 - 892 - // Save to disk. 893 - let path = cards_path(); 894 - let save_clone = save_text.clone(); 895 - match tokio::task::spawn_blocking(move || std::fs::write(&path, &save_clone)).await { 896 - Ok(Ok(())) => save_status.set(SaveStatus::Saved), 897 - Ok(Err(e)) => save_status.set(SaveStatus::Error(e.to_string())), 898 - Err(e) => save_status.set(SaveStatus::Error(e.to_string())), 899 - } 900 - 901 - // If formatting was applied: sync textarea DOM then update source signal. 902 - // (source.set last — it may restart this resource, but save already completed.) 903 - if let Some((ai, new_cursor)) = dom_update { 904 - let new_segs = make_segments(&save_text); 905 - let new_ai = ai.min(new_segs.len().saturating_sub(1)); 906 - let new_frag = new_segs 907 - .get(new_ai) 908 - .map(|s| save_text[s.start..s.end].to_string()) 909 - .unwrap_or_default(); 910 - let b64 = base64::engine::general_purpose::STANDARD.encode(&new_frag); 911 - document::eval(&format!( 912 - "var t=document.querySelector('.card-row.active textarea');\ 913 - if(t){{t.value=atob('{}');t.setSelectionRange({new_cursor},{new_cursor})}}", 914 - b64 915 - )) 916 - .await 917 - .ok(); 918 - source.set(save_text); 919 - } 920 - }); 921 - 922 - // Per-segment render resource: each card fragment compiled independently. 923 - let _render = use_resource(move || async move { 924 - let text = source.read().clone(); 925 - tokio::time::sleep(Duration::from_millis(300)).await; 926 - let dir = card_dir(); 927 - let results = tokio::task::spawn_blocking(move || { 928 - make_segments(&text) 929 - .into_iter() 930 - .map(|seg| match seg.kind { 931 - SegKind::Card => Some(render_preview(&text[seg.start..seg.end], &dir)), 932 - SegKind::Text => None, 933 - }) 934 - .collect::<Vec<_>>() 935 - }) 936 - .await 937 - .unwrap_or_default(); 938 - previews.set(results); 939 - }); 940 - 941 - // ── Draw mode state ──────────────────────────────────────────────────────── 942 - let mut blank_rects_sig = use_signal(Vec::<Vec<[f64; 4]>>::new); 943 - let mut glyph_map_sig = use_signal(Vec::<([f64; 4], std::ops::Range<usize>, bool)>::new); 944 - let mut math_spans_sig = use_signal(Vec::<std::ops::Range<usize>>::new); 945 - let mut image_boxes_sig = use_signal(Vec::<[f64; 4]>::new); 946 - let mut sidecar_rects_sig = use_signal(Vec::<RectEntry>::new); 947 - let mut draw_mode = use_signal(|| false); 948 - let mut drag_start = use_signal(|| Option::<(f64, f64)>::None); 949 - let mut drag_current = use_signal(|| Option::<(f64, f64)>::None); 950 - let mut drawn_boxes = use_signal(Vec::<[f64; 4]>::new); 951 - let cap_dims = use_signal(|| [1.0f64, 1.0, 1.0]); 952 - let mut insert_error = use_signal(|| Option::<String>::None); 953 - let mut selected_rect_sig = use_signal(|| Option::<usize>::None); 954 - let mut edge_drag_active = use_signal(|| false); 955 - // Some((col, going_up)): desired cursor column set by keyboard nav just before 956 - // active_idx changes. Consumed by the new card's onmounted handler. 957 - let mut nav_cursor: Signal<Option<(usize, bool)>> = use_signal(|| None); 958 - 959 - // Update glyph signals when the active card or its render result changes. 960 - use_effect(move || { 961 - let idx = *active_idx.read(); // subscribe 962 - let pvs = previews.read().clone(); // subscribe 963 - let text = source.peek().clone(); // no subscription 964 - let segs = make_segments(&text); 965 - if segs.is_empty() { 966 - blank_rects_sig.set(vec![]); 967 - glyph_map_sig.set(vec![]); 968 - math_spans_sig.set(vec![]); 969 - image_boxes_sig.set(vec![]); 970 - return; 971 - } 972 - let ai = idx.min(segs.len().saturating_sub(1)); 973 - let card_pos = segs[..=ai] 974 - .iter() 975 - .filter(|s| s.kind == SegKind::Card) 976 - .count() 977 - .saturating_sub(1); 978 - let card_id = { 979 - let cards = tala_format::parse_cards(&text); 980 - cards.get(card_pos) 981 - .and_then(|c| c.id.clone()) 982 - .unwrap_or_else(|| card_pos.to_string()) 983 - }; 984 - match segs.get(ai) { 985 - Some(Seg { 986 - kind: SegKind::Card, 987 - .. 988 - }) => { 989 - if let Some(Some(Ok(data))) = pvs.get(ai) { 990 - blank_rects_sig.set(data.blank_rects.clone()); 991 - glyph_map_sig.set(data.glyph_map.clone()); 992 - math_spans_sig.set(data.math_spans.clone()); 993 - image_boxes_sig.set(data.image_boxes.clone()); 994 - } 995 - // Load saved image rects for this card from sidecar. 996 - let rects = Sidecar::load_or_empty_for(&cards_path()) 997 - .ok() 998 - .and_then(|sc| { 999 - if let Some(CardSchedule::Cloze { rects, .. }) = 1000 - sc.cards.get(&card_id) 1001 - { 1002 - Some(rects.clone()) 1003 - } else { 1004 - None 1005 - } 1006 - }) 1007 - .unwrap_or_default(); 1008 - sidecar_rects_sig.set(rects); 1009 - } 1010 - _ => { 1011 - blank_rects_sig.set(Vec::new()); 1012 - glyph_map_sig.set(Vec::new()); 1013 - math_spans_sig.set(Vec::new()); 1014 - image_boxes_sig.set(Vec::new()); 1015 - sidecar_rects_sig.set(Vec::new()); 1016 - } 1017 - } 1018 - }); 1019 - 1020 - // ── Image paste from clipboard ──────────────────────────────────────────── 1021 - use_coroutine(move |_: dioxus::prelude::UnboundedReceiver<()>| async move { 1022 - // webkit2gtk doesn't expose clipboard contents via clipboardData.items, 1023 - // so we use the paste event only as a trigger and read the OS clipboard 1024 - // directly from Rust via arboard. 1025 - let mut eval = document::eval(r#" 1026 - if (window.__talaPasteHandler) { 1027 - window.removeEventListener('paste', window.__talaPasteHandler); 1028 - } 1029 - window.__talaPasteHandler = function(e) { dioxus.send(1); }; 1030 - window.addEventListener('paste', window.__talaPasteHandler); 1031 - "#); 1032 - loop { 1033 - match eval.recv::<i32>().await { 1034 - Ok(_) => { 1035 - // Offload the blocking clipboard read + PNG encode to an OS 1036 - // thread (arboard is not Send on Linux, so spawn_blocking won't 1037 - // work). A oneshot channel bridges the result back. 1038 - let (tx, rx) = tokio::sync::oneshot::channel::<Option<(Vec<u8>, String)>>(); 1039 - std::thread::spawn(move || { let _ = tx.send(read_clipboard_png()); }); 1040 - if let Ok(Some((png_bytes, filename))) = rx.await { 1041 - apply_pasted_image(source, active_idx, save_status, png_bytes, filename); 1042 - } 1043 - } 1044 - Err(_) => break, 1045 - } 1046 - } 1047 - }); 1048 - 1049 - // ── Keyboard nav: Up/Down at textarea boundary → switch segment ──────────── 1050 - // prevent_default() is called synchronously so the browser never moves the 1051 - // cursor; we replicate the movement in JS when not at a boundary. 1052 - let on_keydown = move |e: Event<KeyboardData>| { 1053 - let key = e.data().key().to_string(); 1054 - if key == "ArrowUp" || key == "ArrowDown" { 1055 - e.prevent_default(); 1056 - let going_up = key == "ArrowUp"; 1057 - let cur_idx = *active_idx.read(); 1058 - let n = make_segments(&source.read().clone()).len(); 1059 - let mut ai = active_idx; 1060 - let mut nc = nav_cursor; 1061 - spawn(async move { 1062 - // Returns -1 if not at boundary (cursor moved within card). 1063 - // Returns col (>=0) if at boundary — the column to preserve in the 1064 - // destination card. 1065 - let js = if going_up { 1066 - "var t=document.querySelector('.card-row.active textarea');\ 1067 - if(!t){dioxus.send(-1);return;}\ 1068 - var pos=t.selectionStart;\ 1069 - var before=t.value.slice(0,pos);\ 1070 - var prevNl=before.lastIndexOf('\\n');\ 1071 - if(prevNl===-1){\ 1072 - var col=pos;\ 1073 - dioxus.send(col);\ 1074 - }else{\ 1075 - var col=pos-(prevNl+1);\ 1076 - var prevPrevNl=before.lastIndexOf('\\n',prevNl-1);\ 1077 - var newPos=Math.min(prevPrevNl+1+col,prevNl);\ 1078 - t.setSelectionRange(newPos,newPos);\ 1079 - dioxus.send(-1);\ 1080 - }" 1081 - } else { 1082 - "var t=document.querySelector('.card-row.active textarea');\ 1083 - if(!t){dioxus.send(-1);return;}\ 1084 - var pos=t.selectionStart;\ 1085 - var after=t.value.slice(pos);\ 1086 - var nextNl=after.indexOf('\\n');\ 1087 - if(nextNl===-1){\ 1088 - var before=t.value.slice(0,pos);\ 1089 - var col=pos-(before.lastIndexOf('\\n')+1);\ 1090 - dioxus.send(col);\ 1091 - }else{\ 1092 - var before=t.value.slice(0,pos);\ 1093 - var col=pos-(before.lastIndexOf('\\n')+1);\ 1094 - var nextLineStart=pos+nextNl+1;\ 1095 - var afterNext=t.value.slice(nextLineStart);\ 1096 - var nextNextNl=afterNext.indexOf('\\n');\ 1097 - var nextLineEnd=nextNextNl===-1?t.value.length:nextLineStart+nextNextNl;\ 1098 - t.setSelectionRange(Math.min(nextLineStart+col,nextLineEnd),Math.min(nextLineStart+col,nextLineEnd));\ 1099 - dioxus.send(-1);\ 1100 - }" 1101 - }; 1102 - if let Ok(col) = document::eval(js).recv::<i64>().await { 1103 - if col >= 0 { 1104 - let new_i = if going_up { 1105 - cur_idx.saturating_sub(1) 1106 - } else { 1107 - (cur_idx + 1).min(n.saturating_sub(1)) 1108 - }; 1109 - if new_i != cur_idx { 1110 - // Store nav intent BEFORE re-render so onmounted picks it up. 1111 - nc.set(Some((col as usize, going_up))); 1112 - ai.set(new_i); 1113 - } 1114 - } 1115 - } 1116 - }); 1117 - } 1118 - }; 1119 - 1120 - // ── oninput: splice active segment back into full source ─────────────────── 1121 - // make_segments uses only blank-line positions, never typst spans, so an 1122 - // unclosed bracket cannot bloat ce to end-of-file. 1123 - // If the user types \n\n (creating a new paragraph), the new paragraphs are 1124 - // spliced in and active_idx advances to the last one. 1125 - let on_input = move |e: FormEvent| { 1126 - let new_frag = e.value(); 1127 - let cur = source.read().clone(); 1128 - let segs = make_segments(&cur); 1129 - let ai = (*active_idx.read()).min(segs.len().saturating_sub(1)); 1130 - if let Some(seg) = segs.get(ai) { 1131 - let (cs, ce) = (seg.start, seg.end); 1132 - if ce <= cur.len() { 1133 - let new_src = format!("{}{}{}", &cur[..cs], new_frag, &cur[ce..]); 1134 - let new_segs = make_segments(&new_src); 1135 - source.set(new_src); 1136 - if new_segs.len() > segs.len() { 1137 - let edit_end = cs + new_frag.len(); 1138 - let new_ai = new_segs 1139 - .iter() 1140 - .rposition(|s| s.start <= edit_end) 1141 - .unwrap_or(ai); 1142 - active_idx.set(new_ai); 1143 - spawn(async move { 1144 - document::eval( 1145 - "setTimeout(()=>{var t=document.querySelector('.card-row.active textarea');\ 1146 - if(t){t.focus();t.setSelectionRange(t.value.length,t.value.length);}},50)" 1147 - ).await.ok(); 1148 - }); 1149 - } 1150 - } 1151 - } 1152 - save_status.set(SaveStatus::Dirty); 1153 - spawn(async move { 1154 - document::eval( 1155 - "var t=document.querySelector('.card-row.active textarea');\ 1156 - if(t){t.style.height='auto';t.style.height=(t.scrollHeight+3)+'px';}" 1157 - ).await.ok(); 1158 - }); 1159 - }; 1160 - 1161 - // ── Snapshot values for RSX ──────────────────────────────────────────────── 1162 - let text_snap = source.read().clone(); 1163 - let segs_snap = make_segments(&text_snap); 1164 - let n_segs = segs_snap.len(); 1165 - if n_segs == 0 && !new_card_draft.read().is_empty() { 1166 - new_card_draft.set(String::new()); 1167 - } 1168 - let active_i = (*active_idx.read()).min(n_segs.saturating_sub(1)); 1169 - let active_is_cloze = match segs_snap.get(active_i) { 1170 - Some(Seg { 1171 - start, 1172 - end, 1173 - kind: SegKind::Card, 1174 - }) => matches!( 1175 - tala_format::parse_cards(&text_snap[*start..*end]) 1176 - .first() 1177 - .map(|c| &c.kind), 1178 - Some(CardKind::Cloze { .. }) 1179 - ), 1180 - _ => false, 1181 - }; 1182 - let pvs_snap = previews.read().clone(); 1183 - let (active_cs, active_ce) = segs_snap 1184 - .get(active_i) 1185 - .map(|s| (s.start, s.end)) 1186 - .unwrap_or((0, 0)); 1187 - let active_fragment = text_snap[active_cs..active_ce.min(text_snap.len())].to_string(); 1188 - let in_draw = *draw_mode.read(); 1189 - let ds = *drag_start.read(); 1190 - let dc = *drag_current.read(); 1191 - let drawn = drawn_boxes.read().clone(); 1192 - let blank_rects = blank_rects_sig.read().clone(); 1193 - let active_img_boxes = image_boxes_sig.read().clone(); 1194 - let active_sidecar_rects = sidecar_rects_sig.read().clone(); 1195 - let active_img_names = extract_img_names(&active_fragment); 1196 - let selected_rect = *selected_rect_sig.read(); 1197 - // Load all sidecar image rects once for rendering inactive cards. 1198 - let all_sidecar_rects: std::collections::HashMap<String, Vec<RectEntry>> = { 1199 - let typ_path = cards_path(); 1200 - Sidecar::load_or_empty_for(&typ_path).ok() 1201 - .map(|sc| sc.cards.into_iter().filter_map(|(k, v)| { 1202 - if let CardSchedule::Cloze { rects, .. } = v { Some((k, rects)) } else { None } 1203 - }).collect()) 1204 - .unwrap_or_default() 1205 - }; 1206 - let card_keys_snap: Vec<String> = tala_format::parse_cards(&text_snap) 1207 - .into_iter() 1208 - .enumerate() 1209 - .map(|(i, c)| c.id.unwrap_or_else(|| i.to_string())) 1210 - .collect(); 1211 - 1212 - let filter_tags_snap = editor_selected_tags.read().clone(); 1213 - let filter_mode_snap = editor_tag_mode.read().clone(); 1214 - let all_editor_tags_snap = editor_all_tags.read().clone(); 1215 - let cc = cloze_color(); 1216 - let cloze_fill = cloze_fill_css(&cc); 1217 - let cloze_stroke = cloze_stroke_css(&cc); 1218 - 1219 - rsx! { 1220 - div { id: "editor", 1221 - // ── Toolbar ─────────────────────────────────────────────────────── 1222 - div { class: "preview-toolbar", 1223 - if active_is_cloze { 1224 - button { 1225 - class: if in_draw { "btn active" } else { "btn" }, 1226 - onclick: move |_| { 1227 - let m = *draw_mode.read(); 1228 - draw_mode.set(!m); 1229 - drag_start.set(None); 1230 - drag_current.set(None); 1231 - drawn_boxes.write().clear(); 1232 - insert_error.set(None); 1233 - }, 1234 - if in_draw { "Exit Draw" } else { "Draw Cloze" } 1235 - } 1236 - } 1237 - match &*save_status.read() { 1238 - SaveStatus::Clean => rsx! {}, 1239 - SaveStatus::Dirty => rsx! { span { class: "save-status dirty", "Unsaved" } }, 1240 - SaveStatus::Saved => rsx! { span { class: "save-status saved", "Saved" } }, 1241 - SaveStatus::Error(e) => rsx! { span { class: "save-status error", "Save error: {e}" } }, 1242 - } 1243 - if let Some(err) = &*insert_error.read() { 1244 - span { class: "insert-error", "{err}" } 1245 - } 1246 - // ── Tag filter ─────────────────────────────────────────────── 1247 - if !all_editor_tags_snap.is_empty() { 1248 - div { class: "tag-chip-row", 1249 - for tag in all_editor_tags_snap.clone() { 1250 - { 1251 - let tag2 = tag.clone(); 1252 - let is_sel = filter_tags_snap.contains(&tag); 1253 - rsx! { 1254 - button { 1255 - class: if is_sel { "tag-chip selected" } else { "tag-chip" }, 1256 - onclick: move |_| { 1257 - let mut sel = editor_selected_tags.write(); 1258 - if sel.contains(&tag2) { 1259 - sel.retain(|t| t != &tag2); 1260 - } else { 1261 - sel.push(tag2.clone()); 1262 - } 1263 - }, 1264 - "{tag}" 1265 - } 1266 - } 1267 - } 1268 - } 1269 - if filter_tags_snap.len() > 1 { 1270 - button { 1271 - class: if filter_mode_snap == TagMode::Or { "btn active" } else { "btn" }, 1272 - onclick: move |_| editor_tag_mode.set(TagMode::Or), 1273 - "OR" 1274 - } 1275 - button { 1276 - class: if filter_mode_snap == TagMode::And { "btn active" } else { "btn" }, 1277 - onclick: move |_| editor_tag_mode.set(TagMode::And), 1278 - "AND" 1279 - } 1280 - } 1281 - } 1282 - } 1283 - } 1284 - // ── Card list ───────────────────────────────────────────────────── 1285 - div { class: "card-list", 1286 - if n_segs == 0 { 1287 - div { class: "card-row active", 1288 - textarea { 1289 - class: "card-source", 1290 - spellcheck: "false", 1291 - placeholder: "#cloze[...] or #card[...][...]", 1292 - onmounted: move |_| { 1293 - spawn(async move { 1294 - document::eval( 1295 - "setTimeout(()=>document.querySelector('.card-list .card-row.active textarea')?.focus(),0)" 1296 - ).await.ok(); 1297 - }); 1298 - }, 1299 - oninput: move |e| { 1300 - let val = e.value(); 1301 - new_card_draft.set(val.clone()); 1302 - if !val.trim().is_empty() { 1303 - source.set(val); 1304 - save_status.set(SaveStatus::Dirty); 1305 - } 1306 - }, 1307 - } 1308 - } 1309 - } 1310 - { 1311 - segs_snap.iter().enumerate().map(|(i, seg)| { 1312 - let is_active = i == active_i; 1313 - let row_class = if is_active { "card-row active" } else { "card-row" }; 1314 - let seg_key = if seg.kind == SegKind::Card { format!("c{i}") } else { format!("t{i}") }; 1315 - let seg_visible = if filter_tags_snap.is_empty() || seg.kind != SegKind::Card { 1316 - true 1317 - } else { 1318 - let seg_tags = tala_format::parse_cards(&text_snap[seg.start..seg.end]) 1319 - .into_iter() 1320 - .next() 1321 - .map(|c| c.tags) 1322 - .unwrap_or_default(); 1323 - match filter_mode_snap { 1324 - TagMode::And => filter_tags_snap.iter().all(|t| seg_tags.contains(t)), 1325 - TagMode::Or => filter_tags_snap.iter().any(|t| seg_tags.contains(t)), 1326 - } 1327 - }; 1328 - 1329 - // Render data — naturally None for text segments (previews stores None for them). 1330 - let card_result: Option<Result<PreviewData, String>> = pvs_snap.get(i).cloned().flatten(); 1331 - let iw = card_result.as_ref().and_then(|r| r.as_ref().ok()).map(|d| d.img_w as f64).unwrap_or(1.0); 1332 - let ih = card_result.as_ref().and_then(|r| r.as_ref().ok()).map(|d| d.img_h as f64).unwrap_or(1.0); 1333 - let vb = format!("0 0 {} {}", iw as u32, ih as u32); 1334 - let b64_src = card_result.as_ref() 1335 - .and_then(|r| r.as_ref().ok()) 1336 - .map(|d| format!("data:image/png;base64,{}", d.b64)) 1337 - .unwrap_or_default(); 1338 - let card_blank_rects: Vec<Vec<[f64; 4]>> = if is_active { 1339 - blank_rects.clone() 1340 - } else { 1341 - card_result.as_ref() 1342 - .and_then(|r| r.as_ref().ok()) 1343 - .map(|d| d.blank_rects.clone()) 1344 - .unwrap_or_default() 1345 - }; 1346 - let card_img_boxes_local: Vec<[f64; 4]> = if is_active { 1347 - active_img_boxes.clone() 1348 - } else { 1349 - card_result.as_ref() 1350 - .and_then(|r| r.as_ref().ok()) 1351 - .map(|d| d.image_boxes.clone()) 1352 - .unwrap_or_default() 1353 - }; 1354 - let card_sidecar_rects_local: Vec<RectEntry> = if is_active { 1355 - active_sidecar_rects.clone() 1356 - } else { 1357 - let card_pos = segs_snap[..=i] 1358 - .iter() 1359 - .filter(|s| s.kind == SegKind::Card) 1360 - .count() 1361 - .saturating_sub(1); 1362 - let card_key = card_keys_snap.get(card_pos) 1363 - .cloned() 1364 - .unwrap_or_else(|| card_pos.to_string()); 1365 - all_sidecar_rects.get(&card_key).cloned().unwrap_or_default() 1366 - }; 1367 - let card_img_names_local: Vec<String> = if is_active { 1368 - active_img_names.clone() 1369 - } else { 1370 - extract_img_names(&text_snap[seg.start..seg.end]) 1371 - }; 1372 - let sidecar_rect_order: Vec<usize> = if is_active { 1373 - (0..card_sidecar_rects_local.len()) 1374 - .filter(|&i| Some(i) != selected_rect) 1375 - .chain(selected_rect.into_iter().filter(|&i| i < card_sidecar_rects_local.len())) 1376 - .collect() 1377 - } else { 1378 - (0..card_sidecar_rects_local.len()).collect() 1379 - }; 1380 - let render_err = card_result.and_then(|r| r.err()); 1381 - let preview_text = text_snap[seg.start..seg.end].to_string(); 1382 - let frag_for_mount = active_fragment.clone(); 1383 - 1384 - rsx! { 1385 - div { 1386 - key: "{seg_key}", 1387 - class: row_class, 1388 - style: if !seg_visible { "display:none" } else { "display:block" }, 1389 - onclick: move |_| { 1390 - if !is_active { 1391 - active_idx.set(i); 1392 - spawn(async move { 1393 - document::eval( 1394 - "setTimeout(()=>document.querySelector('.card-row.active textarea')?.focus(),0)" 1395 - ).await.ok(); 1396 - }); 1397 - } 1398 - }, 1399 - div { class: "card-actions", 1400 - button { class: "btn-delete", title: "Delete segment", 1401 - onclick: move |e| { 1402 - e.stop_propagation(); 1403 - let text = source.read().clone(); 1404 - let segs = make_segments(&text); 1405 - if let Some(seg) = segs.get(i) { 1406 - let (cs, ce) = (seg.start, seg.end); 1407 - let ce2 = if text[ce..].starts_with("\n\n") { ce + 2 } else { ce }; 1408 - let cs2 = if ce2 == ce && cs >= 2 && &text[cs-2..cs] == "\n\n" { cs - 2 } else { cs }; 1409 - source.set(format!("{}{}", &text[..cs2], &text[ce2..])); 1410 - let new_n = segs.len().saturating_sub(1); 1411 - if *active_idx.read() >= new_n { active_idx.set(new_n.saturating_sub(1)); } 1412 - save_status.set(SaveStatus::Dirty); 1413 - } 1414 - }, 1415 - "×" 1416 - } 1417 - } 1418 - if is_active { 1419 - textarea { 1420 - class: "card-source", 1421 - spellcheck: "false", 1422 - onmounted: { 1423 - let frag = frag_for_mount; 1424 - move |_| { 1425 - let b64 = base64::engine::general_purpose::STANDARD.encode(&frag); 1426 - // Consume nav_cursor if set by keyboard nav. 1427 - let nav = nav_cursor.write_unchecked(); 1428 - let cursor_js = if let Some((col, going_up)) = *nav { 1429 - if going_up { 1430 - format!("var lastNl=t.value.lastIndexOf('\\n');\ 1431 - var ls=lastNl===-1?0:lastNl+1;\ 1432 - t.setSelectionRange(Math.min(ls+{col},t.value.length),Math.min(ls+{col},t.value.length));") 1433 - } else { 1434 - format!("var firstNl=t.value.indexOf('\\n');\ 1435 - var le=firstNl===-1?t.value.length:firstNl;\ 1436 - t.setSelectionRange(Math.min({col},le),Math.min({col},le));") 1437 - } 1438 - } else { 1439 - // Default: put cursor at end (existing behaviour for mouse clicks / initial mount). 1440 - "t.setSelectionRange(t.value.length,t.value.length);".to_string() 1441 - }; 1442 - drop(nav); // release borrow before mutating 1443 - nav_cursor.set(None); 1444 - spawn(async move { 1445 - document::eval(&format!( 1446 - "var t=document.querySelector('.card-row.active textarea');\ 1447 - if(t){{t.value=atob('{b64}');t.focus();{cursor_js}\ 1448 - t.style.height='auto';t.style.height=(t.scrollHeight+3)+'px';}}" 1449 - )).await.ok(); 1450 - }); 1451 - } 1452 - }, 1453 - oninput: on_input, 1454 - onkeydown: on_keydown, 1455 - } 1456 - } 1457 - match seg.kind { 1458 - SegKind::Text => rsx! { 1459 - if !is_active { 1460 - pre { class: "text-seg-preview", "{preview_text}" } 1461 - } 1462 - }, 1463 - SegKind::Card => rsx! { 1464 - if let Some(err) = render_err { 1465 - pre { class: "render-error", "{err}" } 1466 - } else if b64_src.is_empty() { 1467 - span { class: "status", "Rendering…" } 1468 - } else { 1469 - div { class: "card-preview-wrap", 1470 - img { class: "card-preview", src: "{b64_src}" } 1471 - svg { 1472 - class: "cloze-overlay", 1473 - view_box: "{vb}", 1474 - onclick: move |_| { selected_rect_sig.set(None); }, 1475 - for line_rects in &card_blank_rects { 1476 - for rect in line_rects { 1477 - rect { 1478 - x: "{rect[0] * iw}", y: "{rect[1] * ih}", 1479 - width: "{rect[2] * iw}", height: "{rect[3] * ih}", 1480 - fill: "{cloze_fill}", 1481 - stroke: "{cloze_stroke}", 1482 - stroke_width: "1.5", rx: "3", 1483 - } 1484 - } 1485 - } 1486 - if seg.kind == SegKind::Card { 1487 - // Render selected rect last so it paints above intersecting rects. 1488 - for sri in sidecar_rect_order.clone() { 1489 - { 1490 - let sr = card_sidecar_rects_local[sri].clone(); 1491 - let img_idx = card_img_names_local.iter().position(|n| n == &sr.src).unwrap_or(0); 1492 - let ib = card_img_boxes_local.get(img_idx).copied().unwrap_or([0.0, 0.0, 1.0, 1.0]); 1493 - let px = (ib[0] + sr.rect[0] as f64 * ib[2]) * iw; 1494 - let py = (ib[1] + sr.rect[1] as f64 * ib[3]) * ih; 1495 - let pw = sr.rect[2] as f64 * ib[2] * iw; 1496 - let ph = sr.rect[3] as f64 * ib[3] * ih; 1497 - let is_sel = is_active && selected_rect == Some(sri); 1498 - let [bx, by, bw, bh] = ib; 1499 - let rid = sr.id.clone(); 1500 - rsx! { 1501 - g { key: "{rid}", 1502 - style: if is_active { "pointer-events: all;" } else { "pointer-events: none;" }, 1503 - rect { 1504 - x: "{px}", y: "{py}", 1505 - width: "{pw}", height: "{ph}", 1506 - fill: "{cloze_fill}", 1507 - stroke: if is_sel { "rgb(60,120,220)" } else { "{cloze_stroke}" }, 1508 - stroke_width: if is_sel { "2.5" } else { "1.5" }, 1509 - rx: "3", 1510 - style: if is_sel { "cursor: grab;" } else if is_active { "cursor: pointer;" } else { "" }, 1511 - onclick: move |e| { 1512 - if is_active { 1513 - e.stop_propagation(); 1514 - selected_rect_sig.set(Some(sri)); 1515 - } 1516 - }, 1517 - onmousedown: move |e| { 1518 - if is_active && is_sel { 1519 - e.stop_propagation(); 1520 - edge_drag_active.set(true); 1521 - spawn(move_drag_task(sri, [bx, by, bw, bh], sidecar_rects_sig, edge_drag_active, source, active_idx)); 1522 - } 1523 - }, 1524 - } 1525 - if is_sel { 1526 - text { 1527 - x: "{px + pw - 2.0}", y: "{py + 13.0}", 1528 - font_size: "13", 1529 - fill: "rgb(180,30,30)", 1530 - text_anchor: "end", 1531 - style: "cursor: pointer; user-select: none; font-weight: bold;", 1532 - onclick: move |e| { 1533 - e.stop_propagation(); 1534 - if let Some(updated) = delete_sidecar_rect(sri, &source.peek(), *active_idx.peek()) { 1535 - sidecar_rects_sig.set(updated); 1536 - } 1537 - selected_rect_sig.set(None); 1538 - }, 1539 - "×" 1540 - } 1541 - // Top edge handle 1542 - rect { 1543 - x: "{px + pw * 0.1}", y: "{py - 5.0}", 1544 - width: "{pw * 0.8}", height: "10", 1545 - fill: "rgba(70,130,220,0.2)", 1546 - stroke: "rgb(70,130,220)", stroke_width: "1", 1547 - rx: "2", style: "cursor: ns-resize;", 1548 - onclick: move |e| { e.stop_propagation(); }, 1549 - onmousedown: move |e| { 1550 - e.stop_propagation(); 1551 - edge_drag_active.set(true); 1552 - spawn(edge_drag_task(sri, DragEdge::Top, [bx, by, bw, bh], sidecar_rects_sig, edge_drag_active, source, active_idx)); 1553 - }, 1554 - } 1555 - // Bottom edge handle 1556 - rect { 1557 - x: "{px + pw * 0.1}", y: "{py + ph - 5.0}", 1558 - width: "{pw * 0.8}", height: "10", 1559 - fill: "rgba(70,130,220,0.2)", 1560 - stroke: "rgb(70,130,220)", stroke_width: "1", 1561 - rx: "2", style: "cursor: ns-resize;", 1562 - onclick: move |e| { e.stop_propagation(); }, 1563 - onmousedown: move |e| { 1564 - e.stop_propagation(); 1565 - edge_drag_active.set(true); 1566 - spawn(edge_drag_task(sri, DragEdge::Bottom, [bx, by, bw, bh], sidecar_rects_sig, edge_drag_active, source, active_idx)); 1567 - }, 1568 - } 1569 - // Left edge handle 1570 - rect { 1571 - x: "{px - 5.0}", y: "{py + ph * 0.1}", 1572 - width: "10", height: "{ph * 0.8}", 1573 - fill: "rgba(70,130,220,0.2)", 1574 - stroke: "rgb(70,130,220)", stroke_width: "1", 1575 - rx: "2", style: "cursor: ew-resize;", 1576 - onclick: move |e| { e.stop_propagation(); }, 1577 - onmousedown: move |e| { 1578 - e.stop_propagation(); 1579 - edge_drag_active.set(true); 1580 - spawn(edge_drag_task(sri, DragEdge::Left, [bx, by, bw, bh], sidecar_rects_sig, edge_drag_active, source, active_idx)); 1581 - }, 1582 - } 1583 - // Right edge handle 1584 - rect { 1585 - x: "{px + pw - 5.0}", y: "{py + ph * 0.1}", 1586 - width: "10", height: "{ph * 0.8}", 1587 - fill: "rgba(70,130,220,0.2)", 1588 - stroke: "rgb(70,130,220)", stroke_width: "1", 1589 - rx: "2", style: "cursor: ew-resize;", 1590 - onclick: move |e| { e.stop_propagation(); }, 1591 - onmousedown: move |e| { 1592 - e.stop_propagation(); 1593 - edge_drag_active.set(true); 1594 - spawn(edge_drag_task(sri, DragEdge::Right, [bx, by, bw, bh], sidecar_rects_sig, edge_drag_active, source, active_idx)); 1595 - }, 1596 - } 1597 - } 1598 - } 1599 - } 1600 - } 1601 - } 1602 - } 1603 - if is_active { 1604 - for rect in &drawn { 1605 - rect { 1606 - x: "{rect[0] * iw}", y: "{rect[1] * ih}", 1607 - width: "{rect[2] * iw}", height: "{rect[3] * ih}", 1608 - fill: "rgba(255,100,100,0.2)", 1609 - stroke: "rgb(200,60,60)", 1610 - stroke_width: "1.5", rx: "3", 1611 - } 1612 - } 1613 - if let (Some((sx, sy)), Some((cx, cy))) = (ds, dc) { 1614 - { 1615 - let rx = sx.min(cx) * iw; 1616 - let ry = sy.min(cy) * ih; 1617 - let rw = (cx - sx).abs() * iw; 1618 - let rh = (cy - sy).abs() * ih; 1619 - rsx! { 1620 - rect { 1621 - x: "{rx}", y: "{ry}", 1622 - width: "{rw}", height: "{rh}", 1623 - fill: "rgba(100,180,255,0.1)", 1624 - stroke: "rgb(70,130,220)", 1625 - stroke_width: "1.5", 1626 - stroke_dasharray: "4 2", rx: "3", 1627 - } 1628 - } 1629 - } 1630 - } 1631 - } 1632 - } 1633 - if is_active && in_draw && active_is_cloze { 1634 - div { 1635 - class: "draw-capture", 1636 - onmousedown: move |e| { 1637 - let ox = e.data().element_coordinates().x; 1638 - let oy = e.data().element_coordinates().y; 1639 - let mut ds = drag_start; 1640 - let mut dc = drag_current; 1641 - let mut cd = cap_dims; 1642 - spawn(async move { 1643 - let mut eval = document::eval(r#" 1644 - var el = document.querySelector('.draw-capture'); 1645 - var z = parseFloat(document.documentElement.style.zoom || '1'); 1646 - dioxus.send([el ? el.offsetWidth : 1, el ? el.offsetHeight : 1, z]); 1647 - "#); 1648 - if let Ok(val) = eval.recv::<[f64; 3]>().await { 1649 - cd.set(val); 1650 - let (nx, ny) = normalize_draw_coords(ox, oy, val); 1651 - ds.set(Some((nx, ny))); 1652 - dc.set(Some((nx, ny))); 1653 - } 1654 - }); 1655 - }, 1656 - onmousemove: move |e| { 1657 - if drag_start.read().is_some() { 1658 - let (nx, ny) = normalize_draw_coords( 1659 - e.data().element_coordinates().x, 1660 - e.data().element_coordinates().y, 1661 - *cap_dims.read(), 1662 - ); 1663 - drag_current.set(Some((nx, ny))); 1664 - } 1665 - }, 1666 - onmouseleave: move |_| { 1667 - drag_start.set(None); 1668 - drag_current.set(None); 1669 - }, 1670 - onmouseup: move |e| { 1671 - let Some((sx, sy)) = *drag_start.read() else { return; }; 1672 - let (nx, ny) = normalize_draw_coords( 1673 - e.data().element_coordinates().x, 1674 - e.data().element_coordinates().y, 1675 - *cap_dims.read(), 1676 - ); 1677 - let rx = sx.min(nx); 1678 - let ry = sy.min(ny); 1679 - let rw = (nx - sx).abs(); 1680 - let rh = (ny - sy).abs(); 1681 - drag_start.set(None); 1682 - drag_current.set(None); 1683 - if rw * rh < 0.001 { return; } 1684 - let drawn_rect = [rx, ry, rw, rh]; 1685 - 1686 - let glyphs = glyph_map_sig.read().clone(); 1687 - let hits: Vec<_> = glyphs 1688 - .iter() 1689 - .filter(|(gr, _, _)| rects_overlap(drawn_rect, *gr)) 1690 - .cloned() 1691 - .collect(); 1692 - 1693 - if hits.is_empty() { 1694 - // Check if the rect overlaps an image element. 1695 - let img_boxes = image_boxes_sig.read().clone(); 1696 - let img_hit = img_boxes 1697 - .iter() 1698 - .enumerate() 1699 - .find(|(_, ib)| rects_overlap(drawn_rect, **ib)); 1700 - if let Some((img_idx, img_box)) = img_hit { 1701 - let cur = source.read().clone(); 1702 - let segs = make_segments(&cur); 1703 - let ai = (*active_idx.read()).min(segs.len().saturating_sub(1)); 1704 - let card_key = { 1705 - let card_pos = segs[..=ai] 1706 - .iter() 1707 - .filter(|s| s.kind == SegKind::Card) 1708 - .count() 1709 - .saturating_sub(1); 1710 - let cards = tala_format::parse_cards(&cur); 1711 - cards.get(card_pos) 1712 - .and_then(|c| c.id.clone()) 1713 - .unwrap_or_else(|| card_pos.to_string()) 1714 - }; 1715 - let fragment = segs.get(ai).map(|s| &cur[s.start..s.end]).unwrap_or(""); 1716 - let src = extract_img_names(fragment) 1717 - .into_iter() 1718 - .nth(img_idx) 1719 - .unwrap_or_default(); 1720 - // Convert drawn_rect (page-normalized) to image-local [0,1]. 1721 - let [ix, iy, iw, ih] = *img_box; 1722 - let local_rect = [ 1723 - ((drawn_rect[0] - ix) / iw).clamp(0.0, 1.0) as f32, 1724 - ((drawn_rect[1] - iy) / ih).clamp(0.0, 1.0) as f32, 1725 - (drawn_rect[2] / iw).clamp(0.0, 1.0) as f32, 1726 - (drawn_rect[3] / ih).clamp(0.0, 1.0) as f32, 1727 - ]; 1728 - let typ_path = cards_path(); 1729 - if let Ok(mut sidecar) = Sidecar::load_or_empty_for(&typ_path) { 1730 - // Scope the mutable borrow of sidecar.cards so 1731 - // save_for can borrow sidecar immutably afterward. 1732 - let updated_rects = { 1733 - let entry = sidecar 1734 - .cards 1735 - .entry(card_key) 1736 - .or_insert(CardSchedule::Cloze { 1737 - blanks: std::collections::HashMap::new(), 1738 - rects: Vec::new(), 1739 - }); 1740 - if let CardSchedule::Cloze { rects, .. } = entry { 1741 - let rect_id = format!("r{}", rects.len()); 1742 - rects.push(RectEntry { 1743 - id: rect_id, 1744 - src, 1745 - rect: local_rect, 1746 - schedule: Schedule { 1747 - due: tala_srs::today_str(), 1748 - stability: 0.0, 1749 - difficulty: 0.0, 1750 - }, 1751 - }); 1752 - Some(rects.clone()) 1753 - } else { 1754 - None 1755 - } 1756 - }; 1757 - if let Some(rects) = updated_rects { 1758 - let _ = sidecar.save_for(&typ_path); 1759 - let new_idx = rects.len().saturating_sub(1); 1760 - sidecar_rects_sig.set(rects); 1761 - selected_rect_sig.set(Some(new_idx)); 1762 - } 1763 - } 1764 - insert_error.set(None); 1765 - drawn_boxes.write().clear(); 1766 - } else { 1767 - insert_error.set(Some("No text or image found in drawn region.".into())); 1768 - drawn_boxes.write().push(drawn_rect); 1769 - } 1770 - return; 1771 - } 1772 - let all_math = hits.iter().all(|(_, _, m)| *m); 1773 - let any_math = hits.iter().any(|(_, _, m)| *m); 1774 - let min_start = hits.iter().map(|(_, r, _)| r.start).min().unwrap(); 1775 - let max_end = hits.iter().map(|(_, r, _)| r.end ).max().unwrap(); 1776 - 1777 - let cur = source.read().clone(); 1778 - let segs = make_segments(&cur); 1779 - let ai = (*active_idx.read()).min(segs.len().saturating_sub(1)); 1780 - let (cs, ce) = segs.get(ai).map(|s| (s.start, s.end)).unwrap_or((0, 0)); 1781 - let fragment = cur[cs..ce].to_string(); 1782 - 1783 - if all_math { 1784 - let spans = math_spans_sig.read().clone(); 1785 - let eq = spans.iter() 1786 - .find(|ms| ms.start <= min_start && max_end <= ms.end) 1787 - .cloned(); 1788 - match eq { 1789 - None => { 1790 - insert_error.set(Some("Could not locate equation boundary.".into())); 1791 - drawn_boxes.write().push(drawn_rect); 1792 - } 1793 - Some(eq) => { 1794 - let open_len = if fragment[eq.start..].starts_with("$ ") { 2 } else { 1 }; 1795 - let close_len = if fragment[..eq.end].ends_with(" $") { 2 } else { 1 }; 1796 - let content_start = eq.start + open_len; 1797 - let content_end = eq.end - close_len; 1798 - let (exp_start, exp_end) = expand_math_selection( 1799 - &fragment[content_start..content_end], 1800 - min_start - content_start, 1801 - max_end - content_start, 1802 - ); 1803 - let new_frag = insert_blank_wrap_math( 1804 - &fragment, 1805 - content_start + exp_start, 1806 - content_start + exp_end, 1807 - eq.start, eq.end, 1808 - ); 1809 - source.set(format!("{}{}{}", &cur[..cs], new_frag, &cur[ce..])); 1810 - insert_error.set(None); 1811 - let b64 = base64::engine::general_purpose::STANDARD.encode(&new_frag); 1812 - spawn(async move { 1813 - document::eval(&format!( 1814 - "var t=document.querySelector('.card-row.active textarea');\ 1815 - if(t)t.value=atob('{}');", b64 1816 - )).await.ok(); 1817 - }); 1818 - } 1819 - } 1820 - return; 1821 - } 1822 - if any_math { 1823 - let spans = math_spans_sig.read().clone(); 1824 - let mut lo = min_start; 1825 - let mut hi = max_end; 1826 - for ms in &spans { 1827 - if ms.start < lo && ms.end > lo { lo = ms.start; } 1828 - if ms.start < hi && ms.end > hi { hi = ms.end; } 1829 - } 1830 - let new_frag = insert_blank_wrap(&fragment, lo, hi); 1831 - source.set(format!("{}{}{}", &cur[..cs], new_frag, &cur[ce..])); 1832 - insert_error.set(None); 1833 - let b64 = base64::engine::general_purpose::STANDARD.encode(&new_frag); 1834 - spawn(async move { 1835 - document::eval(&format!( 1836 - "var t=document.querySelector('.card-row.active textarea');\ 1837 - if(t)t.value=atob('{}');", b64 1838 - )).await.ok(); 1839 - }); 1840 - return; 1841 - } 1842 - let new_frag = insert_blank_wrap(&fragment, min_start, max_end); 1843 - source.set(format!("{}{}{}", &cur[..cs], new_frag, &cur[ce..])); 1844 - insert_error.set(None); 1845 - let b64 = base64::engine::general_purpose::STANDARD.encode(&new_frag); 1846 - spawn(async move { 1847 - document::eval(&format!( 1848 - "var t=document.querySelector('.card-row.active textarea');\ 1849 - if(t)t.value=atob('{}');", b64 1850 - )).await.ok(); 1851 - }); 1852 - }, 1853 - } 1854 - } 1855 - } 1856 - } 1857 - }, 1858 - } 1859 - } 1860 - } 1861 - }) 1862 - } 1863 - } 1864 - } 1865 - } 1866 - } 1867 - 1868 - #[cfg(test)] 1869 - mod tests { 1870 - use super::*; 1871 - 1872 - // --- normalize_draw_coords --- 1873 - 1874 - // [offsetWidth, offsetHeight, zoom] 1875 - const DIMS_Z1: [f64; 3] = [400.0, 200.0, 1.0]; 1876 - 1877 - #[test] 1878 - fn normalize_coords_center() { 1879 - let (nx, ny) = normalize_draw_coords(200.0, 100.0, DIMS_Z1); 1880 - assert_eq!(nx, 0.5); 1881 - assert_eq!(ny, 0.5); 1882 - } 1883 - 1884 - /// In webkit2gtk, offsetX/Y are in zoomed px, offsetWidth/Height in logical px. 1885 - /// At zoom Z, offsetX for the visual center = (offsetWidth/2) * Z. 1886 - /// normalize_draw_coords divides out Z to correct this. 1887 - #[test] 1888 - fn normalize_coords_zoom_invariant() { 1889 - let zoom = 1.3_f64; 1890 - // offsetX at visual center = (400/2)*1.3 = 260 (zoomed px) 1891 - let offset_x_zoomed = 200.0 * zoom; 1892 - let dims = [400.0, 200.0, zoom]; 1893 - let (nx, _) = normalize_draw_coords(offset_x_zoomed, 0.0, dims); 1894 - assert!((nx - 0.5).abs() < 1e-10, "nx={nx}"); 1895 - } 1896 - 1897 - #[test] 1898 - fn normalize_coords_clamps_to_unit() { 1899 - let (nx, ny) = normalize_draw_coords(500.0, -10.0, DIMS_Z1); 1900 - assert_eq!(nx, 1.0); 1901 - assert_eq!(ny, 0.0); 1902 - } 1903 - 1904 - // --- rects_overlap --- 1905 - 1906 - #[test] 1907 - fn rects_overlap_yes() { 1908 - assert!(rects_overlap([0.0, 0.0, 0.5, 0.5], [0.25, 0.25, 0.5, 0.5])); 1909 - } 1910 - 1911 - #[test] 1912 - fn rects_overlap_adjacent_no_overlap() { 1913 - // Touching edges do not overlap (strict inequality in the check). 1914 - assert!(!rects_overlap([0.0, 0.0, 0.5, 0.5], [0.5, 0.0, 0.5, 0.5])); 1915 - } 1916 - 1917 - #[test] 1918 - fn rects_overlap_contained() { 1919 - assert!(rects_overlap([0.1, 0.1, 0.3, 0.3], [0.0, 0.0, 1.0, 1.0])); 1920 - } 1921 - 1922 - // --- insert_blank_wrap --- 1923 - 1924 - #[test] 1925 - fn blank_wrap_plain_word() { 1926 - let src = "#cloze[The Gaussian integral is $x$]"; 1927 - // "#cloze[The " = 11 bytes → "G" starts at 11, "Gaussian" = 8 bytes → end = 19 1928 - let start = src.find("Gaussian").unwrap(); 1929 - let end = start + "Gaussian".len(); 1930 - let result = insert_blank_wrap(src, start, end); 1931 - assert!(result.contains("#blank[Gaussian]"), "got: {result}"); 1932 - } 1933 - 1934 - #[test] 1935 - fn blank_wrap_trims_whitespace_outside() { 1936 - // Leading space in the selection stays outside the blank. 1937 - let src = "hello world"; 1938 - // Select " world" (index 5..11): leading space → outside, "world" → inside 1939 - let result = insert_blank_wrap(src, 5, 11); 1940 - assert_eq!(result, "hello #blank[world]"); 1941 - } 1942 - 1943 - // --- insert_blank_wrap_math (inline) --- 1944 - 1945 - #[test] 1946 - fn wrap_math_middle_selection() { 1947 - let src = "$a + b + c$"; 1948 - // Select "b" 1949 - let result = insert_blank_wrap_math(src, 5, 6, 0, 11); 1950 - assert_eq!(result, "$a +$ #blank[$b$] $+ c$"); 1951 - } 1952 - 1953 - #[test] 1954 - fn wrap_math_full_equation() { 1955 - let src = "$sqrt(pi)$"; 1956 - let result = insert_blank_wrap_math(src, 1, 9, 0, 10); 1957 - assert_eq!(result, "#blank[$sqrt(pi)$]"); 1958 - } 1959 - 1960 - #[test] 1961 - fn wrap_math_left_empty() { 1962 - let src = "$e^x + 1$"; 1963 - // Select from start 1964 - let result = insert_blank_wrap_math(src, 1, 4, 0, 9); 1965 - assert_eq!(result, "#blank[$e^x$] $+ 1$"); 1966 - } 1967 - 1968 - // --- insert_blank_wrap_math (display) --- 1969 - 1970 - #[test] 1971 - fn wrap_display_math_rhs() { 1972 - // User selects the RHS of a display math equation. 1973 - // Result must stay inside one display math block, not split into two. 1974 - let src = r#"$ C_(i j)^"SPICE" = - C_(i j)^"Maxwell" $"#; 1975 - let eq_end = src.len(); 1976 - // sel covers `- C_(i j)^"Maxwell"` (content after `= `) 1977 - let sel_start = src.find("- C_").unwrap(); 1978 - let sel_end = src.rfind('"').unwrap() + 1; 1979 - let result = insert_blank_wrap_math(src, sel_start, sel_end, 0, eq_end); 1980 - assert_eq!( 1981 - result, 1982 - r#"$ C_(i j)^"SPICE" = #blank[$- C_(i j)^"Maxwell"$] $"# 1983 - ); 1984 - } 1985 - 1986 - #[test] 1987 - fn wrap_display_math_full() { 1988 - // Entire content selected. 1989 - let src = "$ A = B $"; 1990 - let sel_start = 2; // 'A' 1991 - let sel_end = 7; // after 'B' 1992 - let result = insert_blank_wrap_math(src, sel_start, sel_end, 0, src.len()); 1993 - assert_eq!(result, "$ #blank[$A = B$] $"); 1994 - } 1995 - 1996 - #[test] 1997 - fn wrap_display_math_with_right() { 1998 - // Left and right both non-empty. 1999 - let src = "$ a + b + c $"; 2000 - // 'b' is at byte index 6 in src ("$ a + b + c $") 2001 - let result = insert_blank_wrap_math(src, 6, 7, 0, src.len()); 2002 - assert_eq!(result, "$ a + #blank[$b$] + c $"); 2003 - } 2004 - 2005 - // --- expand_math_selection --- 2006 - 2007 - #[test] 2008 - fn expand_unbalanced_paren() { 2009 - // Glyph hits cover `e^(-x^2` but miss the closing `)` 2010 - let eq = "e^(-x^2)"; 2011 - let (s, e) = expand_math_selection(eq, 0, 7); // 0..7 = "e^(-x^2" 2012 - assert_eq!(&eq[s..e], "e^(-x^2)"); 2013 - } 2014 - 2015 - #[test] 2016 - fn expand_identifier_to_function_call() { 2017 - // Only `s` was hit (glyph for radical sign maps to first char of `sqrt`) 2018 - let eq = "x = sqrt(pi)"; 2019 - let (s, e) = expand_math_selection(eq, 4, 5); // 4..5 = "s" 2020 - assert_eq!(&eq[s..e], "sqrt(pi)"); 2021 - } 2022 - 2023 - #[test] 2024 - fn expand_no_change_when_already_complete() { 2025 - let eq = "a + b"; 2026 - let (s, e) = expand_math_selection(eq, 4, 5); // "b" 2027 - assert_eq!(&eq[s..e], "b"); 2028 - } 2029 - 2030 - #[test] 2031 - fn expand_nested_brackets() { 2032 - // If selection starts inside `[...]`, it should close the bracket. 2033 - let eq = "vec[1, 2, 3]"; 2034 - // Glyph hits: "vec" (0..3), then "[" opens bracket. 2035 - // simulate a hit that catches "vec[1" = 0..5, missing close "]" 2036 - let (s, e) = expand_math_selection(eq, 0, 5); // "vec[1" 2037 - assert_eq!(&eq[s..e], "vec[1, 2, 3]"); 2038 - } 2039 - 2040 - #[test] 2041 - fn expand_unbalanced_string_literal() { 2042 - // Selection covers `- C_(i j)^"Maxwell` but misses the closing `"` 2043 - let eq = r#"C_(i j)^"SPICE" = - C_(i j)^"Maxwell""#; 2044 - // Find index of the `-` and the `l` at end of Maxwell 2045 - let sel_start = eq.find("- C_").unwrap(); 2046 - let sel_end = eq.rfind('"').unwrap(); // index of closing `"` -- select up to but not including it 2047 - let (s, e) = expand_math_selection(eq, sel_start, sel_end); 2048 - assert_eq!(&eq[s..e], r#"- C_(i j)^"Maxwell""#); 2049 - } 2050 - 2051 - // ── format_card_frag / map_cursor_after_format ──────────────────────────── 2052 - 2053 - #[test] 2054 - fn format_frontback_single_line() { 2055 - let frag = "#card()[front][back]"; 2056 - let out = format_card_frag(frag).unwrap(); 2057 - assert_eq!(out, "#card()\n[\n front\n][\n back\n]"); 2058 - } 2059 - 2060 - #[test] 2061 - fn format_cloze_single_line() { 2062 - let frag = "#cloze(tags: (\"t\",))[body #blank[hidden]]"; 2063 - let out = format_card_frag(frag).unwrap(); 2064 - assert_eq!(out, "#cloze(tags: (\"t\",))\n[\n body #blank[hidden]\n]"); 2065 - } 2066 - 2067 - #[test] 2068 - fn format_already_formatted_returns_none() { 2069 - let frag = "#card()\n[\n front\n][\n back\n]"; 2070 - assert!(format_card_frag(frag).is_none()); 2071 - } 2072 - 2073 - #[test] 2074 - fn cursor_in_head_unchanged() { 2075 - let frag = "#card()[front][back]"; 2076 - let (head_end, blocks) = parse_card_structure(frag).unwrap(); 2077 - assert_eq!(map_cursor_after_format(frag, 3, head_end, &blocks), 3); 2078 - } 2079 - 2080 - #[test] 2081 - fn cursor_at_open_bracket_maps_to_formatted_bracket() { 2082 - let frag = "#card()[front][back]"; 2083 - let (head_end, blocks) = parse_card_structure(frag).unwrap(); 2084 - // `[` is at index 7; formatted: "#card()\n[" -> index 8 2085 - let nc = map_cursor_after_format(frag, 7, head_end, &blocks); 2086 - let out = format_card_frag(frag).unwrap(); 2087 - assert_eq!(&out[nc..nc + 1], "["); 2088 - } 2089 - 2090 - #[test] 2091 - fn cursor_in_block0_content_maps_correctly() { 2092 - let frag = "#card()[front][back]"; 2093 - // 'o' in "front" is at index 10 2094 - let (head_end, blocks) = parse_card_structure(frag).unwrap(); 2095 - let nc = map_cursor_after_format(frag, 10, head_end, &blocks); 2096 - let out = format_card_frag(frag).unwrap(); 2097 - assert_eq!(&out[nc..nc + 1], "o"); 2098 - } 2099 - 2100 - #[test] 2101 - fn cursor_in_block1_content_maps_correctly() { 2102 - let frag = "#card()[front][back]"; 2103 - // 'c' in "back" is at index 17 2104 - let (head_end, blocks) = parse_card_structure(frag).unwrap(); 2105 - let nc = map_cursor_after_format(frag, 17, head_end, &blocks); 2106 - let out = format_card_frag(frag).unwrap(); 2107 - assert_eq!(&out[nc..nc + 1], "c"); 2108 - } 2109 - }
+300
crates/tala/src/editor/blank_wrap.rs
··· 1 + /// Wrap `source[start..end]` in `#blank[...]`, keeping surrounding whitespace outside. 2 + pub(super) fn insert_blank_wrap(source: &str, start: usize, end: usize) -> String { 3 + let raw = &source[start..end]; 4 + let inner = raw.trim(); 5 + let lead = raw.len() - raw.trim_start().len(); 6 + let trail = raw.len() - raw.trim_end().len(); 7 + format!( 8 + "{}{}#blank[{}]{}{}", 9 + &source[..start], 10 + &source[start..start + lead], 11 + inner, 12 + &source[end - trail..end], 13 + &source[end..], 14 + ) 15 + } 16 + 17 + /// Given a raw glyph-based `sel_start..sel_end` within `eq_content` (the inner 18 + /// text of an equation, excluding `$` delimiters), expand the selection so it: 19 + /// 1. Extends `sel_end` to the end of any incomplete identifier token. 20 + /// 2. Includes a trailing `(...)` function-call argument if the token is followed by one. 21 + /// 3. Re-balances any unclosed `()`, `[]`, `{}` pairs. 22 + pub(super) fn expand_math_selection(eq_content: &str, sel_start: usize, sel_end: usize) -> (usize, usize) { 23 + let b = eq_content.as_bytes(); 24 + let len = b.len(); 25 + let mut end = sel_end.min(len); 26 + 27 + // 1. Expand to end of identifier/number token if sel_end is mid-token. 28 + let is_alnum = |c: u8| c.is_ascii_alphanumeric() || c == b'_'; 29 + if end > 0 && end < len && is_alnum(b[end - 1]) && is_alnum(b[end]) { 30 + while end < len && is_alnum(b[end]) { 31 + end += 1; 32 + } 33 + } 34 + 35 + // 2. If immediately followed by `(`, include the matched parenthesised group. 36 + if end < len && b[end] == b'(' { 37 + end += 1; 38 + let mut depth = 1usize; 39 + while end < len && depth > 0 { 40 + match b[end] { 41 + b'(' => depth += 1, 42 + b')' => { 43 + depth -= 1; 44 + } 45 + _ => {} 46 + } 47 + end += 1; 48 + } 49 + } 50 + 51 + // 3. Balance any unclosed delimiters in sel_start..end. 52 + let (mut parens, mut brackets, mut braces) = (0i32, 0i32, 0i32); 53 + for &c in &b[sel_start..end] { 54 + match c { 55 + b'(' => parens += 1, 56 + b')' => parens -= 1, 57 + b'[' => brackets += 1, 58 + b']' => brackets -= 1, 59 + b'{' => braces += 1, 60 + b'}' => braces -= 1, 61 + _ => {} 62 + } 63 + } 64 + while end < len && (parens > 0 || brackets > 0 || braces > 0) { 65 + match b[end] { 66 + b'(' => parens += 1, 67 + b')' => parens -= 1, 68 + b'[' => brackets += 1, 69 + b']' => brackets -= 1, 70 + b'{' => braces += 1, 71 + b'}' => braces -= 1, 72 + _ => {} 73 + } 74 + end += 1; 75 + } 76 + 77 + // 4. Balance any unclosed typst string literal (`"..."`) in sel_start..end. 78 + // Quotes use the same character for open and close, so an odd count means 79 + // the selection ends inside a string. 80 + let quote_count = b[sel_start..end].iter().filter(|&&c| c == b'"').count(); 81 + if quote_count % 2 != 0 { 82 + while end < len { 83 + end += 1; 84 + if b[end - 1] == b'"' { 85 + break; 86 + } 87 + } 88 + } 89 + 90 + (sel_start, end) 91 + } 92 + 93 + /// Split an equation at `sel_start..sel_end` (fragment-relative), wrapping the 94 + /// selection in `#blank[...]`. 95 + /// 96 + /// For **display math** (`$ ... $`): the blank is inserted *inside* the single 97 + /// display math block so the equation stays on one line. The blank's content 98 + /// uses inline math `$...$`. 99 + /// `$ A = #blank[$B$] $` 100 + /// 101 + /// For **inline math** (`$...$`): the equation is split into separate inline 102 + /// spans around the blank (existing behaviour). 103 + /// `$A +$ #blank[$b$] $+ C$` 104 + /// 105 + /// `eq_start..eq_end` are the fragment-relative bounds of the full equation node 106 + /// (including the `$` delimiters). Empty left or right parts are omitted. 107 + pub(super) fn insert_blank_wrap_math( 108 + source: &str, 109 + sel_start: usize, 110 + sel_end: usize, 111 + eq_start: usize, 112 + eq_end: usize, 113 + ) -> String { 114 + // Detect display math (`$ ... $`) vs inline math (`$...$`). 115 + let is_display = source[eq_start..].starts_with("$ ") 116 + && source[..eq_end].ends_with(" $"); 117 + let (open, close) = if is_display { ("$ ", " $") } else { ("$", "$") }; 118 + let content_start = eq_start + open.len(); 119 + let content_end = eq_end - close.len(); 120 + 121 + let left_inner = source[content_start..sel_start].trim_end(); 122 + let selected = source[sel_start..sel_end].trim(); 123 + let right_inner = source[sel_end..content_end].trim_start(); 124 + 125 + if is_display { 126 + // Keep everything inside one display math block. 127 + // The blank's content is inline math so it doesn't break the block. 128 + let left_sep = if left_inner.is_empty() { "" } else { " " }; 129 + let right_sep = if right_inner.is_empty() { "" } else { " " }; 130 + format!( 131 + "{}$ {}{}#blank[${}$]{}{} ${}", 132 + &source[..eq_start], 133 + left_inner, 134 + left_sep, 135 + selected, 136 + right_sep, 137 + right_inner, 138 + &source[eq_end..] 139 + ) 140 + } else { 141 + let left_part = if left_inner.is_empty() { 142 + String::new() 143 + } else { 144 + format!("{}{}{} ", open, left_inner, close) 145 + }; 146 + let blank_part = format!("#blank[{}{}{}]", open, selected, close); 147 + let right_part = if right_inner.is_empty() { 148 + String::new() 149 + } else { 150 + format!(" {}{}{}", open, right_inner, close) 151 + }; 152 + format!( 153 + "{}{}{}{}{}", 154 + &source[..eq_start], 155 + left_part, 156 + blank_part, 157 + right_part, 158 + &source[eq_end..] 159 + ) 160 + } 161 + } 162 + 163 + /// Remove whitespace between a card/cloze head and its first `[` content block. 164 + /// Typst markup mode does not attach `[...]` as a content argument when a newline 165 + /// precedes it, so `#card\n[...]` renders incorrectly. Returns the normalized 166 + /// string and the number of characters stripped (used to adjust blank spans). 167 + pub(super) fn strip_head_whitespace(frag: &str) -> (String, usize) { 168 + let bytes = frag.as_bytes(); 169 + let mut paren_depth = 0i32; 170 + let mut bracket_pos = None; 171 + for (i, &b) in bytes.iter().enumerate() { 172 + match b { 173 + b'(' => paren_depth += 1, 174 + b')' => paren_depth -= 1, 175 + b'[' if paren_depth == 0 => { 176 + bracket_pos = Some(i); 177 + break; 178 + } 179 + _ => {} 180 + } 181 + } 182 + let Some(pos) = bracket_pos else { 183 + return (frag.to_string(), 0); 184 + }; 185 + let head = &frag[..pos]; 186 + let trimmed = head.trim_end(); 187 + let stripped = head.len() - trimmed.len(); 188 + if stripped == 0 { 189 + return (frag.to_string(), 0); 190 + } 191 + (format!("{}{}", trimmed, &frag[pos..]), stripped) 192 + } 193 + 194 + #[cfg(test)] 195 + mod tests { 196 + use super::*; 197 + 198 + #[test] 199 + fn blank_wrap_plain_word() { 200 + let src = "#cloze[The Gaussian integral is $x$]"; 201 + let start = src.find("Gaussian").unwrap(); 202 + let end = start + "Gaussian".len(); 203 + let result = insert_blank_wrap(src, start, end); 204 + assert!(result.contains("#blank[Gaussian]"), "got: {result}"); 205 + } 206 + 207 + #[test] 208 + fn blank_wrap_trims_whitespace_outside() { 209 + let src = "hello world"; 210 + let result = insert_blank_wrap(src, 5, 11); 211 + assert_eq!(result, "hello #blank[world]"); 212 + } 213 + 214 + #[test] 215 + fn wrap_math_middle_selection() { 216 + let src = "$a + b + c$"; 217 + let result = insert_blank_wrap_math(src, 5, 6, 0, 11); 218 + assert_eq!(result, "$a +$ #blank[$b$] $+ c$"); 219 + } 220 + 221 + #[test] 222 + fn wrap_math_full_equation() { 223 + let src = "$sqrt(pi)$"; 224 + let result = insert_blank_wrap_math(src, 1, 9, 0, 10); 225 + assert_eq!(result, "#blank[$sqrt(pi)$]"); 226 + } 227 + 228 + #[test] 229 + fn wrap_math_left_empty() { 230 + let src = "$e^x + 1$"; 231 + let result = insert_blank_wrap_math(src, 1, 4, 0, 9); 232 + assert_eq!(result, "#blank[$e^x$] $+ 1$"); 233 + } 234 + 235 + #[test] 236 + fn wrap_display_math_rhs() { 237 + let src = r#"$ C_(i j)^"SPICE" = - C_(i j)^"Maxwell" $"#; 238 + let eq_end = src.len(); 239 + let sel_start = src.find("- C_").unwrap(); 240 + let sel_end = src.rfind('"').unwrap() + 1; 241 + let result = insert_blank_wrap_math(src, sel_start, sel_end, 0, eq_end); 242 + assert_eq!( 243 + result, 244 + r#"$ C_(i j)^"SPICE" = #blank[$- C_(i j)^"Maxwell"$] $"# 245 + ); 246 + } 247 + 248 + #[test] 249 + fn wrap_display_math_full() { 250 + let src = "$ A = B $"; 251 + let sel_start = 2; 252 + let sel_end = 7; 253 + let result = insert_blank_wrap_math(src, sel_start, sel_end, 0, src.len()); 254 + assert_eq!(result, "$ #blank[$A = B$] $"); 255 + } 256 + 257 + #[test] 258 + fn wrap_display_math_with_right() { 259 + let src = "$ a + b + c $"; 260 + let result = insert_blank_wrap_math(src, 6, 7, 0, src.len()); 261 + assert_eq!(result, "$ a + #blank[$b$] + c $"); 262 + } 263 + 264 + #[test] 265 + fn expand_unbalanced_paren() { 266 + let eq = "e^(-x^2)"; 267 + let (s, e) = expand_math_selection(eq, 0, 7); 268 + assert_eq!(&eq[s..e], "e^(-x^2)"); 269 + } 270 + 271 + #[test] 272 + fn expand_identifier_to_function_call() { 273 + let eq = "x = sqrt(pi)"; 274 + let (s, e) = expand_math_selection(eq, 4, 5); 275 + assert_eq!(&eq[s..e], "sqrt(pi)"); 276 + } 277 + 278 + #[test] 279 + fn expand_no_change_when_already_complete() { 280 + let eq = "a + b"; 281 + let (s, e) = expand_math_selection(eq, 4, 5); 282 + assert_eq!(&eq[s..e], "b"); 283 + } 284 + 285 + #[test] 286 + fn expand_nested_brackets() { 287 + let eq = "vec[1, 2, 3]"; 288 + let (s, e) = expand_math_selection(eq, 0, 5); 289 + assert_eq!(&eq[s..e], "vec[1, 2, 3]"); 290 + } 291 + 292 + #[test] 293 + fn expand_unbalanced_string_literal() { 294 + let eq = r#"C_(i j)^"SPICE" = - C_(i j)^"Maxwell""#; 295 + let sel_start = eq.find("- C_").unwrap(); 296 + let sel_end = eq.rfind('"').unwrap(); 297 + let (s, e) = expand_math_selection(eq, sel_start, sel_end); 298 + assert_eq!(&eq[s..e], r#"- C_(i j)^"Maxwell""#); 299 + } 300 + }
+210
crates/tala/src/editor/draw_ops.rs
··· 1 + use dioxus::prelude::*; 2 + use tala_srs::RectEntry; 3 + 4 + use super::segments::active_card_key; 5 + use super::sidecar_ops::commit_sidecar_rects; 6 + 7 + #[derive(Clone, Copy, PartialEq, Debug)] 8 + pub(super) enum DragEdge { 9 + Top, 10 + Bottom, 11 + Left, 12 + Right, 13 + } 14 + 15 + /// Convert element-local `offsetX/offsetY` to normalized [0,1] coords. 16 + /// 17 + /// `dims` is `[offsetWidth, offsetHeight, cssZoom]`. 18 + /// In webkit2gtk with CSS `zoom` on `<html>`, `offsetX/Y` are in zoomed (visual) 19 + /// pixels while `offsetWidth/Height` are in logical pixels. Dividing out the 20 + /// zoom factor before normalizing corrects this. 21 + pub(super) fn normalize_draw_coords(offset_x: f64, offset_y: f64, dims: [f64; 3]) -> (f64, f64) { 22 + let [w, h, zoom] = dims; 23 + let z = zoom.max(0.1); 24 + ( 25 + (offset_x / z / w.max(1.0)).clamp(0.0, 1.0), 26 + (offset_y / z / h.max(1.0)).clamp(0.0, 1.0), 27 + ) 28 + } 29 + 30 + /// True if two normalized [x, y, w, h] rects have any overlap. 31 + pub(super) fn rects_overlap(a: [f64; 4], b: [f64; 4]) -> bool { 32 + a[0] < b[0] + b[2] && a[0] + a[2] > b[0] && a[1] < b[1] + b[3] && a[1] + a[3] > b[1] 33 + } 34 + 35 + pub(super) async fn edge_drag_task( 36 + rect_idx: usize, 37 + edge: DragEdge, 38 + img_box: [f64; 4], 39 + mut rects_sig: Signal<Vec<RectEntry>>, 40 + mut drag_active: Signal<bool>, 41 + source: Signal<String>, 42 + active_idx: Signal<usize>, 43 + ) { 44 + let [bx, by, bw, bh] = img_box; 45 + let mut eval = document::eval( 46 + r#" 47 + var svg = document.querySelector('.card-row.active .cloze-overlay'); 48 + var bb = svg.getBoundingClientRect(); 49 + function onMove(e) { 50 + dioxus.send([0.0, (e.clientX-bb.left)/bb.width, (e.clientY-bb.top)/bb.height]); 51 + } 52 + function onUp() { 53 + document.removeEventListener('mousemove', onMove); 54 + document.removeEventListener('mouseup', onUp); 55 + document.addEventListener('click', function(e){ e.stopPropagation(); }, { capture: true, once: true }); 56 + dioxus.send([1.0, 0.0, 0.0]); 57 + } 58 + document.addEventListener('mousemove', onMove); 59 + document.addEventListener('mouseup', onUp); 60 + "#, 61 + ); 62 + loop { 63 + match eval.recv::<[f64; 3]>().await { 64 + Ok([kind, nx, ny]) => { 65 + if kind < 0.5 { 66 + let local_x = ((nx - bx) / bw) as f32; 67 + let local_y = ((ny - by) / bh) as f32; 68 + let mut rects = rects_sig.read().clone(); 69 + if let Some(r) = rects.get_mut(rect_idx) { 70 + match edge { 71 + DragEdge::Top => { 72 + let bot = r.rect[1] + r.rect[3]; 73 + r.rect[1] = local_y.clamp(0.0, bot - 0.01); 74 + r.rect[3] = bot - r.rect[1]; 75 + } 76 + DragEdge::Bottom => { 77 + r.rect[3] = (local_y - r.rect[1]).max(0.01); 78 + } 79 + DragEdge::Left => { 80 + let right = r.rect[0] + r.rect[2]; 81 + r.rect[0] = local_x.clamp(0.0, right - 0.01); 82 + r.rect[2] = right - r.rect[0]; 83 + } 84 + DragEdge::Right => { 85 + r.rect[2] = (local_x - r.rect[0]).max(0.01); 86 + } 87 + } 88 + rects_sig.set(rects); 89 + } 90 + } else { 91 + drag_active.set(false); 92 + let cur = source.peek().clone(); 93 + let card_key = active_card_key(&cur, *active_idx.peek()); 94 + commit_sidecar_rects(rects_sig.read().clone(), &card_key); 95 + break; 96 + } 97 + } 98 + Err(_) => { 99 + drag_active.set(false); 100 + break; 101 + } 102 + } 103 + } 104 + } 105 + 106 + pub(super) async fn move_drag_task( 107 + rect_idx: usize, 108 + img_box: [f64; 4], 109 + mut rects_sig: Signal<Vec<RectEntry>>, 110 + mut drag_active: Signal<bool>, 111 + source: Signal<String>, 112 + active_idx: Signal<usize>, 113 + ) { 114 + let [_bx, _by, bw, bh] = img_box; 115 + let mut eval = document::eval( 116 + r#" 117 + var svg = document.querySelector('.card-row.active .cloze-overlay'); 118 + var bb = svg.getBoundingClientRect(); 119 + function onMove(e) { 120 + dioxus.send([0.0, (e.clientX-bb.left)/bb.width, (e.clientY-bb.top)/bb.height]); 121 + } 122 + function onUp() { 123 + document.removeEventListener('mousemove', onMove); 124 + document.removeEventListener('mouseup', onUp); 125 + document.addEventListener('click', function(e){ e.stopPropagation(); }, { capture: true, once: true }); 126 + dioxus.send([1.0, 0.0, 0.0]); 127 + } 128 + document.addEventListener('mousemove', onMove); 129 + document.addEventListener('mouseup', onUp); 130 + "#, 131 + ); 132 + let mut last: Option<(f64, f64)> = None; 133 + loop { 134 + match eval.recv::<[f64; 3]>().await { 135 + Ok([kind, nx, ny]) => { 136 + if kind < 0.5 { 137 + if let Some((lx, ly)) = last { 138 + let dx = ((nx - lx) / bw) as f32; 139 + let dy = ((ny - ly) / bh) as f32; 140 + let mut rects = rects_sig.read().clone(); 141 + if let Some(r) = rects.get_mut(rect_idx) { 142 + r.rect[0] = (r.rect[0] + dx).clamp(0.0, 1.0 - r.rect[2]); 143 + r.rect[1] = (r.rect[1] + dy).clamp(0.0, 1.0 - r.rect[3]); 144 + rects_sig.set(rects); 145 + } 146 + } 147 + last = Some((nx, ny)); 148 + } else { 149 + drag_active.set(false); 150 + let cur = source.peek().clone(); 151 + let card_key = active_card_key(&cur, *active_idx.peek()); 152 + commit_sidecar_rects(rects_sig.read().clone(), &card_key); 153 + break; 154 + } 155 + } 156 + Err(_) => { 157 + drag_active.set(false); 158 + break; 159 + } 160 + } 161 + } 162 + } 163 + 164 + #[cfg(test)] 165 + mod tests { 166 + use super::*; 167 + 168 + const DIMS_Z1: [f64; 3] = [400.0, 200.0, 1.0]; 169 + 170 + #[test] 171 + fn normalize_coords_center() { 172 + let (nx, ny) = normalize_draw_coords(200.0, 100.0, DIMS_Z1); 173 + assert_eq!(nx, 0.5); 174 + assert_eq!(ny, 0.5); 175 + } 176 + 177 + /// In webkit2gtk, offsetX/Y are in zoomed px, offsetWidth/Height in logical px. 178 + /// At zoom Z, offsetX for the visual center = (offsetWidth/2) * Z. 179 + /// normalize_draw_coords divides out Z to correct this. 180 + #[test] 181 + fn normalize_coords_zoom_invariant() { 182 + let zoom = 1.3_f64; 183 + let offset_x_zoomed = 200.0 * zoom; 184 + let dims = [400.0, 200.0, zoom]; 185 + let (nx, _) = normalize_draw_coords(offset_x_zoomed, 0.0, dims); 186 + assert!((nx - 0.5).abs() < 1e-10, "nx={nx}"); 187 + } 188 + 189 + #[test] 190 + fn normalize_coords_clamps_to_unit() { 191 + let (nx, ny) = normalize_draw_coords(500.0, -10.0, DIMS_Z1); 192 + assert_eq!(nx, 1.0); 193 + assert_eq!(ny, 0.0); 194 + } 195 + 196 + #[test] 197 + fn rects_overlap_yes() { 198 + assert!(rects_overlap([0.0, 0.0, 0.5, 0.5], [0.25, 0.25, 0.5, 0.5])); 199 + } 200 + 201 + #[test] 202 + fn rects_overlap_adjacent_no_overlap() { 203 + assert!(!rects_overlap([0.0, 0.0, 0.5, 0.5], [0.5, 0.0, 0.5, 0.5])); 204 + } 205 + 206 + #[test] 207 + fn rects_overlap_contained() { 208 + assert!(rects_overlap([0.1, 0.1, 0.3, 0.3], [0.0, 0.0, 1.0, 1.0])); 209 + } 210 + }
+204
crates/tala/src/editor/format.rs
··· 1 + /// Find the head boundary and content block spans in a card fragment. 2 + /// 3 + /// Returns `(head_end, blocks)` where `head_end` is the byte index of the first 4 + /// top-level `[` and each element of `blocks` is `(content_start, content_end)`: 5 + /// `content_start` is one past `[` and `content_end` is the index of the matching `]`. 6 + pub(super) fn parse_card_structure(frag: &str) -> Option<(usize, Vec<(usize, usize)>)> { 7 + let b = frag.as_bytes(); 8 + let len = b.len(); 9 + 10 + // Find head_end: first `[` not inside parentheses. 11 + let mut paren_depth = 0i32; 12 + let mut head_end = None; 13 + for i in 0..len { 14 + match b[i] { 15 + b'(' => paren_depth += 1, 16 + b')' => paren_depth -= 1, 17 + b'[' if paren_depth == 0 => { 18 + head_end = Some(i); 19 + break; 20 + } 21 + _ => {} 22 + } 23 + } 24 + let head_end = head_end?; 25 + 26 + // Collect top-level `[...]` blocks with depth tracking. 27 + let mut blocks = Vec::new(); 28 + let mut i = head_end; 29 + while i < len { 30 + if b[i] == b'[' { 31 + let block_start = i + 1; 32 + let mut depth = 1i32; 33 + i += 1; 34 + while i < len && depth > 0 { 35 + match b[i] { 36 + b'[' => depth += 1, 37 + b']' => depth -= 1, 38 + _ => {} 39 + } 40 + if depth > 0 { 41 + i += 1; 42 + } 43 + } 44 + blocks.push((block_start, i)); // content_end is index of `]` 45 + i += 1; // skip `]` 46 + } else { 47 + i += 1; 48 + } 49 + } 50 + 51 + if blocks.is_empty() { 52 + return None; 53 + } 54 + Some((head_end, blocks)) 55 + } 56 + 57 + /// Reformat a card fragment to multi-line form: 58 + /// `{head}\n[\n {content0}\n][\n {content1}\n]` 59 + /// 60 + /// Returns `None` if already in that form or not a recognised card. 61 + pub(super) fn format_card_frag(frag: &str) -> Option<String> { 62 + let (head_end, ref blocks) = parse_card_structure(frag)?; 63 + // If there is non-whitespace content after the last block's closing `]`, 64 + // the brackets don't balance (e.g. user deleted `#blank[` but not its `]`). 65 + // Skip formatting entirely rather than silently dropping that trailing content. 66 + let last_close = blocks.last().map(|&(_, ce)| ce + 1).unwrap_or(head_end); 67 + if !frag[last_close..].trim().is_empty() { 68 + return None; 69 + } 70 + let head = frag[..head_end].trim_end_matches('\n'); 71 + let mut out = head.to_string(); 72 + for (bi, &(cs, ce)) in blocks.iter().enumerate() { 73 + let content = frag[cs..ce].trim(); 74 + let prefix = if bi == 0 { "\n" } else { "" }; 75 + out.push_str(&format!("{}[\n {}\n]", prefix, content)); 76 + } 77 + if out == frag || out.as_str() == frag.trim_end_matches('\n') { 78 + return None; 79 + } 80 + Some(out) 81 + } 82 + 83 + /// Map a cursor position from the original fragment to the formatted fragment. 84 + /// 85 + /// Each block's opening `[` gains `\n ` (3 chars) after it; each closing `]` 86 + /// gains a `\n` before it; a `\n` is inserted after the head. Leading/trailing 87 + /// whitespace stripped from block content shifts the cursor back accordingly. 88 + pub(super) fn map_cursor_after_format( 89 + frag: &str, 90 + cursor: usize, 91 + head_end: usize, 92 + blocks: &[(usize, usize)], 93 + ) -> usize { 94 + // Effective head end after trimming trailing newlines (mirrors format_card_frag). 95 + let trimmed_head_end = frag[..head_end].trim_end_matches('\n').len(); 96 + if cursor < trimmed_head_end { 97 + return cursor; 98 + } 99 + // Cursor in trailing head whitespace or past head: offset by the net change. 100 + // Trimmed head is trimmed_head_end chars; formatted adds one \n after it. 101 + // So everything from trimmed_head_end onward shifts by (1 - (head_end - trimmed_head_end)). 102 + let head_ws_removed = (head_end - trimmed_head_end) as i64; 103 + // +1 for the \n inserted after head 104 + let mut offset: i64 = 1 - head_ws_removed; 105 + for &(content_start, content_end) in blocks { 106 + let open_bracket = content_start - 1; 107 + if cursor <= open_bracket { 108 + break; 109 + } 110 + // Past `[`: +3 for `\n ` inserted after `[` 111 + offset += 3; 112 + if cursor <= content_start { 113 + break; 114 + } 115 + let raw = &frag[content_start..content_end]; 116 + let leading_ws = raw.len() - raw.trim_start().len(); 117 + if cursor <= content_start + leading_ws { 118 + // Cursor in leading whitespace: snap to start of trimmed content 119 + break; 120 + } 121 + offset -= leading_ws as i64; 122 + let trailing_ws = raw.len() - raw.trim_end().len(); 123 + if cursor <= content_end - trailing_ws { 124 + break; // Cursor within actual content 125 + } 126 + offset -= trailing_ws as i64; 127 + if cursor <= content_end { 128 + break; // Cursor on or before `]` 129 + } 130 + // Past `]`: +1 for `\n` inserted before `]` 131 + offset += 1; 132 + } 133 + (cursor as i64 + offset).max(0) as usize 134 + } 135 + 136 + #[cfg(test)] 137 + mod tests { 138 + use super::*; 139 + 140 + #[test] 141 + fn format_frontback_single_line() { 142 + let frag = "#card()[front][back]"; 143 + let out = format_card_frag(frag).unwrap(); 144 + assert_eq!(out, "#card()\n[\n front\n][\n back\n]"); 145 + } 146 + 147 + #[test] 148 + fn format_cloze_single_line() { 149 + let frag = "#cloze(tags: (\"t\",))[body #blank[hidden]]"; 150 + let out = format_card_frag(frag).unwrap(); 151 + assert_eq!(out, "#cloze(tags: (\"t\",))\n[\n body #blank[hidden]\n]"); 152 + } 153 + 154 + #[test] 155 + fn format_already_formatted_returns_none() { 156 + let frag = "#card()\n[\n front\n][\n back\n]"; 157 + assert!(format_card_frag(frag).is_none()); 158 + } 159 + 160 + #[test] 161 + fn cursor_in_head_unchanged() { 162 + let frag = "#card()[front][back]"; 163 + let (head_end, blocks) = parse_card_structure(frag).unwrap(); 164 + assert_eq!(map_cursor_after_format(frag, 3, head_end, &blocks), 3); 165 + } 166 + 167 + #[test] 168 + fn cursor_at_open_bracket_maps_to_formatted_bracket() { 169 + let frag = "#card()[front][back]"; 170 + let (head_end, blocks) = parse_card_structure(frag).unwrap(); 171 + let nc = map_cursor_after_format(frag, 7, head_end, &blocks); 172 + let out = format_card_frag(frag).unwrap(); 173 + assert_eq!(&out[nc..nc + 1], "["); 174 + } 175 + 176 + #[test] 177 + fn cursor_in_block0_content_maps_correctly() { 178 + let frag = "#card()[front][back]"; 179 + let (head_end, blocks) = parse_card_structure(frag).unwrap(); 180 + let nc = map_cursor_after_format(frag, 10, head_end, &blocks); 181 + let out = format_card_frag(frag).unwrap(); 182 + assert_eq!(&out[nc..nc + 1], "o"); 183 + } 184 + 185 + #[test] 186 + fn cursor_in_block1_content_maps_correctly() { 187 + let frag = "#card()[front][back]"; 188 + let (head_end, blocks) = parse_card_structure(frag).unwrap(); 189 + let nc = map_cursor_after_format(frag, 17, head_end, &blocks); 190 + let out = format_card_frag(frag).unwrap(); 191 + assert_eq!(&out[nc..nc + 1], "c"); 192 + } 193 + 194 + #[test] 195 + fn format_skipped_when_trailing_content_after_last_block() { 196 + // Simulates deleting `#blank[` but leaving the closing `]`: 197 + // `abc #blank[def] ghi` → `abc def] ghi` inside the outer `[...]`. 198 + // parse_card_structure finds one block ending at the first `]`, leaving 199 + // ` ghi\n]` unaccounted for. format_card_frag must return None to avoid 200 + // silently dropping that trailing content. 201 + let frag = "#cloze\n[\n abc def] ghi\n]"; 202 + assert!(format_card_frag(frag).is_none()); 203 + } 204 + }
+85
crates/tala/src/editor/image_paste.rs
··· 1 + use base64::Engine as _; 2 + use dioxus::prelude::*; 3 + 4 + use super::SaveStatus; 5 + use super::segments::{Seg, make_segments}; 6 + use crate::util::card_dir; 7 + 8 + pub(super) fn paste_image_filename() -> String { 9 + use std::time::{SystemTime, UNIX_EPOCH}; 10 + let secs = SystemTime::now() 11 + .duration_since(UNIX_EPOCH) 12 + .unwrap_or_default() 13 + .as_secs() as i64; 14 + // Date part (Gregorian, from http://howardhinnant.github.io/date_algorithms.html) 15 + let days = secs / 86400; 16 + let z = days + 719468; 17 + let era = if z >= 0 { z } else { z - 146096 } / 146097; 18 + let doe = z - era * 146097; 19 + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; 20 + let y = yoe + era * 400; 21 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); 22 + let mp = (5 * doy + 2) / 153; 23 + let d = doy - (153 * mp + 2) / 5 + 1; 24 + let m = if mp < 10 { mp + 3 } else { mp - 9 }; 25 + let y = if m <= 2 { y + 1 } else { y }; 26 + let tod = (secs % 86400) as u32; 27 + let h = tod / 3600; 28 + let min = (tod % 3600) / 60; 29 + let s = tod % 60; 30 + format!("Pasted-image-{y:04}{m:02}{d:02}-{h:02}{min:02}{s:02}.png") 31 + } 32 + 33 + /// Reads an image from the OS clipboard and encodes it as PNG bytes. 34 + /// Intended to run on a dedicated OS thread (arboard is not Send on Linux). 35 + pub(super) fn read_clipboard_png() -> Option<(Vec<u8>, String)> { 36 + let img_data = arboard::Clipboard::new().and_then(|mut c| c.get_image()).ok()?; 37 + let rgba = image::RgbaImage::from_raw( 38 + img_data.width as u32, 39 + img_data.height as u32, 40 + img_data.bytes.into_owned(), 41 + )?; 42 + let mut png_bytes: Vec<u8> = Vec::new(); 43 + image::DynamicImage::ImageRgba8(rgba) 44 + .write_to(&mut std::io::Cursor::new(&mut png_bytes), image::ImageFormat::Png) 45 + .ok()?; 46 + Some((png_bytes, paste_image_filename())) 47 + } 48 + 49 + pub(super) fn apply_pasted_image( 50 + mut source: Signal<String>, 51 + active_idx: Signal<usize>, 52 + mut save_status: Signal<SaveStatus>, 53 + png_bytes: Vec<u8>, 54 + filename: String, 55 + ) { 56 + let stem = filename.trim_end_matches(".png").to_string(); 57 + if std::fs::write(card_dir().join("images").join(&filename), &png_bytes).is_err() { 58 + return; 59 + } 60 + 61 + // Append \n#img("stem") at end of active segment. 62 + let cur = source.peek().clone(); 63 + let segs = make_segments(&cur); 64 + let ai = (*active_idx.peek()).min(segs.len().saturating_sub(1)); 65 + let snippet = format!("\n#img(\"{}\")", stem); 66 + let new_src = if let Some(seg) = segs.get(ai) { 67 + format!("{}{}{}", &cur[..seg.end], snippet, &cur[seg.end..]) 68 + } else { 69 + format!("{}{}", cur, snippet) 70 + }; 71 + source.set(new_src.clone()); 72 + save_status.set(SaveStatus::Dirty); 73 + 74 + // Update textarea to reflect the new fragment. 75 + let new_segs = make_segments(&new_src); 76 + let ai2 = ai.min(new_segs.len().saturating_sub(1)); 77 + let new_frag = new_segs.get(ai2).map(|s: &Seg| new_src[s.start..s.end].to_string()).unwrap_or_default(); 78 + let b64 = base64::engine::general_purpose::STANDARD.encode(&new_frag); 79 + spawn(async move { 80 + document::eval(&format!( 81 + "var t=document.querySelector('.card-row.active textarea');\ 82 + if(t)t.value=atob('{}');", b64 83 + )).await.ok(); 84 + }); 85 + }
+1061
crates/tala/src/editor/mod.rs
··· 1 + use std::time::Duration; 2 + 3 + use base64::Engine as _; 4 + use dioxus::prelude::*; 5 + use tala_format::CardKind; 6 + use tala_srs::{CardSchedule, RectEntry, Schedule, Sidecar}; 7 + 8 + use crate::util::{TagMode, card_dir, cards_path, cloze_color, cloze_fill_css, cloze_stroke_css, extract_img_names}; 9 + 10 + mod blank_wrap; 11 + mod draw_ops; 12 + mod format; 13 + mod image_paste; 14 + mod render; 15 + mod segments; 16 + mod sidecar_ops; 17 + 18 + use blank_wrap::{expand_math_selection, insert_blank_wrap, insert_blank_wrap_math, strip_head_whitespace}; 19 + use draw_ops::{DragEdge, edge_drag_task, move_drag_task, normalize_draw_coords, rects_overlap}; 20 + use format::{format_card_frag, map_cursor_after_format, parse_card_structure}; 21 + use image_paste::{apply_pasted_image, read_clipboard_png}; 22 + use render::{PreviewData, render_preview}; 23 + use segments::{Seg, SegKind, make_segments}; 24 + use sidecar_ops::delete_sidecar_rect; 25 + 26 + #[derive(Clone, PartialEq)] 27 + enum SaveStatus { 28 + Clean, 29 + Dirty, 30 + Saved, 31 + Error(String), 32 + } 33 + 34 + #[component] 35 + pub fn Editor() -> Element { 36 + // ── Source & save ───────────────────────────────────────────────────────── 37 + let mut source = use_signal(|| std::fs::read_to_string(cards_path()).unwrap_or_default()); 38 + let mut save_status = use_signal(|| SaveStatus::Clean); 39 + let mut new_card_draft = use_signal(|| String::new()); 40 + 41 + // ── Tag filter ──────────────────────────────────────────────────────────── 42 + let mut editor_selected_tags: Signal<Vec<String>> = use_signal(Vec::new); 43 + let mut editor_tag_mode = use_signal(|| TagMode::Or); 44 + let editor_all_tags = use_memo(move || crate::util::collect_all_tags_from_source(&source.read())); 45 + 46 + // ── Multi-card state ─────────────────────────────────────────────────────── 47 + let mut active_idx = use_signal(|| 0usize); 48 + // Indexed by segment. None = text segment (no render attempt). 49 + let mut previews = use_signal(Vec::<Option<Result<PreviewData, String>>>::new); 50 + 51 + // Auto-save (1s debounce) + auto-format active card on save. 52 + // Sequence: read cursor -> format -> save formatted -> sync DOM -> update source. 53 + // Updating source last avoids cancelling the task before the save completes. 54 + let _saver = use_resource(move || async move { 55 + let text = source.read().clone(); 56 + let ai = *active_idx.read(); 57 + tokio::time::sleep(Duration::from_millis(1000)).await; 58 + 59 + // Check if the active card segment needs formatting. 60 + let segs = make_segments(&text); 61 + let ai = ai.min(segs.len().saturating_sub(1)); 62 + let format_result = segs.get(ai).filter(|s| s.kind == SegKind::Card).and_then(|seg| { 63 + let frag = text[seg.start..seg.end].to_string(); 64 + let formatted = format_card_frag(&frag)?; 65 + let (head_end, blocks) = parse_card_structure(&frag)?; 66 + Some((seg.start, seg.end, frag, formatted, head_end, blocks)) 67 + }); 68 + 69 + let (save_text, dom_update) = if let Some((seg_start, seg_end, frag, formatted, head_end, blocks)) = 70 + format_result 71 + { 72 + // Read cursor before any mutation. 73 + let mut eval = document::eval( 74 + "var t=document.querySelector('.card-row.active textarea');\ 75 + dioxus.send(t?t.selectionStart:0);" 76 + ); 77 + let cursor = eval.recv::<i64>().await.unwrap_or(0).max(0) as usize; 78 + let new_cursor = map_cursor_after_format(&frag, cursor, head_end, &blocks); 79 + 80 + let new_src = format!("{}{}{}", &text[..seg_start], formatted, &text[seg_end..]); 81 + (new_src, Some((ai, new_cursor))) 82 + } else { 83 + (text, None) 84 + }; 85 + 86 + // Save to disk. 87 + let path = cards_path(); 88 + let save_clone = save_text.clone(); 89 + match tokio::task::spawn_blocking(move || std::fs::write(&path, &save_clone)).await { 90 + Ok(Ok(())) => save_status.set(SaveStatus::Saved), 91 + Ok(Err(e)) => save_status.set(SaveStatus::Error(e.to_string())), 92 + Err(e) => save_status.set(SaveStatus::Error(e.to_string())), 93 + } 94 + 95 + // If formatting was applied: sync textarea DOM then update source signal. 96 + // (source.set last — it may restart this resource, but save already completed.) 97 + if let Some((ai, new_cursor)) = dom_update { 98 + let new_segs = make_segments(&save_text); 99 + let new_ai = ai.min(new_segs.len().saturating_sub(1)); 100 + let new_frag = new_segs 101 + .get(new_ai) 102 + .map(|s| save_text[s.start..s.end].to_string()) 103 + .unwrap_or_default(); 104 + let b64 = base64::engine::general_purpose::STANDARD.encode(&new_frag); 105 + document::eval(&format!( 106 + "var t=document.querySelector('.card-row.active textarea');\ 107 + if(t){{t.value=atob('{}');t.setSelectionRange({new_cursor},{new_cursor})}}", 108 + b64 109 + )) 110 + .await 111 + .ok(); 112 + source.set(save_text); 113 + } 114 + }); 115 + 116 + // Per-segment render resource: each card fragment compiled independently. 117 + let _render = use_resource(move || async move { 118 + let text = source.read().clone(); 119 + tokio::time::sleep(Duration::from_millis(300)).await; 120 + let dir = card_dir(); 121 + let results = tokio::task::spawn_blocking(move || { 122 + make_segments(&text) 123 + .into_iter() 124 + .map(|seg| match seg.kind { 125 + SegKind::Card => Some(render_preview(&text[seg.start..seg.end], &dir)), 126 + SegKind::Text => None, 127 + }) 128 + .collect::<Vec<_>>() 129 + }) 130 + .await 131 + .unwrap_or_default(); 132 + previews.set(results); 133 + }); 134 + 135 + // ── Draw mode state ──────────────────────────────────────────────────────── 136 + let mut blank_rects_sig = use_signal(Vec::<Vec<[f64; 4]>>::new); 137 + let mut glyph_map_sig = use_signal(Vec::<([f64; 4], std::ops::Range<usize>, bool)>::new); 138 + let mut math_spans_sig = use_signal(Vec::<std::ops::Range<usize>>::new); 139 + let mut image_boxes_sig = use_signal(Vec::<[f64; 4]>::new); 140 + let mut sidecar_rects_sig = use_signal(Vec::<RectEntry>::new); 141 + let mut draw_mode = use_signal(|| false); 142 + let mut drag_start = use_signal(|| Option::<(f64, f64)>::None); 143 + let mut drag_current = use_signal(|| Option::<(f64, f64)>::None); 144 + let mut drawn_boxes = use_signal(Vec::<[f64; 4]>::new); 145 + let cap_dims = use_signal(|| [1.0f64, 1.0, 1.0]); 146 + let mut insert_error = use_signal(|| Option::<String>::None); 147 + let mut selected_rect_sig = use_signal(|| Option::<usize>::None); 148 + let mut edge_drag_active = use_signal(|| false); 149 + // Some((col, going_up)): desired cursor column set by keyboard nav just before 150 + // active_idx changes. Consumed by the new card's onmounted handler. 151 + let mut nav_cursor: Signal<Option<(usize, bool)>> = use_signal(|| None); 152 + 153 + // Update glyph signals when the active card or its render result changes. 154 + use_effect(move || { 155 + let idx = *active_idx.read(); // subscribe 156 + let pvs = previews.read().clone(); // subscribe 157 + let text = source.peek().clone(); // no subscription 158 + let segs = make_segments(&text); 159 + if segs.is_empty() { 160 + blank_rects_sig.set(vec![]); 161 + glyph_map_sig.set(vec![]); 162 + math_spans_sig.set(vec![]); 163 + image_boxes_sig.set(vec![]); 164 + return; 165 + } 166 + let ai = idx.min(segs.len().saturating_sub(1)); 167 + let card_pos = segs[..=ai] 168 + .iter() 169 + .filter(|s| s.kind == SegKind::Card) 170 + .count() 171 + .saturating_sub(1); 172 + let card_id = { 173 + let cards = tala_format::parse_cards(&text); 174 + cards.get(card_pos) 175 + .and_then(|c| c.id.clone()) 176 + .unwrap_or_else(|| card_pos.to_string()) 177 + }; 178 + match segs.get(ai) { 179 + Some(Seg { 180 + kind: SegKind::Card, 181 + .. 182 + }) => { 183 + if let Some(Some(Ok(data))) = pvs.get(ai) { 184 + blank_rects_sig.set(data.blank_rects.clone()); 185 + glyph_map_sig.set(data.glyph_map.clone()); 186 + math_spans_sig.set(data.math_spans.clone()); 187 + image_boxes_sig.set(data.image_boxes.clone()); 188 + } 189 + // Load saved image rects for this card from sidecar. 190 + let rects = Sidecar::load_or_empty_for(&cards_path()) 191 + .ok() 192 + .and_then(|sc| { 193 + if let Some(CardSchedule::Cloze { rects, .. }) = 194 + sc.cards.get(&card_id) 195 + { 196 + Some(rects.clone()) 197 + } else { 198 + None 199 + } 200 + }) 201 + .unwrap_or_default(); 202 + sidecar_rects_sig.set(rects); 203 + } 204 + _ => { 205 + blank_rects_sig.set(Vec::new()); 206 + glyph_map_sig.set(Vec::new()); 207 + math_spans_sig.set(Vec::new()); 208 + image_boxes_sig.set(Vec::new()); 209 + sidecar_rects_sig.set(Vec::new()); 210 + } 211 + } 212 + }); 213 + 214 + // ── Image paste from clipboard ──────────────────────────────────────────── 215 + use_coroutine(move |_: dioxus::prelude::UnboundedReceiver<()>| async move { 216 + // webkit2gtk doesn't expose clipboard contents via clipboardData.items, 217 + // so we use the paste event only as a trigger and read the OS clipboard 218 + // directly from Rust via arboard. 219 + let mut eval = document::eval(r#" 220 + if (window.__talaPasteHandler) { 221 + window.removeEventListener('paste', window.__talaPasteHandler); 222 + } 223 + window.__talaPasteHandler = function(e) { dioxus.send(1); }; 224 + window.addEventListener('paste', window.__talaPasteHandler); 225 + "#); 226 + loop { 227 + match eval.recv::<i32>().await { 228 + Ok(_) => { 229 + // Offload the blocking clipboard read + PNG encode to an OS 230 + // thread (arboard is not Send on Linux, so spawn_blocking won't 231 + // work). A oneshot channel bridges the result back. 232 + let (tx, rx) = tokio::sync::oneshot::channel::<Option<(Vec<u8>, String)>>(); 233 + std::thread::spawn(move || { let _ = tx.send(read_clipboard_png()); }); 234 + if let Ok(Some((png_bytes, filename))) = rx.await { 235 + apply_pasted_image(source, active_idx, save_status, png_bytes, filename); 236 + } 237 + } 238 + Err(_) => break, 239 + } 240 + } 241 + }); 242 + 243 + // ── Keyboard nav: Up/Down at textarea boundary → switch segment ──────────── 244 + // prevent_default() is called synchronously so the browser never moves the 245 + // cursor; we replicate the movement in JS when not at a boundary. 246 + let on_keydown = move |e: Event<KeyboardData>| { 247 + let key = e.data().key().to_string(); 248 + if key == "ArrowUp" || key == "ArrowDown" { 249 + e.prevent_default(); 250 + let going_up = key == "ArrowUp"; 251 + let cur_idx = *active_idx.read(); 252 + let n = make_segments(&source.read().clone()).len(); 253 + let mut ai = active_idx; 254 + let mut nc = nav_cursor; 255 + spawn(async move { 256 + // Returns -1 if not at boundary (cursor moved within card). 257 + // Returns col (>=0) if at boundary — the column to preserve in the 258 + // destination card. 259 + let js = if going_up { 260 + "var t=document.querySelector('.card-row.active textarea');\ 261 + if(!t){dioxus.send(-1);return;}\ 262 + var pos=t.selectionStart;\ 263 + var before=t.value.slice(0,pos);\ 264 + var prevNl=before.lastIndexOf('\\n');\ 265 + if(prevNl===-1){\ 266 + var col=pos;\ 267 + dioxus.send(col);\ 268 + }else{\ 269 + var col=pos-(prevNl+1);\ 270 + var prevPrevNl=before.lastIndexOf('\\n',prevNl-1);\ 271 + var newPos=Math.min(prevPrevNl+1+col,prevNl);\ 272 + t.setSelectionRange(newPos,newPos);\ 273 + dioxus.send(-1);\ 274 + }" 275 + } else { 276 + "var t=document.querySelector('.card-row.active textarea');\ 277 + if(!t){dioxus.send(-1);return;}\ 278 + var pos=t.selectionStart;\ 279 + var after=t.value.slice(pos);\ 280 + var nextNl=after.indexOf('\\n');\ 281 + if(nextNl===-1){\ 282 + var before=t.value.slice(0,pos);\ 283 + var col=pos-(before.lastIndexOf('\\n')+1);\ 284 + dioxus.send(col);\ 285 + }else{\ 286 + var before=t.value.slice(0,pos);\ 287 + var col=pos-(before.lastIndexOf('\\n')+1);\ 288 + var nextLineStart=pos+nextNl+1;\ 289 + var afterNext=t.value.slice(nextLineStart);\ 290 + var nextNextNl=afterNext.indexOf('\\n');\ 291 + var nextLineEnd=nextNextNl===-1?t.value.length:nextLineStart+nextNextNl;\ 292 + t.setSelectionRange(Math.min(nextLineStart+col,nextLineEnd),Math.min(nextLineStart+col,nextLineEnd));\ 293 + dioxus.send(-1);\ 294 + }" 295 + }; 296 + if let Ok(col) = document::eval(js).recv::<i64>().await { 297 + if col >= 0 { 298 + let new_i = if going_up { 299 + cur_idx.saturating_sub(1) 300 + } else { 301 + (cur_idx + 1).min(n.saturating_sub(1)) 302 + }; 303 + if new_i != cur_idx { 304 + // Store nav intent BEFORE re-render so onmounted picks it up. 305 + nc.set(Some((col as usize, going_up))); 306 + ai.set(new_i); 307 + } 308 + } 309 + } 310 + }); 311 + } 312 + }; 313 + 314 + // ── oninput: splice active segment back into full source ─────────────────── 315 + // make_segments uses only blank-line positions, never typst spans, so an 316 + // unclosed bracket cannot bloat ce to end-of-file. 317 + // If the user types \n\n (creating a new paragraph), the new paragraphs are 318 + // spliced in and active_idx advances to the last one. 319 + let on_input = move |e: FormEvent| { 320 + let new_frag = e.value(); 321 + let cur = source.read().clone(); 322 + let segs = make_segments(&cur); 323 + let ai = (*active_idx.read()).min(segs.len().saturating_sub(1)); 324 + if let Some(seg) = segs.get(ai) { 325 + let (cs, ce) = (seg.start, seg.end); 326 + if ce <= cur.len() { 327 + let new_src = format!("{}{}{}", &cur[..cs], new_frag, &cur[ce..]); 328 + let new_segs = make_segments(&new_src); 329 + source.set(new_src); 330 + if new_segs.len() > segs.len() { 331 + let edit_end = cs + new_frag.len(); 332 + let new_ai = new_segs 333 + .iter() 334 + .rposition(|s| s.start <= edit_end) 335 + .unwrap_or(ai); 336 + active_idx.set(new_ai); 337 + spawn(async move { 338 + document::eval( 339 + "setTimeout(()=>{var t=document.querySelector('.card-row.active textarea');\ 340 + if(t){t.focus();t.setSelectionRange(t.value.length,t.value.length);}},50)" 341 + ).await.ok(); 342 + }); 343 + } 344 + } 345 + } 346 + save_status.set(SaveStatus::Dirty); 347 + spawn(async move { 348 + document::eval( 349 + "var t=document.querySelector('.card-row.active textarea');\ 350 + if(t){t.style.height='auto';t.style.height=(t.scrollHeight+3)+'px';}" 351 + ).await.ok(); 352 + }); 353 + }; 354 + 355 + // ── Snapshot values for RSX ──────────────────────────────────────────────── 356 + let text_snap = source.read().clone(); 357 + let segs_snap = make_segments(&text_snap); 358 + let n_segs = segs_snap.len(); 359 + if n_segs == 0 && !new_card_draft.read().is_empty() { 360 + new_card_draft.set(String::new()); 361 + } 362 + let active_i = (*active_idx.read()).min(n_segs.saturating_sub(1)); 363 + let active_is_cloze = match segs_snap.get(active_i) { 364 + Some(Seg { 365 + start, 366 + end, 367 + kind: SegKind::Card, 368 + }) => { 369 + let (stripped, _) = strip_head_whitespace(&text_snap[*start..*end]); 370 + matches!( 371 + tala_format::parse_cards(&stripped).first().map(|c| &c.kind), 372 + Some(CardKind::Cloze { .. }) 373 + ) 374 + } 375 + _ => false, 376 + }; 377 + let pvs_snap = previews.read().clone(); 378 + let (active_cs, active_ce) = segs_snap 379 + .get(active_i) 380 + .map(|s| (s.start, s.end)) 381 + .unwrap_or((0, 0)); 382 + let active_fragment = text_snap[active_cs..active_ce.min(text_snap.len())].to_string(); 383 + let in_draw = *draw_mode.read(); 384 + let ds = *drag_start.read(); 385 + let dc = *drag_current.read(); 386 + let drawn = drawn_boxes.read().clone(); 387 + let blank_rects = blank_rects_sig.read().clone(); 388 + let active_img_boxes = image_boxes_sig.read().clone(); 389 + let active_sidecar_rects = sidecar_rects_sig.read().clone(); 390 + let active_img_names = extract_img_names(&active_fragment); 391 + let selected_rect = *selected_rect_sig.read(); 392 + // Load all sidecar image rects once for rendering inactive cards. 393 + let all_sidecar_rects: std::collections::HashMap<String, Vec<RectEntry>> = { 394 + let typ_path = cards_path(); 395 + Sidecar::load_or_empty_for(&typ_path).ok() 396 + .map(|sc| sc.cards.into_iter().filter_map(|(k, v)| { 397 + if let CardSchedule::Cloze { rects, .. } = v { Some((k, rects)) } else { None } 398 + }).collect()) 399 + .unwrap_or_default() 400 + }; 401 + let card_keys_snap: Vec<String> = tala_format::parse_cards(&text_snap) 402 + .into_iter() 403 + .enumerate() 404 + .map(|(i, c)| c.id.unwrap_or_else(|| i.to_string())) 405 + .collect(); 406 + 407 + let filter_tags_snap = editor_selected_tags.read().clone(); 408 + let filter_mode_snap = editor_tag_mode.read().clone(); 409 + let all_editor_tags_snap = editor_all_tags.read().clone(); 410 + let cc = cloze_color(); 411 + let cloze_fill = cloze_fill_css(&cc); 412 + let cloze_stroke = cloze_stroke_css(&cc); 413 + 414 + rsx! { 415 + div { id: "editor", 416 + // ── Toolbar ─────────────────────────────────────────────────────── 417 + div { class: "preview-toolbar", 418 + if active_is_cloze { 419 + button { 420 + class: if in_draw { "btn active" } else { "btn" }, 421 + onclick: move |_| { 422 + let m = *draw_mode.read(); 423 + draw_mode.set(!m); 424 + drag_start.set(None); 425 + drag_current.set(None); 426 + drawn_boxes.write().clear(); 427 + insert_error.set(None); 428 + }, 429 + if in_draw { "Exit Draw" } else { "Draw Cloze" } 430 + } 431 + } 432 + match &*save_status.read() { 433 + SaveStatus::Clean => rsx! {}, 434 + SaveStatus::Dirty => rsx! { span { class: "save-status dirty", "Unsaved" } }, 435 + SaveStatus::Saved => rsx! { span { class: "save-status saved", "Saved" } }, 436 + SaveStatus::Error(e) => rsx! { span { class: "save-status error", "Save error: {e}" } }, 437 + } 438 + if let Some(err) = &*insert_error.read() { 439 + span { class: "insert-error", "{err}" } 440 + } 441 + // ── Tag filter ─────────────────────────────────────────────── 442 + if !all_editor_tags_snap.is_empty() { 443 + div { class: "tag-chip-row", 444 + for tag in all_editor_tags_snap.clone() { 445 + { 446 + let tag2 = tag.clone(); 447 + let is_sel = filter_tags_snap.contains(&tag); 448 + rsx! { 449 + button { 450 + class: if is_sel { "tag-chip selected" } else { "tag-chip" }, 451 + onclick: move |_| { 452 + let mut sel = editor_selected_tags.write(); 453 + if sel.contains(&tag2) { 454 + sel.retain(|t| t != &tag2); 455 + } else { 456 + sel.push(tag2.clone()); 457 + } 458 + }, 459 + "{tag}" 460 + } 461 + } 462 + } 463 + } 464 + if filter_tags_snap.len() > 1 { 465 + button { 466 + class: if filter_mode_snap == TagMode::Or { "btn active" } else { "btn" }, 467 + onclick: move |_| editor_tag_mode.set(TagMode::Or), 468 + "OR" 469 + } 470 + button { 471 + class: if filter_mode_snap == TagMode::And { "btn active" } else { "btn" }, 472 + onclick: move |_| editor_tag_mode.set(TagMode::And), 473 + "AND" 474 + } 475 + } 476 + } 477 + } 478 + } 479 + // ── Card list ───────────────────────────────────────────────────── 480 + div { class: "card-list", 481 + if n_segs == 0 { 482 + div { class: "card-row active", 483 + textarea { 484 + class: "card-source", 485 + spellcheck: "false", 486 + placeholder: "#cloze[...] or #card[...][...]", 487 + onmounted: move |_| { 488 + spawn(async move { 489 + document::eval( 490 + "setTimeout(()=>document.querySelector('.card-list .card-row.active textarea')?.focus(),0)" 491 + ).await.ok(); 492 + }); 493 + }, 494 + oninput: move |e| { 495 + let val = e.value(); 496 + new_card_draft.set(val.clone()); 497 + if !val.trim().is_empty() { 498 + source.set(val); 499 + save_status.set(SaveStatus::Dirty); 500 + } 501 + }, 502 + } 503 + } 504 + } 505 + { 506 + segs_snap.iter().enumerate().map(|(i, seg)| { 507 + let is_active = i == active_i; 508 + let row_class = if is_active { "card-row active" } else { "card-row" }; 509 + let seg_key = if seg.kind == SegKind::Card { format!("c{i}") } else { format!("t{i}") }; 510 + let seg_visible = if filter_tags_snap.is_empty() || seg.kind != SegKind::Card { 511 + true 512 + } else { 513 + let seg_tags = tala_format::parse_cards(&text_snap[seg.start..seg.end]) 514 + .into_iter() 515 + .next() 516 + .map(|c| c.tags) 517 + .unwrap_or_default(); 518 + match filter_mode_snap { 519 + TagMode::And => filter_tags_snap.iter().all(|t| seg_tags.contains(t)), 520 + TagMode::Or => filter_tags_snap.iter().any(|t| seg_tags.contains(t)), 521 + } 522 + }; 523 + 524 + // Render data — naturally None for text segments (previews stores None for them). 525 + let card_result: Option<Result<PreviewData, String>> = pvs_snap.get(i).cloned().flatten(); 526 + let iw = card_result.as_ref().and_then(|r| r.as_ref().ok()).map(|d| d.img_w as f64).unwrap_or(1.0); 527 + let ih = card_result.as_ref().and_then(|r| r.as_ref().ok()).map(|d| d.img_h as f64).unwrap_or(1.0); 528 + let vb = format!("0 0 {} {}", iw as u32, ih as u32); 529 + let b64_src = card_result.as_ref() 530 + .and_then(|r| r.as_ref().ok()) 531 + .map(|d| format!("data:image/png;base64,{}", d.b64)) 532 + .unwrap_or_default(); 533 + let card_blank_rects: Vec<Vec<[f64; 4]>> = if is_active { 534 + blank_rects.clone() 535 + } else { 536 + card_result.as_ref() 537 + .and_then(|r| r.as_ref().ok()) 538 + .map(|d| d.blank_rects.clone()) 539 + .unwrap_or_default() 540 + }; 541 + let card_img_boxes_local: Vec<[f64; 4]> = if is_active { 542 + active_img_boxes.clone() 543 + } else { 544 + card_result.as_ref() 545 + .and_then(|r| r.as_ref().ok()) 546 + .map(|d| d.image_boxes.clone()) 547 + .unwrap_or_default() 548 + }; 549 + let card_sidecar_rects_local: Vec<RectEntry> = if is_active { 550 + active_sidecar_rects.clone() 551 + } else { 552 + let card_pos = segs_snap[..=i] 553 + .iter() 554 + .filter(|s| s.kind == SegKind::Card) 555 + .count() 556 + .saturating_sub(1); 557 + let card_key = card_keys_snap.get(card_pos) 558 + .cloned() 559 + .unwrap_or_else(|| card_pos.to_string()); 560 + all_sidecar_rects.get(&card_key).cloned().unwrap_or_default() 561 + }; 562 + let card_img_names_local: Vec<String> = if is_active { 563 + active_img_names.clone() 564 + } else { 565 + extract_img_names(&text_snap[seg.start..seg.end]) 566 + }; 567 + let sidecar_rect_order: Vec<usize> = if is_active { 568 + (0..card_sidecar_rects_local.len()) 569 + .filter(|&i| Some(i) != selected_rect) 570 + .chain(selected_rect.into_iter().filter(|&i| i < card_sidecar_rects_local.len())) 571 + .collect() 572 + } else { 573 + (0..card_sidecar_rects_local.len()).collect() 574 + }; 575 + let render_err = card_result.and_then(|r| r.err()); 576 + let preview_text = text_snap[seg.start..seg.end].to_string(); 577 + let frag_for_mount = active_fragment.clone(); 578 + 579 + rsx! { 580 + div { 581 + key: "{seg_key}", 582 + class: row_class, 583 + style: if !seg_visible { "display:none" } else { "display:block" }, 584 + onclick: move |_| { 585 + if !is_active { 586 + active_idx.set(i); 587 + spawn(async move { 588 + document::eval( 589 + "setTimeout(()=>document.querySelector('.card-row.active textarea')?.focus(),0)" 590 + ).await.ok(); 591 + }); 592 + } 593 + }, 594 + div { class: "card-actions", 595 + button { class: "btn-delete", title: "Delete segment", 596 + onclick: move |e| { 597 + e.stop_propagation(); 598 + let text = source.read().clone(); 599 + let segs = make_segments(&text); 600 + if let Some(seg) = segs.get(i) { 601 + let (cs, ce) = (seg.start, seg.end); 602 + let ce2 = if text[ce..].starts_with("\n\n") { ce + 2 } else { ce }; 603 + let cs2 = if ce2 == ce && cs >= 2 && &text[cs-2..cs] == "\n\n" { cs - 2 } else { cs }; 604 + source.set(format!("{}{}", &text[..cs2], &text[ce2..])); 605 + let new_n = segs.len().saturating_sub(1); 606 + if *active_idx.read() >= new_n { active_idx.set(new_n.saturating_sub(1)); } 607 + save_status.set(SaveStatus::Dirty); 608 + } 609 + }, 610 + "×" 611 + } 612 + } 613 + if is_active { 614 + textarea { 615 + class: "card-source", 616 + spellcheck: "false", 617 + onmounted: { 618 + let frag = frag_for_mount; 619 + move |_| { 620 + let b64 = base64::engine::general_purpose::STANDARD.encode(&frag); 621 + // Consume nav_cursor if set by keyboard nav. 622 + let nav = nav_cursor.write_unchecked(); 623 + let cursor_js = if let Some((col, going_up)) = *nav { 624 + if going_up { 625 + format!("var lastNl=t.value.lastIndexOf('\\n');\ 626 + var ls=lastNl===-1?0:lastNl+1;\ 627 + t.setSelectionRange(Math.min(ls+{col},t.value.length),Math.min(ls+{col},t.value.length));") 628 + } else { 629 + format!("var firstNl=t.value.indexOf('\\n');\ 630 + var le=firstNl===-1?t.value.length:firstNl;\ 631 + t.setSelectionRange(Math.min({col},le),Math.min({col},le));") 632 + } 633 + } else { 634 + // Default: put cursor at end (existing behaviour for mouse clicks / initial mount). 635 + "t.setSelectionRange(t.value.length,t.value.length);".to_string() 636 + }; 637 + drop(nav); // release borrow before mutating 638 + nav_cursor.set(None); 639 + spawn(async move { 640 + document::eval(&format!( 641 + "var t=document.querySelector('.card-row.active textarea');\ 642 + if(t){{t.value=atob('{b64}');t.focus();{cursor_js}\ 643 + t.style.height='auto';t.style.height=(t.scrollHeight+3)+'px';}}" 644 + )).await.ok(); 645 + }); 646 + } 647 + }, 648 + oninput: on_input, 649 + onkeydown: on_keydown, 650 + } 651 + } 652 + match seg.kind { 653 + SegKind::Text => rsx! { 654 + if !is_active { 655 + pre { class: "text-seg-preview", "{preview_text}" } 656 + } 657 + }, 658 + SegKind::Card => rsx! { 659 + if let Some(err) = render_err { 660 + pre { class: "render-error", "{err}" } 661 + } else if b64_src.is_empty() { 662 + span { class: "status", "Rendering…" } 663 + } else { 664 + div { class: "card-preview-wrap", 665 + img { class: "card-preview", src: "{b64_src}" } 666 + svg { 667 + class: "cloze-overlay", 668 + view_box: "{vb}", 669 + onclick: move |_| { selected_rect_sig.set(None); }, 670 + for line_rects in &card_blank_rects { 671 + for rect in line_rects { 672 + rect { 673 + x: "{rect[0] * iw}", y: "{rect[1] * ih}", 674 + width: "{rect[2] * iw}", height: "{rect[3] * ih}", 675 + fill: "{cloze_fill}", 676 + stroke: "{cloze_stroke}", 677 + stroke_width: "1.5", rx: "3", 678 + } 679 + } 680 + } 681 + if seg.kind == SegKind::Card { 682 + // Render selected rect last so it paints above intersecting rects. 683 + for sri in sidecar_rect_order.clone() { 684 + { 685 + let sr = card_sidecar_rects_local[sri].clone(); 686 + let img_idx = card_img_names_local.iter().position(|n| n == &sr.src).unwrap_or(0); 687 + let ib = card_img_boxes_local.get(img_idx).copied().unwrap_or([0.0, 0.0, 1.0, 1.0]); 688 + let px = (ib[0] + sr.rect[0] as f64 * ib[2]) * iw; 689 + let py = (ib[1] + sr.rect[1] as f64 * ib[3]) * ih; 690 + let pw = sr.rect[2] as f64 * ib[2] * iw; 691 + let ph = sr.rect[3] as f64 * ib[3] * ih; 692 + let is_sel = is_active && selected_rect == Some(sri); 693 + let [bx, by, bw, bh] = ib; 694 + let rid = sr.id.clone(); 695 + rsx! { 696 + g { key: "{rid}", 697 + style: if is_active { "pointer-events: all;" } else { "pointer-events: none;" }, 698 + rect { 699 + x: "{px}", y: "{py}", 700 + width: "{pw}", height: "{ph}", 701 + fill: "{cloze_fill}", 702 + stroke: if is_sel { "rgb(60,120,220)" } else { "{cloze_stroke}" }, 703 + stroke_width: if is_sel { "2.5" } else { "1.5" }, 704 + rx: "3", 705 + style: if is_sel { "cursor: grab;" } else if is_active { "cursor: pointer;" } else { "" }, 706 + onclick: move |e| { 707 + if is_active { 708 + e.stop_propagation(); 709 + selected_rect_sig.set(Some(sri)); 710 + } 711 + }, 712 + onmousedown: move |e| { 713 + if is_active && is_sel { 714 + e.stop_propagation(); 715 + edge_drag_active.set(true); 716 + spawn(move_drag_task(sri, [bx, by, bw, bh], sidecar_rects_sig, edge_drag_active, source, active_idx)); 717 + } 718 + }, 719 + } 720 + if is_sel { 721 + text { 722 + x: "{px + pw - 2.0}", y: "{py + 13.0}", 723 + font_size: "13", 724 + fill: "rgb(180,30,30)", 725 + text_anchor: "end", 726 + style: "cursor: pointer; user-select: none; font-weight: bold;", 727 + onclick: move |e| { 728 + e.stop_propagation(); 729 + if let Some(updated) = delete_sidecar_rect(sri, &source.peek(), *active_idx.peek()) { 730 + sidecar_rects_sig.set(updated); 731 + } 732 + selected_rect_sig.set(None); 733 + }, 734 + "×" 735 + } 736 + // Top edge handle 737 + rect { 738 + x: "{px + pw * 0.1}", y: "{py - 5.0}", 739 + width: "{pw * 0.8}", height: "10", 740 + fill: "rgba(70,130,220,0.2)", 741 + stroke: "rgb(70,130,220)", stroke_width: "1", 742 + rx: "2", style: "cursor: ns-resize;", 743 + onclick: move |e| { e.stop_propagation(); }, 744 + onmousedown: move |e| { 745 + e.stop_propagation(); 746 + edge_drag_active.set(true); 747 + spawn(edge_drag_task(sri, DragEdge::Top, [bx, by, bw, bh], sidecar_rects_sig, edge_drag_active, source, active_idx)); 748 + }, 749 + } 750 + // Bottom edge handle 751 + rect { 752 + x: "{px + pw * 0.1}", y: "{py + ph - 5.0}", 753 + width: "{pw * 0.8}", height: "10", 754 + fill: "rgba(70,130,220,0.2)", 755 + stroke: "rgb(70,130,220)", stroke_width: "1", 756 + rx: "2", style: "cursor: ns-resize;", 757 + onclick: move |e| { e.stop_propagation(); }, 758 + onmousedown: move |e| { 759 + e.stop_propagation(); 760 + edge_drag_active.set(true); 761 + spawn(edge_drag_task(sri, DragEdge::Bottom, [bx, by, bw, bh], sidecar_rects_sig, edge_drag_active, source, active_idx)); 762 + }, 763 + } 764 + // Left edge handle 765 + rect { 766 + x: "{px - 5.0}", y: "{py + ph * 0.1}", 767 + width: "10", height: "{ph * 0.8}", 768 + fill: "rgba(70,130,220,0.2)", 769 + stroke: "rgb(70,130,220)", stroke_width: "1", 770 + rx: "2", style: "cursor: ew-resize;", 771 + onclick: move |e| { e.stop_propagation(); }, 772 + onmousedown: move |e| { 773 + e.stop_propagation(); 774 + edge_drag_active.set(true); 775 + spawn(edge_drag_task(sri, DragEdge::Left, [bx, by, bw, bh], sidecar_rects_sig, edge_drag_active, source, active_idx)); 776 + }, 777 + } 778 + // Right edge handle 779 + rect { 780 + x: "{px + pw - 5.0}", y: "{py + ph * 0.1}", 781 + width: "10", height: "{ph * 0.8}", 782 + fill: "rgba(70,130,220,0.2)", 783 + stroke: "rgb(70,130,220)", stroke_width: "1", 784 + rx: "2", style: "cursor: ew-resize;", 785 + onclick: move |e| { e.stop_propagation(); }, 786 + onmousedown: move |e| { 787 + e.stop_propagation(); 788 + edge_drag_active.set(true); 789 + spawn(edge_drag_task(sri, DragEdge::Right, [bx, by, bw, bh], sidecar_rects_sig, edge_drag_active, source, active_idx)); 790 + }, 791 + } 792 + } 793 + } 794 + } 795 + } 796 + } 797 + } 798 + if is_active { 799 + for rect in &drawn { 800 + rect { 801 + x: "{rect[0] * iw}", y: "{rect[1] * ih}", 802 + width: "{rect[2] * iw}", height: "{rect[3] * ih}", 803 + fill: "rgba(255,100,100,0.2)", 804 + stroke: "rgb(200,60,60)", 805 + stroke_width: "1.5", rx: "3", 806 + } 807 + } 808 + if let (Some((sx, sy)), Some((cx, cy))) = (ds, dc) { 809 + { 810 + let rx = sx.min(cx) * iw; 811 + let ry = sy.min(cy) * ih; 812 + let rw = (cx - sx).abs() * iw; 813 + let rh = (cy - sy).abs() * ih; 814 + rsx! { 815 + rect { 816 + x: "{rx}", y: "{ry}", 817 + width: "{rw}", height: "{rh}", 818 + fill: "rgba(100,180,255,0.1)", 819 + stroke: "rgb(70,130,220)", 820 + stroke_width: "1.5", 821 + stroke_dasharray: "4 2", rx: "3", 822 + } 823 + } 824 + } 825 + } 826 + } 827 + } 828 + if is_active && in_draw && active_is_cloze { 829 + div { 830 + class: "draw-capture", 831 + onmousedown: move |e| { 832 + let ox = e.data().element_coordinates().x; 833 + let oy = e.data().element_coordinates().y; 834 + let mut ds = drag_start; 835 + let mut dc = drag_current; 836 + let mut cd = cap_dims; 837 + spawn(async move { 838 + let mut eval = document::eval(r#" 839 + var el = document.querySelector('.draw-capture'); 840 + var z = parseFloat(document.documentElement.style.zoom || '1'); 841 + dioxus.send([el ? el.offsetWidth : 1, el ? el.offsetHeight : 1, z]); 842 + "#); 843 + if let Ok(val) = eval.recv::<[f64; 3]>().await { 844 + cd.set(val); 845 + let (nx, ny) = normalize_draw_coords(ox, oy, val); 846 + ds.set(Some((nx, ny))); 847 + dc.set(Some((nx, ny))); 848 + } 849 + }); 850 + }, 851 + onmousemove: move |e| { 852 + if drag_start.read().is_some() { 853 + let (nx, ny) = normalize_draw_coords( 854 + e.data().element_coordinates().x, 855 + e.data().element_coordinates().y, 856 + *cap_dims.read(), 857 + ); 858 + drag_current.set(Some((nx, ny))); 859 + } 860 + }, 861 + onmouseleave: move |_| { 862 + drag_start.set(None); 863 + drag_current.set(None); 864 + }, 865 + onmouseup: move |e| { 866 + let Some((sx, sy)) = *drag_start.read() else { return; }; 867 + let (nx, ny) = normalize_draw_coords( 868 + e.data().element_coordinates().x, 869 + e.data().element_coordinates().y, 870 + *cap_dims.read(), 871 + ); 872 + let rx = sx.min(nx); 873 + let ry = sy.min(ny); 874 + let rw = (nx - sx).abs(); 875 + let rh = (ny - sy).abs(); 876 + drag_start.set(None); 877 + drag_current.set(None); 878 + if rw * rh < 0.001 { return; } 879 + let drawn_rect = [rx, ry, rw, rh]; 880 + 881 + let glyphs = glyph_map_sig.read().clone(); 882 + let hits: Vec<_> = glyphs 883 + .iter() 884 + .filter(|(gr, _, _)| rects_overlap(drawn_rect, *gr)) 885 + .cloned() 886 + .collect(); 887 + 888 + if hits.is_empty() { 889 + // Check if the rect overlaps an image element. 890 + let img_boxes = image_boxes_sig.read().clone(); 891 + let img_hit = img_boxes 892 + .iter() 893 + .enumerate() 894 + .find(|(_, ib)| rects_overlap(drawn_rect, **ib)); 895 + if let Some((img_idx, img_box)) = img_hit { 896 + let cur = source.read().clone(); 897 + let segs = make_segments(&cur); 898 + let ai = (*active_idx.read()).min(segs.len().saturating_sub(1)); 899 + let card_key = { 900 + let card_pos = segs[..=ai] 901 + .iter() 902 + .filter(|s| s.kind == SegKind::Card) 903 + .count() 904 + .saturating_sub(1); 905 + let cards = tala_format::parse_cards(&cur); 906 + cards.get(card_pos) 907 + .and_then(|c| c.id.clone()) 908 + .unwrap_or_else(|| card_pos.to_string()) 909 + }; 910 + let fragment = segs.get(ai).map(|s| &cur[s.start..s.end]).unwrap_or(""); 911 + let src = extract_img_names(fragment) 912 + .into_iter() 913 + .nth(img_idx) 914 + .unwrap_or_default(); 915 + // Convert drawn_rect (page-normalized) to image-local [0,1]. 916 + let [ix, iy, iw, ih] = *img_box; 917 + let local_rect = [ 918 + ((drawn_rect[0] - ix) / iw).clamp(0.0, 1.0) as f32, 919 + ((drawn_rect[1] - iy) / ih).clamp(0.0, 1.0) as f32, 920 + (drawn_rect[2] / iw).clamp(0.0, 1.0) as f32, 921 + (drawn_rect[3] / ih).clamp(0.0, 1.0) as f32, 922 + ]; 923 + let typ_path = cards_path(); 924 + if let Ok(mut sidecar) = Sidecar::load_or_empty_for(&typ_path) { 925 + // Scope the mutable borrow of sidecar.cards so 926 + // save_for can borrow sidecar immutably afterward. 927 + let updated_rects = { 928 + let entry = sidecar 929 + .cards 930 + .entry(card_key) 931 + .or_insert(CardSchedule::Cloze { 932 + blanks: std::collections::HashMap::new(), 933 + rects: Vec::new(), 934 + }); 935 + if let CardSchedule::Cloze { rects, .. } = entry { 936 + let rect_id = format!("r{}", rects.len()); 937 + rects.push(RectEntry { 938 + id: rect_id, 939 + src, 940 + rect: local_rect, 941 + schedule: Schedule { 942 + due: tala_srs::today_str(), 943 + stability: 0.0, 944 + difficulty: 0.0, 945 + }, 946 + }); 947 + Some(rects.clone()) 948 + } else { 949 + None 950 + } 951 + }; 952 + if let Some(rects) = updated_rects { 953 + let _ = sidecar.save_for(&typ_path); 954 + let new_idx = rects.len().saturating_sub(1); 955 + sidecar_rects_sig.set(rects); 956 + selected_rect_sig.set(Some(new_idx)); 957 + } 958 + } 959 + insert_error.set(None); 960 + drawn_boxes.write().clear(); 961 + } else { 962 + insert_error.set(Some("No text or image found in drawn region.".into())); 963 + drawn_boxes.write().push(drawn_rect); 964 + } 965 + return; 966 + } 967 + let all_math = hits.iter().all(|(_, _, m)| *m); 968 + let any_math = hits.iter().any(|(_, _, m)| *m); 969 + let min_start = hits.iter().map(|(_, r, _)| r.start).min().unwrap(); 970 + let max_end = hits.iter().map(|(_, r, _)| r.end ).max().unwrap(); 971 + 972 + let cur = source.read().clone(); 973 + let segs = make_segments(&cur); 974 + let ai = (*active_idx.read()).min(segs.len().saturating_sub(1)); 975 + let (cs, ce) = segs.get(ai).map(|s| (s.start, s.end)).unwrap_or((0, 0)); 976 + let fragment = cur[cs..ce].to_string(); 977 + 978 + if all_math { 979 + let spans = math_spans_sig.read().clone(); 980 + let eq = spans.iter() 981 + .find(|ms| ms.start <= min_start && max_end <= ms.end) 982 + .cloned(); 983 + match eq { 984 + None => { 985 + insert_error.set(Some("Could not locate equation boundary.".into())); 986 + drawn_boxes.write().push(drawn_rect); 987 + } 988 + Some(eq) => { 989 + let open_len = if fragment[eq.start..].starts_with("$ ") { 2 } else { 1 }; 990 + let close_len = if fragment[..eq.end].ends_with(" $") { 2 } else { 1 }; 991 + let content_start = eq.start + open_len; 992 + let content_end = eq.end - close_len; 993 + let (exp_start, exp_end) = expand_math_selection( 994 + &fragment[content_start..content_end], 995 + min_start - content_start, 996 + max_end - content_start, 997 + ); 998 + let new_frag = insert_blank_wrap_math( 999 + &fragment, 1000 + content_start + exp_start, 1001 + content_start + exp_end, 1002 + eq.start, eq.end, 1003 + ); 1004 + source.set(format!("{}{}{}", &cur[..cs], new_frag, &cur[ce..])); 1005 + insert_error.set(None); 1006 + let b64 = base64::engine::general_purpose::STANDARD.encode(&new_frag); 1007 + spawn(async move { 1008 + document::eval(&format!( 1009 + "var t=document.querySelector('.card-row.active textarea');\ 1010 + if(t)t.value=atob('{}');", b64 1011 + )).await.ok(); 1012 + }); 1013 + } 1014 + } 1015 + return; 1016 + } 1017 + if any_math { 1018 + let spans = math_spans_sig.read().clone(); 1019 + let mut lo = min_start; 1020 + let mut hi = max_end; 1021 + for ms in &spans { 1022 + if ms.start < lo && ms.end > lo { lo = ms.start; } 1023 + if ms.start < hi && ms.end > hi { hi = ms.end; } 1024 + } 1025 + let new_frag = insert_blank_wrap(&fragment, lo, hi); 1026 + source.set(format!("{}{}{}", &cur[..cs], new_frag, &cur[ce..])); 1027 + insert_error.set(None); 1028 + let b64 = base64::engine::general_purpose::STANDARD.encode(&new_frag); 1029 + spawn(async move { 1030 + document::eval(&format!( 1031 + "var t=document.querySelector('.card-row.active textarea');\ 1032 + if(t)t.value=atob('{}');", b64 1033 + )).await.ok(); 1034 + }); 1035 + return; 1036 + } 1037 + let new_frag = insert_blank_wrap(&fragment, min_start, max_end); 1038 + source.set(format!("{}{}{}", &cur[..cs], new_frag, &cur[ce..])); 1039 + insert_error.set(None); 1040 + let b64 = base64::engine::general_purpose::STANDARD.encode(&new_frag); 1041 + spawn(async move { 1042 + document::eval(&format!( 1043 + "var t=document.querySelector('.card-row.active textarea');\ 1044 + if(t)t.value=atob('{}');", b64 1045 + )).await.ok(); 1046 + }); 1047 + }, 1048 + } 1049 + } 1050 + } 1051 + } 1052 + }, 1053 + } 1054 + } 1055 + } 1056 + }) 1057 + } 1058 + } 1059 + } 1060 + } 1061 + }
+140
crates/tala/src/editor/render.rs
··· 1 + use std::path::Path; 2 + 3 + use base64::Engine as _; 4 + use image::RgbaImage; 5 + use tala_format::CardKind; 6 + 7 + use super::blank_wrap::strip_head_whitespace; 8 + 9 + #[derive(Clone)] 10 + pub(super) struct PreviewData { 11 + pub(super) b64: String, 12 + pub(super) img_w: u32, 13 + pub(super) img_h: u32, 14 + /// Per-blank list of normalized [x, y, w, h] rects in [0.0, 1.0], one per rendered line. 15 + pub(super) blank_rects: Vec<Vec<[f64; 4]>>, 16 + /// Normalized rect, fragment-relative byte range, is_in_math for every glyph. 17 + pub(super) glyph_map: Vec<([f64; 4], std::ops::Range<usize>, bool)>, 18 + /// Fragment-relative byte ranges of every equation in the source. 19 + pub(super) math_spans: Vec<std::ops::Range<usize>>, 20 + /// Normalized [x, y, w, h] in [0.0, 1.0] for each image element, in document order. 21 + pub(super) image_boxes: Vec<[f64; 4]>, 22 + } 23 + 24 + pub(super) fn render_preview(source: &str, dir: &Path) -> Result<PreviewData, String> { 25 + let deck_dir = dir.to_path_buf(); 26 + 27 + // Normalize `#card\n[` → `#card[` so typst attaches content blocks correctly. 28 + // parse_cards must see the stripped form so it correctly resolves content blocks. 29 + let (render_source, _) = strip_head_whitespace(source); 30 + let render_spans: Vec<std::ops::Range<usize>> = tala_format::parse_cards(&render_source) 31 + .into_iter() 32 + .filter_map(|c| { 33 + if let CardKind::Cloze { blanks, .. } = c.kind { 34 + Some(blanks) 35 + } else { 36 + None 37 + } 38 + }) 39 + .flatten() 40 + .map(|b| b.content_span) 41 + .collect(); 42 + 43 + let result = tala_typst::render( 44 + &deck_dir, 45 + &render_source, 46 + tala_typst::Preamble::Authoring, 47 + &render_spans, 48 + ) 49 + .map_err(|e| e.to_string())?; 50 + 51 + // Normalize blank boxes to [0,1]. 52 + let iw = result.width as f64; 53 + let ih = result.height as f64; 54 + let blank_rects = result 55 + .blank_boxes 56 + .iter() 57 + .map(|line_rects| { 58 + line_rects 59 + .iter() 60 + .filter(|b| b[2] > 0.0) // skip zero-width (not found) 61 + .map(|b| { 62 + [ 63 + b[0] as f64 / iw, 64 + b[1] as f64 / ih, 65 + b[2] as f64 / iw, 66 + b[3] as f64 / ih, 67 + ] 68 + }) 69 + .collect::<Vec<_>>() 70 + }) 71 + .collect(); 72 + 73 + // Premultiplied → straight alpha. 74 + let straight: Vec<u8> = result 75 + .rgba 76 + .chunks_exact(4) 77 + .flat_map(|px| { 78 + let a = px[3]; 79 + if a == 0 { 80 + [0u8, 0, 0, 0] 81 + } else { 82 + let af = a as f32 / 255.0; 83 + [ 84 + (px[0] as f32 / af).min(255.0) as u8, 85 + (px[1] as f32 / af).min(255.0) as u8, 86 + (px[2] as f32 / af).min(255.0) as u8, 87 + a, 88 + ] 89 + } 90 + }) 91 + .collect(); 92 + 93 + let img = RgbaImage::from_raw(result.width, result.height, straight) 94 + .ok_or("image buffer size mismatch")?; 95 + 96 + let mut buf = std::io::Cursor::new(Vec::new()); 97 + image::DynamicImage::ImageRgba8(img) 98 + .write_to(&mut buf, image::ImageFormat::Png) 99 + .map_err(|e| e.to_string())?; 100 + 101 + let glyph_map = result 102 + .glyph_map 103 + .iter() 104 + .map(|(rect, range, in_math)| { 105 + ( 106 + [ 107 + rect[0] as f64 / iw, 108 + rect[1] as f64 / ih, 109 + rect[2] as f64 / iw, 110 + rect[3] as f64 / ih, 111 + ], 112 + range.clone(), 113 + *in_math, 114 + ) 115 + }) 116 + .collect(); 117 + 118 + let image_boxes = result 119 + .image_boxes 120 + .iter() 121 + .map(|b| { 122 + [ 123 + b[0] as f64 / iw, 124 + b[1] as f64 / ih, 125 + b[2] as f64 / iw, 126 + b[3] as f64 / ih, 127 + ] 128 + }) 129 + .collect(); 130 + 131 + Ok(PreviewData { 132 + b64: base64::engine::general_purpose::STANDARD.encode(buf.into_inner()), 133 + img_w: result.width, 134 + img_h: result.height, 135 + blank_rects, 136 + glyph_map, 137 + math_spans: result.math_spans, 138 + image_boxes, 139 + }) 140 + }
+91
crates/tala/src/editor/segments.rs
··· 1 + /// Whether a paragraph segment is a card definition or plain text. 2 + #[derive(Clone, PartialEq)] 3 + pub(super) enum SegKind { 4 + Card, 5 + Text, 6 + } 7 + 8 + /// A blank-line-delimited paragraph in the source file. 9 + #[derive(Clone)] 10 + pub(super) struct Seg { 11 + pub(super) start: usize, 12 + pub(super) end: usize, 13 + pub(super) kind: SegKind, 14 + } 15 + 16 + /// True if a source fragment looks like a card definition by prefix alone. 17 + pub(super) fn is_card_frag(s: &str) -> bool { 18 + let s = s.trim_start(); 19 + let tail = if s.starts_with("#card") { 20 + &s[5..] 21 + } else if s.starts_with("#cloze") { 22 + &s[6..] 23 + } else { 24 + return false; 25 + }; 26 + // Allow `#card[`, `#card(`, `#card\n[` (multiline formatted), and bare `#card`. 27 + matches!( 28 + tail.as_bytes().first(), 29 + None | Some(b'[') | Some(b'(') | Some(b'\n') | Some(b'\r') 30 + ) 31 + } 32 + 33 + /// Split source into paragraph segments (blank-line separated) and classify each. 34 + /// Typst is never called here — classification is a pure prefix check. 35 + pub(super) fn make_segments(source: &str) -> Vec<Seg> { 36 + let bytes = source.as_bytes(); 37 + let len = bytes.len(); 38 + let mut segs = Vec::new(); 39 + let mut seg_start = 0; 40 + let mut i = 0; 41 + while i < len { 42 + if bytes[i] == b'\n' && i + 1 < len && bytes[i + 1] == b'\n' { 43 + let chunk = &source[seg_start..i]; 44 + if !chunk.trim().is_empty() { 45 + let kind = if is_card_frag(chunk) { 46 + SegKind::Card 47 + } else { 48 + SegKind::Text 49 + }; 50 + segs.push(Seg { 51 + start: seg_start, 52 + end: i, 53 + kind, 54 + }); 55 + } 56 + while i < len && bytes[i] == b'\n' { 57 + i += 1; 58 + } 59 + seg_start = i; 60 + } else { 61 + i += 1; 62 + } 63 + } 64 + if seg_start < len && !source[seg_start..].trim().is_empty() { 65 + let kind = if is_card_frag(&source[seg_start..]) { 66 + SegKind::Card 67 + } else { 68 + SegKind::Text 69 + }; 70 + segs.push(Seg { 71 + start: seg_start, 72 + end: len, 73 + kind, 74 + }); 75 + } 76 + segs 77 + } 78 + 79 + pub(super) fn active_card_key(source: &str, active_idx: usize) -> String { 80 + let segs = make_segments(source); 81 + let ai = active_idx.min(segs.len().saturating_sub(1)); 82 + let card_pos = segs[..=ai] 83 + .iter() 84 + .filter(|s| s.kind == SegKind::Card) 85 + .count() 86 + .saturating_sub(1); 87 + let cards = tala_format::parse_cards(source); 88 + cards.get(card_pos) 89 + .and_then(|c| c.id.clone()) 90 + .unwrap_or_else(|| card_pos.to_string()) 91 + }
+29
crates/tala/src/editor/sidecar_ops.rs
··· 1 + use tala_srs::{CardSchedule, RectEntry, Sidecar}; 2 + 3 + use super::segments::active_card_key; 4 + use crate::util::cards_path; 5 + 6 + pub(super) fn commit_sidecar_rects(rects: Vec<RectEntry>, card_key: &str) { 7 + let typ_path = cards_path(); 8 + if let Ok(mut sc) = Sidecar::load_or_empty_for(&typ_path) { 9 + if let Some(CardSchedule::Cloze { rects: r, .. }) = sc.cards.get_mut(card_key) { 10 + *r = rects; 11 + let _ = sc.save_for(&typ_path); 12 + } 13 + } 14 + } 15 + 16 + pub(super) fn delete_sidecar_rect(idx: usize, source: &str, active_idx: usize) -> Option<Vec<RectEntry>> { 17 + let card_key = active_card_key(source, active_idx); 18 + let typ_path = cards_path(); 19 + let mut sc = Sidecar::load_or_empty_for(&typ_path).ok()?; 20 + if let Some(CardSchedule::Cloze { rects, .. }) = sc.cards.get_mut(&card_key) { 21 + if idx < rects.len() { 22 + rects.remove(idx); 23 + let updated = rects.clone(); 24 + sc.save_for(&typ_path).ok()?; 25 + return Some(updated); 26 + } 27 + } 28 + None 29 + }