Replace js by ts

This commit is contained in:
2026-04-01 01:27:51 +02:00
parent 4613b8e5dd
commit 6306c73f26
291 changed files with 501210 additions and 525 deletions

View File

@@ -13,7 +13,7 @@ wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["DataTransfer", "DragEvent", "HtmlIFrameElement", "KeyboardEvent", "MessageEvent", "Window"] }
web-sys = { version = "0.3", features = ["DataTransfer", "DragEvent", "EventTarget", "HtmlIFrameElement", "KeyboardEvent", "MessageEvent", "Window"] }
[dev-dependencies]
serde_json = "1"

View File

@@ -7,11 +7,63 @@
//! Using an `<iframe>` keeps the viewer alive when the tab is hidden (via
//! `display:none`), so scrolling position and zoom are preserved across
//! tab switches.
//!
//! ## Annotation flow
//!
//! ```text
//! iframe (viewer.js) Leptos (this component)
//! ───────────────────────────────── ──────────────────────────────────
//! brittle:annotations-request → get_annotations (Tauri IPC)
//! ← brittle:annotations-set
//! brittle:annotation-create → create_annotation (Tauri IPC)
//! ← brittle:annotations-set (updated)
//! brittle:annotation-click → show comment editor
//! (save / delete in comment editor) → update_annotation / delete_annotation
//! ← brittle:annotations-set (updated)
//! ```
use brittle_keymap::actions;
use brittle_model::{Annotation, AnnotationId, AnnotationType, Color, Quad, ReferenceId,
TextMarkupType};
use leptos::prelude::*;
use uuid::Uuid;
use wasm_bindgen::JsValue;
// ── Helper types for IPC message deserialization ──────────────────────────────
#[derive(serde::Deserialize)]
struct IframeMsgHeader {
#[serde(rename = "type")]
kind: String,
#[serde(rename = "refId", default)]
ref_id: String,
}
#[derive(serde::Deserialize)]
struct IframeAnnotCreateMsg {
page: u32,
quads: Vec<Quad>,
#[serde(rename = "selectedText")]
selected_text: Option<String>,
}
#[derive(serde::Deserialize)]
struct IframeAnnotClickMsg {
#[serde(rename = "annotationId")]
annotation_id: String,
}
// ── Outbound message ──────────────────────────────────────────────────────────
#[derive(serde::Serialize)]
struct AnnotationsSetMsg<'a> {
#[serde(rename = "type")]
kind: &'static str,
annotations: &'a [Annotation],
}
// ── Component ─────────────────────────────────────────────────────────────────
/// Renders the PDF viewer for a single reference.
///
/// `ref_id` must be the UUID string of a reference that has an attached PDF.
@@ -30,6 +82,17 @@ pub fn PdfViewer(
let iframe_ref = NodeRef::<leptos::html::Iframe>::new();
// Store ref_id in a Copy handle so it can be captured in multiple closures
let ref_id_sv: StoredValue<String> = StoredValue::new(ref_id);
// Per-tab annotation state
let annotations: RwSignal<Vec<Annotation>> = RwSignal::new(vec![]);
// (annotation_id_string, current_content) — Some when comment editor is open
let selected_annotation: RwSignal<Option<(String, Option<String>)>> = RwSignal::new(None);
let comment_text: RwSignal<String> = RwSignal::new(String::new());
// ── Keymap → iframe forwarding ────────────────────────────────────────────
let keymap_action = use_context::<crate::KeymapAction>()
.expect("KeymapAction context missing")
.0;
@@ -50,13 +113,202 @@ pub fn PdfViewer(
}
});
// ── Window message listener for annotation IPC ────────────────────────────
{
use wasm_bindgen::{closure::Closure, JsCast as _};
let cb: Closure<dyn Fn(web_sys::MessageEvent)> =
Closure::new(move |ev: web_sys::MessageEvent| {
let Ok(header) =
serde_wasm_bindgen::from_value::<IframeMsgHeader>(ev.data())
else {
return;
};
if header.ref_id != ref_id_sv.get_value() { return; }
match header.kind.as_str() {
// Viewer is ready and asking for the annotation set
"brittle:annotations-request" => {
let anns = annotations.get_untracked();
send_annotations(&anns, &iframe_ref);
}
// User finished selecting text → create a highlight
"brittle:annotation-create" => {
let Ok(msg) =
serde_wasm_bindgen::from_value::<IframeAnnotCreateMsg>(ev.data())
else {
return;
};
let ref_id_c = ref_id_sv.get_value();
leptos::task::spawn_local(async move {
let Ok(uuid) = Uuid::parse_str(&ref_id_c) else { return };
let reference_id = ReferenceId(uuid);
let annotation_type = AnnotationType::TextMarkup {
markup_type: TextMarkupType::Highlight,
quads: msg.quads,
color: Color::YELLOW,
selected_text: msg.selected_text,
};
if crate::tauri::create_annotation(
&reference_id,
msg.page,
annotation_type,
None,
)
.await
.is_err()
{
return;
}
// Reload the full set and push to the iframe
if let Ok(anns) =
crate::tauri::get_annotations(&reference_id).await
{
annotations.set(anns.clone());
send_annotations(&anns, &iframe_ref);
}
});
}
// User clicked an existing annotation → open comment editor
"brittle:annotation-click" => {
let Ok(msg) =
serde_wasm_bindgen::from_value::<IframeAnnotClickMsg>(ev.data())
else {
return;
};
let content = annotations.with_untracked(|anns| {
anns.iter()
.find(|a| a.id.to_string() == msg.annotation_id)
.and_then(|a| a.content.clone())
});
comment_text.set(content.clone().unwrap_or_default());
selected_annotation.set(Some((msg.annotation_id, content)));
}
_ => {}
}
});
// Keep the JS function ref for cleanup so the listener is removed when
// the tab is closed.
let js_fn = cb.as_ref().unchecked_ref::<js_sys::Function>().clone();
if let Some(win) = web_sys::window() {
let _ = win.add_event_listener_with_callback("message", &js_fn);
}
let js_fn_cleanup = js_fn;
on_cleanup(move || {
if let Some(win) = web_sys::window() {
let _ = win.remove_event_listener_with_callback("message", &js_fn_cleanup);
}
});
cb.forget();
}
// ── Initial annotation load ───────────────────────────────────────────────
// The viewer also sends brittle:annotations-request on startup, but pre-
// loading here means the response is instant when the request arrives.
Effect::new(move |_| {
leptos::task::spawn_local(async move {
let Ok(uuid) = Uuid::parse_str(&ref_id_sv.get_value()) else { return };
let reference_id = ReferenceId(uuid);
if let Ok(anns) = crate::tauri::get_annotations(&reference_id).await {
annotations.set(anns);
}
});
});
// ── View ──────────────────────────────────────────────────────────────────
view! {
<iframe
node_ref=iframe_ref
class="pdf-frame"
src=url
// Intentionally no `sandbox` attribute — the brittle:// protocol
// and PDF.js require unrestricted access within the webview.
/>
<div class="pdf-tab-container">
<iframe
node_ref=iframe_ref
class="pdf-frame"
src=url
// Intentionally no `sandbox` attribute — the brittle:// protocol
// and PDF.js require unrestricted access within the webview.
/>
<Show when=move || selected_annotation.get().is_some()>
<div class="comment-editor">
<div class="comment-editor-header">
<span>"Highlight note"</span>
<button class="comment-close" on:click=move |_| {
selected_annotation.set(None);
comment_text.set(String::new());
}>"×"</button>
</div>
<textarea
class="comment-textarea"
prop:value=move || comment_text.get()
on:input=move |ev| comment_text.set(event_target_value(&ev))
placeholder="Add a note to this highlight…"
/>
<div class="comment-actions">
<button class="comment-save" on:click=move |_| {
let Some((ann_id_str, _)) = selected_annotation.get_untracked()
else { return };
let text = comment_text.get_untracked();
leptos::task::spawn_local(async move {
let Ok(ref_uuid) = Uuid::parse_str(&ref_id_sv.get_value())
else { return };
let reference_id = ReferenceId(ref_uuid);
let ann_opt = annotations.with_untracked(|anns| {
anns.iter()
.find(|a| a.id.to_string() == ann_id_str)
.cloned()
});
let Some(mut ann) = ann_opt else { return };
ann.content = if text.is_empty() { None } else { Some(text) };
if crate::tauri::update_annotation(ann).await.is_ok() {
if let Ok(anns) =
crate::tauri::get_annotations(&reference_id).await
{
annotations.set(anns.clone());
send_annotations(&anns, &iframe_ref);
}
}
selected_annotation.set(None);
});
}>"Save"</button>
<button class="comment-delete" on:click=move |_| {
let Some((ann_id_str, _)) = selected_annotation.get_untracked()
else { return };
leptos::task::spawn_local(async move {
let Ok(ref_uuid) = Uuid::parse_str(&ref_id_sv.get_value())
else { return };
let Ok(ann_uuid) = Uuid::parse_str(&ann_id_str) else { return };
let reference_id = ReferenceId(ref_uuid);
let annotation_id = AnnotationId(ann_uuid);
if crate::tauri::delete_annotation(&reference_id, &annotation_id)
.await
.is_ok()
{
if let Ok(anns) =
crate::tauri::get_annotations(&reference_id).await
{
annotations.set(anns.clone());
send_annotations(&anns, &iframe_ref);
}
}
selected_annotation.set(None);
});
}>"Delete highlight"</button>
</div>
</div>
</Show>
</div>
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/// Serialize `annotations` to a `brittle:annotations-set` message and post it
/// into the given iframe's content window.
fn send_annotations(annotations: &[Annotation], iframe_ref: &NodeRef<leptos::html::Iframe>) {
let Some(iframe) = iframe_ref.get_untracked() else { return };
let Some(win) = iframe.content_window() else { return };
let msg = AnnotationsSetMsg { kind: "brittle:annotations-set", annotations };
let Ok(js_val) = serde_wasm_bindgen::to_value(&msg) else { return };
let _ = win.post_message(&js_val, "*");
}

View File

@@ -8,7 +8,10 @@ use std::collections::HashMap;
use serde::Serialize;
use brittle_model::{Library, LibraryId, Reference, ReferenceId, ReferenceSummary};
use brittle_model::{
Annotation, AnnotationId, AnnotationType, Library, LibraryId, Reference, ReferenceId,
ReferenceSummary,
};
// ── Low-level invoke ───────────────────────────────────────────────────────────
@@ -235,3 +238,52 @@ pub async fn get_reference(id: &ReferenceId) -> Result<Reference, String> {
}
invoke("get_reference", &Args { id }).await
}
// ── Annotation commands ────────────────────────────────────────────────────────
pub async fn get_annotations(reference_id: &ReferenceId) -> Result<Vec<Annotation>, String> {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Args<'a> {
reference_id: &'a ReferenceId,
}
invoke("get_annotations", &Args { reference_id }).await
}
pub async fn create_annotation(
reference_id: &ReferenceId,
page: u32,
annotation_type: AnnotationType,
content: Option<String>,
) -> Result<Annotation, String> {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Args<'a> {
reference_id: &'a ReferenceId,
page: u32,
annotation_type: AnnotationType,
content: Option<String>,
}
invoke("create_annotation", &Args { reference_id, page, annotation_type, content }).await
}
pub async fn update_annotation(annotation: Annotation) -> Result<Annotation, String> {
#[derive(Serialize)]
struct Args {
annotation: Annotation,
}
invoke("update_annotation", &Args { annotation }).await
}
pub async fn delete_annotation(
reference_id: &ReferenceId,
annotation_id: &AnnotationId,
) -> Result<(), String> {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Args<'a> {
reference_id: &'a ReferenceId,
annotation_id: &'a AnnotationId,
}
invoke("delete_annotation", &Args { reference_id, annotation_id }).await
}

View File

@@ -356,11 +356,108 @@ ul { list-style: none; }
/* ── PDF viewer frame ─────────────────────────────────────────────────────── */
.pdf-frame {
.pdf-tab-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.pdf-frame {
flex: 1;
width: 100%;
border: none;
display: block;
min-height: 0;
}
/* Comment editor — appears at the bottom of the PDF frame when a highlight
is clicked. Positioned absolutely so it overlays the iframe. */
.comment-editor {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: var(--bg1, #3c3836);
border-top: 1px solid var(--border, #504945);
padding: 10px 14px 12px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 10;
}
.comment-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: var(--fg2, #a89984);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.comment-close {
background: none;
border: none;
color: var(--fg2, #a89984);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 2px;
}
.comment-close:hover { color: var(--fg, #ebdbb2); }
.comment-textarea {
width: 100%;
min-height: 72px;
resize: vertical;
background: var(--bg, #282828);
color: var(--fg, #ebdbb2);
border: 1px solid var(--border, #504945);
border-radius: 4px;
padding: 6px 8px;
font-family: inherit;
font-size: 13px;
line-height: 1.5;
box-sizing: border-box;
}
.comment-textarea:focus {
outline: none;
border-color: var(--yellow, #d79921);
}
.comment-actions {
display: flex;
gap: 8px;
}
.comment-save {
background: var(--yellow, #d79921);
color: #1d2021;
border: none;
border-radius: 4px;
padding: 4px 14px;
font-size: 13px;
cursor: pointer;
font-weight: 600;
}
.comment-save:hover { filter: brightness(1.1); }
.comment-delete {
background: none;
border: 1px solid var(--border, #504945);
color: var(--fg2, #a89984);
border-radius: 4px;
padding: 4px 12px;
font-size: 13px;
cursor: pointer;
}
.comment-delete:hover {
border-color: var(--red, #cc241d);
color: var(--red, #cc241d);
}
/* ── Drag-and-drop ────────────────────────────────────────────────────────── */