1use ecow::EcoVec;
2use typst::WorldExt;
3use typst::foundations::AsOutput;
4use typst::introspection::{DocumentPosition, HtmlPosition, PagedPosition};
5use typst::layout::{Frame, FrameItem, Point, Size};
6use typst::model::{Destination, Url};
7use typst::syntax::{FileId, LinkedNode, Side, Source, Span, SyntaxKind};
8use typst::visualize::{Curve, CurveItem, FillRule, Geometry};
9use typst_html::{HtmlDocument, HtmlElement, HtmlNode, HtmlSliceExt};
10use typst_layout::PagedDocument;
11
12use crate::IdeWorld;
13
14#[derive(Debug, Clone, Eq, PartialEq)]
16pub enum Jump {
17 File(FileId, usize),
19 Url(Url),
21 Position(PagedPosition),
23}
24
25impl Jump {
26 fn from_span(world: &dyn IdeWorld, span: Span) -> Option<Self> {
27 let id = span.id()?;
28 let offset = world.range(span)?.start;
29 Some(Self::File(id, offset))
30 }
31}
32
33pub fn jump_from_click<D: JumpFromDocument>(
35 world: &dyn IdeWorld,
36 document: &D,
37 position: &D::Position,
38) -> Option<Jump> {
39 document.resolve_position(world, position)
40}
41
42pub trait JumpFromDocument: jump_from_document_sealed::JumpFromDocument {}
45
46impl JumpFromDocument for PagedDocument {}
48impl JumpFromDocument for HtmlDocument {}
49
50mod jump_from_document_sealed {
51 use typst::introspection::{HtmlPosition, InnerHtmlPosition, PagedPosition};
52 use typst::syntax::SyntaxKind;
53 use typst_html::{HtmlDocument, HtmlNode, HtmlSliceExt};
54 use typst_layout::PagedDocument;
55
56 use super::{Jump, jump_from_click_in_frame};
57 use crate::IdeWorld;
58
59 pub trait JumpFromDocument {
61 type Position;
62
63 fn resolve_position(
64 &self,
65 world: &dyn IdeWorld,
66 position: &Self::Position,
67 ) -> Option<Jump>;
68 }
69
70 impl JumpFromDocument for PagedDocument {
71 type Position = PagedPosition;
72
73 fn resolve_position(
74 &self,
75 world: &dyn IdeWorld,
76 position: &Self::Position,
77 ) -> Option<Jump> {
78 let page = self.pages().get(position.page.get() - 1)?;
79 let click = position.point;
80 jump_from_click_in_frame(world, self, &page.frame, click)
81 }
82 }
83
84 impl JumpFromDocument for HtmlDocument {
85 type Position = HtmlPosition;
86
87 fn resolve_position(
88 &self,
89 world: &dyn IdeWorld,
90 position: &Self::Position,
91 ) -> Option<Jump> {
92 let mut current_node = self.root_node();
93 let mut prefix_len = 0;
94
95 let indices_count = position.element().count();
96 for (i, index) in position.element().enumerate() {
97 let reached_leaf_node = i == indices_count - 1;
98 match current_node {
99 HtmlNode::Element(html_element) => {
100 let (child_index, (mut child, _)) = html_element
101 .children
102 .iter_with_dom_indices()
103 .enumerate()
104 .find(|&(_, (child, dom_index))| {
105 !matches!(child, HtmlNode::Tag(_)) && dom_index == index
106 })?;
107
108 if reached_leaf_node
129 && let HtmlNode::Text(_, _) = child
130 && let Some(InnerHtmlPosition::Character(offset)) =
131 position.details()
132 {
133 let mut text_char_count = 0;
134 let mut text_node_part = child;
135 let mut text_node_offset = 0;
136
137 while text_char_count < *offset {
140 prefix_len = text_char_count;
141
142 text_node_part = html_element
144 .children
145 .get(child_index + text_node_offset)?;
146
147 let text_node_part_len =
152 if let HtmlNode::Text(text, _) = text_node_part {
153 text.chars().count()
154 } else {
155 0
156 };
157
158 text_char_count += text_node_part_len;
162 text_node_offset += 1;
163 }
164
165 child = text_node_part
166 }
167
168 current_node = child;
169 }
170 HtmlNode::Tag(_) | HtmlNode::Text(_, _) | HtmlNode::Frame(_) => {
171 return None;
172 }
173 }
174 }
175
176 let span = current_node.span();
177 let id = span.id()?;
178 let source = world.source(id).ok()?;
179 let ast_node = source.find(span)?;
180 let is_text_node = ast_node.kind() == SyntaxKind::Text;
181
182 if let (HtmlNode::Frame(frame), Some(InnerHtmlPosition::Frame(point))) =
183 (current_node, &position.details())
184 {
185 return jump_from_click_in_frame(world, self, &frame.inner, *point);
186 }
187
188 let source_range = ast_node.range();
189 Some(Jump::File(
190 id,
191 source_range.start
192 + match (is_text_node, &position.details()) {
193 (true, Some(InnerHtmlPosition::Character(i))) => {
194 let source_text = &source.text()[source_range];
195 let slice: String = source_text
196 .chars()
197 .take(i.saturating_sub(prefix_len))
198 .collect();
199 slice.len()
200 }
201 _ => 0,
202 },
203 ))
204 }
205 }
206}
207
208pub fn jump_from_click_in_frame(
210 world: &dyn IdeWorld,
211 output: impl AsOutput,
212 frame: &Frame,
213 click: Point,
214) -> Option<Jump> {
215 let output = output.as_output();
216
217 for (pos, item) in frame.items() {
219 if let FrameItem::Link(dest, size) = item
220 && is_in_rect(*pos, *size, click)
221 {
222 match dest {
223 Destination::Url(url) => return Some(Jump::Url(url.clone())),
224 Destination::Position(pos) => return Some(Jump::Position(*pos)),
225 Destination::Location(loc) => {
226 if let Some(DocumentPosition::Paged(pos)) =
227 output.introspector().position(*loc)
228 {
229 return Some(Jump::Position(pos));
230 }
231 }
232 }
233 }
234 }
235
236 for &(mut pos, ref item) in frame.items().rev() {
238 match item {
239 FrameItem::Group(group) => {
240 let pos = click - pos;
241 if let Some(clip) = &group.clip
242 && !clip.contains(FillRule::NonZero, pos)
243 {
244 continue;
245 }
246 let Some(inv_transform) = group.transform.invert() else {
250 continue;
251 };
252 let pos = pos.transform_inf(inv_transform);
253 if let Some(span) =
254 jump_from_click_in_frame(world, output, &group.frame, pos)
255 {
256 return Some(span);
257 }
258 }
259
260 FrameItem::Text(text) => {
261 for glyph in &text.glyphs {
262 let width = glyph.x_advance.at(text.size);
263 if is_in_rect(
264 Point::new(pos.x, pos.y - text.size),
265 Size::new(width, text.size),
266 click,
267 ) {
268 let (span, span_offset) = glyph.span;
269 let Some(id) = span.id() else { continue };
270 let source = world.source(id).ok()?;
271 let node = source.find(span)?;
272 let pos = if matches!(
273 node.kind(),
274 SyntaxKind::Text | SyntaxKind::MathText
275 ) {
276 let range = node.range();
277 let mut offset = range.start + usize::from(span_offset);
278 if (click.x - pos.x) > width / 2.0 {
279 offset += glyph.range().len();
280 }
281 offset.min(range.end)
282 } else {
283 node.offset()
284 };
285 return Some(Jump::File(source.id(), pos));
286 }
287
288 pos.x += width;
289 }
290 }
291
292 FrameItem::Shape(shape, span) => {
293 if shape.fill.is_some() {
294 let within = match &shape.geometry {
295 Geometry::Line(..) => false,
296 Geometry::Rect(size) => is_in_rect(pos, *size, click),
297 Geometry::Curve(curve) => {
298 curve.contains(shape.fill_rule, click - pos)
299 }
300 };
301 if within {
302 return Jump::from_span(world, *span);
303 }
304 }
305
306 if let Some(stroke) = &shape.stroke {
307 let within = !stroke.thickness.approx_empty() && {
308 let base_curve = match &shape.geometry {
310 Geometry::Line(to) => &Curve(vec![CurveItem::Line(*to)]),
311 Geometry::Rect(size) => &Curve::rect(*size),
312 Geometry::Curve(curve) => curve,
313 };
314 base_curve.stroke_contains(stroke, click - pos)
315 };
316 if within {
317 return Jump::from_span(world, *span);
318 }
319 }
320 }
321
322 FrameItem::Image(_, size, span) if is_in_rect(pos, *size, click) => {
323 return Jump::from_span(world, *span);
324 }
325
326 _ => {}
327 }
328 }
329
330 None
331}
332
333fn is_in_rect(pos: Point, size: Size, click: Point) -> bool {
336 pos.x <= click.x
337 && pos.x + size.x >= click.x
338 && pos.y <= click.y
339 && pos.y + size.y >= click.y
340}
341
342pub fn jump_from_cursor<D: JumpInDocument>(
344 document: &D,
345 source: &Source,
346 cursor: usize,
347) -> Vec<D::Position> {
348 fn is_text(node: &LinkedNode) -> bool {
349 matches!(node.kind(), SyntaxKind::Text | SyntaxKind::MathText)
350 }
351
352 let root = LinkedNode::new(source.root());
353 let Some(node) = root
354 .leaf_at(cursor, Side::Before)
355 .filter(is_text)
356 .or_else(|| root.leaf_at(cursor, Side::After).filter(is_text))
357 else {
358 return vec![];
359 };
360
361 let span = node.span();
362 document.find_span(span)
363}
364
365pub trait JumpInDocument: jump_in_document_sealed::JumpInDocument {}
368
369impl JumpInDocument for PagedDocument {}
371impl JumpInDocument for HtmlDocument {}
372
373mod jump_in_document_sealed {
375 use std::num::NonZeroUsize;
376
377 use ecow::EcoVec;
378 use typst::introspection::{HtmlPosition, PagedPosition};
379 use typst::syntax::Span;
380 use typst_html::HtmlDocument;
381 use typst_layout::PagedDocument;
382
383 use super::{find_in_elem, find_in_frame};
384
385 pub trait JumpInDocument {
387 type Position;
388
389 fn find_span(&self, span: Span) -> Vec<Self::Position>;
390 }
391
392 impl JumpInDocument for PagedDocument {
393 type Position = PagedPosition;
394
395 fn find_span(&self, span: Span) -> Vec<Self::Position> {
396 self.pages()
397 .iter()
398 .enumerate()
399 .filter_map(|(i, page)| {
400 find_in_frame(&page.frame, span).map(|point| PagedPosition {
401 page: NonZeroUsize::new(i + 1).unwrap(),
402 point,
403 })
404 })
405 .collect()
406 }
407 }
408
409 impl JumpInDocument for HtmlDocument {
410 type Position = HtmlPosition;
411
412 fn find_span(&self, span: Span) -> Vec<Self::Position> {
413 find_in_elem(self.root(), span, &mut EcoVec::new())
414 }
415 }
416}
417
418fn find_in_frame(frame: &Frame, span: Span) -> Option<Point> {
420 for &(mut pos, ref item) in frame.items() {
421 if let FrameItem::Group(group) = item
422 && let Some(point) = find_in_frame(&group.frame, span)
423 {
424 return Some(pos + point.transform(group.transform));
425 }
426
427 if let FrameItem::Text(text) = item {
428 for glyph in &text.glyphs {
429 if glyph.span.0 == span {
430 return Some(pos);
431 }
432 pos.x += glyph.x_advance.at(text.size);
433 }
434 }
435 }
436
437 None
438}
439
440fn find_in_elem(
442 elem: &HtmlElement,
443 span: Span,
444 current_position: &mut EcoVec<usize>,
445) -> Vec<HtmlPosition> {
446 let mut result = Vec::new();
447
448 for (child, dom_index) in elem.children.iter_with_dom_indices() {
449 match child {
450 HtmlNode::Tag(_) => {}
451 HtmlNode::Element(e) => {
452 current_position.push(dom_index);
453 result.extend(find_in_elem(e, span, current_position));
454 current_position.pop();
455 }
456 HtmlNode::Text(_, node_span) => {
457 if *node_span == span {
458 return vec![HtmlPosition::new(current_position.clone())];
459 }
460 }
461 HtmlNode::Frame(frame) => {
462 if let Some(frame_pos) = find_in_frame(&frame.inner, span) {
463 let mut position = current_position.clone();
464 position.push(dom_index);
465 return vec![HtmlPosition::new(position).in_frame(frame_pos)];
466 }
467 }
468 }
469 }
470
471 result
472}
473
474#[cfg(test)]
475mod tests {
476 use std::borrow::Borrow;
486 use std::num::NonZeroUsize;
487
488 use ecow::eco_vec;
489 use typst::introspection::{HtmlPosition, PagedPosition};
490 use typst::layout::{Abs, Point};
491 use typst::utils::NonZeroExt;
492 use typst_html::HtmlDocument;
493 use typst_layout::PagedDocument;
494
495 use super::{Jump, jump_from_click, jump_from_cursor};
496 use crate::tests::{FilePos, TestWorld, WorldLike};
497
498 fn point(x: f64, y: f64) -> Point {
499 Point::new(Abs::pt(x), Abs::pt(y))
500 }
501
502 fn cursor(cursor: usize) -> Option<Jump> {
503 Some(Jump::File(TestWorld::main_id(), cursor))
504 }
505
506 fn pos(page: usize, x: f64, y: f64) -> Option<PagedPosition> {
507 Some(PagedPosition {
508 page: NonZeroUsize::new(page).unwrap(),
509 point: point(x, y),
510 })
511 }
512
513 macro_rules! assert_approx_eq {
514 ($l:expr, $r:expr) => {
515 assert!(($l - $r).abs() < Abs::pt(0.1), "{:?} ≉ {:?}", $l, $r);
516 };
517 }
518
519 #[track_caller]
520 fn assert_jump_eq(jump: Option<Jump>, expected: Option<Jump>) {
521 if let (Some(Jump::Position(pos)), Some(Jump::Position(expected))) =
522 (&jump, &expected)
523 {
524 assert_eq!(pos.page, expected.page);
525 assert_approx_eq!(pos.point.x, expected.point.x);
526 assert_approx_eq!(pos.point.y, expected.point.y);
527 } else {
528 assert_eq!(jump, expected);
529 }
530 }
531
532 #[track_caller]
533 fn test_click(world: impl WorldLike, click: Point, expected: Option<Jump>) {
534 let world = world.acquire();
535 let world = world.borrow();
536 let doc: PagedDocument = typst::compile(world).output.unwrap();
537 let jump = jump_from_click(
538 world,
539 &doc,
540 &PagedPosition { page: NonZeroUsize::ONE, point: click },
541 );
542 assert_jump_eq(jump, expected);
543 }
544
545 #[track_caller]
546 fn test_click_html(
547 world: impl WorldLike,
548 click: HtmlPosition,
549 expected: Option<Jump>,
550 ) {
551 let world = world.acquire();
552 let world = world.borrow();
553 let doc: HtmlDocument = typst::compile(world).output.unwrap();
554 let jump = jump_from_click(world, &doc, &click);
555 assert_jump_eq(jump, expected);
556 }
557
558 #[track_caller]
559 fn test_cursor(
560 world: impl WorldLike,
561 pos: impl FilePos,
562 expected: Option<PagedPosition>,
563 ) {
564 let world = world.acquire();
565 let world = world.borrow();
566 let doc: PagedDocument = typst::compile(world).output.unwrap();
567 let (source, cursor) = pos.resolve(world);
568 let pos = jump_from_cursor(&doc, &source, cursor);
569 assert_eq!(!pos.is_empty(), expected.is_some());
570 if let (Some(pos), Some(expected)) = (pos.first(), expected) {
571 assert_eq!(pos.page, expected.page);
572 assert_approx_eq!(pos.point.x, expected.point.x);
573 assert_approx_eq!(pos.point.y, expected.point.y);
574 }
575 }
576
577 #[test]
578 fn test_jump_from_click() {
579 let s = "*Hello* #box[ABC] World";
580 test_click(s, point(0.0, 0.0), None);
581 test_click(s, point(70.0, 5.0), None);
582 test_click(s, point(45.0, 15.0), cursor(14));
583 test_click(s, point(48.0, 15.0), cursor(15));
584 test_click(s, point(72.0, 10.0), cursor(20));
585 }
586
587 #[test]
588 fn test_jump_from_click_par_indents() {
589 let s = "#set par(first-line-indent: 1cm, hanging-indent: 1cm);Hello";
592 test_click(s, point(21.0, 12.0), cursor(56));
593 }
594
595 #[test]
596 fn test_jump_from_click_math() {
597 test_click("$a + b$", point(28.0, 14.0), cursor(5));
598 }
599
600 #[test]
601 fn test_jump_from_click_transform_clip() {
602 let margin = point(10.0, 10.0);
603 test_click(
604 "#rect(width: 20pt, height: 20pt, fill: black)",
605 point(10.0, 10.0) + margin,
606 cursor(1),
607 );
608 test_click(
609 "#rect(width: 60pt, height: 10pt, fill: black)",
610 point(5.0, 30.0) + margin,
611 None,
612 );
613 test_click(
614 "#rotate(90deg, origin: bottom + left, rect(width: 60pt, height: 10pt, fill: black))",
615 point(5.0, 30.0) + margin,
616 cursor(38),
617 );
618 test_click(
619 "#scale(x: 300%, y: 300%, origin: top + left, rect(width: 10pt, height: 10pt, fill: black))",
620 point(20.0, 20.0) + margin,
621 cursor(45),
622 );
623 test_click(
624 "#box(width: 10pt, height: 10pt, clip: true, scale(x: 300%, y: 300%, \
625 origin: top + left, rect(width: 10pt, height: 10pt, fill: black)))",
626 point(20.0, 20.0) + margin,
627 None,
628 );
629 test_click(
630 "#box(width: 10pt, height: 10pt, clip: false, rect(width: 30pt, height: 30pt, fill: black))",
631 point(20.0, 20.0) + margin,
632 cursor(45),
633 );
634 test_click(
635 "#box(width: 10pt, height: 10pt, clip: true, rect(width: 30pt, height: 30pt, fill: black))",
636 point(20.0, 20.0) + margin,
637 None,
638 );
639 test_click(
640 "#rotate(90deg, origin: bottom + left)[hello world]",
641 point(5.0, 15.0) + margin,
642 cursor(40),
643 );
644 }
645
646 #[test]
647 fn test_jump_from_click_shapes() {
648 let margin = point(10.0, 10.0);
649
650 test_click(
651 "#rect(width: 30pt, height: 30pt, fill: black)",
652 point(15.0, 15.0) + margin,
653 cursor(1),
654 );
655
656 let circle = "#circle(width: 30pt, height: 30pt, fill: black)";
657 test_click(circle, point(15.0, 15.0) + margin, cursor(1));
658 test_click(circle, point(1.0, 1.0) + margin, None);
659
660 let bowtie =
661 "#polygon(fill: black, (0pt, 0pt), (20pt, 20pt), (20pt, 0pt), (0pt, 20pt))";
662 test_click(bowtie, point(1.0, 2.0) + margin, cursor(1));
663 test_click(bowtie, point(2.0, 1.0) + margin, None);
664 test_click(bowtie, point(19.0, 10.0) + margin, cursor(1));
665
666 let evenodd = r#"#polygon(fill: black, fill-rule: "even-odd",
667 (0pt, 10pt), (30pt, 10pt), (30pt, 20pt), (20pt, 20pt),
668 (20pt, 0pt), (10pt, 0pt), (10pt, 30pt), (20pt, 30pt),
669 (20pt, 20pt), (0pt, 20pt))"#;
670 test_click(evenodd, point(15.0, 15.0) + margin, None);
671 test_click(evenodd, point(5.0, 15.0) + margin, cursor(1));
672 test_click(evenodd, point(15.0, 5.0) + margin, cursor(1));
673 }
674
675 #[test]
676 fn test_jump_from_click_shapes_stroke() {
677 let margin = point(10.0, 10.0);
678
679 let rect =
680 "#place(dx: 10pt, dy: 10pt, rect(width: 10pt, height: 10pt, stroke: 5pt))";
681 test_click(rect, point(15.0, 15.0) + margin, None);
682 test_click(rect, point(10.0, 15.0) + margin, cursor(27));
683
684 test_click(
685 "#line(angle: 45deg, length: 10pt, stroke: 2pt)",
686 point(2.0, 2.0) + margin,
687 cursor(1),
688 );
689 }
690
691 #[test]
692 fn test_jump_from_click_html() {
693 test_click_html(
694 "This is a test.\n\nWith multiple elements.\n\nAnd some *formatting*.",
695 HtmlPosition::new(eco_vec![1, 2, 0]).at_char(6),
697 cursor(48),
698 );
699 }
700
701 #[test]
702 fn test_jump_from_click_html_introspection() {
703 test_click_html(
704 "This is a test.\n\n```\nwith some code\n```\n\nAnd `some` *formatting*.",
707 HtmlPosition::new(eco_vec![1, 2, 1, 0]).at_char(2),
709 cursor(48),
710 );
711 }
712
713 #[test]
714 fn test_jump_from_click_html_frame() {
715 test_click_html(
716 "A math formula:\n\n#html.frame($a x + b = 0$)",
717 HtmlPosition::new(eco_vec![1, 1]).in_frame(point(27.0, 5.0)),
719 cursor(37),
720 );
721 }
722
723 #[test]
724 fn test_jump_from_click_html_bindings() {
725 let src = "#let a = [This]; #let b = [exists]; #a#b";
726 test_click_html(
727 src,
728 HtmlPosition::new(eco_vec![1, 0, 0]).at_char(8),
730 cursor(src.find("ts];").unwrap()),
731 );
732 }
733
734 #[test]
735 fn test_jump_from_click_html_figcaption() {
736 let src = "#figure([Hello, world!], caption: [Output of the program.])";
737 test_click_html(
738 src,
739 HtmlPosition::new(eco_vec![1, 0, 1, 0])
741 .at_char("Fig. 1 — Out".chars().count()),
742 cursor(src.find("tput of").unwrap()),
743 );
744 }
745
746 #[test]
750 fn test_jump_from_click_html_offset_units() {
751 test_click_html(
752 "Ça va ?",
753 HtmlPosition::new(eco_vec![1, 0]).at_char(1),
756 cursor(2),
757 );
758 }
759
760 #[test]
761 fn test_jump_from_cursor() {
762 let s = "*Hello* #box[ABC] World";
763 test_cursor(s, 12, None);
764 test_cursor(s, 14, pos(1, 37.55, 16.58));
765 }
766
767 #[test]
768 fn test_jump_from_cursor_math() {
769 test_cursor("$a + b$", -3, pos(1, 27.51, 16.83));
770 }
771
772 #[test]
773 fn test_jump_from_cursor_transform() {
774 test_cursor(
775 r#"#rotate(90deg, origin: bottom + left, [hello world])"#,
776 -5,
777 pos(1, 10.0, 16.58),
778 );
779 }
780
781 #[test]
782 fn test_footnote_links() {
783 let s = "#footnote[Hi]";
784 test_click(s, point(10.0, 10.0), pos(1, 10.0, 31.58).map(Jump::Position));
785 test_click(s, point(19.0, 33.0), pos(1, 10.0, 16.58).map(Jump::Position));
786 }
787
788 #[test]
789 fn test_footnote_link_entry_customized() {
790 let s = "#show footnote.entry: [Replaced]; #footnote[Hi]";
791 test_click(s, point(10.0, 10.0), pos(1, 10.0, 31.58).map(Jump::Position));
792 }
793
794 #[test]
795 fn test_text_show_rule_jump() {
796 test_click(
802 r#"#show "word": underline; This is \ my word."#,
803 point(33.0, 27.0),
806 cursor(36),
807 );
808 }
809}