T21b and T21c: Documents, reading and knowledge
- DocumentViewer: pages the app drew on a desk, the page under the view's middle counted, zoom steps and fit, thumbnails, notes pinned where a press lands, and the owner's FindWidget over the pages, hits lit and the current one revealed - PageThumbnailList, EpubReader (chapters, type size, progress), PrintPreview - MarkdownRenderer feeds TableOfContents through sections and outline - Footnote, TooltipTerm, Glossary, ReadingProgress, ZenMode - Backlinks, Favorites, TrashBin, PageCover, PageIcon, TemplatePicker, VersionHistory with PageHistoryDiff - editor::FindWidget shows the replace fold and option toggles only with handlers - block editor tests move to documents/blocks - icons: a-arrow-down, a-arrow-up - gallery: reading and knowledge sections; scripted find, changes and zen
This commit is contained in:
1 parent
14a8990793
commit
9854cfa460
32 files changed
+4269
-411
No files matched your search
@@ -0,0 +1,487 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels,
|
||||
Point, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Window,
|
||||
canvas, div, img, point, prelude::*,
|
||||
};
|
||||
|
||||
use super::{
|
||||
find::{Find, reveal},
|
||||
thumbs::PageThumbnailList,
|
||||
};
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton, ToggleButton, ToggleItem},
|
||||
documents::blocks::source,
|
||||
editor::FindWidget,
|
||||
overlays::HoverCard,
|
||||
primitives::{Icon, IconName},
|
||||
shell::ToolbarSeparator,
|
||||
theme::{ActiveTheme, ControlSize, Elevation, IconSize, Radius, TextSize},
|
||||
typography::tabular,
|
||||
};
|
||||
|
||||
/// Zoom steps, as shares of a page's own size.
|
||||
const STEPS: [f32; 9] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
|
||||
|
||||
/// A page the app drew: its picture, a file or a web address, and its size in points.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DocPage {
|
||||
pub source: SharedString,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
/// A stretch of a page a search found, in the page's points.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PageHit {
|
||||
pub page: usize,
|
||||
pub bounds: Bounds<f32>,
|
||||
}
|
||||
|
||||
/// A note pinned to a page, at a point in its points.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PageNote {
|
||||
pub page: usize,
|
||||
pub at: Point<f32>,
|
||||
pub text: SharedString,
|
||||
}
|
||||
|
||||
type OnNote = Rc<dyn Fn(usize, Point<f32>, &mut Window, &mut App)>;
|
||||
type OnZoom = Rc<dyn Fn(f32, &mut Window, &mut App)>;
|
||||
|
||||
/// The zoom step past `zoom` in `direction`, when there is one.
|
||||
pub(crate) fn step(zoom: f32, up: bool) -> Option<f32> {
|
||||
if up {
|
||||
STEPS.into_iter().find(|step| *step > zoom + f32::EPSILON)
|
||||
} else {
|
||||
STEPS
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|step| *step < zoom - f32::EPSILON)
|
||||
}
|
||||
}
|
||||
|
||||
/// The first page whose bottom lies past `y`, or the last; the first before any layout.
|
||||
pub(crate) fn page_at(bottoms: &[Pixels], y: Pixels) -> usize {
|
||||
bottoms
|
||||
.iter()
|
||||
.position(|bottom| *bottom >= y)
|
||||
.unwrap_or(bottoms.len().saturating_sub(1))
|
||||
}
|
||||
|
||||
/// The page under the middle of the view, from the last layout.
|
||||
fn page_in_view(scroll: &ScrollHandle, count: usize) -> usize {
|
||||
let middle = scroll.bounds().center().y - scroll.offset().y;
|
||||
let bottoms: Vec<Pixels> = (0..count)
|
||||
.map_while(|ix| scroll.bounds_for_item(ix))
|
||||
.map(|page| page.bottom())
|
||||
.collect();
|
||||
page_at(&bottoms, middle)
|
||||
}
|
||||
|
||||
/// Pages the app drew, one under another on a desk: the page in view and its count, zoom by steps or to the width, the pages as thumbnails beside, a find bar whose hits light on the page, and notes pinned where a press puts them.
|
||||
#[derive(IntoElement)]
|
||||
pub struct DocumentViewer {
|
||||
id: ElementId,
|
||||
title: SharedString,
|
||||
pages: Rc<Vec<DocPage>>,
|
||||
zoom: f32,
|
||||
on_zoom: Option<OnZoom>,
|
||||
thumbnails: bool,
|
||||
find: Option<Find>,
|
||||
notes: Vec<PageNote>,
|
||||
on_note: Option<OnNote>,
|
||||
}
|
||||
|
||||
impl DocumentViewer {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
title: impl Into<SharedString>,
|
||||
pages: impl Into<Rc<Vec<DocPage>>>,
|
||||
) -> Self {
|
||||
let pages = pages.into();
|
||||
for page in pages.iter() {
|
||||
assert!(
|
||||
page.width > 0.0 && page.height > 0.0,
|
||||
"page {} has no size",
|
||||
page.source
|
||||
);
|
||||
}
|
||||
Self {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
pages,
|
||||
zoom: 1.0,
|
||||
on_zoom: None,
|
||||
thumbnails: false,
|
||||
find: None,
|
||||
notes: Vec::new(),
|
||||
on_note: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scales pages from their size in points; the owner keeps the zoom the steps and fit ask for.
|
||||
pub fn zoom(
|
||||
mut self,
|
||||
zoom: f32,
|
||||
on_zoom: impl Fn(f32, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
assert!(zoom > 0.0, "a zoom of {zoom} shows nothing");
|
||||
self.zoom = zoom;
|
||||
self.on_zoom = Some(Rc::new(on_zoom));
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows the pages small beside the large ones.
|
||||
pub fn thumbnails(mut self) -> Self {
|
||||
self.thumbnails = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// The owner's find box, floating over the pages; the owner searches them, and `hits` light where they are, the current one scrolled into view.
|
||||
pub fn find(mut self, widget: FindWidget, hits: Vec<PageHit>, current: Option<usize>) -> Self {
|
||||
assert!(
|
||||
current.is_none_or(|current| current < hits.len()),
|
||||
"hit {current:?} of {}",
|
||||
hits.len()
|
||||
);
|
||||
self.find = Some(Find {
|
||||
widget,
|
||||
hits,
|
||||
current,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Notes on the pages; with `on_note`, a press on a page while notes are on asks the owner to pin one there.
|
||||
pub fn notes(
|
||||
mut self,
|
||||
notes: Vec<PageNote>,
|
||||
on_note: impl Fn(usize, Point<f32>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.notes = notes;
|
||||
self.on_note = Some(Rc::new(on_note));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The viewer's own state: its scroll, the page in view, the width it fits, whether presses pin notes, and the hit last brought into view.
|
||||
#[derive(Default)]
|
||||
struct Desk {
|
||||
scroll: ScrollHandle,
|
||||
page: usize,
|
||||
width: f32,
|
||||
pinning: bool,
|
||||
shown_hit: Option<usize>,
|
||||
}
|
||||
|
||||
impl RenderOnce for DocumentViewer {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let desk = window.use_keyed_state((self.id.clone(), "desk"), cx, |_, _| Desk::default());
|
||||
let (scroll, page_now, width, pinning) = {
|
||||
let desk = desk.read(cx);
|
||||
(desk.scroll.clone(), desk.page, desk.width, desk.pinning)
|
||||
};
|
||||
let zoom = self.zoom;
|
||||
if let Some(Find {
|
||||
hits,
|
||||
current: Some(current),
|
||||
..
|
||||
}) = &self.find
|
||||
&& desk.read(cx).shown_hit != Some(*current)
|
||||
{
|
||||
let hit = &hits[*current];
|
||||
log::info!("document viewer: hit {current} on page {}", hit.page);
|
||||
reveal(&scroll, hit, zoom);
|
||||
desk.update(cx, |desk, _| desk.shown_hit = Some(*current));
|
||||
}
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let count = self.pages.len();
|
||||
let go = |page: usize, scroll: &ScrollHandle| {
|
||||
let scroll = scroll.clone();
|
||||
move |_: &gpui::ClickEvent, _: &mut Window, _: &mut App| {
|
||||
scroll.scroll_to_top_of_item(page)
|
||||
}
|
||||
};
|
||||
let zoom_to = |next: Option<f32>, icon: IconName, tip: &'static str| {
|
||||
let on_zoom = self.on_zoom.clone();
|
||||
IconButton::new((self.id.clone(), tip), icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(tip)
|
||||
.disabled(next.is_none() || on_zoom.is_none())
|
||||
.when_some(next.zip(on_zoom), |button, (next, on_zoom)| {
|
||||
button.on_click(move |_, window, cx| on_zoom(next, window, cx))
|
||||
})
|
||||
};
|
||||
let widest = self.pages.iter().map(|page| page.width).fold(0.0, f32::max);
|
||||
let fit = (width > 0.0 && widest > 0.0)
|
||||
.then(|| (width / widest).clamp(STEPS[0], STEPS[STEPS.len() - 1]));
|
||||
let header = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_3()
|
||||
.h(theme.control_height(ControlSize::Lg))
|
||||
.border_b_1()
|
||||
.border_color(colors.border)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_color(colors.fg_muted)
|
||||
.child(self.title.clone()),
|
||||
)
|
||||
.child(
|
||||
IconButton::new((self.id.clone(), "previous"), IconName::ChevronUp)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Previous page")
|
||||
.disabled(page_now == 0)
|
||||
.on_click(go(page_now.saturating_sub(1), &scroll)),
|
||||
)
|
||||
.child(
|
||||
tabular(div().text_size(theme.text_size(TextSize::Sm)))
|
||||
.child(format!("{} of {count}", page_now + 1)),
|
||||
)
|
||||
.child(
|
||||
IconButton::new((self.id.clone(), "next"), IconName::ChevronDown)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Next page")
|
||||
.disabled(page_now + 1 >= count)
|
||||
.on_click(go((page_now + 1).min(count.saturating_sub(1)), &scroll)),
|
||||
)
|
||||
.child(ToolbarSeparator)
|
||||
.child(zoom_to(step(zoom, false), IconName::ZoomOut, "Zoom out"))
|
||||
.child(
|
||||
tabular(
|
||||
div()
|
||||
.min_w_10()
|
||||
.text_center()
|
||||
.text_size(theme.text_size(TextSize::Sm)),
|
||||
)
|
||||
.child(format!("{:.0}%", zoom * 100.0)),
|
||||
)
|
||||
.child(zoom_to(step(zoom, true), IconName::ZoomIn, "Zoom in"))
|
||||
.child(zoom_to(fit, IconName::Maximize2, "Fit to width"))
|
||||
.when(self.on_note.is_some(), |header| {
|
||||
let desk = desk.clone();
|
||||
header.child(
|
||||
ToggleButton::new(
|
||||
(self.id.clone(), "pin"),
|
||||
ToggleItem::new("notes")
|
||||
.icon(IconName::MessageSquare)
|
||||
.tooltip("Pin notes"),
|
||||
pinning,
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.on_toggle(move |on, _, cx| {
|
||||
desk.update(cx, |desk, cx| {
|
||||
desk.pinning = on;
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
});
|
||||
let (widget, hits, current) = match self.find {
|
||||
Some(Find {
|
||||
widget,
|
||||
hits,
|
||||
current,
|
||||
}) => (Some(widget), hits, current),
|
||||
None => (None, Vec::new(), None),
|
||||
};
|
||||
let pages = self.pages.iter().enumerate().map(|(ix, page)| {
|
||||
let (on_note, scroll) = (self.on_note.clone().filter(|_| pinning), scroll.clone());
|
||||
div()
|
||||
.id((self.id.clone(), format!("page-{ix}")))
|
||||
.relative()
|
||||
.flex_none()
|
||||
.w(Pixels::from(page.width * zoom))
|
||||
.h(Pixels::from(page.height * zoom))
|
||||
.bg(colors.paper)
|
||||
.shadow(theme.elevation(Elevation::Raised))
|
||||
.when(pinning, |page| page.cursor_crosshair())
|
||||
.when_some(on_note, |page, on_note| {
|
||||
page.on_mouse_down(MouseButton::Left, move |event, window, cx| {
|
||||
let Some(bounds) = scroll.bounds_for_item(ix) else {
|
||||
return;
|
||||
};
|
||||
let at = event.position - bounds.origin - scroll.offset();
|
||||
let at = point(f32::from(at.x) / zoom, f32::from(at.y) / zoom);
|
||||
log::info!("document viewer: note asked on page {ix} at {at:?}");
|
||||
on_note(ix, at, window, cx);
|
||||
})
|
||||
})
|
||||
.child(
|
||||
img(source(&page.source))
|
||||
.id((self.id.clone(), format!("picture-{ix}")))
|
||||
.size_full(),
|
||||
)
|
||||
.children(
|
||||
hits.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, hit)| hit.page == ix)
|
||||
.map(|(at, hit)| {
|
||||
div()
|
||||
.absolute()
|
||||
.left(Pixels::from(hit.bounds.origin.x * zoom))
|
||||
.top(Pixels::from(hit.bounds.origin.y * zoom))
|
||||
.w(Pixels::from(hit.bounds.size.width * zoom))
|
||||
.h(Pixels::from(hit.bounds.size.height * zoom))
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(if current == Some(at) {
|
||||
colors.warning.opacity(0.45)
|
||||
} else {
|
||||
colors.warning.opacity(0.2)
|
||||
})
|
||||
}),
|
||||
)
|
||||
.children(
|
||||
self.notes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, note)| note.page == ix)
|
||||
.map(|(at, note)| {
|
||||
let text = note.text.clone();
|
||||
div()
|
||||
.absolute()
|
||||
.left(Pixels::from(note.at.x * zoom))
|
||||
.top(Pixels::from(note.at.y * zoom))
|
||||
.child(HoverCard::new(
|
||||
(self.id.clone(), format!("note-{at}")),
|
||||
div()
|
||||
.size_6()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(colors.warning)
|
||||
.child(
|
||||
Icon::new(IconName::MessageSquare)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.on_accent),
|
||||
),
|
||||
move |_, _| div().max_w_64().child(text.clone()),
|
||||
))
|
||||
}),
|
||||
)
|
||||
});
|
||||
let (tracked, measured) = (desk.clone(), desk.clone());
|
||||
let body = div()
|
||||
.flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.when(self.thumbnails, |body| {
|
||||
let scroll = scroll.clone();
|
||||
body.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.border_r_1()
|
||||
.border_color(colors.border)
|
||||
.child(
|
||||
PageThumbnailList::new(
|
||||
(self.id.clone(), "thumbs"),
|
||||
self.pages.clone(),
|
||||
page_now,
|
||||
)
|
||||
.on_pick(move |page, _, _| scroll.scroll_to_top_of_item(page)),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.bg(colors.sunken)
|
||||
.child(
|
||||
div()
|
||||
.id((self.id.clone(), "pages"))
|
||||
.size_full()
|
||||
.overflow_scroll()
|
||||
.track_scroll(&scroll)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_4()
|
||||
.p_6()
|
||||
.children(pages),
|
||||
)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, window, cx| {
|
||||
let (page, width) = {
|
||||
let desk = tracked.read(cx);
|
||||
(
|
||||
page_in_view(&desk.scroll, count),
|
||||
f32::from(bounds.size.width),
|
||||
)
|
||||
};
|
||||
if (page, width) != (tracked.read(cx).page, tracked.read(cx).width)
|
||||
{
|
||||
measured.update(cx, |desk, cx| {
|
||||
desk.page = page;
|
||||
desk.width = width;
|
||||
cx.notify();
|
||||
});
|
||||
window.request_animation_frame();
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
.children(
|
||||
widget.map(|widget| div().absolute().top_2().right_2().child(widget)),
|
||||
),
|
||||
);
|
||||
div()
|
||||
.id(self.id)
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.overflow_hidden()
|
||||
.bg(colors.surface)
|
||||
.child(header)
|
||||
.child(body)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui::px;
|
||||
|
||||
use super::{page_at, step};
|
||||
|
||||
#[test]
|
||||
fn the_page_in_view_is_the_one_under_the_middle() {
|
||||
let bottoms = [px(100.0), px(220.0), px(340.0)];
|
||||
assert_eq!(page_at(&bottoms, px(50.0)), 0);
|
||||
assert_eq!(page_at(&bottoms, px(150.0)), 1);
|
||||
assert_eq!(page_at(&bottoms, px(500.0)), 2, "past the end is the last");
|
||||
assert_eq!(page_at(&[], px(50.0)), 0, "no layout yet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_steps_walk_the_scale_and_stop_at_its_ends() {
|
||||
assert_eq!(step(1.0, true), Some(1.25));
|
||||
assert_eq!(step(1.0, false), Some(0.75));
|
||||
assert_eq!(
|
||||
step(1.1, false),
|
||||
Some(1.0),
|
||||
"between steps goes to the nearer side"
|
||||
);
|
||||
assert_eq!(step(4.0, true), None);
|
||||
assert_eq!(step(0.25, false), None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user