this repo has no description
1
fork

Configure Feed

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

tala: Refactor Editor(), Images() to separate modules

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