Replace js by ts
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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, "*");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user