this repo has no description
1
fork

Configure Feed

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

feat: image paste + gallery page

Pasting an image (Ctrl+V) while in the editor saves it to
<deck>/images/Pasted-image-YYYYMMDD-HHmmss.png via arboard (reads the
OS clipboard directly, bypassing webkit2gtk's empty clipboardData) and
appends #img("stem") to the active card segment.

New /images route shows all images in the deck's images/ directory as
base64-encoded thumbnails. Each tile has Copy (#img snippet to
clipboard) and Rename (renames file + updates all references in
cards.typ). Click any thumbnail to open a full-size lightbox; click
outside to dismiss.

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

+460
+86
Cargo.lock
··· 128 128 ] 129 129 130 130 [[package]] 131 + name = "arboard" 132 + version = "3.6.1" 133 + source = "registry+https://github.com/rust-lang/crates.io-index" 134 + checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" 135 + dependencies = [ 136 + "clipboard-win", 137 + "image", 138 + "log", 139 + "objc2", 140 + "objc2-app-kit", 141 + "objc2-core-foundation", 142 + "objc2-core-graphics", 143 + "objc2-foundation", 144 + "parking_lot", 145 + "percent-encoding", 146 + "windows-sys 0.60.2", 147 + "x11rb", 148 + ] 149 + 150 + [[package]] 131 151 name = "arrayref" 132 152 version = "0.3.9" 133 153 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1102 1122 version = "1.1.0" 1103 1123 source = "registry+https://github.com/rust-lang/crates.io-index" 1104 1124 checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" 1125 + 1126 + [[package]] 1127 + name = "clipboard-win" 1128 + version = "5.4.1" 1129 + source = "registry+https://github.com/rust-lang/crates.io-index" 1130 + checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" 1131 + dependencies = [ 1132 + "error-code", 1133 + ] 1105 1134 1106 1135 [[package]] 1107 1136 name = "cobs" ··· 2848 2877 ] 2849 2878 2850 2879 [[package]] 2880 + name = "error-code" 2881 + version = "3.3.2" 2882 + source = "registry+https://github.com/rust-lang/crates.io-index" 2883 + checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" 2884 + 2885 + [[package]] 2851 2886 name = "euclid" 2852 2887 version = "0.22.14" 2853 2888 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2902 2937 checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 2903 2938 2904 2939 [[package]] 2940 + name = "fax" 2941 + version = "0.2.6" 2942 + source = "registry+https://github.com/rust-lang/crates.io-index" 2943 + checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" 2944 + dependencies = [ 2945 + "fax_derive", 2946 + ] 2947 + 2948 + [[package]] 2949 + name = "fax_derive" 2950 + version = "0.2.0" 2951 + source = "registry+https://github.com/rust-lang/crates.io-index" 2952 + checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" 2953 + dependencies = [ 2954 + "proc-macro2", 2955 + "quote", 2956 + "syn 2.0.117", 2957 + ] 2958 + 2959 + [[package]] 2905 2960 name = "fdeflate" 2906 2961 version = "0.3.7" 2907 2962 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4513 4568 "moxcms 0.8.1", 4514 4569 "num-traits", 4515 4570 "png 0.18.1", 4571 + "tiff", 4516 4572 "zune-core 0.5.1", 4517 4573 "zune-jpeg 0.5.13", 4518 4574 ] ··· 5620 5676 "block2", 5621 5677 "objc2", 5622 5678 "objc2-core-foundation", 5679 + "objc2-core-graphics", 5623 5680 "objc2-foundation", 5624 5681 ] 5625 5682 ··· 5641 5698 checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" 5642 5699 dependencies = [ 5643 5700 "bitflags 2.11.0", 5701 + "dispatch2", 5702 + "objc2", 5644 5703 "objc2-core-foundation", 5704 + "objc2-io-surface", 5645 5705 ] 5646 5706 5647 5707 [[package]] ··· 5672 5732 ] 5673 5733 5674 5734 [[package]] 5735 + name = "objc2-io-surface" 5736 + version = "0.3.2" 5737 + source = "registry+https://github.com/rust-lang/crates.io-index" 5738 + checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" 5739 + dependencies = [ 5740 + "bitflags 2.11.0", 5741 + "objc2", 5742 + "objc2-core-foundation", 5743 + ] 5744 + 5745 + [[package]] 5675 5746 name = "objc2-ui-kit" 5676 5747 version = "0.3.2" 5677 5748 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7885 7956 name = "tala" 7886 7957 version = "0.1.0" 7887 7958 dependencies = [ 7959 + "arboard", 7888 7960 "base64", 7889 7961 "dioxus", 7890 7962 "image", ··· 8096 8168 checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 8097 8169 dependencies = [ 8098 8170 "cfg-if", 8171 + ] 8172 + 8173 + [[package]] 8174 + name = "tiff" 8175 + version = "0.11.3" 8176 + source = "registry+https://github.com/rust-lang/crates.io-index" 8177 + checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" 8178 + dependencies = [ 8179 + "fax", 8180 + "flate2", 8181 + "half", 8182 + "quick-error", 8183 + "weezl", 8184 + "zune-jpeg 0.5.13", 8099 8185 ] 8100 8186 8101 8187 [[package]]
+1
crates/tala/Cargo.toml
··· 15 15 base64 = "0.22" 16 16 tokio = { version = "1", features = ["time"] } 17 17 rfd = "0.15" 18 + arboard = "3" 18 19 19 20 [features] 20 21 default = ["desktop"]
+93
crates/tala/assets/main.css
··· 59 59 overflow-y: auto; 60 60 } 61 61 62 + /* images-page has its own padding/overflow rules below */ 63 + 62 64 #home h2, #settings h2 { 63 65 margin-top: 0; 64 66 font-size: 20px; ··· 244 246 font-size: 12px; 245 247 font-family: 'JetBrains Mono', 'Fira Code', monospace; 246 248 } 249 + 250 + /* ── Images page ─────────────────────────────────────────────────────────── */ 251 + #images-page { 252 + padding: 32px 40px; 253 + flex: 1; 254 + overflow-y: auto; 255 + } 256 + 257 + #images-page h2 { 258 + margin-top: 0; 259 + font-size: 20px; 260 + color: #c8d0e8; 261 + } 262 + 263 + .image-grid { 264 + display: flex; 265 + flex-wrap: wrap; 266 + gap: 16px; 267 + margin-top: 16px; 268 + } 269 + 270 + .image-tile { 271 + width: 180px; 272 + border: 1px solid #1e2130; 273 + border-radius: 6px; 274 + overflow: hidden; 275 + background: #13151c; 276 + display: flex; 277 + flex-direction: column; 278 + } 279 + 280 + .image-tile-thumb { 281 + width: 100%; 282 + height: 130px; 283 + object-fit: contain; 284 + background: #0f1116; 285 + display: block; 286 + } 287 + 288 + .image-tile-footer { 289 + padding: 6px 8px; 290 + display: flex; 291 + flex-direction: column; 292 + gap: 6px; 293 + } 294 + 295 + .image-tile-name { 296 + font-family: 'JetBrains Mono', 'Fira Code', monospace; 297 + font-size: 11px; 298 + color: #8892aa; 299 + white-space: nowrap; 300 + overflow: hidden; 301 + text-overflow: ellipsis; 302 + } 303 + 304 + .image-tile-buttons { 305 + display: flex; 306 + gap: 4px; 307 + } 308 + 309 + .lightbox-overlay { 310 + position: fixed; 311 + inset: 0; 312 + background: rgba(0, 0, 0, 0.88); 313 + display: flex; 314 + align-items: center; 315 + justify-content: center; 316 + z-index: 1000; 317 + cursor: pointer; 318 + } 319 + 320 + .lightbox-img { 321 + max-width: 90vw; 322 + max-height: 90vh; 323 + object-fit: contain; 324 + border-radius: 4px; 325 + cursor: default; 326 + box-shadow: 0 8px 48px rgba(0, 0, 0, 0.8); 327 + } 328 + 329 + .image-tile-rename-input { 330 + width: 100%; 331 + background: #1a1d24; 332 + color: #d4d8e8; 333 + font-family: 'JetBrains Mono', 'Fira Code', monospace; 334 + font-size: 11px; 335 + border: 1px solid #4a5080; 336 + border-radius: 3px; 337 + padding: 2px 4px; 338 + outline: none; 339 + }
+280
crates/tala/src/main.rs
··· 94 94 Home {}, 95 95 #[route("/editor")] 96 96 Editor {}, 97 + #[route("/images")] 98 + Images {}, 97 99 #[route("/settings")] 98 100 Settings {}, 99 101 } ··· 125 127 nav { id: "navbar", 126 128 Link { to: Route::Home {}, "Home" } 127 129 Link { to: Route::Editor {}, "Editor" } 130 + Link { to: Route::Images {}, "Images" } 128 131 Link { to: Route::Settings {}, "Settings" } 129 132 span { class: "nav-dir", "{dir_name}" } 130 133 } ··· 563 566 math_spans_sig.set(Vec::new()); 564 567 image_boxes_sig.set(Vec::new()); 565 568 sidecar_rects_sig.set(Vec::new()); 569 + } 570 + } 571 + }); 572 + 573 + // ── Image paste from clipboard ──────────────────────────────────────────── 574 + use_coroutine(move |_: dioxus::prelude::UnboundedReceiver<()>| async move { 575 + // webkit2gtk doesn't expose clipboard contents via clipboardData.items, 576 + // so we use the paste event only as a trigger and read the OS clipboard 577 + // directly from Rust via arboard. 578 + let mut eval = document::eval(r#" 579 + if (window.__talaPasteHandler) { 580 + window.removeEventListener('paste', window.__talaPasteHandler); 581 + } 582 + window.__talaPasteHandler = function(e) { dioxus.send(1); }; 583 + window.addEventListener('paste', window.__talaPasteHandler); 584 + "#); 585 + loop { 586 + match eval.recv::<i32>().await { 587 + Ok(_) => handle_pasted_image(source, active_idx, save_status), 588 + Err(_) => break, 566 589 } 567 590 } 568 591 }); ··· 1229 1252 } 1230 1253 } 1231 1254 }) 1255 + } 1256 + } 1257 + } 1258 + } 1259 + } 1260 + 1261 + // ── Image paste helpers ─────────────────────────────────────────────────────── 1262 + 1263 + fn paste_image_filename() -> String { 1264 + use std::time::{SystemTime, UNIX_EPOCH}; 1265 + let secs = SystemTime::now() 1266 + .duration_since(UNIX_EPOCH) 1267 + .unwrap_or_default() 1268 + .as_secs() as i64; 1269 + // Date part (Gregorian, from http://howardhinnant.github.io/date_algorithms.html) 1270 + let days = secs / 86400; 1271 + let z = days + 719468; 1272 + let era = if z >= 0 { z } else { z - 146096 } / 146097; 1273 + let doe = z - era * 146097; 1274 + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; 1275 + let y = yoe + era * 400; 1276 + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); 1277 + let mp = (5 * doy + 2) / 153; 1278 + let d = doy - (153 * mp + 2) / 5 + 1; 1279 + let m = if mp < 10 { mp + 3 } else { mp - 9 }; 1280 + let y = if m <= 2 { y + 1 } else { y }; 1281 + let tod = (secs % 86400) as u32; 1282 + let h = tod / 3600; 1283 + let min = (tod % 3600) / 60; 1284 + let s = tod % 60; 1285 + format!("Pasted-image-{y:04}{m:02}{d:02}-{h:02}{min:02}{s:02}.png") 1286 + } 1287 + 1288 + fn handle_pasted_image( 1289 + mut source: Signal<String>, 1290 + active_idx: Signal<usize>, 1291 + mut save_status: Signal<SaveStatus>, 1292 + ) { 1293 + // Read image from OS clipboard (bypasses webkit2gtk's empty clipboardData). 1294 + let img_data = match arboard::Clipboard::new().and_then(|mut c| c.get_image()) { 1295 + Ok(d) => d, 1296 + Err(_) => return, // no image in clipboard 1297 + }; 1298 + 1299 + // Encode RGBA pixels to PNG. 1300 + let rgba = image::RgbaImage::from_raw( 1301 + img_data.width as u32, 1302 + img_data.height as u32, 1303 + img_data.bytes.into_owned(), 1304 + ); 1305 + let Some(rgba) = rgba else { return }; 1306 + let mut png_bytes: Vec<u8> = Vec::new(); 1307 + if image::DynamicImage::ImageRgba8(rgba) 1308 + .write_to(&mut std::io::Cursor::new(&mut png_bytes), image::ImageFormat::Png) 1309 + .is_err() 1310 + { 1311 + return; 1312 + } 1313 + 1314 + let filename = paste_image_filename(); 1315 + let stem = filename.trim_end_matches(".png").to_string(); 1316 + if std::fs::write(card_dir().join("images").join(&filename), &png_bytes).is_err() { 1317 + return; 1318 + } 1319 + 1320 + // Append \n#img("stem") at end of active segment. 1321 + let cur = source.peek().clone(); 1322 + let segs = make_segments(&cur); 1323 + let ai = (*active_idx.peek()).min(segs.len().saturating_sub(1)); 1324 + let snippet = format!("\n#img(\"{}\")", stem); 1325 + let new_src = if let Some(seg) = segs.get(ai) { 1326 + format!("{}{}{}", &cur[..seg.end], snippet, &cur[seg.end..]) 1327 + } else { 1328 + format!("{}{}", cur, snippet) 1329 + }; 1330 + source.set(new_src.clone()); 1331 + save_status.set(SaveStatus::Dirty); 1332 + 1333 + // Update textarea to reflect the new fragment. 1334 + let new_segs = make_segments(&new_src); 1335 + let ai2 = ai.min(new_segs.len().saturating_sub(1)); 1336 + let new_frag = new_segs.get(ai2).map(|s| new_src[s.start..s.end].to_string()).unwrap_or_default(); 1337 + let b64 = base64::engine::general_purpose::STANDARD.encode(&new_frag); 1338 + spawn(async move { 1339 + document::eval(&format!( 1340 + "var t=document.querySelector('.card-row.active textarea');\ 1341 + if(t)t.value=atob('{}');", b64 1342 + )).await.ok(); 1343 + }); 1344 + } 1345 + 1346 + // ── Images page ─────────────────────────────────────────────────────────────── 1347 + 1348 + fn list_images(dir: &Path) -> Vec<PathBuf> { 1349 + let mut files: Vec<PathBuf> = std::fs::read_dir(dir) 1350 + .into_iter() 1351 + .flatten() 1352 + .filter_map(|e| e.ok().map(|e| e.path())) 1353 + .filter(|p| { 1354 + matches!( 1355 + p.extension().and_then(|e| e.to_str()), 1356 + Some("png" | "jpg" | "jpeg" | "gif" | "webp") 1357 + ) 1358 + }) 1359 + .collect(); 1360 + files.sort(); 1361 + files 1362 + } 1363 + 1364 + #[component] 1365 + fn Images() -> Element { 1366 + let images_dir = card_dir().join("images"); 1367 + let mut files = use_signal(|| list_images(&images_dir)); 1368 + let mut lightbox: Signal<Option<String>> = use_signal(|| None); 1369 + let images_dir_str = images_dir.display().to_string(); 1370 + 1371 + rsx! { 1372 + div { id: "images-page", 1373 + h2 { "Images" } 1374 + p { class: "dir-path", "{images_dir_str}" } 1375 + if files.read().is_empty() { 1376 + p { class: "status", "No images yet. Paste an image while editing a card." } 1377 + } 1378 + div { class: "image-grid", 1379 + { 1380 + files.read().clone().into_iter().map(|path| { 1381 + let key = path.display().to_string(); 1382 + rsx! { 1383 + ImageTile { 1384 + key: "{key}", 1385 + path: path, 1386 + on_renamed: move |_| { 1387 + files.set(list_images(&card_dir().join("images"))); 1388 + }, 1389 + on_view: move |uri: String| lightbox.set(Some(uri)), 1390 + } 1391 + } 1392 + }) 1393 + } 1394 + } 1395 + } 1396 + if let Some(uri) = lightbox.read().clone() { 1397 + div { 1398 + class: "lightbox-overlay", 1399 + onclick: move |_| lightbox.set(None), 1400 + img { 1401 + class: "lightbox-img", 1402 + src: "{uri}", 1403 + onclick: move |e| e.stop_propagation(), 1404 + } 1405 + } 1406 + } 1407 + } 1408 + } 1409 + 1410 + #[component] 1411 + fn ImageTile(path: PathBuf, on_renamed: EventHandler<()>, on_view: EventHandler<String>) -> Element { 1412 + let stem = path 1413 + .file_stem() 1414 + .map(|s| s.to_string_lossy().into_owned()) 1415 + .unwrap_or_default(); 1416 + let mut renaming = use_signal(|| false); 1417 + let mut rename_val = use_signal(|| stem.clone()); 1418 + let mut copied = use_signal(|| false); 1419 + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("png"); 1420 + let mime = match ext { "jpg" | "jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", _ => "image/png" }; 1421 + let data_uri = std::fs::read(&path) 1422 + .map(|bytes| format!("data:{};base64,{}", mime, base64::engine::general_purpose::STANDARD.encode(&bytes))) 1423 + .unwrap_or_default(); 1424 + 1425 + rsx! { 1426 + div { class: "image-tile", 1427 + img { 1428 + class: "image-tile-thumb", 1429 + src: "{data_uri}", 1430 + alt: "{stem}", 1431 + style: "cursor: zoom-in;", 1432 + onclick: { 1433 + let uri = data_uri.clone(); 1434 + move |_| on_view.call(uri.clone()) 1435 + }, 1436 + } 1437 + div { class: "image-tile-footer", 1438 + if *renaming.read() { 1439 + input { 1440 + class: "image-tile-rename-input", 1441 + value: "{rename_val}", 1442 + onmounted: move |_| { 1443 + spawn(async move { 1444 + document::eval( 1445 + "var inp=document.querySelector('.image-tile-rename-input');\ 1446 + if(inp){inp.focus();inp.select();}" 1447 + ).await.ok(); 1448 + }); 1449 + }, 1450 + oninput: move |e| rename_val.set(e.value()), 1451 + onkeydown: { 1452 + let path = path.clone(); 1453 + let stem = stem.clone(); 1454 + move |e: Event<KeyboardData>| { 1455 + let key = e.data().key().to_string(); 1456 + if key == "Enter" { 1457 + let new_stem = rename_val.peek().trim().to_string(); 1458 + if !new_stem.is_empty() && new_stem != stem { 1459 + let new_path = path.with_file_name(format!("{}.png", new_stem)); 1460 + if std::fs::rename(&path, &new_path).is_ok() { 1461 + if let Ok(mut src) = std::fs::read_to_string(cards_path()) { 1462 + src = src.replace( 1463 + &format!("#img(\"{}\")", stem), 1464 + &format!("#img(\"{}\")", new_stem), 1465 + ); 1466 + let _ = std::fs::write(cards_path(), &src); 1467 + } 1468 + on_renamed.call(()); 1469 + } 1470 + } 1471 + renaming.set(false); 1472 + } else if key == "Escape" { 1473 + renaming.set(false); 1474 + } 1475 + } 1476 + }, 1477 + } 1478 + } else { 1479 + span { class: "image-tile-name", title: "{stem}", "{stem}" } 1480 + } 1481 + div { class: "image-tile-buttons", 1482 + button { 1483 + class: if *copied.read() { "btn active" } else { "btn" }, 1484 + onclick: { 1485 + let stem = stem.clone(); 1486 + move |_| { 1487 + let s = stem.clone(); 1488 + spawn(async move { 1489 + document::eval(&format!( 1490 + "navigator.clipboard.writeText('#img(\"{}\")')", 1491 + s 1492 + )).await.ok(); 1493 + copied.set(true); 1494 + tokio::time::sleep(Duration::from_millis(1500)).await; 1495 + copied.set(false); 1496 + }); 1497 + } 1498 + }, 1499 + if *copied.read() { "Copied!" } else { "Copy" } 1500 + } 1501 + button { 1502 + class: "btn", 1503 + onclick: { 1504 + let stem = stem.clone(); 1505 + move |_| { 1506 + rename_val.set(stem.clone()); 1507 + renaming.set(true); 1508 + } 1509 + }, 1510 + "Rename" 1511 + } 1232 1512 } 1233 1513 } 1234 1514 }