@typbase/codemirror 0.3.2 → 0.4.0

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 +123 -65
  3. package/dist/index.mjs +581 -348
  4. package/package.json +3 -3
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { acceptCompletion, autocompletion, closeBracketsKeymap, 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";
@@ -583,6 +583,25 @@ const typstLanguageData = EditorState.languageData.of(() => [{
583
583
  }]);
584
584
  //#endregion
585
585
  //#region src/notebook.ts
586
+ /** Every type, in the order the menu lists them. */
587
+ const NOTEBOOK_CELL_TYPES = [
588
+ "prose",
589
+ "code",
590
+ "log",
591
+ "hidden"
592
+ ];
593
+ /** True when the cell renders in place rather than showing source plus output. */
594
+ function rendersInPlace(kind) {
595
+ return kind === "prose";
596
+ }
597
+ /** True when the cell's source is not part of what the reader sees. */
598
+ function hidesSource(kind) {
599
+ return kind === "log" || kind === "hidden";
600
+ }
601
+ /** True when the cell has an output area below its source. */
602
+ function hasOutput(kind) {
603
+ return kind === "code" || kind === "log";
604
+ }
586
605
  /** The options of the notebook extension active in this state, if any. */
587
606
  const notebookOptionsFacet = Facet.define({ combine: (values) => values.at(-1) });
588
607
  function notebookOptions(state) {
@@ -590,13 +609,12 @@ function notebookOptions(state) {
590
609
  }
591
610
  /** Re-renders notebooks from cached frames without recompiling. */
592
611
  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). */
612
+ /** First position that belongs to the cell (attribute line, or content). */
596
613
  function cellStart(cell) {
597
614
  return cell.marker_end > cell.marker_start ? cell.marker_start : cell.content_start;
598
615
  }
599
- function hasMarker(cell) {
616
+ /** True when the cell opened with an attribute line rather than a heading. */
617
+ function hasAttribute(cell) {
600
618
  return cell.marker_end > cell.marker_start;
601
619
  }
602
620
  /** Index of the cell containing `pos`. Gaps belong to the cell above. */
@@ -615,128 +633,137 @@ function blockEnd(cells, index, text) {
615
633
  const next = cells[index + 1];
616
634
  return next ? cellStart(next) : text.length;
617
635
  }
618
- /** The marker line for a cell type. Markup is the unlabeled default. */
619
- function markerText(type) {
620
- return type === "code" ? "// %% [code]" : "// %%";
636
+ /**
637
+ * The attribute line for a cell type. Prose needs no `kind=`, so a plain cell is
638
+ * three characters.
639
+ */
640
+ function attributeText(type, name) {
641
+ const fields = [name ? `name=${name}` : "", type === "prose" ? "" : `kind=${type}`].filter(Boolean);
642
+ return fields.length ? `//% ${fields.join(" ")}` : "//%";
621
643
  }
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
- }
644
+ /**
645
+ * Matches a whole attribute line, indentation and newline included. The rest of
646
+ * the line is taken whole rather than field by field, because this is a text
647
+ * cleaner for exports and the engine already decided what is an attribute.
648
+ */
649
+ const ATTRIBUTE_LINE = /^[ \t]*\/\/%[^\r\n]*\r?\n?/gm;
650
+ /**
651
+ * Removes cell attribute lines. Notebooks are comments-plus-content, so a
652
+ * cleaned file is the same document for anyone who does not know the
653
+ * convention. Only `//%` goes: any other comment is left as written.
654
+ */
655
+ function stripCellAttributes(text) {
656
+ return text.replace(ATTRIBUTE_LINE, "");
657
+ }
658
+ /**
659
+ * Splits a cell at `pos` inside its content. The new cell takes the text from
660
+ * `pos` and inherits the old cell's kind, because a split is a continuation.
661
+ */
662
+ function splitCellText(text, cells, index, pos) {
635
663
  const cell = cells[index];
636
- const from = where === "above" ? cellStart(cell) : blockEnd(cells, index, text);
664
+ if (!cell || pos < cell.content_start || pos > cell.content_end) return null;
665
+ const trimmed = text.slice(pos, cell.content_end).replace(/^\n+/, "");
666
+ const insert = `\n\n${attributeText(cell.kind, cell.name)}\n\n${trimmed}`;
637
667
  return {
638
668
  changes: {
639
- from,
640
- insert: `${marker}\n${content ? `${content}\n` : ""}\n`
669
+ from: pos,
670
+ to: cell.content_end,
671
+ insert
641
672
  },
642
- anchor: from + marker.length + 1 + content.length
673
+ anchor: pos + 2 + attributeText(cell.kind, cell.name).length + 2
643
674
  };
644
675
  }
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) {
676
+ /**
677
+ * Merges a cell into the one above. The result takes the lower cell's kind,
678
+ * since that is the one the reader was last looking at.
679
+ */
680
+ function mergeCellText(cells, index) {
650
681
  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
- };
682
+ const above = cells[index - 1];
683
+ if (!cell || !above) return null;
659
684
  const from = cellStart(cell);
685
+ const head = above.content_end > above.content_start ? above.content_end : from;
686
+ const gap = text_between_gap(head, cell.content_start);
660
687
  return {
661
688
  changes: {
662
- from,
663
- to: blockEnd(cells, index, text),
664
- insert: ""
689
+ from: head,
690
+ to: cell.content_start,
691
+ insert: gap
665
692
  },
666
- anchor: from
693
+ anchor: head
667
694
  };
668
695
  }
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 {
696
+ /** The whitespace between two offsets, which a merge keeps as a separator. */
697
+ function text_between_gap(from, to) {
698
+ return from < to ? "\n\n" : "";
699
+ }
700
+ /**
701
+ * Rewrites a cell's attribute line. A cell that began at a heading has no line
702
+ * to rewrite, so one is inserted.
703
+ */
704
+ function setCellTypeText(text, cells, index, type) {
705
+ const cell = cells[index];
706
+ if (!cell) return null;
707
+ if (cell.kind === type) return null;
708
+ const attribute = attributeText(type, cell.name);
709
+ if (hasAttribute(cell)) return {
681
710
  changes: {
682
- from,
683
- to,
684
- insert: `${delta < 0 ? `${a}\n\n${b}` : `${b}\n\n${a}`}${to < text.length ? "\n\n" : "\n"}`
711
+ from: cell.marker_start,
712
+ to: cell.marker_end,
713
+ insert: attribute
685
714
  },
686
- anchor: delta < 0 ? bStart : bStart + b.length + 2
715
+ anchor: cell.content_start + (attribute.length - (cell.marker_end - cell.marker_start))
687
716
  };
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
717
  return {
693
718
  changes: {
694
- from: pos,
695
- insert: `\n\n${markerText(cell.kind)}\n\n`
719
+ from: cellStart(cell),
720
+ insert: `${attribute}\n`
696
721
  },
697
- anchor: pos
722
+ anchor: cell.content_start + attribute.length + 1
698
723
  };
699
724
  }
700
- function mergeCellText(text, cells, index) {
701
- const cell = cells[index];
702
- if (index === 0 || !hasMarker(cell)) return null;
725
+ /** Appends a cell of `type` after `index` (or at the end of the document). */
726
+ function insertCellText(text, cells, index, type, content = "") {
727
+ const attribute = attributeText(type);
728
+ const at = blockEnd(cells, index, text);
729
+ const prefix = text.slice(Math.max(0, at - 2), at).includes("\n\n") || at === 0 ? "" : "\n\n";
703
730
  return {
704
731
  changes: {
705
- from: cell.marker_start,
706
- to: cell.content_start,
707
- insert: ""
732
+ from: at,
733
+ insert: `${prefix}${attribute}\n${content}\n\n`
708
734
  },
709
- anchor: cell.marker_start
735
+ anchor: at + prefix.length + attribute.length + 1 + content.length
710
736
  };
711
737
  }
712
- function setCellTypeText(text, cells, index, type) {
738
+ function deleteCellText(cells, index, docLength) {
713
739
  const cell = cells[index];
714
- const marker = markerText(type);
715
- if (hasMarker(cell)) return {
740
+ if (!cell) return null;
741
+ const next = cells[index + 1];
742
+ const nextStart = next ? cellStart(next) : docLength;
743
+ const from = hasAttribute(cell) ? cell.marker_start : cell.content_start;
744
+ if (!cells.length || index === 0 && !next && from === 0) return {
716
745
  changes: {
717
- from: cell.marker_start,
718
- to: cell.marker_end,
719
- insert: marker
746
+ from: 0,
747
+ to: docLength,
748
+ insert: ""
720
749
  },
721
- anchor: cell.content_start + (marker.length - (cell.marker_end - cell.marker_start))
750
+ anchor: 0
722
751
  };
723
752
  return {
724
753
  changes: {
725
- from: cellStart(cell),
726
- insert: `${marker}\n`
754
+ from,
755
+ to: nextStart,
756
+ insert: ""
727
757
  },
728
- anchor: cell.content_start + marker.length + 1
758
+ anchor: from
729
759
  };
730
760
  }
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, "");
761
+ /** Copies a cell's kind, name, and body below it. */
762
+ function duplicateCellText(text, cells, index) {
763
+ const cell = cells[index];
764
+ if (!cell) return null;
765
+ const body = text.slice(cell.content_start, cell.content_end);
766
+ return insertCellText(text, cells, index, cell.kind, body);
740
767
  }
741
768
  function dispatchEdit(view, edit) {
742
769
  view.dispatch({
@@ -746,22 +773,13 @@ function dispatchEdit(view, edit) {
746
773
  });
747
774
  return true;
748
775
  }
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
- }
776
+ /** Selects a cell's content so the keyboard commands act on it. */
760
777
  function focusCell(view, index, edge = "start") {
761
778
  const cell = cellsOf(view.state)[index];
762
779
  if (!cell) return false;
780
+ const pos = edge === "start" ? cell.content_start : cell.content_end;
763
781
  view.dispatch({
764
- selection: { anchor: edge === "start" ? cell.content_start : cell.content_end },
782
+ selection: { anchor: pos },
765
783
  scrollIntoView: true
766
784
  });
767
785
  view.focus();
@@ -770,148 +788,105 @@ function focusCell(view, index, edge = "start") {
770
788
  function currentCell(view) {
771
789
  return cellIndexAt(cellsOf(view.state), view.state.selection.main.head);
772
790
  }
773
- function insertCell(view, where) {
774
- return insertCellAt(view, currentCell(view), where);
775
- }
776
- function insertCellAt(view, index, where) {
791
+ /** Inserts a cell below `index`, or at the end of the document. */
792
+ function insertCell(view, type, content = "", index = -1) {
777
793
  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));
794
+ const edit = insertCellText(view.state.doc.toString(), cells, index >= 0 ? index : cells.length ? currentCell(view) : -1, type, content);
795
+ return edit ? dispatchEdit(view, edit) : false;
784
796
  }
785
- function duplicateCellAt(view, index) {
797
+ function duplicateCell(view, index = -1) {
786
798
  const cells = cellsOf(view.state);
787
- if (index < 0 || !cells[index]) return false;
788
- return dispatchEdit(view, duplicateCellText(view.state.doc.toString(), cells, index));
799
+ const at = index >= 0 ? index : currentCell(view);
800
+ const edit = duplicateCellText(view.state.doc.toString(), cells, at);
801
+ return edit ? dispatchEdit(view, edit) : false;
789
802
  }
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));
803
+ function deleteCell(view, index = -1) {
804
+ const edit = deleteCellText(cellsOf(view.state), index >= 0 ? index : currentCell(view), view.state.doc.length);
805
+ return edit ? dispatchEdit(view, edit) : false;
797
806
  }
807
+ /** Moves the active cell up or down. */
798
808
  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
809
  const cells = cellsOf(view.state);
810
810
  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));
811
+ const next = index + delta;
812
+ if (next < 0 || next >= cells.length || index < 0) return false;
813
+ const text = view.state.doc.toString();
814
+ const first = cells[index];
815
+ const second = cells[next];
816
+ const from = cellStart(first);
817
+ const to = cellStart(second);
818
+ const firstEnd = blockEnd(cells, index, text);
819
+ const secondEnd = blockEnd(cells, next, text);
820
+ view.dispatch({
821
+ changes: [{
822
+ from,
823
+ to: firstEnd,
824
+ insert: text.slice(to, secondEnd)
825
+ }, {
826
+ from: to,
827
+ to: secondEnd,
828
+ insert: text.slice(from, firstEnd)
829
+ }],
830
+ selection: { anchor: from },
831
+ scrollIntoView: true
832
+ });
833
+ return true;
830
834
  }
831
835
  function setCellType(view, index, type) {
832
836
  const cells = cellsOf(view.state);
833
- if (!cells[index]) return false;
834
- return dispatchEdit(view, setCellTypeText(view.state.doc.toString(), cells, index, type));
837
+ const edit = setCellTypeText(view.state.doc.toString(), cells, index, type);
838
+ return edit ? dispatchEdit(view, edit) : false;
835
839
  }
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) {
840
+ function splitCell(view) {
846
841
  const cells = cellsOf(view.state);
847
842
  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"));
843
+ const edit = splitCellText(view.state.doc.toString(), cells, index, view.state.selection.main.from);
844
+ return edit ? dispatchEdit(view, edit) : false;
851
845
  }
852
- function runCurrent(view) {
853
- const index = currentCell(view);
854
- return index < 0 ? false : runCell(view, index);
855
- }
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);
846
+ function mergeCell(view) {
847
+ const edit = mergeCellText(cellsOf(view.state), currentCell(view));
848
+ return edit ? dispatchEdit(view, edit) : false;
873
849
  }
874
850
  /**
875
- * Notebook keymap. Mounted with high precedence so Alt-ArrowUp/Down move
876
- * cells instead of lines and Escape reaches command mode.
851
+ * The editor's cell commands. Everything here is also in the cell menu, and
852
+ * every one of them is a text edit, so the undo stack covers them all.
877
853
  */
878
- const notebookKeymap = Prec.high(keymap.of([
879
- {
880
- key: "Shift-Enter",
881
- run: runAndAdvance
882
- },
854
+ const notebookKeymap = keymap.of([
883
855
  {
884
856
  key: "Mod-Enter",
885
- run: runCurrent
857
+ preventDefault: true,
858
+ run: (view) => focusCell(view, currentCell(view), "end")
886
859
  },
887
860
  {
888
- key: "Alt-Enter",
889
- run: runAndInsert
861
+ key: "Mod-Shift-Enter",
862
+ preventDefault: true,
863
+ run: (view) => splitCell(view)
890
864
  },
891
865
  {
892
- key: "Alt-ArrowUp",
866
+ key: "Mod-Shift-ArrowUp",
867
+ preventDefault: true,
893
868
  run: (view) => moveCell(view, -1)
894
869
  },
895
870
  {
896
- key: "Alt-ArrowDown",
871
+ key: "Mod-Shift-ArrowDown",
872
+ preventDefault: true,
897
873
  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
874
  }
914
- ]));
875
+ ]);
876
+ //#endregion
877
+ //#region src/sync.ts
878
+ /**
879
+ * Keeps the engine's sources in step with the editor, without rendering.
880
+ */
881
+ const typstSourceSync = (fileId, prelude, typstState) => EditorState.transactionExtender.of((transaction) => {
882
+ if (!transaction.docChanged) return null;
883
+ try {
884
+ typstState.syncSource(fileId, transaction.newDoc.toString(), prelude.value);
885
+ } catch (error) {
886
+ console.error("[typst] source sync panicked:", error);
887
+ }
888
+ return null;
889
+ });
915
890
  //#endregion
916
891
  //#region src/diagnostics.ts
917
892
  /**
@@ -919,7 +894,7 @@ const notebookKeymap = Prec.high(keymap.of([
919
894
  * diagnostics of every compile here, so the linter source can reuse them
920
895
  * instead of compiling the same text a second time.
921
896
  */
922
- const diagnosticsCache = /* @__PURE__ */ new Map();
897
+ const diagnosticsCache = new LRUCache({ max: 2 });
923
898
  function rememberDiagnostics(path, text, diagnostics) {
924
899
  diagnosticsCache.set(path, {
925
900
  text,
@@ -958,11 +933,6 @@ function toLintDiagnostics(typstDiagnostics) {
958
933
  }
959
934
  /**
960
935
  * 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
936
  */
967
937
  const typstLinter = (fileId, spaceId, path, prelude, typstState, options = {}) => linter(async (view) => {
968
938
  const text = view.state.doc.toString();
@@ -970,14 +940,14 @@ const typstLinter = (fileId, spaceId, path, prelude, typstState, options = {}) =
970
940
  if (cached) return toLintDiagnostics(cached);
971
941
  let result;
972
942
  try {
973
- result = typstState.compilePaged(fileId, text, prelude.value);
943
+ result = typstState.diagnosePaged(fileId, text, prelude.value);
974
944
  } catch (error) {
975
945
  console.error("[typst] diagnostics compile panicked:", error);
976
946
  options.onPanic?.(fileId);
977
947
  return [];
978
948
  }
979
949
  if (result.requests.length > 0 && options.onRequests) {
980
- if (await options.onRequests(result.requests, spaceId)) result = typstState.compilePaged(fileId, text, prelude.value);
950
+ if (await options.onRequests(result.requests, spaceId)) result = typstState.diagnosePaged(fileId, text, prelude.value);
981
951
  }
982
952
  rememberDiagnostics(path, text, result.diagnostics);
983
953
  options.onCompile?.();
@@ -986,6 +956,26 @@ const typstLinter = (fileId, spaceId, path, prelude, typstState, options = {}) =
986
956
  //#endregion
987
957
  //#region src/frames.ts
988
958
  const containerCache = new LRUCache({ max: 128 });
959
+ const SVG_NS = "http://www.w3.org/2000/svg";
960
+ let sharedDefs;
961
+ /**
962
+ * Places the glyph and gradient definitions a frame set references.
963
+ */
964
+ function installSharedDefs(defs) {
965
+ if (!defs) return;
966
+ if (!sharedDefs || !sharedDefs.isConnected) {
967
+ const host = document.createElementNS(SVG_NS, "svg");
968
+ host.setAttribute("width", "0");
969
+ host.setAttribute("height", "0");
970
+ host.setAttribute("aria-hidden", "true");
971
+ host.style.position = "absolute";
972
+ sharedDefs = document.createElementNS(SVG_NS, "defs");
973
+ host.append(sharedDefs);
974
+ document.body.append(host);
975
+ }
976
+ sharedDefs.innerHTML = "";
977
+ sharedDefs.innerHTML = defs;
978
+ }
989
979
  const openInNewTab = (url) => {
990
980
  window.open(url, "_blank", "noopener,noreferrer");
991
981
  };
@@ -1147,46 +1137,46 @@ var TypstWidget = class extends WidgetType {
1147
1137
  };
1148
1138
  //#endregion
1149
1139
  //#region src/notebook-widgets.ts
1150
- function iconSpan(name) {
1140
+ function icon(name, size) {
1151
1141
  const span = document.createElement("span");
1152
1142
  span.className = "ms-icon material-symbols-rounded";
1153
- span.textContent = name;
1154
1143
  span.setAttribute("aria-hidden", "true");
1144
+ if (size !== void 0) span.style.fontSize = `calc(${size}px * var(--ui-size, 1))`;
1145
+ span.textContent = name;
1155
1146
  return span;
1156
1147
  }
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) => {
1148
+ /** A button that must not hand the mousedown to CodeMirror's selection. */
1149
+ function button(name, label, onClick, className = "") {
1150
+ const element = document.createElement("button");
1151
+ element.type = "button";
1152
+ element.className = `tb-cell-btn${className ? ` ${className}` : ""}`;
1153
+ element.title = label;
1154
+ element.setAttribute("aria-label", label);
1155
+ element.append(icon(name, RAIL_ICON_SIZE));
1156
+ element.addEventListener("mousedown", (event) => event.preventDefault());
1157
+ element.addEventListener("click", (event) => {
1181
1158
  event.preventDefault();
1182
1159
  event.stopPropagation();
1183
- onClick(event);
1160
+ onClick();
1184
1161
  });
1185
- return button;
1162
+ return element;
1186
1163
  }
1187
- var NotebookHeaderWidget = class extends WidgetType {
1164
+ /**
1165
+ * Icon size across the rail: the chip glyph, its overflow button, and the menu's
1166
+ * items, which is the same row and `UiMenuItem`'s default.
1167
+ */
1168
+ const RAIL_ICON_SIZE = 20;
1169
+ /** The glyph and label for a cell kind. */
1170
+ const KIND_PRESENTATION = {
1171
+ prose: "notes",
1172
+ code: "code",
1173
+ log: "terminal",
1174
+ hidden: "visibility_off"
1175
+ };
1176
+ var NotebookRailWidget = class extends WidgetType {
1188
1177
  view;
1189
1178
  args;
1179
+ menu = null;
1190
1180
  constructor(view, args) {
1191
1181
  super();
1192
1182
  this.view = view;
@@ -1195,48 +1185,139 @@ var NotebookHeaderWidget = class extends WidgetType {
1195
1185
  eq(other) {
1196
1186
  const a = this.args;
1197
1187
  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;
1188
+ 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;
1189
+ }
1190
+ ignoreEvent(event) {
1191
+ return event.type === "mousedown" && event.target.classList.contains("tb-cell-rail");
1199
1192
  }
1200
1193
  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);
1194
+ const { index, kind, name, labels } = this.args;
1195
+ const rail = document.createElement("div");
1196
+ rail.className = "tb-cell-rail";
1197
+ rail.dataset.kind = kind;
1198
+ rail.dataset.cell = String(index);
1199
+ const chip = document.createElement("span");
1200
+ chip.className = "tb-cell-chip";
1201
+ chip.append(icon(KIND_PRESENTATION[kind], RAIL_ICON_SIZE));
1202
+ chip.append(document.createTextNode(name || labels[kind]));
1203
+ rail.append(chip);
1204
+ if (this.args.held) {
1205
+ const held = document.createElement("span");
1206
+ held.className = "tb-cell-held";
1207
+ held.textContent = labels.held;
1208
+ rail.append(held);
1214
1209
  }
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;
1210
+ const menu = button("more_vert", labels.menu, () => this.toggleMenu(rail), "tb-cell-btn--menu");
1211
+ rail.append(menu);
1212
+ rail.addEventListener("mousedown", (event) => {
1213
+ if (event.target.closest("button")) return;
1231
1214
  event.preventDefault();
1232
1215
  focusCell(this.view, index);
1233
1216
  });
1234
- return header;
1217
+ return rail;
1235
1218
  }
1236
- get estimatedHeight() {
1237
- return 24;
1219
+ toggleMenu(anchor) {
1220
+ if (this.menu) {
1221
+ this.closeMenu();
1222
+ return;
1223
+ }
1224
+ const { labels, index, collapsed, held } = this.args;
1225
+ const items = [
1226
+ {
1227
+ command: "toggleSource",
1228
+ label: labels.toggleSource,
1229
+ name: collapsed ? "unfold_more" : "unfold_less"
1230
+ },
1231
+ {
1232
+ command: "split",
1233
+ label: labels.split,
1234
+ name: "call_split"
1235
+ },
1236
+ {
1237
+ command: "merge",
1238
+ label: labels.merge,
1239
+ name: "merge_type"
1240
+ },
1241
+ {
1242
+ command: "moveUp",
1243
+ label: labels.moveUp,
1244
+ name: "keyboard_double_arrow_up"
1245
+ },
1246
+ {
1247
+ command: "moveDown",
1248
+ label: labels.moveDown,
1249
+ name: "keyboard_double_arrow_down"
1250
+ },
1251
+ {
1252
+ command: "duplicate",
1253
+ label: labels.duplicate,
1254
+ name: "content_copy"
1255
+ },
1256
+ {
1257
+ command: "copy",
1258
+ label: labels.copy,
1259
+ name: "content_paste"
1260
+ },
1261
+ {
1262
+ command: held ? "release" : "hold",
1263
+ label: held ? labels.release : labels.hold,
1264
+ name: held ? "play_arrow" : "pause"
1265
+ },
1266
+ {
1267
+ command: "clearOutput",
1268
+ label: labels.clearOutput,
1269
+ name: "delete_sweep"
1270
+ },
1271
+ {
1272
+ command: "remove",
1273
+ label: labels.remove,
1274
+ name: "delete"
1275
+ }
1276
+ ];
1277
+ const menu = document.createElement("div");
1278
+ menu.className = "menu tb-cell-menu";
1279
+ menu.setAttribute("role", "menu");
1280
+ for (const item of items) {
1281
+ const entry = document.createElement("button");
1282
+ entry.type = "button";
1283
+ entry.className = "menu__item";
1284
+ entry.setAttribute("role", "menuitem");
1285
+ entry.append(icon(item.name, RAIL_ICON_SIZE));
1286
+ entry.append(document.createTextNode(item.label));
1287
+ entry.addEventListener("click", () => {
1288
+ this.closeMenu();
1289
+ this.args.onCommand(item.command, index);
1290
+ });
1291
+ menu.append(entry);
1292
+ }
1293
+ document.body.append(menu);
1294
+ const box = anchor.getBoundingClientRect();
1295
+ menu.style.position = "fixed";
1296
+ menu.style.left = `${Math.round(box.left)}px`;
1297
+ menu.style.top = `${Math.round(box.bottom + 4)}px`;
1298
+ this.menu = menu;
1299
+ const dismiss = (event) => {
1300
+ if (menu.contains(event.target)) return;
1301
+ this.closeMenu();
1302
+ };
1303
+ setTimeout(() => document.addEventListener("mousedown", dismiss), 0);
1304
+ document.addEventListener("keyup", this.onEscape);
1305
+ }
1306
+ onEscape = (event) => {
1307
+ if (event.key === "Escape") this.closeMenu();
1308
+ };
1309
+ closeMenu() {
1310
+ this.menu?.remove();
1311
+ this.menu = null;
1312
+ document.removeEventListener("keyup", this.onEscape);
1313
+ }
1314
+ destroy() {
1315
+ this.closeMenu();
1238
1316
  }
1239
1317
  };
1318
+ /**
1319
+ * A cell's output: its frames and the diagnostics whose span falls inside it.
1320
+ */
1240
1321
  var NotebookOutputWidget = class extends WidgetType {
1241
1322
  view;
1242
1323
  fileId;
@@ -1251,30 +1332,25 @@ var NotebookOutputWidget = class extends WidgetType {
1251
1332
  this.args = args;
1252
1333
  this.openExternal = openExternal;
1253
1334
  }
1254
- /** Hash of everything the DOM depends on, since DOM identity is expensive. */
1255
1335
  get key() {
1256
1336
  const frames = this.args.frames.map((frame) => {
1257
1337
  const size = frameSize(frame);
1258
1338
  return `${frame.render.hash}:${Math.round(size.width)}x${Math.round(size.height)}`;
1259
1339
  }).join(",");
1260
1340
  const diagnostics = this.args.diagnostics.map((diagnostic) => diagnostic.message).join("|");
1261
- return `${this.args.index}:${this.args.cleared}:${this.args.hasRun}:${frames}:${diagnostics}`;
1341
+ return `${this.args.index}:${this.args.cleared}:${this.args.held}:${frames}:${diagnostics}`;
1262
1342
  }
1263
1343
  eq(other) {
1264
1344
  return this.key === other.key;
1265
1345
  }
1266
1346
  toDOM() {
1267
1347
  const root = document.createElement("div");
1268
- const { cleared, labels } = this.args;
1348
+ const { cleared, held, labels } = this.args;
1269
1349
  root.className = "tb-cell-output";
1270
1350
  if (cleared) {
1271
1351
  root.classList.add("tb-cell-output--cleared");
1272
1352
  return root;
1273
1353
  }
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
1354
  const body = document.createElement("div");
1279
1355
  body.className = "tb-cell-output-body";
1280
1356
  for (const diagnostic of this.args.diagnostics) {
@@ -1300,7 +1376,12 @@ var NotebookOutputWidget = class extends WidgetType {
1300
1376
  holder.append(container);
1301
1377
  body.append(holder);
1302
1378
  }
1303
- if (!this.args.frames.length && !this.args.diagnostics.length && this.args.hasRun) {
1379
+ if (held) {
1380
+ const note = document.createElement("div");
1381
+ note.className = "tb-cell-stale";
1382
+ note.textContent = labels.held;
1383
+ body.append(note);
1384
+ } else if (!this.args.frames.length && !this.args.diagnostics.length) {
1304
1385
  const empty = document.createElement("div");
1305
1386
  empty.className = "tb-cell-empty";
1306
1387
  empty.textContent = labels.noOutput;
@@ -1315,51 +1396,109 @@ var NotebookOutputWidget = class extends WidgetType {
1315
1396
  return height + 16;
1316
1397
  }
1317
1398
  };
1399
+ /**
1400
+ * The height an off-screen cell's output reserves, without building any of it.
1401
+ */
1402
+ var NotebookOutputSpacer = class extends WidgetType {
1403
+ height;
1404
+ constructor(height) {
1405
+ super();
1406
+ this.height = height;
1407
+ }
1408
+ eq(other) {
1409
+ return this.height === other.height;
1410
+ }
1411
+ toDOM() {
1412
+ const root = document.createElement("div");
1413
+ root.className = "tb-cell-output tb-cell-output--offscreen";
1414
+ return root;
1415
+ }
1416
+ get estimatedHeight() {
1417
+ return this.height;
1418
+ }
1419
+ };
1318
1420
  function decorateNotebook(args) {
1319
- const { view, state, cells, frames, diagnostics, fileId, typstState, locked, onExternalLink, options } = args;
1421
+ const { view, state, cells, frames, diagnostics, fileId, typstState, locked, readOnly, renderFrames, activeIndex, onCommand, onExternalLink, options } = args;
1320
1422
  const decorations = [];
1321
1423
  const byCell = cells.map(() => []);
1322
1424
  for (const frame of frames) {
1323
1425
  const index = cellIndexAt(cells, frame.range.start);
1324
1426
  if (index >= 0) byCell[index].push(frame);
1325
1427
  }
1326
- const counter = options.counters?.() ?? false;
1428
+ const labels = options.labels ?? FALLBACK_LABELS;
1429
+ const cellState = (index) => options.state?.(index) ?? {};
1430
+ const visible = /* @__PURE__ */ new Set();
1431
+ for (const range of view.visibleRanges) {
1432
+ const from = cellIndexAt(cells, range.from);
1433
+ const to = cellIndexAt(cells, range.to);
1434
+ for (let index = Math.max(0, from); index <= Math.min(cells.length - 1, to); index++) visible.add(index);
1435
+ }
1327
1436
  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;
1437
+ const own = cellState(index);
1438
+ const firstLine = state.doc.lineAt(cellStart(cell)).from;
1439
+ if (index === activeIndex) decorations.push(Decoration.line({ class: "tb-cell-active" }).range(firstLine));
1440
+ if (!readOnly) {
1441
+ decorations.push(Decoration.widget({
1442
+ widget: new NotebookRailWidget(view, {
1443
+ index,
1444
+ kind: cell.kind,
1445
+ name: cell.name,
1446
+ collapsed: own.collapsed ?? false,
1447
+ held: own.held ?? false,
1448
+ labels,
1449
+ onCommand
1450
+ }),
1451
+ block: !hasAttribute(cell),
1452
+ side: -1
1453
+ }).range(firstLine));
1454
+ if (hasAttribute(cell)) decorations.push(Decoration.line({ class: "tb-cell-attribute" }).range(state.doc.lineAt(cell.marker_start).from));
1455
+ }
1456
+ const contentStart = cell.content_start;
1457
+ const contentEnd = cell.content_end;
1458
+ const hideSource = (own.collapsed ?? false) || hidesSource(cell.kind);
1459
+ if (hideSource && contentEnd > contentStart) decorations.push(Decoration.replace({}).range(contentStart, contentEnd));
1460
+ if (hideSource) {
1461
+ if (renderFrames) pushDiagnostics(decorations, state, cell, diagnostics);
1462
+ return;
1463
+ }
1464
+ if (rendersInPlace(cell.kind)) {
1465
+ if (!renderFrames) return;
1345
1466
  for (const frame of byCell[index] ?? []) {
1346
1467
  const { start, end } = frame.range;
1347
1468
  if (!frame.render || end <= start) continue;
1348
1469
  if (frameIsInactive(view, state, start, end)) decorations.push(Decoration.replace({ widget: new TypstWidget(view, frame, locked, fileId, typstState, onExternalLink) }).range(start, end));
1349
1470
  else decorations.push(...frameActiveDecorations(view, state, frame));
1350
1471
  }
1472
+ if (renderFrames) pushDiagnostics(decorations, state, cell, diagnostics);
1473
+ return;
1474
+ }
1475
+ if (!hasOutput(cell.kind)) return;
1476
+ if (!renderFrames) return;
1477
+ const outputPos = contentEnd > contentStart ? state.doc.lineAt(contentEnd).to : contentStart;
1478
+ const held = own.held ?? false;
1479
+ const output = held ? own.frozen ?? {
1480
+ frames: [],
1481
+ diagnostics: []
1482
+ } : {
1483
+ frames: byCell[index] ?? [],
1484
+ diagnostics: cellDiagnosticsFor(cell, diagnostics)
1485
+ };
1486
+ if (!visible.has(index)) {
1487
+ decorations.push(Decoration.widget({
1488
+ widget: new NotebookOutputSpacer(outputHeight(output.frames)),
1489
+ block: true,
1490
+ side: 1
1491
+ }).range(outputPos));
1351
1492
  return;
1352
1493
  }
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
1494
  decorations.push(Decoration.widget({
1356
1495
  widget: new NotebookOutputWidget(view, fileId, typstState, {
1357
1496
  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
1497
+ frames: output.frames,
1498
+ diagnostics: output.diagnostics,
1499
+ cleared: own.cleared ?? false,
1500
+ held,
1501
+ labels
1363
1502
  }, onExternalLink),
1364
1503
  block: true,
1365
1504
  side: 1
@@ -1367,9 +1506,79 @@ function decorateNotebook(args) {
1367
1506
  });
1368
1507
  return decorations;
1369
1508
  }
1509
+ function outputHeight(frames) {
1510
+ let height = 0;
1511
+ for (const frame of frames) height += frameSize(frame).height;
1512
+ return height + 16;
1513
+ }
1514
+ /** The diagnostics whose span starts inside a cell's content. */
1515
+ function cellDiagnosticsFor(cell, diagnostics) {
1516
+ return diagnostics.filter((diagnostic) => diagnostic.range.start >= cell.content_start && diagnostic.range.start <= cell.content_end);
1517
+ }
1518
+ /**
1519
+ * A cell's diagnostics as a block under its content, so an error is locatable
1520
+ * by cell rather than only in the lint panel.
1521
+ */
1522
+ function pushDiagnostics(decorations, state, cell, diagnostics) {
1523
+ const own = cellDiagnosticsFor(cell, diagnostics);
1524
+ if (!own.length) return;
1525
+ const pos = cell.content_end > cell.content_start ? state.doc.lineAt(cell.content_end).to : cell.content_start;
1526
+ decorations.push(Decoration.widget({
1527
+ widget: new NotebookDiagnosticsWidget(own),
1528
+ block: true,
1529
+ side: 1
1530
+ }).range(pos));
1531
+ }
1532
+ var NotebookDiagnosticsWidget = class extends WidgetType {
1533
+ diagnostics;
1534
+ constructor(diagnostics) {
1535
+ super();
1536
+ this.diagnostics = diagnostics;
1537
+ }
1538
+ eq(other) {
1539
+ return this.diagnostics.length === other.diagnostics.length && this.diagnostics.every((diagnostic, index) => diagnostic.message === other.diagnostics[index]?.message);
1540
+ }
1541
+ toDOM() {
1542
+ const root = document.createElement("div");
1543
+ root.className = "tb-cell-diagnostics";
1544
+ for (const diagnostic of this.diagnostics) {
1545
+ const item = document.createElement("div");
1546
+ item.className = `tb-cell-diagnostic tb-cell-diagnostic--${diagnostic.severity}`;
1547
+ item.textContent = diagnostic.message;
1548
+ root.append(item);
1549
+ }
1550
+ return root;
1551
+ }
1552
+ get estimatedHeight() {
1553
+ return this.diagnostics.length * 22 + 8;
1554
+ }
1555
+ };
1556
+ const FALLBACK_LABELS = {
1557
+ code: "Code",
1558
+ prose: "Text",
1559
+ log: "Log",
1560
+ hidden: "Hidden",
1561
+ menu: "Cell actions",
1562
+ split: "Split cell",
1563
+ merge: "Merge with the cell above",
1564
+ moveUp: "Move cell up",
1565
+ moveDown: "Move cell down",
1566
+ duplicate: "Duplicate cell",
1567
+ remove: "Delete cell",
1568
+ copy: "Copy cell",
1569
+ hold: "Hold output",
1570
+ release: "Release output",
1571
+ clearOutput: "Clear output",
1572
+ toggleSource: "Collapse cell",
1573
+ noOutput: "No output",
1574
+ held: "Held"
1575
+ };
1370
1576
  //#endregion
1371
1577
  //#region src/widgets.ts
1372
- const compileCache = new LRUCache({ max: 8 });
1578
+ /**
1579
+ * Compiled frames per cache key, where the key is `path:revision:appRevision`.
1580
+ */
1581
+ const compileCache = new LRUCache({ max: 2 });
1373
1582
  const updateFlagStore = /* @__PURE__ */ new Set();
1374
1583
  /**
1375
1584
  * Dispatch this to recompile even when the document did not change. Used when
@@ -1390,11 +1599,14 @@ function editorRenderWidth(scrollDOM, contentDOM) {
1390
1599
  const tooltipsStateEffect = StateEffect.define();
1391
1600
  const tooltipsStateField = StateField.define({
1392
1601
  create() {
1393
- return [];
1602
+ return {
1603
+ tooltips: [],
1604
+ ranges: []
1605
+ };
1394
1606
  },
1395
- update(frames, transaction) {
1607
+ update(overlays, transaction) {
1396
1608
  const effect = transaction.effects.find((e) => e.is(tooltipsStateEffect));
1397
- return effect ? effect.value : frames;
1609
+ return effect ? effect.value : overlays;
1398
1610
  }
1399
1611
  });
1400
1612
  /**
@@ -1440,25 +1652,27 @@ function measureLineHeights(view, frames) {
1440
1652
  }
1441
1653
  return heights;
1442
1654
  }
1443
- function decorate({ fileId, spaceId, path, prelude, locked, update, updateInWidget: _updateInWidget, widthChanged, forced, typstState, revision, onRequests, onPanic, onCompile, onExternalLink, notebook }) {
1655
+ function decorate({ fileId, spaceId, path, prelude, locked, update, updateInWidget: _updateInWidget, widthChanged, forced, typstState, revision, onRequests, onPanic, onCompile, onExternalLink, notebook, renderFrames = true }) {
1444
1656
  const text = update.state.doc.toString();
1445
1657
  const isFlaggedForUpdate = updateFlagStore.has(path);
1446
1658
  const cacheKey = compileCacheKey(path, typstState, revision);
1447
1659
  let frames;
1448
1660
  let tooltips;
1661
+ let equationRanges;
1449
1662
  let diagnostics;
1450
1663
  if (forced || update.docChanged || widthChanged || !compileCache.has(cacheKey)) {
1451
1664
  if (isFlaggedForUpdate) updateFlagStore.delete(path);
1452
1665
  else updateFlagStore.add(path);
1453
1666
  let compileResult;
1454
1667
  try {
1455
- compileResult = typstState.compilePaged(fileId, text, prelude);
1668
+ compileResult = typstState.compilePaged(fileId, text, prelude, update.state.selection.main.from);
1456
1669
  } catch (error) {
1457
1670
  console.error("[typst] compile panicked:", error);
1458
1671
  onPanic?.(fileId);
1459
1672
  return {
1460
1673
  decorations: Decoration.none,
1461
1674
  tooltips: [],
1675
+ equationRanges: [],
1462
1676
  frames: [],
1463
1677
  active: []
1464
1678
  };
@@ -1470,21 +1684,18 @@ function decorate({ fileId, spaceId, path, prelude, locked, update, updateInWidg
1470
1684
  if (!wasUpdated) return;
1471
1685
  update.view.dispatch({ effects: typstRecompileEffect.of(null) });
1472
1686
  });
1473
- ({frames, tooltips} = compileResult);
1687
+ installSharedDefs(compileResult.defs);
1688
+ ({frames, tooltips, equation_ranges: equationRanges} = compileResult);
1474
1689
  diagnostics = compileResult.diagnostics;
1475
1690
  compileCache.set(cacheKey, {
1476
1691
  frames,
1477
1692
  tooltips,
1478
- diagnostics
1479
- });
1480
- notebook?.onCompile?.({
1481
- text,
1482
- frames,
1693
+ equationRanges,
1483
1694
  diagnostics
1484
1695
  });
1485
1696
  } else {
1486
1697
  const cached = compileCache.get(cacheKey);
1487
- ({frames, tooltips, diagnostics} = cached);
1698
+ ({frames, tooltips, equationRanges, diagnostics} = cached);
1488
1699
  }
1489
1700
  const { view, state } = update;
1490
1701
  if (notebook) {
@@ -1497,12 +1708,19 @@ function decorate({ fileId, spaceId, path, prelude, locked, update, updateInWidg
1497
1708
  return {
1498
1709
  decorations: Decoration.none,
1499
1710
  tooltips: [],
1711
+ equationRanges: [],
1500
1712
  frames: [],
1501
1713
  active: []
1502
1714
  };
1503
1715
  }
1504
1716
  notebook.onCells?.(cells);
1505
- notebook.onActiveCell?.(cells.length ? cellIndexAt(cells, state.selection.main.head) : null);
1717
+ notebook.onCompile?.({
1718
+ text,
1719
+ frames,
1720
+ diagnostics
1721
+ });
1722
+ const activeIndex = cells.length ? cellIndexAt(cells, state.selection.main.head) : null;
1723
+ notebook.onActiveCell?.(activeIndex);
1506
1724
  return {
1507
1725
  decorations: Decoration.set(decorateNotebook({
1508
1726
  view,
@@ -1513,10 +1731,15 @@ function decorate({ fileId, spaceId, path, prelude, locked, update, updateInWidg
1513
1731
  fileId,
1514
1732
  typstState,
1515
1733
  locked,
1734
+ readOnly: notebook.readOnly?.() ?? false,
1735
+ renderFrames,
1736
+ activeIndex,
1516
1737
  onExternalLink,
1738
+ onCommand: (command, index) => notebook.onCommand?.(command, index),
1517
1739
  options: notebook
1518
1740
  }), true),
1519
1741
  tooltips,
1742
+ equationRanges,
1520
1743
  frames,
1521
1744
  active: []
1522
1745
  };
@@ -1533,6 +1756,7 @@ function decorate({ fileId, spaceId, path, prelude, locked, update, updateInWidg
1533
1756
  return {
1534
1757
  decorations: built.decorations,
1535
1758
  tooltips,
1759
+ equationRanges,
1536
1760
  frames,
1537
1761
  active: built.active
1538
1762
  };
@@ -1552,6 +1776,7 @@ const typstStateField = StateField.define({
1552
1776
  const typstViewPlugin = (fileId, spaceId, path, text, prelude, locked, typstState, options = {}) => ViewPlugin.define((_view) => {
1553
1777
  let firstUpdate = true;
1554
1778
  let resizeTimer;
1779
+ const renderFrames = options.renderFrames ?? true;
1555
1780
  const remeasureKey = {};
1556
1781
  /**
1557
1782
  * A pass that shows a block's source runs while the block's widget is still
@@ -1591,12 +1816,9 @@ const typstViewPlugin = (fileId, spaceId, path, text, prelude, locked, typstStat
1591
1816
  return {
1592
1817
  update(update) {
1593
1818
  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));
1819
+ const refresh = update.transactions.flatMap((transaction) => transaction.effects).some((effect) => effect.is(notebookRefreshEffect));
1597
1820
  const forced = firstUpdate || update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(typstRecompileEffect)));
1598
1821
  firstUpdate = false;
1599
- if (run) options.notebook?.onRun?.(run.value.index);
1600
1822
  if (update.geometryChanged) {
1601
1823
  const { scrollDOM, contentDOM } = update.view;
1602
1824
  try {
@@ -1613,7 +1835,7 @@ const typstViewPlugin = (fileId, spaceId, path, text, prelude, locked, typstStat
1613
1835
  update.view.dispatch({ effects: typstRecompileEffect.of(null) });
1614
1836
  }, 150);
1615
1837
  }
1616
- if (update.docChanged || update.selectionSet || update.focusChanged || forced || run || refresh) {
1838
+ if (update.docChanged || update.selectionSet || update.focusChanged || forced || refresh) {
1617
1839
  queueMicrotask(() => {
1618
1840
  let result;
1619
1841
  try {
@@ -1626,14 +1848,15 @@ const typstViewPlugin = (fileId, spaceId, path, text, prelude, locked, typstStat
1626
1848
  update,
1627
1849
  updateInWidget: false,
1628
1850
  widthChanged,
1629
- forced: forced || Boolean(run),
1851
+ forced,
1630
1852
  typstState,
1631
1853
  revision: options.revision,
1632
1854
  onRequests: options.onRequests,
1633
1855
  onPanic: options.onPanic,
1634
1856
  onCompile: options.onCompile,
1635
1857
  onExternalLink: options.onExternalLink,
1636
- notebook: options.notebook
1858
+ notebook: options.notebook,
1859
+ renderFrames
1637
1860
  });
1638
1861
  } catch (error) {
1639
1862
  console.error("[typst] decorate panicked:", error);
@@ -1641,7 +1864,10 @@ const typstViewPlugin = (fileId, spaceId, path, text, prelude, locked, typstStat
1641
1864
  return;
1642
1865
  }
1643
1866
  if (result) {
1644
- const stateEffects = [typstStateEffect.of({ decorations: result.decorations }), tooltipsStateEffect.of(result.tooltips)];
1867
+ const stateEffects = [typstStateEffect.of({ decorations: result.decorations }), tooltipsStateEffect.of({
1868
+ tooltips: result.tooltips,
1869
+ ranges: result.equationRanges
1870
+ })];
1645
1871
  update.view.dispatch({ effects: stateEffects });
1646
1872
  remeasureActive(update, result);
1647
1873
  }
@@ -1680,7 +1906,13 @@ const tooltipViewPlugin = () => ViewPlugin.define((view) => ({ update(update) {
1680
1906
  }
1681
1907
  const state = view.state;
1682
1908
  const pos = state.selection.main.from;
1683
- const tooltips = state.field(tooltipsStateField);
1909
+ const { tooltips, ranges } = state.field(tooltipsStateField);
1910
+ if (!tooltips.some(({ range }) => pos >= range.start && pos <= range.end)) {
1911
+ if (ranges.some(([start, end]) => pos >= start && pos <= end)) {
1912
+ view.dispatch({ effects: typstRecompileEffect.of(null) });
1913
+ return;
1914
+ }
1915
+ }
1684
1916
  let tooltip = null;
1685
1917
  for (const { render, range } of tooltips) {
1686
1918
  const { start, end } = range;
@@ -1694,8 +1926,8 @@ const tooltipViewPlugin = () => ViewPlugin.define((view) => ({ update(update) {
1694
1926
  svg.setHTMLUnsafe(render.svg);
1695
1927
  container.append(svg);
1696
1928
  tooltip = {
1697
- pos: start + 1,
1698
- end: end - 1,
1929
+ pos: start,
1930
+ end,
1699
1931
  create(tooltipView) {
1700
1932
  return {
1701
1933
  dom: container,
@@ -1704,8 +1936,8 @@ const tooltipViewPlugin = () => ViewPlugin.define((view) => ({ update(update) {
1704
1936
  const endCoords = tooltipView.coordsAtPos(end);
1705
1937
  return {
1706
1938
  left: startCoords.left,
1707
- right: startCoords.right,
1708
- top: endCoords.top,
1939
+ right: endCoords.right,
1940
+ top: startCoords.top,
1709
1941
  bottom: endCoords.bottom
1710
1942
  };
1711
1943
  }
@@ -1730,6 +1962,7 @@ const typstPlugin = (fileId, spaceId, path, text, prelude, locked, typstState, o
1730
1962
  autocompletion({ override: [(context) => autocomplete(context, fileId, typstState)] }),
1731
1963
  typstKeymap,
1732
1964
  typstLanguageData,
1965
+ typstSourceSync(fileId, prelude, typstState),
1733
1966
  typstSyntaxHighlighting(fileId, typstState),
1734
1967
  typstHoverTooltip(fileId, typstState),
1735
1968
  addSpaceBeforeClosingBracket,
@@ -2098,4 +2331,4 @@ function stringEnd(line, from) {
2098
2331
  return line.length;
2099
2332
  }
2100
2333
  //#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 };
2334
+ 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, typstHoverTooltip, typstKeymap, typstLanguageData, typstLinter, typstPlugin, typstRecompileEffect, typstSourceSync, typstStateField, typstStaticHighlighting, typstSyntaxHighlighting, typstViewPlugin, wrapOrInsert };