@typbase/codemirror 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +190 -661
  2. package/dist/index.d.mts +135 -65
  3. package/dist/index.mjs +648 -387
  4. package/package.json +3 -3
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { acceptCompletion, autocompletion, closeBracketsKeymap, completionKeymap, snippet, startCompletion } from "@codemirror/autocomplete";
1
+ import { acceptCompletion, autocompletion, closeBracketsKeymap, closeCompletion, completionKeymap, snippet, startCompletion } from "@codemirror/autocomplete";
2
2
  import { indentService } from "@codemirror/language";
3
3
  import { Decoration, EditorView, ViewPlugin, WidgetType, hoverTooltip, keymap, showTooltip } from "@codemirror/view";
4
- import { ChangeSet, EditorSelection, EditorState, Facet, Prec, RangeSetBuilder, StateEffect, StateField, countColumn, findColumn } from "@codemirror/state";
4
+ import { ChangeSet, EditorSelection, EditorState, Facet, RangeSetBuilder, StateEffect, StateField, countColumn, findColumn } from "@codemirror/state";
5
5
  import { copyLineDown, copyLineUp, indentLess, indentMore, insertBlankLine, moveLineDown, moveLineUp, redo, redoSelection, simplifySelection, standardKeymap, toggleBlockComment, toggleLineComment, undo, undoSelection } from "@codemirror/commands";
6
6
  import { linter, nextDiagnostic, setDiagnostics } from "@codemirror/lint";
7
7
  import { closeSearchPanel, findNext, findPrevious, gotoLine, openSearchPanel, selectNextOccurrence, selectSelectionMatches } from "@codemirror/search";
@@ -20,15 +20,29 @@ const typstSyntaxHighlighting = (fileId, typstState) => StateField.define({
20
20
  }
21
21
  });
22
22
  function buildDecorations$2(state, fileId, typstState) {
23
- const builder = new RangeSetBuilder();
24
23
  let tokens;
24
+ let equations;
25
25
  try {
26
26
  tokens = typstState.highlight(fileId, state.doc.toString());
27
+ equations = typstState.extractEquations(fileId, state.doc.toString());
27
28
  } catch (error) {
28
29
  console.error("[typst] highlight panicked:", error);
29
30
  return Decoration.none;
30
31
  }
31
- for (const token of tokens) builder.add(token.range.start, token.range.end, Decoration.mark({ class: token.tag }));
32
+ const builder = new RangeSetBuilder();
33
+ const equationMark = Decoration.mark({ class: "typ-equation" });
34
+ let nextEquation = 0;
35
+ for (const token of tokens) {
36
+ while (nextEquation < equations.length && equations[nextEquation].from <= token.range.start) {
37
+ const equation = equations[nextEquation++];
38
+ builder.add(equation.from, equation.to, equationMark);
39
+ }
40
+ builder.add(token.range.start, token.range.end, Decoration.mark({ class: token.tag }));
41
+ }
42
+ for (; nextEquation < equations.length; nextEquation++) {
43
+ const equation = equations[nextEquation];
44
+ builder.add(equation.from, equation.to, equationMark);
45
+ }
32
46
  return builder.finish();
33
47
  }
34
48
  const parseBackticks = (str, into) => {
@@ -372,8 +386,19 @@ function cursorAddVertical(view, offset) {
372
386
  }
373
387
  //#endregion
374
388
  //#region src/keymap.ts
389
+ /**
390
+ * Whether the editor is running vim bindings. Vim handles keys from a DOM
391
+ * listener that CodeMirror's keymap runs before, so Escape has to be left
392
+ * unhandled or it never reaches the vim plugin. Set through the same
393
+ * compartment as the vim extension.
394
+ */
395
+ const vimKeymapFacet = Facet.define({ combine: (values) => values.some(Boolean) });
396
+ /**
397
+ * The app's shortcuts dialog mirrors the common entries here. When a chord
398
+ * changes, update `EDITOR_SHORTCUTS` in `apps/web/src/lib/keybindings.ts` too.
399
+ */
375
400
  const typstKeymap = keymap.of([
376
- ...completionKeymap,
401
+ ...completionKeymap.filter((binding) => binding.key !== "Escape"),
377
402
  ...closeBracketsKeymap,
378
403
  {
379
404
  key: "Tab",
@@ -488,7 +513,13 @@ const typstKeymap = keymap.of([
488
513
  },
489
514
  {
490
515
  key: "Escape",
491
- run: simplifySelection
516
+ run: (view) => {
517
+ if (view.state.facet(vimKeymapFacet)) {
518
+ closeCompletion(view);
519
+ return false;
520
+ }
521
+ return closeCompletion(view) || simplifySelection(view);
522
+ }
492
523
  },
493
524
  {
494
525
  key: "Mod-Alt-ArrowUp",
@@ -524,7 +555,7 @@ const typstKeymap = keymap.of([
524
555
  run: selectNextOccurrence,
525
556
  preventDefault: true
526
557
  },
527
- ...standardKeymap
558
+ ...standardKeymap.filter((binding) => binding.key !== "Escape")
528
559
  ]);
529
560
  //#endregion
530
561
  //#region src/language.ts
@@ -583,6 +614,25 @@ const typstLanguageData = EditorState.languageData.of(() => [{
583
614
  }]);
584
615
  //#endregion
585
616
  //#region src/notebook.ts
617
+ /** Every type, in the order the menu lists them. */
618
+ const NOTEBOOK_CELL_TYPES = [
619
+ "prose",
620
+ "code",
621
+ "log",
622
+ "hidden"
623
+ ];
624
+ /** True when the cell renders in place rather than showing source plus output. */
625
+ function rendersInPlace(kind) {
626
+ return kind === "prose";
627
+ }
628
+ /** True when the cell's source is not part of what the reader sees. */
629
+ function hidesSource(kind) {
630
+ return kind === "log" || kind === "hidden";
631
+ }
632
+ /** True when the cell has an output area below its source. */
633
+ function hasOutput(kind) {
634
+ return kind === "code" || kind === "log";
635
+ }
586
636
  /** The options of the notebook extension active in this state, if any. */
587
637
  const notebookOptionsFacet = Facet.define({ combine: (values) => values.at(-1) });
588
638
  function notebookOptions(state) {
@@ -590,13 +640,12 @@ function notebookOptions(state) {
590
640
  }
591
641
  /** Re-renders notebooks from cached frames without recompiling. */
592
642
  const notebookRefreshEffect = StateEffect.define();
593
- /** Asks the plugin to recompile and report the result through `onRun`. */
594
- const notebookRunEffect = StateEffect.define();
595
- /** First position that belongs to the cell (marker line, or content). */
643
+ /** First position that belongs to the cell (attribute line, or content). */
596
644
  function cellStart(cell) {
597
645
  return cell.marker_end > cell.marker_start ? cell.marker_start : cell.content_start;
598
646
  }
599
- function hasMarker(cell) {
647
+ /** True when the cell opened with an attribute line rather than a heading. */
648
+ function hasAttribute(cell) {
600
649
  return cell.marker_end > cell.marker_start;
601
650
  }
602
651
  /** Index of the cell containing `pos`. Gaps belong to the cell above. */
@@ -615,128 +664,137 @@ function blockEnd(cells, index, text) {
615
664
  const next = cells[index + 1];
616
665
  return next ? cellStart(next) : text.length;
617
666
  }
618
- /** The marker line for a cell type. Markup is the unlabeled default. */
619
- function markerText(type) {
620
- return type === "code" ? "// %% [code]" : "// %%";
667
+ /**
668
+ * The attribute line for a cell type. Prose needs no `kind=`, so a plain cell is
669
+ * three characters.
670
+ */
671
+ function attributeText(type, name) {
672
+ const fields = [name ? `name=${name}` : "", type === "prose" ? "" : `kind=${type}`].filter(Boolean);
673
+ return fields.length ? `//% ${fields.join(" ")}` : "//%";
621
674
  }
622
- function insertCellText(text, cells, index, where, type, content = "") {
623
- const marker = markerText(type);
624
- if (where === "end" || cells.length === 0) {
625
- const from = text.length;
626
- const insert = `${from === 0 || text.endsWith("\n\n") ? "" : text.endsWith("\n") ? "\n" : "\n\n"}${marker}\n${content ? `${content}\n` : ""}`;
627
- return {
628
- changes: {
629
- from,
630
- insert
631
- },
632
- anchor: from + insert.length
633
- };
634
- }
675
+ /**
676
+ * Matches a whole attribute line, indentation and newline included. The rest of
677
+ * the line is taken whole rather than field by field, because this is a text
678
+ * cleaner for exports and the engine already decided what is an attribute.
679
+ */
680
+ const ATTRIBUTE_LINE = /^[ \t]*\/\/%[^\r\n]*\r?\n?/gm;
681
+ /**
682
+ * Removes cell attribute lines. Notebooks are comments-plus-content, so a
683
+ * cleaned file is the same document for anyone who does not know the
684
+ * convention. Only `//%` goes: any other comment is left as written.
685
+ */
686
+ function stripCellAttributes(text) {
687
+ return text.replace(ATTRIBUTE_LINE, "");
688
+ }
689
+ /**
690
+ * Splits a cell at `pos` inside its content. The new cell takes the text from
691
+ * `pos` and inherits the old cell's kind, because a split is a continuation.
692
+ */
693
+ function splitCellText(text, cells, index, pos) {
635
694
  const cell = cells[index];
636
- const from = where === "above" ? cellStart(cell) : blockEnd(cells, index, text);
695
+ if (!cell || pos < cell.content_start || pos > cell.content_end) return null;
696
+ const trimmed = text.slice(pos, cell.content_end).replace(/^\n+/, "");
697
+ const insert = `\n\n${attributeText(cell.kind, cell.name)}\n\n${trimmed}`;
637
698
  return {
638
699
  changes: {
639
- from,
640
- insert: `${marker}\n${content ? `${content}\n` : ""}\n`
700
+ from: pos,
701
+ to: cell.content_end,
702
+ insert
641
703
  },
642
- anchor: from + marker.length + 1 + content.length
704
+ anchor: pos + 2 + attributeText(cell.kind, cell.name).length + 2
643
705
  };
644
706
  }
645
- function duplicateCellText(text, cells, index) {
646
- const cell = cells[index];
647
- return insertCellText(text, cells, index, "below", cell.kind, text.slice(cell.content_start, cell.content_end));
648
- }
649
- function deleteCellText(text, cells, index) {
707
+ /**
708
+ * Merges a cell into the one above. The result takes the lower cell's kind,
709
+ * since that is the one the reader was last looking at.
710
+ */
711
+ function mergeCellText(cells, index) {
650
712
  const cell = cells[index];
651
- if (cells.length <= 1) return {
652
- changes: {
653
- from: 0,
654
- to: text.length,
655
- insert: ""
656
- },
657
- anchor: 0
658
- };
713
+ const above = cells[index - 1];
714
+ if (!cell || !above) return null;
659
715
  const from = cellStart(cell);
716
+ const head = above.content_end > above.content_start ? above.content_end : from;
717
+ const gap = text_between_gap(head, cell.content_start);
660
718
  return {
661
719
  changes: {
662
- from,
663
- to: blockEnd(cells, index, text),
664
- insert: ""
720
+ from: head,
721
+ to: cell.content_start,
722
+ insert: gap
665
723
  },
666
- anchor: from
724
+ anchor: head
667
725
  };
668
726
  }
669
- function moveCellText(text, cells, index, delta) {
670
- const target = index + delta;
671
- if (target < 0 || target >= cells.length) return null;
672
- const aStart = cellStart(cells[index]);
673
- const aEnd = blockEnd(cells, index, text);
674
- const bStart = cellStart(cells[target]);
675
- const bEnd = blockEnd(cells, target, text);
676
- const from = Math.min(aStart, bStart);
677
- const to = Math.max(aEnd, bEnd);
678
- const a = text.slice(aStart, aEnd).trimEnd();
679
- const b = text.slice(bStart, bEnd).trimEnd();
680
- return {
727
+ /** The whitespace between two offsets, which a merge keeps as a separator. */
728
+ function text_between_gap(from, to) {
729
+ return from < to ? "\n\n" : "";
730
+ }
731
+ /**
732
+ * Rewrites a cell's attribute line. A cell that began at a heading has no line
733
+ * to rewrite, so one is inserted.
734
+ */
735
+ function setCellTypeText(text, cells, index, type) {
736
+ const cell = cells[index];
737
+ if (!cell) return null;
738
+ if (cell.kind === type) return null;
739
+ const attribute = attributeText(type, cell.name);
740
+ if (hasAttribute(cell)) return {
681
741
  changes: {
682
- from,
683
- to,
684
- insert: `${delta < 0 ? `${a}\n\n${b}` : `${b}\n\n${a}`}${to < text.length ? "\n\n" : "\n"}`
742
+ from: cell.marker_start,
743
+ to: cell.marker_end,
744
+ insert: attribute
685
745
  },
686
- anchor: delta < 0 ? bStart : bStart + b.length + 2
746
+ anchor: cell.content_start + (attribute.length - (cell.marker_end - cell.marker_start))
687
747
  };
688
- }
689
- function splitCellText(text, cells, index, pos) {
690
- const cell = cells[index];
691
- if (pos < cell.content_start || pos > cell.content_end) return null;
692
748
  return {
693
749
  changes: {
694
- from: pos,
695
- insert: `\n\n${markerText(cell.kind)}\n\n`
750
+ from: cellStart(cell),
751
+ insert: `${attribute}\n`
696
752
  },
697
- anchor: pos
753
+ anchor: cell.content_start + attribute.length + 1
698
754
  };
699
755
  }
700
- function mergeCellText(text, cells, index) {
701
- const cell = cells[index];
702
- if (index === 0 || !hasMarker(cell)) return null;
756
+ /** Appends a cell of `type` after `index` (or at the end of the document). */
757
+ function insertCellText(text, cells, index, type, content = "") {
758
+ const attribute = attributeText(type);
759
+ const at = blockEnd(cells, index, text);
760
+ const prefix = text.slice(Math.max(0, at - 2), at).includes("\n\n") || at === 0 ? "" : "\n\n";
703
761
  return {
704
762
  changes: {
705
- from: cell.marker_start,
706
- to: cell.content_start,
707
- insert: ""
763
+ from: at,
764
+ insert: `${prefix}${attribute}\n${content}\n\n`
708
765
  },
709
- anchor: cell.marker_start
766
+ anchor: at + prefix.length + attribute.length + 1 + content.length
710
767
  };
711
768
  }
712
- function setCellTypeText(text, cells, index, type) {
769
+ function deleteCellText(cells, index, docLength) {
713
770
  const cell = cells[index];
714
- const marker = markerText(type);
715
- if (hasMarker(cell)) return {
771
+ if (!cell) return null;
772
+ const next = cells[index + 1];
773
+ const nextStart = next ? cellStart(next) : docLength;
774
+ const from = hasAttribute(cell) ? cell.marker_start : cell.content_start;
775
+ if (!cells.length || index === 0 && !next && from === 0) return {
716
776
  changes: {
717
- from: cell.marker_start,
718
- to: cell.marker_end,
719
- insert: marker
777
+ from: 0,
778
+ to: docLength,
779
+ insert: ""
720
780
  },
721
- anchor: cell.content_start + (marker.length - (cell.marker_end - cell.marker_start))
781
+ anchor: 0
722
782
  };
723
783
  return {
724
784
  changes: {
725
- from: cellStart(cell),
726
- insert: `${marker}\n`
785
+ from,
786
+ to: nextStart,
787
+ insert: ""
727
788
  },
728
- anchor: cell.content_start + marker.length + 1
789
+ anchor: from
729
790
  };
730
791
  }
731
- /** Matches a whole cell marker line, indentation and newline included. */
732
- const MARKER_LINE = /^[ \t]*\/\/[ \t]*%%(?:[ \t]*\[(?:code|markup|text)\])?[ \t]*\r?\n?/gm;
733
- /**
734
- * Removes `// %%` cell markers. Notebooks are comments-plus-content, so a
735
- * cleaned file is the same document for anyone who does not know the
736
- * convention.
737
- */
738
- function stripCellMarkers(text) {
739
- return text.replace(MARKER_LINE, "");
792
+ /** Copies a cell's kind, name, and body below it. */
793
+ function duplicateCellText(text, cells, index) {
794
+ const cell = cells[index];
795
+ if (!cell) return null;
796
+ const body = text.slice(cell.content_start, cell.content_end);
797
+ return insertCellText(text, cells, index, cell.kind, body);
740
798
  }
741
799
  function dispatchEdit(view, edit) {
742
800
  view.dispatch({
@@ -746,22 +804,13 @@ function dispatchEdit(view, edit) {
746
804
  });
747
805
  return true;
748
806
  }
749
- /** Compiles and reports through `onRun`, but does not move the cursor. */
750
- function runCell(view, index) {
751
- if (!notebookOptions(view.state) || index < 0) return false;
752
- view.dispatch({ effects: notebookRunEffect.of({ index }) });
753
- return true;
754
- }
755
- function runAllCells(view) {
756
- if (!notebookOptions(view.state)) return false;
757
- view.dispatch({ effects: notebookRunEffect.of({ index: "all" }) });
758
- return true;
759
- }
807
+ /** Selects a cell's content so the keyboard commands act on it. */
760
808
  function focusCell(view, index, edge = "start") {
761
809
  const cell = cellsOf(view.state)[index];
762
810
  if (!cell) return false;
811
+ const pos = edge === "start" ? cell.content_start : cell.content_end;
763
812
  view.dispatch({
764
- selection: { anchor: edge === "start" ? cell.content_start : cell.content_end },
813
+ selection: { anchor: pos },
765
814
  scrollIntoView: true
766
815
  });
767
816
  view.focus();
@@ -770,148 +819,105 @@ function focusCell(view, index, edge = "start") {
770
819
  function currentCell(view) {
771
820
  return cellIndexAt(cellsOf(view.state), view.state.selection.main.head);
772
821
  }
773
- function insertCell(view, where) {
774
- return insertCellAt(view, currentCell(view), where);
775
- }
776
- function insertCellAt(view, index, where) {
822
+ /** Inserts a cell below `index`, or at the end of the document. */
823
+ function insertCell(view, type, content = "", index = -1) {
777
824
  const cells = cellsOf(view.state);
778
- if (index < 0 || !cells[index]) return false;
779
- const type = cells[index]?.kind ?? "markup";
780
- return dispatchEdit(view, insertCellText(view.state.doc.toString(), cells, index, where, type));
781
- }
782
- function duplicateCell(view) {
783
- return duplicateCellAt(view, currentCell(view));
825
+ const edit = insertCellText(view.state.doc.toString(), cells, index >= 0 ? index : cells.length ? currentCell(view) : -1, type, content);
826
+ return edit ? dispatchEdit(view, edit) : false;
784
827
  }
785
- function duplicateCellAt(view, index) {
828
+ function duplicateCell(view, index = -1) {
786
829
  const cells = cellsOf(view.state);
787
- if (index < 0 || !cells[index]) return false;
788
- return dispatchEdit(view, duplicateCellText(view.state.doc.toString(), cells, index));
830
+ const at = index >= 0 ? index : currentCell(view);
831
+ const edit = duplicateCellText(view.state.doc.toString(), cells, at);
832
+ return edit ? dispatchEdit(view, edit) : false;
789
833
  }
790
- function deleteCell(view) {
791
- return deleteCellAt(view, currentCell(view));
792
- }
793
- function deleteCellAt(view, index) {
794
- const cells = cellsOf(view.state);
795
- if (index < 0 || !cells[index]) return false;
796
- return dispatchEdit(view, deleteCellText(view.state.doc.toString(), cells, index));
834
+ function deleteCell(view, index = -1) {
835
+ const edit = deleteCellText(cellsOf(view.state), index >= 0 ? index : currentCell(view), view.state.doc.length);
836
+ return edit ? dispatchEdit(view, edit) : false;
797
837
  }
838
+ /** Moves the active cell up or down. */
798
839
  function moveCell(view, delta) {
799
- return moveCellAt(view, currentCell(view), delta);
800
- }
801
- function moveCellAt(view, index, delta) {
802
- const cells = cellsOf(view.state);
803
- if (index < 0 || !cells[index]) return false;
804
- const edit = moveCellText(view.state.doc.toString(), cells, index, delta);
805
- if (!edit) return false;
806
- return dispatchEdit(view, edit);
807
- }
808
- function splitCell(view) {
809
840
  const cells = cellsOf(view.state);
810
841
  const index = currentCell(view);
811
- if (index < 0) return false;
812
- const edit = splitCellText(view.state.doc.toString(), cells, index, view.state.selection.main.head);
813
- if (!edit) return false;
814
- return dispatchEdit(view, edit);
815
- }
816
- function mergeCell(view) {
817
- const cells = cellsOf(view.state);
818
- const index = currentCell(view);
819
- if (index < 0) return false;
820
- const edit = mergeCellText(view.state.doc.toString(), cells, index);
821
- if (!edit) return false;
822
- return dispatchEdit(view, edit);
823
- }
824
- function toggleCellType(view, index) {
825
- const cells = cellsOf(view.state);
826
- const cell = cells[index];
827
- if (!cell) return false;
828
- const next = cell.kind === "code" ? "markup" : "code";
829
- return dispatchEdit(view, setCellTypeText(view.state.doc.toString(), cells, index, next));
842
+ const next = index + delta;
843
+ if (next < 0 || next >= cells.length || index < 0) return false;
844
+ const text = view.state.doc.toString();
845
+ const first = cells[index];
846
+ const second = cells[next];
847
+ const from = cellStart(first);
848
+ const to = cellStart(second);
849
+ const firstEnd = blockEnd(cells, index, text);
850
+ const secondEnd = blockEnd(cells, next, text);
851
+ view.dispatch({
852
+ changes: [{
853
+ from,
854
+ to: firstEnd,
855
+ insert: text.slice(to, secondEnd)
856
+ }, {
857
+ from: to,
858
+ to: secondEnd,
859
+ insert: text.slice(from, firstEnd)
860
+ }],
861
+ selection: { anchor: from },
862
+ scrollIntoView: true
863
+ });
864
+ return true;
830
865
  }
831
866
  function setCellType(view, index, type) {
832
867
  const cells = cellsOf(view.state);
833
- if (!cells[index]) return false;
834
- return dispatchEdit(view, setCellTypeText(view.state.doc.toString(), cells, index, type));
868
+ const edit = setCellTypeText(view.state.doc.toString(), cells, index, type);
869
+ return edit ? dispatchEdit(view, edit) : false;
835
870
  }
836
- function runAndAdvance(view) {
837
- const cells = cellsOf(view.state);
838
- const index = currentCell(view);
839
- if (index < 0) return false;
840
- runCell(view, index);
841
- if (cells[index + 1]) focusCell(view, index + 1);
842
- else dispatchEdit(view, insertCellText(view.state.doc.toString(), cells, index, "below", cells[index]?.kind ?? "markup"));
843
- return true;
844
- }
845
- function runAndInsert(view) {
871
+ function splitCell(view) {
846
872
  const cells = cellsOf(view.state);
847
873
  const index = currentCell(view);
848
- if (index < 0) return false;
849
- runCell(view, index);
850
- return dispatchEdit(view, insertCellText(view.state.doc.toString(), cells, index, "below", cells[index]?.kind ?? "markup"));
851
- }
852
- function runCurrent(view) {
853
- const index = currentCell(view);
854
- return index < 0 ? false : runCell(view, index);
874
+ const edit = splitCellText(view.state.doc.toString(), cells, index, view.state.selection.main.from);
875
+ return edit ? dispatchEdit(view, edit) : false;
855
876
  }
856
- /** Arrow keys at a cell's first or last line move between cells. */
857
- function boundaryMove(view, direction) {
858
- const { state } = view;
859
- const range = state.selection.main;
860
- if (!range.empty) return false;
861
- const cells = cellsOf(state);
862
- const index = cellIndexAt(cells, range.head);
863
- const cell = cells[index];
864
- if (!cell) return false;
865
- if (direction === -1) {
866
- const first = state.doc.lineAt(cell.content_start);
867
- if (range.head < first.from || range.head > first.to || index === 0) return false;
868
- return focusCell(view, index - 1, "end");
869
- }
870
- const last = state.doc.lineAt(cell.content_end);
871
- if (range.head < last.from || range.head > last.to || index >= cells.length - 1) return false;
872
- return focusCell(view, index + 1);
877
+ function mergeCell(view) {
878
+ const edit = mergeCellText(cellsOf(view.state), currentCell(view));
879
+ return edit ? dispatchEdit(view, edit) : false;
873
880
  }
874
881
  /**
875
- * Notebook keymap. Mounted with high precedence so Alt-ArrowUp/Down move
876
- * cells instead of lines and Escape reaches command mode.
882
+ * The editor's cell commands. Everything here is also in the cell menu, and
883
+ * every one of them is a text edit, so the undo stack covers them all.
877
884
  */
878
- const notebookKeymap = Prec.high(keymap.of([
879
- {
880
- key: "Shift-Enter",
881
- run: runAndAdvance
882
- },
885
+ const notebookKeymap = keymap.of([
883
886
  {
884
887
  key: "Mod-Enter",
885
- run: runCurrent
888
+ preventDefault: true,
889
+ run: (view) => focusCell(view, currentCell(view), "end")
886
890
  },
887
891
  {
888
- key: "Alt-Enter",
889
- run: runAndInsert
892
+ key: "Mod-Shift-Enter",
893
+ preventDefault: true,
894
+ run: (view) => splitCell(view)
890
895
  },
891
896
  {
892
- key: "Alt-ArrowUp",
897
+ key: "Mod-Shift-ArrowUp",
898
+ preventDefault: true,
893
899
  run: (view) => moveCell(view, -1)
894
900
  },
895
901
  {
896
- key: "Alt-ArrowDown",
902
+ key: "Mod-Shift-ArrowDown",
903
+ preventDefault: true,
897
904
  run: (view) => moveCell(view, 1)
898
- },
899
- {
900
- key: "Escape",
901
- run: (view) => {
902
- notebookOptions(view.state)?.onCommandMode?.();
903
- return true;
904
- }
905
- },
906
- {
907
- key: "ArrowUp",
908
- run: (view) => boundaryMove(view, -1)
909
- },
910
- {
911
- key: "ArrowDown",
912
- run: (view) => boundaryMove(view, 1)
913
905
  }
914
- ]));
906
+ ]);
907
+ //#endregion
908
+ //#region src/sync.ts
909
+ /**
910
+ * Keeps the engine's sources in step with the editor, without rendering.
911
+ */
912
+ const typstSourceSync = (fileId, prelude, typstState) => EditorState.transactionExtender.of((transaction) => {
913
+ if (!transaction.docChanged) return null;
914
+ try {
915
+ typstState.syncSource(fileId, transaction.newDoc.toString(), prelude.value);
916
+ } catch (error) {
917
+ console.error("[typst] source sync panicked:", error);
918
+ }
919
+ return null;
920
+ });
915
921
  //#endregion
916
922
  //#region src/diagnostics.ts
917
923
  /**
@@ -919,7 +925,7 @@ const notebookKeymap = Prec.high(keymap.of([
919
925
  * diagnostics of every compile here, so the linter source can reuse them
920
926
  * instead of compiling the same text a second time.
921
927
  */
922
- const diagnosticsCache = /* @__PURE__ */ new Map();
928
+ const diagnosticsCache = new LRUCache({ max: 2 });
923
929
  function rememberDiagnostics(path, text, diagnostics) {
924
930
  diagnosticsCache.set(path, {
925
931
  text,
@@ -958,11 +964,6 @@ function toLintDiagnostics(typstDiagnostics) {
958
964
  }
959
965
  /**
960
966
  * Typst diagnostics as a CodeMirror linter source.
961
- *
962
- * This runs in every editor mode and combines with other sources (the
963
- * spellcheck linter), so neither one replaces the other. The WYSIWYG plugin's
964
- * compile feeds the cache. Split and source mode fall back to a compile here
965
- * so they get diagnostics too.
966
967
  */
967
968
  const typstLinter = (fileId, spaceId, path, prelude, typstState, options = {}) => linter(async (view) => {
968
969
  const text = view.state.doc.toString();
@@ -970,14 +971,14 @@ const typstLinter = (fileId, spaceId, path, prelude, typstState, options = {}) =
970
971
  if (cached) return toLintDiagnostics(cached);
971
972
  let result;
972
973
  try {
973
- result = typstState.compilePaged(fileId, text, prelude.value);
974
+ result = typstState.diagnosePaged(fileId, text, prelude.value);
974
975
  } catch (error) {
975
976
  console.error("[typst] diagnostics compile panicked:", error);
976
977
  options.onPanic?.(fileId);
977
978
  return [];
978
979
  }
979
980
  if (result.requests.length > 0 && options.onRequests) {
980
- if (await options.onRequests(result.requests, spaceId)) result = typstState.compilePaged(fileId, text, prelude.value);
981
+ if (await options.onRequests(result.requests, spaceId)) result = typstState.diagnosePaged(fileId, text, prelude.value);
981
982
  }
982
983
  rememberDiagnostics(path, text, result.diagnostics);
983
984
  options.onCompile?.();
@@ -986,6 +987,26 @@ const typstLinter = (fileId, spaceId, path, prelude, typstState, options = {}) =
986
987
  //#endregion
987
988
  //#region src/frames.ts
988
989
  const containerCache = new LRUCache({ max: 128 });
990
+ const SVG_NS = "http://www.w3.org/2000/svg";
991
+ let sharedDefs;
992
+ /**
993
+ * Places the glyph and gradient definitions a frame set references.
994
+ */
995
+ function installSharedDefs(defs) {
996
+ if (!defs) return;
997
+ if (!sharedDefs || !sharedDefs.isConnected) {
998
+ const host = document.createElementNS(SVG_NS, "svg");
999
+ host.setAttribute("width", "0");
1000
+ host.setAttribute("height", "0");
1001
+ host.setAttribute("aria-hidden", "true");
1002
+ host.style.position = "absolute";
1003
+ sharedDefs = document.createElementNS(SVG_NS, "defs");
1004
+ host.append(sharedDefs);
1005
+ document.body.append(host);
1006
+ }
1007
+ sharedDefs.innerHTML = "";
1008
+ sharedDefs.innerHTML = defs;
1009
+ }
989
1010
  const openInNewTab = (url) => {
990
1011
  window.open(url, "_blank", "noopener,noreferrer");
991
1012
  };
@@ -1147,46 +1168,46 @@ var TypstWidget = class extends WidgetType {
1147
1168
  };
1148
1169
  //#endregion
1149
1170
  //#region src/notebook-widgets.ts
1150
- function iconSpan(name) {
1171
+ function icon(name, size) {
1151
1172
  const span = document.createElement("span");
1152
1173
  span.className = "ms-icon material-symbols-rounded";
1153
- span.textContent = name;
1154
1174
  span.setAttribute("aria-hidden", "true");
1175
+ if (size !== void 0) span.style.fontSize = `calc(${size}px * var(--ui-size, 1))`;
1176
+ span.textContent = name;
1155
1177
  return span;
1156
1178
  }
1157
- /** Fallback labels for consumers that enable cells without i18n strings. */
1158
- const FALLBACK_LABELS = {
1159
- run: "Run cell",
1160
- code: "Code",
1161
- markup: "Text",
1162
- moveUp: "Move cell up",
1163
- moveDown: "Move cell down",
1164
- duplicate: "Duplicate cell",
1165
- remove: "Delete cell",
1166
- clearOutput: "Clear output",
1167
- toggleSource: "Collapse cell",
1168
- noOutput: "No output",
1169
- error: "Cell error"
1170
- };
1171
- /** Buttons must not hand the mousedown to CodeMirror's selection handling. */
1172
- function actionButton(icon, label, onClick, className = "") {
1173
- const button = document.createElement("button");
1174
- button.type = "button";
1175
- button.className = `tb-cell-btn ${className}`.trim();
1176
- button.title = label;
1177
- button.setAttribute("aria-label", label);
1178
- button.append(iconSpan(icon));
1179
- button.addEventListener("mousedown", (event) => event.preventDefault());
1180
- button.addEventListener("click", (event) => {
1179
+ /** A button that must not hand the mousedown to CodeMirror's selection. */
1180
+ function button(name, label, onClick, className = "") {
1181
+ const element = document.createElement("button");
1182
+ element.type = "button";
1183
+ element.className = `tb-cell-btn${className ? ` ${className}` : ""}`;
1184
+ element.title = label;
1185
+ element.setAttribute("aria-label", label);
1186
+ element.append(icon(name, RAIL_ICON_SIZE));
1187
+ element.addEventListener("mousedown", (event) => event.preventDefault());
1188
+ element.addEventListener("click", (event) => {
1181
1189
  event.preventDefault();
1182
1190
  event.stopPropagation();
1183
- onClick(event);
1191
+ onClick();
1184
1192
  });
1185
- return button;
1193
+ return element;
1186
1194
  }
1187
- var NotebookHeaderWidget = class extends WidgetType {
1195
+ /**
1196
+ * Icon size across the rail: the chip glyph, its overflow button, and the menu's
1197
+ * items, which is the same row and `UiMenuItem`'s default.
1198
+ */
1199
+ const RAIL_ICON_SIZE = 20;
1200
+ /** The glyph and label for a cell kind. */
1201
+ const KIND_PRESENTATION = {
1202
+ prose: "notes",
1203
+ code: "code",
1204
+ log: "terminal",
1205
+ hidden: "visibility_off"
1206
+ };
1207
+ var NotebookRailWidget = class extends WidgetType {
1188
1208
  view;
1189
1209
  args;
1210
+ menu = null;
1190
1211
  constructor(view, args) {
1191
1212
  super();
1192
1213
  this.view = view;
@@ -1195,48 +1216,139 @@ var NotebookHeaderWidget = class extends WidgetType {
1195
1216
  eq(other) {
1196
1217
  const a = this.args;
1197
1218
  const b = other.args;
1198
- return a.index === b.index && a.kind === b.kind && a.counter === b.counter && a.count === b.count && a.collapsed === b.collapsed && a.labels === b.labels;
1219
+ return a.index === b.index && a.kind === b.kind && a.name === b.name && a.collapsed === b.collapsed && a.held === b.held && a.labels === b.labels;
1220
+ }
1221
+ ignoreEvent(event) {
1222
+ return event.type === "mousedown" && event.target.classList.contains("tb-cell-rail");
1199
1223
  }
1200
1224
  toDOM() {
1201
- const { index, kind, labels } = this.args;
1202
- const header = document.createElement("div");
1203
- header.className = "tb-cell-header";
1204
- header.dataset.kind = kind;
1205
- header.dataset.cell = String(index);
1206
- const run = actionButton("play_arrow", labels.run, () => runCell(this.view, index));
1207
- run.classList.add("tb-cell-btn--run");
1208
- header.append(run);
1209
- if (this.args.counter) {
1210
- const chip = document.createElement("span");
1211
- chip.className = "tb-cell-counter";
1212
- chip.textContent = this.args.count === void 0 ? "[ ]" : `[${this.args.count}]`;
1213
- header.append(chip);
1225
+ const { index, kind, name, labels } = this.args;
1226
+ const rail = document.createElement("div");
1227
+ rail.className = "tb-cell-rail";
1228
+ rail.dataset.kind = kind;
1229
+ rail.dataset.cell = String(index);
1230
+ const chip = document.createElement("span");
1231
+ chip.className = "tb-cell-chip";
1232
+ chip.append(icon(KIND_PRESENTATION[kind], RAIL_ICON_SIZE));
1233
+ chip.append(document.createTextNode(name || labels[kind]));
1234
+ rail.append(chip);
1235
+ if (this.args.held) {
1236
+ const held = document.createElement("span");
1237
+ held.className = "tb-cell-held";
1238
+ held.textContent = labels.held;
1239
+ rail.append(held);
1214
1240
  }
1215
- header.append(actionButton(kind === "code" ? "code" : "notes", kind === "code" ? labels.code : labels.markup, () => toggleCellType(this.view, index), "tb-cell-btn--type"));
1216
- const spacer = document.createElement("span");
1217
- spacer.className = "tb-cell-spacer";
1218
- header.append(spacer);
1219
- const actions = document.createElement("span");
1220
- actions.className = "tb-cell-actions";
1221
- actions.append(actionButton("arrow_upward", labels.moveUp, () => moveCellAt(this.view, index, -1)), actionButton("arrow_downward", labels.moveDown, () => moveCellAt(this.view, index, 1)), actionButton("content_copy", labels.duplicate, () => duplicateCellAt(this.view, index)), actionButton(this.args.collapsed ? "unfold_more" : "unfold_less", labels.toggleSource, () => {
1222
- notebookOptions(this.view.state)?.onToggleCollapse?.(index);
1223
- this.view.dispatch({ effects: notebookRefreshEffect.of(null) });
1224
- }), actionButton("delete_sweep", labels.clearOutput, () => {
1225
- notebookOptions(this.view.state)?.onClearOutput?.(index);
1226
- this.view.dispatch({ effects: notebookRefreshEffect.of(null) });
1227
- }), actionButton("delete", labels.remove, () => deleteCellAt(this.view, index)));
1228
- header.append(actions);
1229
- header.addEventListener("mousedown", (event) => {
1230
- if (event.target?.closest("button")) return;
1241
+ const menu = button("more_vert", labels.menu, () => this.toggleMenu(rail), "tb-cell-btn--menu");
1242
+ rail.append(menu);
1243
+ rail.addEventListener("mousedown", (event) => {
1244
+ if (event.target.closest("button")) return;
1231
1245
  event.preventDefault();
1232
1246
  focusCell(this.view, index);
1233
1247
  });
1234
- return header;
1248
+ return rail;
1235
1249
  }
1236
- get estimatedHeight() {
1237
- return 24;
1250
+ toggleMenu(anchor) {
1251
+ if (this.menu) {
1252
+ this.closeMenu();
1253
+ return;
1254
+ }
1255
+ const { labels, index, collapsed, held } = this.args;
1256
+ const items = [
1257
+ {
1258
+ command: "toggleSource",
1259
+ label: labels.toggleSource,
1260
+ name: collapsed ? "unfold_more" : "unfold_less"
1261
+ },
1262
+ {
1263
+ command: "split",
1264
+ label: labels.split,
1265
+ name: "call_split"
1266
+ },
1267
+ {
1268
+ command: "merge",
1269
+ label: labels.merge,
1270
+ name: "merge_type"
1271
+ },
1272
+ {
1273
+ command: "moveUp",
1274
+ label: labels.moveUp,
1275
+ name: "keyboard_double_arrow_up"
1276
+ },
1277
+ {
1278
+ command: "moveDown",
1279
+ label: labels.moveDown,
1280
+ name: "keyboard_double_arrow_down"
1281
+ },
1282
+ {
1283
+ command: "duplicate",
1284
+ label: labels.duplicate,
1285
+ name: "content_copy"
1286
+ },
1287
+ {
1288
+ command: "copy",
1289
+ label: labels.copy,
1290
+ name: "content_paste"
1291
+ },
1292
+ {
1293
+ command: held ? "release" : "hold",
1294
+ label: held ? labels.release : labels.hold,
1295
+ name: held ? "play_arrow" : "pause"
1296
+ },
1297
+ {
1298
+ command: "clearOutput",
1299
+ label: labels.clearOutput,
1300
+ name: "delete_sweep"
1301
+ },
1302
+ {
1303
+ command: "remove",
1304
+ label: labels.remove,
1305
+ name: "delete"
1306
+ }
1307
+ ];
1308
+ const menu = document.createElement("div");
1309
+ menu.className = "menu tb-cell-menu";
1310
+ menu.setAttribute("role", "menu");
1311
+ for (const item of items) {
1312
+ const entry = document.createElement("button");
1313
+ entry.type = "button";
1314
+ entry.className = "menu__item";
1315
+ entry.setAttribute("role", "menuitem");
1316
+ entry.append(icon(item.name, RAIL_ICON_SIZE));
1317
+ entry.append(document.createTextNode(item.label));
1318
+ entry.addEventListener("click", () => {
1319
+ this.closeMenu();
1320
+ this.args.onCommand(item.command, index);
1321
+ });
1322
+ menu.append(entry);
1323
+ }
1324
+ document.body.append(menu);
1325
+ const box = anchor.getBoundingClientRect();
1326
+ menu.style.position = "fixed";
1327
+ menu.style.left = `${Math.round(box.left)}px`;
1328
+ menu.style.top = `${Math.round(box.bottom + 4)}px`;
1329
+ this.menu = menu;
1330
+ const dismiss = (event) => {
1331
+ if (menu.contains(event.target)) return;
1332
+ this.closeMenu();
1333
+ };
1334
+ setTimeout(() => document.addEventListener("mousedown", dismiss), 0);
1335
+ document.addEventListener("keyup", this.onEscape);
1336
+ }
1337
+ onEscape = (event) => {
1338
+ if (event.key === "Escape") this.closeMenu();
1339
+ };
1340
+ closeMenu() {
1341
+ this.menu?.remove();
1342
+ this.menu = null;
1343
+ document.removeEventListener("keyup", this.onEscape);
1344
+ }
1345
+ destroy() {
1346
+ this.closeMenu();
1238
1347
  }
1239
1348
  };
1349
+ /**
1350
+ * A cell's output: its frames and the diagnostics whose span falls inside it.
1351
+ */
1240
1352
  var NotebookOutputWidget = class extends WidgetType {
1241
1353
  view;
1242
1354
  fileId;
@@ -1251,30 +1363,25 @@ var NotebookOutputWidget = class extends WidgetType {
1251
1363
  this.args = args;
1252
1364
  this.openExternal = openExternal;
1253
1365
  }
1254
- /** Hash of everything the DOM depends on, since DOM identity is expensive. */
1255
1366
  get key() {
1256
1367
  const frames = this.args.frames.map((frame) => {
1257
1368
  const size = frameSize(frame);
1258
1369
  return `${frame.render.hash}:${Math.round(size.width)}x${Math.round(size.height)}`;
1259
1370
  }).join(",");
1260
1371
  const diagnostics = this.args.diagnostics.map((diagnostic) => diagnostic.message).join("|");
1261
- return `${this.args.index}:${this.args.cleared}:${this.args.hasRun}:${frames}:${diagnostics}`;
1372
+ return `${this.args.index}:${this.args.cleared}:${this.args.held}:${frames}:${diagnostics}`;
1262
1373
  }
1263
1374
  eq(other) {
1264
1375
  return this.key === other.key;
1265
1376
  }
1266
1377
  toDOM() {
1267
1378
  const root = document.createElement("div");
1268
- const { cleared, labels } = this.args;
1379
+ const { cleared, held, labels } = this.args;
1269
1380
  root.className = "tb-cell-output";
1270
1381
  if (cleared) {
1271
1382
  root.classList.add("tb-cell-output--cleared");
1272
1383
  return root;
1273
1384
  }
1274
- if (!(this.args.frames.length > 0 || this.args.diagnostics.length > 0 || this.args.hasRun)) {
1275
- root.classList.add("tb-cell-output--empty");
1276
- return root;
1277
- }
1278
1385
  const body = document.createElement("div");
1279
1386
  body.className = "tb-cell-output-body";
1280
1387
  for (const diagnostic of this.args.diagnostics) {
@@ -1300,7 +1407,12 @@ var NotebookOutputWidget = class extends WidgetType {
1300
1407
  holder.append(container);
1301
1408
  body.append(holder);
1302
1409
  }
1303
- if (!this.args.frames.length && !this.args.diagnostics.length && this.args.hasRun) {
1410
+ if (held) {
1411
+ const note = document.createElement("div");
1412
+ note.className = "tb-cell-stale";
1413
+ note.textContent = labels.held;
1414
+ body.append(note);
1415
+ } else if (!this.args.frames.length && !this.args.diagnostics.length) {
1304
1416
  const empty = document.createElement("div");
1305
1417
  empty.className = "tb-cell-empty";
1306
1418
  empty.textContent = labels.noOutput;
@@ -1315,51 +1427,109 @@ var NotebookOutputWidget = class extends WidgetType {
1315
1427
  return height + 16;
1316
1428
  }
1317
1429
  };
1430
+ /**
1431
+ * The height an off-screen cell's output reserves, without building any of it.
1432
+ */
1433
+ var NotebookOutputSpacer = class extends WidgetType {
1434
+ height;
1435
+ constructor(height) {
1436
+ super();
1437
+ this.height = height;
1438
+ }
1439
+ eq(other) {
1440
+ return this.height === other.height;
1441
+ }
1442
+ toDOM() {
1443
+ const root = document.createElement("div");
1444
+ root.className = "tb-cell-output tb-cell-output--offscreen";
1445
+ return root;
1446
+ }
1447
+ get estimatedHeight() {
1448
+ return this.height;
1449
+ }
1450
+ };
1318
1451
  function decorateNotebook(args) {
1319
- const { view, state, cells, frames, diagnostics, fileId, typstState, locked, onExternalLink, options } = args;
1452
+ const { view, state, cells, frames, diagnostics, fileId, typstState, locked, readOnly, renderFrames, activeIndex, onCommand, onExternalLink, options } = args;
1320
1453
  const decorations = [];
1321
1454
  const byCell = cells.map(() => []);
1322
1455
  for (const frame of frames) {
1323
1456
  const index = cellIndexAt(cells, frame.range.start);
1324
1457
  if (index >= 0) byCell[index].push(frame);
1325
1458
  }
1326
- const counter = options.counters?.() ?? false;
1459
+ const labels = options.labels ?? FALLBACK_LABELS;
1460
+ const cellState = (index) => options.state?.(index) ?? {};
1461
+ const visible = /* @__PURE__ */ new Set();
1462
+ for (const range of view.visibleRanges) {
1463
+ const from = cellIndexAt(cells, range.from);
1464
+ const to = cellIndexAt(cells, range.to);
1465
+ for (let index = Math.max(0, from); index <= Math.min(cells.length - 1, to); index++) visible.add(index);
1466
+ }
1327
1467
  cells.forEach((cell, index) => {
1328
- const cellState = options.state?.(index) ?? {};
1329
- const headerPos = state.doc.lineAt(cellStart(cell)).from;
1330
- decorations.push(Decoration.widget({
1331
- widget: new NotebookHeaderWidget(view, {
1332
- index,
1333
- kind: cell.kind,
1334
- counter,
1335
- count: cellState.count,
1336
- collapsed: cellState.collapsed ?? false,
1337
- labels: options.labels ?? FALLBACK_LABELS
1338
- }),
1339
- side: -1
1340
- }).range(headerPos));
1341
- if (hasMarker(cell)) decorations.push(Decoration.line({ class: "tb-cell-marker" }).range(state.doc.lineAt(cell.marker_start).from));
1342
- if (cellState.collapsed && cell.content_end > cell.content_start) decorations.push(Decoration.replace({}).range(cell.content_start, cell.content_end));
1343
- if (cell.kind === "markup") {
1344
- if (cellState.collapsed) return;
1468
+ const own = cellState(index);
1469
+ const firstLine = state.doc.lineAt(cellStart(cell)).from;
1470
+ if (index === activeIndex) decorations.push(Decoration.line({ class: "tb-cell-active" }).range(firstLine));
1471
+ if (!readOnly) {
1472
+ decorations.push(Decoration.widget({
1473
+ widget: new NotebookRailWidget(view, {
1474
+ index,
1475
+ kind: cell.kind,
1476
+ name: cell.name,
1477
+ collapsed: own.collapsed ?? false,
1478
+ held: own.held ?? false,
1479
+ labels,
1480
+ onCommand
1481
+ }),
1482
+ block: !hasAttribute(cell),
1483
+ side: -1
1484
+ }).range(firstLine));
1485
+ if (hasAttribute(cell)) decorations.push(Decoration.line({ class: "tb-cell-attribute" }).range(state.doc.lineAt(cell.marker_start).from));
1486
+ }
1487
+ const contentStart = cell.content_start;
1488
+ const contentEnd = cell.content_end;
1489
+ const hideSource = (own.collapsed ?? false) || hidesSource(cell.kind);
1490
+ if (hideSource && contentEnd > contentStart) decorations.push(Decoration.replace({}).range(contentStart, contentEnd));
1491
+ if (hideSource) {
1492
+ if (renderFrames) pushDiagnostics(decorations, state, cell, diagnostics);
1493
+ return;
1494
+ }
1495
+ if (rendersInPlace(cell.kind)) {
1496
+ if (!renderFrames) return;
1345
1497
  for (const frame of byCell[index] ?? []) {
1346
1498
  const { start, end } = frame.range;
1347
1499
  if (!frame.render || end <= start) continue;
1348
1500
  if (frameIsInactive(view, state, start, end)) decorations.push(Decoration.replace({ widget: new TypstWidget(view, frame, locked, fileId, typstState, onExternalLink) }).range(start, end));
1349
1501
  else decorations.push(...frameActiveDecorations(view, state, frame));
1350
1502
  }
1503
+ if (renderFrames) pushDiagnostics(decorations, state, cell, diagnostics);
1504
+ return;
1505
+ }
1506
+ if (!hasOutput(cell.kind)) return;
1507
+ if (!renderFrames) return;
1508
+ const outputPos = contentEnd > contentStart ? state.doc.lineAt(contentEnd).to : contentStart;
1509
+ const held = own.held ?? false;
1510
+ const output = held ? own.frozen ?? {
1511
+ frames: [],
1512
+ diagnostics: []
1513
+ } : {
1514
+ frames: byCell[index] ?? [],
1515
+ diagnostics: cellDiagnosticsFor(cell, diagnostics)
1516
+ };
1517
+ if (!visible.has(index)) {
1518
+ decorations.push(Decoration.widget({
1519
+ widget: new NotebookOutputSpacer(outputHeight(output.frames)),
1520
+ block: true,
1521
+ side: 1
1522
+ }).range(outputPos));
1351
1523
  return;
1352
1524
  }
1353
- const outputPos = cell.content_end > cell.content_start ? state.doc.lineAt(cell.content_end).to : cell.content_start;
1354
- const cellDiagnostics = diagnostics.filter((diagnostic) => diagnostic.range.start >= cell.content_start && diagnostic.range.start <= cell.content_end);
1355
1525
  decorations.push(Decoration.widget({
1356
1526
  widget: new NotebookOutputWidget(view, fileId, typstState, {
1357
1527
  index,
1358
- frames: byCell[index] ?? [],
1359
- diagnostics: cellDiagnostics,
1360
- cleared: cellState.cleared ?? false,
1361
- hasRun: cellState.count !== void 0,
1362
- labels: options.labels ?? FALLBACK_LABELS
1528
+ frames: output.frames,
1529
+ diagnostics: output.diagnostics,
1530
+ cleared: own.cleared ?? false,
1531
+ held,
1532
+ labels
1363
1533
  }, onExternalLink),
1364
1534
  block: true,
1365
1535
  side: 1
@@ -1367,9 +1537,79 @@ function decorateNotebook(args) {
1367
1537
  });
1368
1538
  return decorations;
1369
1539
  }
1540
+ function outputHeight(frames) {
1541
+ let height = 0;
1542
+ for (const frame of frames) height += frameSize(frame).height;
1543
+ return height + 16;
1544
+ }
1545
+ /** The diagnostics whose span starts inside a cell's content. */
1546
+ function cellDiagnosticsFor(cell, diagnostics) {
1547
+ return diagnostics.filter((diagnostic) => diagnostic.range.start >= cell.content_start && diagnostic.range.start <= cell.content_end);
1548
+ }
1549
+ /**
1550
+ * A cell's diagnostics as a block under its content, so an error is locatable
1551
+ * by cell rather than only in the lint panel.
1552
+ */
1553
+ function pushDiagnostics(decorations, state, cell, diagnostics) {
1554
+ const own = cellDiagnosticsFor(cell, diagnostics);
1555
+ if (!own.length) return;
1556
+ const pos = cell.content_end > cell.content_start ? state.doc.lineAt(cell.content_end).to : cell.content_start;
1557
+ decorations.push(Decoration.widget({
1558
+ widget: new NotebookDiagnosticsWidget(own),
1559
+ block: true,
1560
+ side: 1
1561
+ }).range(pos));
1562
+ }
1563
+ var NotebookDiagnosticsWidget = class extends WidgetType {
1564
+ diagnostics;
1565
+ constructor(diagnostics) {
1566
+ super();
1567
+ this.diagnostics = diagnostics;
1568
+ }
1569
+ eq(other) {
1570
+ return this.diagnostics.length === other.diagnostics.length && this.diagnostics.every((diagnostic, index) => diagnostic.message === other.diagnostics[index]?.message);
1571
+ }
1572
+ toDOM() {
1573
+ const root = document.createElement("div");
1574
+ root.className = "tb-cell-diagnostics";
1575
+ for (const diagnostic of this.diagnostics) {
1576
+ const item = document.createElement("div");
1577
+ item.className = `tb-cell-diagnostic tb-cell-diagnostic--${diagnostic.severity}`;
1578
+ item.textContent = diagnostic.message;
1579
+ root.append(item);
1580
+ }
1581
+ return root;
1582
+ }
1583
+ get estimatedHeight() {
1584
+ return this.diagnostics.length * 22 + 8;
1585
+ }
1586
+ };
1587
+ const FALLBACK_LABELS = {
1588
+ code: "Code",
1589
+ prose: "Text",
1590
+ log: "Log",
1591
+ hidden: "Hidden",
1592
+ menu: "Cell actions",
1593
+ split: "Split cell",
1594
+ merge: "Merge with the cell above",
1595
+ moveUp: "Move cell up",
1596
+ moveDown: "Move cell down",
1597
+ duplicate: "Duplicate cell",
1598
+ remove: "Delete cell",
1599
+ copy: "Copy cell",
1600
+ hold: "Hold output",
1601
+ release: "Release output",
1602
+ clearOutput: "Clear output",
1603
+ toggleSource: "Collapse cell",
1604
+ noOutput: "No output",
1605
+ held: "Held"
1606
+ };
1370
1607
  //#endregion
1371
1608
  //#region src/widgets.ts
1372
- const compileCache = new LRUCache({ max: 8 });
1609
+ /**
1610
+ * Compiled frames per cache key, where the key is `path:revision:appRevision`.
1611
+ */
1612
+ const compileCache = new LRUCache({ max: 2 });
1373
1613
  const updateFlagStore = /* @__PURE__ */ new Set();
1374
1614
  /**
1375
1615
  * Dispatch this to recompile even when the document did not change. Used when
@@ -1390,11 +1630,14 @@ function editorRenderWidth(scrollDOM, contentDOM) {
1390
1630
  const tooltipsStateEffect = StateEffect.define();
1391
1631
  const tooltipsStateField = StateField.define({
1392
1632
  create() {
1393
- return [];
1633
+ return {
1634
+ tooltips: [],
1635
+ ranges: []
1636
+ };
1394
1637
  },
1395
- update(frames, transaction) {
1638
+ update(overlays, transaction) {
1396
1639
  const effect = transaction.effects.find((e) => e.is(tooltipsStateEffect));
1397
- return effect ? effect.value : frames;
1640
+ return effect ? effect.value : overlays;
1398
1641
  }
1399
1642
  });
1400
1643
  /**
@@ -1440,25 +1683,27 @@ function measureLineHeights(view, frames) {
1440
1683
  }
1441
1684
  return heights;
1442
1685
  }
1443
- function decorate({ fileId, spaceId, path, prelude, locked, update, updateInWidget: _updateInWidget, widthChanged, forced, typstState, revision, onRequests, onPanic, onCompile, onExternalLink, notebook }) {
1686
+ function decorate({ fileId, spaceId, path, prelude, locked, update, updateInWidget: _updateInWidget, widthChanged, forced, typstState, revision, onRequests, onPanic, onCompile, onExternalLink, notebook, renderFrames = true }) {
1444
1687
  const text = update.state.doc.toString();
1445
1688
  const isFlaggedForUpdate = updateFlagStore.has(path);
1446
1689
  const cacheKey = compileCacheKey(path, typstState, revision);
1447
1690
  let frames;
1448
1691
  let tooltips;
1692
+ let equationRanges;
1449
1693
  let diagnostics;
1450
1694
  if (forced || update.docChanged || widthChanged || !compileCache.has(cacheKey)) {
1451
1695
  if (isFlaggedForUpdate) updateFlagStore.delete(path);
1452
1696
  else updateFlagStore.add(path);
1453
1697
  let compileResult;
1454
1698
  try {
1455
- compileResult = typstState.compilePaged(fileId, text, prelude);
1699
+ compileResult = typstState.compilePaged(fileId, text, prelude, update.state.selection.main.from);
1456
1700
  } catch (error) {
1457
1701
  console.error("[typst] compile panicked:", error);
1458
1702
  onPanic?.(fileId);
1459
1703
  return {
1460
1704
  decorations: Decoration.none,
1461
1705
  tooltips: [],
1706
+ equationRanges: [],
1462
1707
  frames: [],
1463
1708
  active: []
1464
1709
  };
@@ -1470,21 +1715,18 @@ function decorate({ fileId, spaceId, path, prelude, locked, update, updateInWidg
1470
1715
  if (!wasUpdated) return;
1471
1716
  update.view.dispatch({ effects: typstRecompileEffect.of(null) });
1472
1717
  });
1473
- ({frames, tooltips} = compileResult);
1718
+ installSharedDefs(compileResult.defs);
1719
+ ({frames, tooltips, equation_ranges: equationRanges} = compileResult);
1474
1720
  diagnostics = compileResult.diagnostics;
1475
1721
  compileCache.set(cacheKey, {
1476
1722
  frames,
1477
1723
  tooltips,
1478
- diagnostics
1479
- });
1480
- notebook?.onCompile?.({
1481
- text,
1482
- frames,
1724
+ equationRanges,
1483
1725
  diagnostics
1484
1726
  });
1485
1727
  } else {
1486
1728
  const cached = compileCache.get(cacheKey);
1487
- ({frames, tooltips, diagnostics} = cached);
1729
+ ({frames, tooltips, equationRanges, diagnostics} = cached);
1488
1730
  }
1489
1731
  const { view, state } = update;
1490
1732
  if (notebook) {
@@ -1497,12 +1739,19 @@ function decorate({ fileId, spaceId, path, prelude, locked, update, updateInWidg
1497
1739
  return {
1498
1740
  decorations: Decoration.none,
1499
1741
  tooltips: [],
1742
+ equationRanges: [],
1500
1743
  frames: [],
1501
1744
  active: []
1502
1745
  };
1503
1746
  }
1504
1747
  notebook.onCells?.(cells);
1505
- notebook.onActiveCell?.(cells.length ? cellIndexAt(cells, state.selection.main.head) : null);
1748
+ notebook.onCompile?.({
1749
+ text,
1750
+ frames,
1751
+ diagnostics
1752
+ });
1753
+ const activeIndex = cells.length ? cellIndexAt(cells, state.selection.main.head) : null;
1754
+ notebook.onActiveCell?.(activeIndex);
1506
1755
  return {
1507
1756
  decorations: Decoration.set(decorateNotebook({
1508
1757
  view,
@@ -1513,10 +1762,15 @@ function decorate({ fileId, spaceId, path, prelude, locked, update, updateInWidg
1513
1762
  fileId,
1514
1763
  typstState,
1515
1764
  locked,
1765
+ readOnly: notebook.readOnly?.() ?? false,
1766
+ renderFrames,
1767
+ activeIndex,
1516
1768
  onExternalLink,
1769
+ onCommand: (command, index) => notebook.onCommand?.(command, index),
1517
1770
  options: notebook
1518
1771
  }), true),
1519
1772
  tooltips,
1773
+ equationRanges,
1520
1774
  frames,
1521
1775
  active: []
1522
1776
  };
@@ -1533,6 +1787,7 @@ function decorate({ fileId, spaceId, path, prelude, locked, update, updateInWidg
1533
1787
  return {
1534
1788
  decorations: built.decorations,
1535
1789
  tooltips,
1790
+ equationRanges,
1536
1791
  frames,
1537
1792
  active: built.active
1538
1793
  };
@@ -1552,6 +1807,7 @@ const typstStateField = StateField.define({
1552
1807
  const typstViewPlugin = (fileId, spaceId, path, text, prelude, locked, typstState, options = {}) => ViewPlugin.define((_view) => {
1553
1808
  let firstUpdate = true;
1554
1809
  let resizeTimer;
1810
+ const renderFrames = options.renderFrames ?? true;
1555
1811
  const remeasureKey = {};
1556
1812
  /**
1557
1813
  * A pass that shows a block's source runs while the block's widget is still
@@ -1591,12 +1847,9 @@ const typstViewPlugin = (fileId, spaceId, path, text, prelude, locked, typstStat
1591
1847
  return {
1592
1848
  update(update) {
1593
1849
  let widthChanged = false;
1594
- const effects = update.transactions.flatMap((transaction) => transaction.effects);
1595
- const run = effects.find((effect) => effect.is(notebookRunEffect));
1596
- const refresh = effects.some((effect) => effect.is(notebookRefreshEffect));
1850
+ const refresh = update.transactions.flatMap((transaction) => transaction.effects).some((effect) => effect.is(notebookRefreshEffect));
1597
1851
  const forced = firstUpdate || update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(typstRecompileEffect)));
1598
1852
  firstUpdate = false;
1599
- if (run) options.notebook?.onRun?.(run.value.index);
1600
1853
  if (update.geometryChanged) {
1601
1854
  const { scrollDOM, contentDOM } = update.view;
1602
1855
  try {
@@ -1613,7 +1866,7 @@ const typstViewPlugin = (fileId, spaceId, path, text, prelude, locked, typstStat
1613
1866
  update.view.dispatch({ effects: typstRecompileEffect.of(null) });
1614
1867
  }, 150);
1615
1868
  }
1616
- if (update.docChanged || update.selectionSet || update.focusChanged || forced || run || refresh) {
1869
+ if (update.docChanged || update.selectionSet || update.focusChanged || forced || refresh) {
1617
1870
  queueMicrotask(() => {
1618
1871
  let result;
1619
1872
  try {
@@ -1626,14 +1879,15 @@ const typstViewPlugin = (fileId, spaceId, path, text, prelude, locked, typstStat
1626
1879
  update,
1627
1880
  updateInWidget: false,
1628
1881
  widthChanged,
1629
- forced: forced || Boolean(run),
1882
+ forced,
1630
1883
  typstState,
1631
1884
  revision: options.revision,
1632
1885
  onRequests: options.onRequests,
1633
1886
  onPanic: options.onPanic,
1634
1887
  onCompile: options.onCompile,
1635
1888
  onExternalLink: options.onExternalLink,
1636
- notebook: options.notebook
1889
+ notebook: options.notebook,
1890
+ renderFrames
1637
1891
  });
1638
1892
  } catch (error) {
1639
1893
  console.error("[typst] decorate panicked:", error);
@@ -1641,7 +1895,10 @@ const typstViewPlugin = (fileId, spaceId, path, text, prelude, locked, typstStat
1641
1895
  return;
1642
1896
  }
1643
1897
  if (result) {
1644
- const stateEffects = [typstStateEffect.of({ decorations: result.decorations }), tooltipsStateEffect.of(result.tooltips)];
1898
+ const stateEffects = [typstStateEffect.of({ decorations: result.decorations }), tooltipsStateEffect.of({
1899
+ tooltips: result.tooltips,
1900
+ ranges: result.equationRanges
1901
+ })];
1645
1902
  update.view.dispatch({ effects: stateEffects });
1646
1903
  remeasureActive(update, result);
1647
1904
  }
@@ -1680,7 +1937,13 @@ const tooltipViewPlugin = () => ViewPlugin.define((view) => ({ update(update) {
1680
1937
  }
1681
1938
  const state = view.state;
1682
1939
  const pos = state.selection.main.from;
1683
- const tooltips = state.field(tooltipsStateField);
1940
+ const { tooltips, ranges } = state.field(tooltipsStateField);
1941
+ if (!tooltips.some(({ range }) => pos >= range.start && pos <= range.end)) {
1942
+ if (ranges.some(([start, end]) => pos >= start && pos <= end)) {
1943
+ view.dispatch({ effects: typstRecompileEffect.of(null) });
1944
+ return;
1945
+ }
1946
+ }
1684
1947
  let tooltip = null;
1685
1948
  for (const { render, range } of tooltips) {
1686
1949
  const { start, end } = range;
@@ -1694,8 +1957,8 @@ const tooltipViewPlugin = () => ViewPlugin.define((view) => ({ update(update) {
1694
1957
  svg.setHTMLUnsafe(render.svg);
1695
1958
  container.append(svg);
1696
1959
  tooltip = {
1697
- pos: start + 1,
1698
- end: end - 1,
1960
+ pos: start,
1961
+ end,
1699
1962
  create(tooltipView) {
1700
1963
  return {
1701
1964
  dom: container,
@@ -1704,8 +1967,8 @@ const tooltipViewPlugin = () => ViewPlugin.define((view) => ({ update(update) {
1704
1967
  const endCoords = tooltipView.coordsAtPos(end);
1705
1968
  return {
1706
1969
  left: startCoords.left,
1707
- right: startCoords.right,
1708
- top: endCoords.top,
1970
+ right: endCoords.right,
1971
+ top: startCoords.top,
1709
1972
  bottom: endCoords.bottom
1710
1973
  };
1711
1974
  }
@@ -1718,8 +1981,35 @@ const tooltipViewPlugin = () => ViewPlugin.define((view) => ({ update(update) {
1718
1981
  view.dispatch({ effects: tooltipStateEffect.of(tooltip) });
1719
1982
  });
1720
1983
  } }));
1721
- //#endregion
1722
- //#region src/plugin.ts
1984
+ const typstEditingExtensions = [EditorView.inputHandler.of((view, from, to, text) => {
1985
+ if (text !== " " || from !== to) return false;
1986
+ const { state } = view;
1987
+ const pos = from;
1988
+ const bracketPairs = {
1989
+ "(": ")",
1990
+ "[": "]",
1991
+ "{": "}",
1992
+ $: "$"
1993
+ };
1994
+ const before = state.doc.sliceString(pos - 1, pos);
1995
+ const after = state.doc.sliceString(pos, pos + 1);
1996
+ if (!bracketPairs[before] || after !== bracketPairs[before]) return false;
1997
+ view.dispatch({
1998
+ changes: {
1999
+ from: pos,
2000
+ to: pos,
2001
+ insert: " "
2002
+ },
2003
+ selection: { anchor: pos + 1 }
2004
+ });
2005
+ return true;
2006
+ }), indentService.of((ctx, pos) => {
2007
+ const last = Math.max(0, pos - 1);
2008
+ const prev = ctx.lineAt(last).text;
2009
+ if (prev.endsWith("$") && prev !== "$") return 0;
2010
+ const indent = /[{[($]\s*$/.test(prev);
2011
+ return ctx.lineIndent(last) + (indent ? ctx.unit : 0);
2012
+ })];
1723
2013
  const typstPlugin = (fileId, spaceId, path, text, prelude, locked, typstState, options = {}) => {
1724
2014
  const extensions = [
1725
2015
  typstStateField,
@@ -1730,43 +2020,14 @@ const typstPlugin = (fileId, spaceId, path, text, prelude, locked, typstState, o
1730
2020
  autocompletion({ override: [(context) => autocomplete(context, fileId, typstState)] }),
1731
2021
  typstKeymap,
1732
2022
  typstLanguageData,
2023
+ typstSourceSync(fileId, prelude, typstState),
1733
2024
  typstSyntaxHighlighting(fileId, typstState),
1734
2025
  typstHoverTooltip(fileId, typstState),
1735
- addSpaceBeforeClosingBracket,
1736
- indentService.of((ctx, pos) => {
1737
- const last = Math.max(0, pos - 1);
1738
- const prev = ctx.lineAt(last).text;
1739
- if (prev.endsWith("$") && prev !== "$") return 0;
1740
- const indent = /[{[($]\s*$/.test(prev);
1741
- return ctx.lineIndent(last) + (indent ? ctx.unit : 0);
1742
- })
2026
+ typstEditingExtensions
1743
2027
  ];
1744
2028
  if (options.notebook) extensions.push(notebookOptionsFacet.of(options.notebook), notebookKeymap);
1745
2029
  return extensions;
1746
2030
  };
1747
- const addSpaceBeforeClosingBracket = EditorView.inputHandler.of((view, from, to, text) => {
1748
- if (text === " ") {
1749
- const { state } = view;
1750
- const pos = from;
1751
- const bracketPairs = {
1752
- "(": ")",
1753
- "[": "]",
1754
- "{": "}",
1755
- $: "$"
1756
- };
1757
- const before = state.doc.sliceString(pos - 1, pos);
1758
- const after = state.doc.sliceString(pos, pos + 1);
1759
- if (bracketPairs[before] && after === bracketPairs[before]) view.dispatch({
1760
- changes: {
1761
- from: pos,
1762
- to: pos,
1763
- insert: " "
1764
- },
1765
- selection: { anchor: pos + 1 }
1766
- });
1767
- }
1768
- return false;
1769
- });
1770
2031
  //#endregion
1771
2032
  //#region src/static-highlight.ts
1772
2033
  const KEYWORDS = /* @__PURE__ */ new Set([
@@ -2098,4 +2359,4 @@ function stringEnd(line, from) {
2098
2359
  return line.length;
2099
2360
  }
2100
2361
  //#endregion
2101
- export { TypstWidget, attachFrameInteractions, autocomplete, cellIndexAt, cellStart, cellsOf, compileCache, createFrameContainer, cursorAddVertical, cycleHeading, deleteCell, deleteCellAt, deleteCellText, duplicateCell, duplicateCellAt, duplicateCellText, focusCell, frameActiveDecorations, frameIsInactive, frameSize, hasMarker, insertAtCursor, insertCell, insertCellAt, insertCellText, insertFontFamily, insertTabLike, markerText, mergeCell, mergeCellText, moveCell, moveCellAt, moveCellText, notebookKeymap, notebookOptions, notebookOptionsFacet, notebookRefreshEffect, notebookRunEffect, parseBackticks, rememberDiagnostics, runAllCells, runCell, setCellType, setCellTypeText, splitCell, splitCellText, stripCellMarkers, syncFrameContainer, toLintDiagnostics, toggleAroundSelection, toggleCellType, toggleCode, toggleEmph, toggleEnum, toggleList, toggleListLike, toggleMath, toggleStrike, toggleStrong, toggleUnderline, tooltipStateField, tooltipViewPlugin, tooltipsStateEffect, tooltipsStateField, typstHoverTooltip, typstKeymap, typstLanguageData, typstLinter, typstPlugin, typstRecompileEffect, typstStateField, typstStaticHighlighting, typstSyntaxHighlighting, typstViewPlugin, wrapOrInsert };
2362
+ export { NOTEBOOK_CELL_TYPES, TypstWidget, attachFrameInteractions, attributeText, autocomplete, cellIndexAt, cellStart, cellsOf, compileCache, createFrameContainer, currentCell, cursorAddVertical, cycleHeading, deleteCell, deleteCellText, duplicateCell, duplicateCellText, focusCell, frameActiveDecorations, frameIsInactive, frameSize, hasAttribute, hasOutput, hidesSource, insertAtCursor, insertCell, insertCellText, insertFontFamily, insertTabLike, installSharedDefs, mergeCell, mergeCellText, moveCell, notebookKeymap, notebookOptions, notebookOptionsFacet, notebookRefreshEffect, parseBackticks, rememberDiagnostics, rendersInPlace, setCellType, setCellTypeText, splitCell, splitCellText, stripCellAttributes, syncFrameContainer, toLintDiagnostics, toggleAroundSelection, toggleCode, toggleEmph, toggleEnum, toggleList, toggleListLike, toggleMath, toggleStrike, toggleStrong, toggleUnderline, tooltipStateField, tooltipViewPlugin, tooltipsStateEffect, tooltipsStateField, typstEditingExtensions, typstHoverTooltip, typstKeymap, typstLanguageData, typstLinter, typstPlugin, typstRecompileEffect, typstSourceSync, typstStateField, typstStaticHighlighting, typstSyntaxHighlighting, typstViewPlugin, vimKeymapFacet, wrapOrInsert };