@adecore/editor 0.41.1 → 0.43.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.
package/src/types.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import type { EditorSearchClientOptions } from './search-client.ts';
2
2
  import type { Keymap } from './keymap-table.ts';
3
- import type { EditorCommand, FoldRole } from '@adecore/editor-core';
3
+ import type { EditorCommand, FoldRole, WhitespaceCleanupOptions } from '@adecore/editor-core';
4
4
 
5
5
  export type { EditorCommand, FoldRole };
6
6
 
7
+ /* What a cleanup before a save does; everything is off unless set, except that the lines with a caret keep their trailing whitespace. */
8
+ export type EditorWhitespaceCleanup = WhitespaceCleanupOptions;
9
+
7
10
  export type EditorFoldOutline = 'off' | 'hover' | 'always';
8
11
 
9
- /* The commands that fold, which the view answers since it knows where the ranges are. */
12
+ /* The commands that fold or change how the text is drawn, which the view answers since it knows where the ranges are. */
10
13
  export type EditorViewCommand =
11
14
  | 'collapseRegion'
12
15
  | 'expandRegion'
@@ -22,7 +25,25 @@ export type EditorViewCommand =
22
25
  | 'expandAllToLevel3'
23
26
  | 'expandAllToLevel4'
24
27
  | 'expandAllToLevel5'
25
- | 'toggleColumnMode';
28
+ | 'toggleColumnMode'
29
+ | 'increaseFontSize'
30
+ | 'decreaseFontSize'
31
+ | 'resetFontSize'
32
+ | 'methodUp'
33
+ | 'methodDown'
34
+ | 'nextChange'
35
+ | 'previousChange'
36
+ | 'pasteAsPlainText';
37
+
38
+ /* How far a person may zoom the text, and whether the wheel does. */
39
+ export interface EditorZoom {
40
+ /* In pixels; 12 by default, and never less. */
41
+ readonly min?: number;
42
+ /* In pixels; 90 by default. */
43
+ readonly max?: number;
44
+ /* Ctrl with the wheel (Cmd on macOS), or a pinch, zooms; on unless false. */
45
+ readonly wheel?: boolean;
46
+ }
26
47
 
27
48
  /* Everything `runCommand` runs. */
28
49
  export type EditorRunCommand = EditorCommand | EditorViewCommand;
@@ -87,6 +108,11 @@ export interface EditorOptions {
87
108
  readonly column?: number;
88
109
  /* In pixels, where the view opens; without it the cursor's line is brought into view. */
89
110
  readonly scrollTop?: number;
111
+ /* The words that mark a TODO comment, matched as whole words in any case; `DEFAULT_TODO_WORDS` without them, and none drawn with an empty list. */
112
+ readonly todoWords?: readonly string[];
113
+ /* In pixels, a size a person zoomed to that the host kept; without it the code has the page's `--code-font-size`. */
114
+ readonly fontSize?: number;
115
+ readonly zoom?: EditorZoom;
90
116
  }
91
117
 
92
118
  /* The few sentences the editor says on its own. */
@@ -262,6 +288,21 @@ export interface EditorContentChange {
262
288
  readonly text: string;
263
289
  }
264
290
 
291
+ /* What a copy or cut put on the clipboard. */
292
+ export interface EditorCopy {
293
+ readonly text: string;
294
+ /* A copy of bare carets takes their lines, which paste back above the lines of bare carets. */
295
+ readonly wholeLines: boolean;
296
+ readonly cut: boolean;
297
+ }
298
+
299
+ export interface EditorPasteOptions {
300
+ /* Pastes above the lines of bare carets, as the paste of a copy of bare carets does; by default when the text is the last such copy. */
301
+ readonly wholeLines?: boolean;
302
+ /* False leaves the indentation of the text alone; the editor's indent on paste setting otherwise. */
303
+ readonly reindent?: boolean;
304
+ }
305
+
265
306
  export interface EditorApplyEditsOptions {
266
307
  readonly mapSelections?: (range: EditorRange) => EditorRange;
267
308
  readonly mergeUndo?: boolean;
@@ -451,6 +492,8 @@ export interface EditorInlayHint {
451
492
  /* A gap of 4 pixels that keeps the pill off the character before it or the one after it. */
452
493
  readonly paddingLeft?: boolean;
453
494
  readonly paddingRight?: boolean;
495
+ /* A square of this CSS color in place of the label, such as the color a literal stands for; the label is then what assistive technology reads. */
496
+ readonly swatch?: string;
454
497
  }
455
498
 
456
499
  /* A hint, or one part of its label, under the pointer. */
@@ -623,9 +666,23 @@ export interface Editor {
623
666
  /* The column to draw a line at; null takes it away. */
624
667
  setRightMargin(column: number | null): void;
625
668
  setFoldOutline(outline: EditorFoldOutline): void;
669
+ /* The words that mark a TODO comment; an empty list draws none. Optional so an editor from before it still fits. */
670
+ setTodoWords?(words: readonly string[]): void;
626
671
  setLabel(label: string): void;
627
672
  /* What a language server knows about the folds: the bodies of symbols and the ranges it folds. They follow their text through edits until set again; null forgets them. */
628
673
  setFoldHints(hints: EditorFoldHints | null): void;
674
+ /* The places Method Up and Down stop at, such as the names of a language server's symbols. They follow their text through edits until set again; null goes back to the editor's own reading of the structure. Optional so an editor from before it still fits. */
675
+ setDeclarations?(positions: readonly EditorPosition[] | null): void;
676
+ /* A command that navigates (Method Up and Down, Code Block Start and End) moved the primary caret away from `from`. Optional so an editor from before it still fits. */
677
+ onNavigate?(listener: (from: EditorPosition) => void): () => void;
678
+ /* Whether the editor has the keyboard focus. Optional so an editor from before it still fits. */
679
+ hasFocus?(): boolean;
680
+ /* The texts of the other open documents, which Hippie completion takes words from after those of this one; null takes them away. Optional so an editor from before it still fits. */
681
+ setWordSources?(source: (() => readonly string[]) | null): void;
682
+ /* Told after a copy or cut, with the text it put on the clipboard. Optional so an editor from before it still fits. */
683
+ onCopy?(listener: (copy: EditorCopy) => void): () => void;
684
+ /* Pastes `text` as a paste from the clipboard does: at every caret, with indent on paste, and refused with its notice when the editor is read-only. False when nothing was pasted. Optional so an editor from before it still fits. */
685
+ paste?(text: string, options?: EditorPasteOptions): boolean;
629
686
  /*
630
687
  * Marks that follow their lines through edits until the host sets them again. Every layer (default: the unnamed one)
631
688
  * holds its own marks and the editor draws the union, so setting one layer never clears another; an empty list
@@ -676,7 +733,8 @@ export interface Editor {
676
733
  /* Colors by what the language servers know, over what the grammar made of the text; null takes them away. */
677
734
  setSemanticTokens(tokens: readonly EditorSemanticToken[] | null): void;
678
735
  /* Hints to draw as soft pills in the text, replacing the ones set before. They follow their text through edits until set again. */
679
- setInlayHints(hints: readonly EditorInlayHint[]): void;
736
+ /* Each owner sets its own hints and keeps those of the others; ids stay unique across owners. */
737
+ setInlayHints(hints: readonly EditorInlayHint[], owner?: string): void;
680
738
  /* The pointer moved onto a hint or one of its parts, or null when it left them. Optional so an editor from before it still fits. */
681
739
  onInlayHover?(listener: (target: EditorInlayTarget | null) => void): () => void;
682
740
  /* Handlers see a press of the primary button on a hint before the editor does, the first to take it winning. */
@@ -765,9 +823,23 @@ export interface Editor {
765
823
  trackRange(range: EditorRange): EditorTrackedRange;
766
824
  /* Replaces ranges of the current text at once, as one step of the undo history. `mapSelections` places each selection in the new text, keeping its direction, and redo restores it. `mergeUndo` joins the step before it when the text is exactly as that step left it, so one undo takes both back. */
767
825
  applyEdits(edits: readonly EditorContentChange[], options?: EditorApplyEditsOptions): boolean;
826
+ /*
827
+ * Call right before writing the file: cleans up the whitespace as the options say, as one undo step, and
828
+ * marks the text as saved, so `modified` counts from here next time. Call it on every save, with all off too.
829
+ * False when the text did not change. Optional so an editor from before it still fits.
830
+ */
831
+ cleanUpWhitespace?(options: EditorWhitespaceCleanup): boolean;
832
+ /* Marks the text as saved without a cleanup, such as after a reload from disk. Optional so an editor from before it still fits. */
833
+ markSaved?(): void;
768
834
  setTheme(theme: EditorTheme): void;
769
835
  /* Reads the code face off the page again, after the page changed its size, family or ligatures. */
770
836
  refreshFont(): void;
837
+ /* Draws the code at a size of its own, kept inside the zoom range in whole pixels; null goes back to the page's size. Optional so an editor from before it still fits. */
838
+ setFontSize?(size: number | null): void;
839
+ /* The size the code is drawn at, in pixels. Optional so an editor from before it still fits. */
840
+ getFontSize?(): number;
841
+ /* A person zoomed: the new size in pixels, or null when they went back to the page's size. Not told of `setFontSize`. Optional so an editor from before it still fits. */
842
+ onFontSizeChange?(listener: (size: number | null) => void): () => void;
771
843
  setReadOnly(readOnly: boolean, reason?: string): void;
772
844
  /* Whether the editor refuses edits. Optional so an editor from before it still fits. */
773
845
  isReadOnly?(): boolean;
package/src/view.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  type VerticalWindow
28
28
  } from './layout.ts';
29
29
  import { createMetrics, type EditorFont, readEditorFont } from './metrics.ts';
30
+ import { zoomedFont } from './zoom.ts';
30
31
  import { type AttributedLines, AttributionRuns, colorValue, tintValue } from './attribution.ts';
31
32
  import { renderCodeBlock } from './code-block.ts';
32
33
  import { emit, subscribe } from './listeners.ts';
@@ -50,6 +51,7 @@ import {
50
51
  type WrapSign
51
52
  } from './paint.ts';
52
53
  import { overlayTokens, type SemanticSpan } from './semantic.ts';
54
+ import { TodoMarks } from './todos.ts';
53
55
  import { TokenCache } from './tokens.ts';
54
56
  import type {
55
57
  EditorBlock,
@@ -118,6 +120,8 @@ export interface ViewSettings {
118
120
  folding?: boolean;
119
121
  /* The headers of the blocks scrolled out of sight stay pinned on top; on unless false. */
120
122
  stickyScroll?: boolean;
123
+ /* The words that mark a TODO comment; the default ones without them, none drawn with an empty list. */
124
+ todoWords?: readonly string[];
121
125
  }
122
126
 
123
127
  /* The lines past the visible ones that are colored ahead of the scroll, and how long one slice of that work may take. */
@@ -201,6 +205,7 @@ export class EditorView {
201
205
  readonly layout: EditorLayout;
202
206
  readonly find: FindController;
203
207
  readonly tokens: TokenCache;
208
+ private readonly todos: TodoMarks;
204
209
  private readonly content: HTMLElement;
205
210
  private readonly gutter: HTMLElement;
206
211
  private readonly gutterLines: HTMLElement;
@@ -275,6 +280,8 @@ export class EditorView {
275
280
  private caretTimer: ReturnType<typeof setTimeout> | undefined;
276
281
  private caretKey = '';
277
282
  private renderedScroll = { top: -1, left: -1 };
283
+ /* A size the code is zoomed to, over the page's; null draws it at the page's. */
284
+ private fontSize: number | null = null;
278
285
  private font: EditorFont;
279
286
  private layoutVersion = 0;
280
287
  private revision: number;
@@ -291,6 +298,7 @@ export class EditorView {
291
298
  private lenses: BlockWidget[] = [];
292
299
  private readonly replacements = new Map<string, BlockReplacement[]>();
293
300
  private occurrences: Occurrence[] = [];
301
+ private hippieSource: { from: number; to: number } | null = null;
294
302
  private replacePreview: { text: string; preserveCase: boolean } | null = null;
295
303
  private dropCaret: number | null = null;
296
304
  private activeGuideCache: { key: string; guide: { column: number; first: number; last: number } | null } | undefined;
@@ -309,6 +317,7 @@ export class EditorView {
309
317
  private collapsed = new Set<number>();
310
318
  /* What a language server said about the folds, in offsets that follow the text through edits. */
311
319
  private foldHints: FoldHints | null = null;
320
+ private declarations: number[] | null = null;
312
321
  /* The roles that fold by themselves while the file is as it was opened; null once a person edited or the file opened with folds of its own. */
313
322
  private defaultRoles: ReadonlySet<FoldRole> | null = null;
314
323
  /* The folds somebody already decided on, a person or the defaults, so a later answer of a server leaves them as they are. */
@@ -424,6 +433,7 @@ export class EditorView {
424
433
  });
425
434
 
426
435
  this.tokens = new TokenCache({ getLineCount: () => model.getLineCount(), getLineText: (line) => model.getLine(line).text });
436
+ this.todos = new TodoMarks(model, () => this.requestRender());
427
437
  const ResizeObserverClass = document.defaultView?.ResizeObserver;
428
438
 
429
439
  this.blockObserver = ResizeObserverClass
@@ -459,6 +469,7 @@ export class EditorView {
459
469
  this.root.dataset.readonly = String(this.settings.readOnly);
460
470
  this.root.dataset.wrap = String(this.settings.wrap);
461
471
  this.root.dataset.foldOutline = this.settings.foldOutline;
472
+ this.todos.configure(this.settings.language, this.settings.todoWords);
462
473
  this.layoutVersion++;
463
474
  this.layout.configure({ metrics: createMetrics(this.font, this.settings.tabSize, this.document) });
464
475
  this.render();
@@ -470,8 +481,30 @@ export class EditorView {
470
481
  }
471
482
 
472
483
  refreshFont(): void {
473
- this.font = readEditorFont(this.root);
474
- this.applySettings();
484
+ const style = this.root.style;
485
+ style.removeProperty('--code-font-size');
486
+ style.removeProperty('--code-line-height');
487
+ const base = readEditorFont(this.root);
488
+ this.font = this.fontSize === null ? base : zoomedFont(base, this.fontSize);
489
+
490
+ // On the element itself, so everything the editor draws at the code's size follows, and the page's size stays the base.
491
+ if (this.fontSize !== null) {
492
+ style.setProperty('--code-font-size', `${this.font.size}px`);
493
+ style.setProperty('--code-line-height', `${this.font.lineHeight}px`);
494
+ }
495
+
496
+ this.anchorScroll(() => this.applySettings());
497
+ }
498
+
499
+ /* Draws the code at a size of its own in whole pixels; null goes back to the page's. */
500
+ setFontSize(size: number | null): void {
501
+ this.fontSize = size;
502
+ this.refreshFont();
503
+ }
504
+
505
+ /* The size the code is drawn at, in pixels. */
506
+ getFontSize(): number {
507
+ return this.font.size;
475
508
  }
476
509
 
477
510
  setTokenizer(tokenizer: LineTokenizer | null): void {
@@ -569,6 +602,16 @@ export class EditorView {
569
602
  this.render();
570
603
  }
571
604
 
605
+ /* The word Hippie completion took from this document, shown until the next key or edit; null takes the mark away. */
606
+ setHippieSource(range: { from: number; to: number } | null): void {
607
+ if (range === null && this.hippieSource === null) {
608
+ return;
609
+ }
610
+
611
+ this.hippieSource = range;
612
+ this.render();
613
+ }
614
+
572
615
  /* A name underlined as a link a press follows, with the pointer to match; null takes it away. */
573
616
  setLink(link: { from: number; to: number } | null): void {
574
617
  this.link = link;
@@ -591,8 +634,13 @@ export class EditorView {
591
634
  this.render();
592
635
  }
593
636
 
594
- /* The grammar's colors of a line with the servers' laid over them, the same object for as long as neither changed. */
637
+ /* The colors of a line with its TODOs drawn over them, the same object for as long as nothing under them changed. */
595
638
  private styledTokensOf(line: number): readonly LineToken[] | null {
639
+ return this.todos.styled(line, this.semanticTokensOf(line));
640
+ }
641
+
642
+ /* The grammar's colors of a line with the servers' laid over them, the same object for as long as neither changed. */
643
+ private semanticTokensOf(line: number): readonly LineToken[] | null {
596
644
  const base = this.lineTokenProvider === null ? this.tokens.tokensOf(line) : (this.lineTokenProvider(line) ?? null);
597
645
 
598
646
  if (base === null || this.semantic.length === 0 || this.scopeColors === null) {
@@ -813,6 +861,7 @@ export class EditorView {
813
861
  this.markerVersion++;
814
862
  const anchor = this.topAnchor;
815
863
  this.occurrences = [];
864
+ this.hippieSource = null;
816
865
  this.link = null;
817
866
  this.content.classList.remove('se-content-link');
818
867
  this.outline.edited(batches);
@@ -841,6 +890,7 @@ export class EditorView {
841
890
  this.find.refresh();
842
891
  this.announceFind();
843
892
  this.scheduleFolds();
893
+ this.todos.schedule();
844
894
  this.colorAhead(true);
845
895
  }
846
896
 
@@ -879,6 +929,10 @@ export class EditorView {
879
929
  this.foldHints = mapFoldHints(this.foldHints, map);
880
930
  }
881
931
 
932
+ if (this.declarations !== null) {
933
+ this.declarations = this.declarations.map((offset) => map(offset));
934
+ }
935
+
882
936
  this.customFolds = this.customFolds.map((fold) => mapRange(fold, map));
883
937
  this.find.mapBounds(changes);
884
938
 
@@ -1052,15 +1106,7 @@ export class EditorView {
1052
1106
  return;
1053
1107
  }
1054
1108
 
1055
- const ranges =
1056
- this.model.getLength() > FOLD_LIMIT
1057
- ? []
1058
- : this.model.getFoldingRanges({
1059
- indentation: /^(python|py|yaml|yml)$/.test(this.settings.language ?? ''),
1060
- tabSize: this.settings.tabSize,
1061
- ...(this.settings.language === undefined ? {} : { language: this.settings.language }),
1062
- ...(this.foldHints === null ? {} : { hints: this.foldHints })
1063
- });
1109
+ const ranges = this.structureRanges();
1064
1110
 
1065
1111
  this.foldRanges = this.withCustomFolds(ranges);
1066
1112
 
@@ -1078,6 +1124,20 @@ export class EditorView {
1078
1124
  this.refoldLayout();
1079
1125
  }
1080
1126
 
1127
+ /* The folding ranges the document has now, a server's included, whether or not the folds are drawn. */
1128
+ structureRanges(): FoldingRange[] {
1129
+ if (this.model.getLength() > FOLD_LIMIT) {
1130
+ return [];
1131
+ }
1132
+
1133
+ return this.model.getFoldingRanges({
1134
+ indentation: /^(python|py|yaml|yml)$/.test(this.settings.language ?? ''),
1135
+ tabSize: this.settings.tabSize,
1136
+ ...(this.settings.language === undefined ? {} : { language: this.settings.language }),
1137
+ ...(this.foldHints === null ? {} : { hints: this.foldHints })
1138
+ });
1139
+ }
1140
+
1081
1141
  /* The folds a person made of a selection, which the document's own structure does not know, among the ones it does. */
1082
1142
  private withCustomFolds(ranges: readonly FoldingRange[]): ViewFold[] {
1083
1143
  const custom = this.customFolds.flatMap((fold): ViewFold[] => {
@@ -1346,6 +1406,65 @@ export class EditorView {
1346
1406
  this.refoldLayout();
1347
1407
  }
1348
1408
 
1409
+ /* The places Method Up and Down stop at; they follow their text through edits until set again, and null goes back to the editor's own reading. */
1410
+ setDeclarations(offsets: readonly number[] | null): void {
1411
+ this.declarations = offsets === null ? null : [...new Set(offsets)].sort((left, right) => left - right);
1412
+ }
1413
+
1414
+ private methodStops(): number[] {
1415
+ if (this.declarations !== null) {
1416
+ return this.declarations;
1417
+ }
1418
+
1419
+ const sections = this.foldRanges.filter((range) => range.kind === 'section').map((range) => this.model.getLine(range.startLine).start);
1420
+
1421
+ return [...new Set([...this.outline.declarationStops(this.model), ...sections])].sort((left, right) => left - right);
1422
+ }
1423
+
1424
+ /* Moves the one caret to the first line of the next or previous change mark, of any layer. False when there is none. */
1425
+ goToChange(direction: -1 | 1): boolean {
1426
+ const line = this.model.positionAt(this.model.getPrimary().head).line;
1427
+ const { runs } = this.resolvedChanges();
1428
+
1429
+ // A run the caret is in is where it already is; a deleted run is the line its mark sits on.
1430
+ const target = direction === 1 ? runs.find((run) => run.start > line) : runs.findLast((run) => (run.kind === 'deleted' ? run.start : run.end) < line);
1431
+
1432
+ if (target === undefined) {
1433
+ return false;
1434
+ }
1435
+
1436
+ const offset = this.model.getLine(Math.min(target.start, this.model.getLineCount() - 1)).start;
1437
+ this.model.setSelections([{ anchor: offset, head: offset }]);
1438
+ this.revealOffset(offset, 'center');
1439
+
1440
+ return true;
1441
+ }
1442
+
1443
+ /* Moves the one caret to the previous or next declaration on another line. False when there is none. */
1444
+ goToMethod(direction: -1 | 1): boolean {
1445
+ // The structure is read after a pause in typing, so a press right after an edit reads it now.
1446
+ if (this.declarations === null) {
1447
+ this.refreshFolds();
1448
+ }
1449
+
1450
+ const line = this.model.positionAt(this.model.getPrimary().head).line;
1451
+ const stops = this.methodStops();
1452
+
1453
+ const target =
1454
+ direction === 1
1455
+ ? stops.find((offset) => this.model.positionAt(offset).line > line)
1456
+ : stops.findLast((offset) => this.model.positionAt(offset).line < line);
1457
+
1458
+ if (target === undefined) {
1459
+ return false;
1460
+ }
1461
+
1462
+ this.model.setSelections([{ anchor: target, head: target }]);
1463
+ this.revealOffset(target, direction === 1 ? 'centerDown' : 'centerUp');
1464
+
1465
+ return true;
1466
+ }
1467
+
1349
1468
  /* Hands over what a language server knows about the folds; null forgets it. The defaults fold what the answer names, once. */
1350
1469
  setFoldHints(hints: FoldHints | null): void {
1351
1470
  this.foldHints = hints;
@@ -2443,6 +2562,10 @@ export class EditorView {
2443
2562
  }
2444
2563
  }
2445
2564
 
2565
+ if (this.hippieSource !== null) {
2566
+ marks.push({ className: 'se-hippie-source', rects: this.paintRectangles(this.hippieSource.from, this.hippieSource.to, rows) });
2567
+ }
2568
+
2446
2569
  const marginX = this.settings.rightMargin === null ? null : this.settings.rightMargin * this.layout.metrics.charWidth;
2447
2570
 
2448
2571
  // A margin past the content would stretch the scroll extent to itself, so it shows only where the text or the view reaches it.
@@ -2963,7 +3086,7 @@ export class EditorView {
2963
3086
  const matches = this.find.matches;
2964
3087
  const current = this.find.current;
2965
3088
  const alike = this.selectionOccurrences();
2966
- const key = `${this.revision}|${this.changeVersion}|${this.markerVersion}|${this.layout.height}|${trackHeight}|${matches.length}|${current}|${matches[0]?.from}|${matches.at(-1)?.from}|${this.selectionOccurrenceCache.key}`;
3089
+ const key = `${this.revision}|${this.changeVersion}|${this.markerVersion}|${this.todos.version}|${this.layout.height}|${trackHeight}|${matches.length}|${current}|${matches[0]?.from}|${matches.at(-1)?.from}|${this.selectionOccurrenceCache.key}`;
2967
3090
 
2968
3091
  if (key === this.overviewKey) {
2969
3092
  return;
@@ -2999,6 +3122,12 @@ export class EditorView {
2999
3122
  }
3000
3123
  }
3001
3124
 
3125
+ for (const todo of this.todos.marks) {
3126
+ if (todo.line < this.model.getLineCount()) {
3127
+ spans.push({ kind: 'todo', top: rowTop(todo.line), bottom: rowBottom(todo.line), offset: todo.from, title: todo.text });
3128
+ }
3129
+ }
3130
+
3002
3131
  for (const range of alike) {
3003
3132
  const line = this.model.positionAt(range.from).line;
3004
3133
  spans.push({ kind: 'occurrence', top: rowTop(line), bottom: rowBottom(line) });
@@ -3224,6 +3353,7 @@ export class EditorView {
3224
3353
  this.blockObserver?.disconnect();
3225
3354
  clearTimeout(this.bracketTimer);
3226
3355
  clearTimeout(this.foldTimer);
3356
+ this.todos.dispose();
3227
3357
  clearTimeout(this.colorTimer);
3228
3358
  clearTimeout(this.noticeTimer);
3229
3359
  clearTimeout(this.liveTimer);
package/src/zoom.ts ADDED
@@ -0,0 +1,62 @@
1
+ import type { EditorFont } from './metrics.ts';
2
+ import type { EditorZoom } from './types.ts';
3
+
4
+ /* Nothing a person reads is smaller. */
5
+ export const MIN_FONT_SIZE = 12;
6
+ export const MAX_FONT_SIZE = 90;
7
+ /* A trackpad or a pinch sends the wheel in small steps; this many pixels of them are one step of the size. */
8
+ const PIXELS_PER_STEP = 20;
9
+
10
+ export interface FontSizeRange {
11
+ readonly min: number;
12
+ readonly max: number;
13
+ }
14
+
15
+ export function fontSizeRange(zoom: EditorZoom | undefined): FontSizeRange {
16
+ const min = Math.max(MIN_FONT_SIZE, Math.round(zoom?.min ?? MIN_FONT_SIZE));
17
+
18
+ return { min, max: Math.max(min, Math.round(zoom?.max ?? MAX_FONT_SIZE)) };
19
+ }
20
+
21
+ /* In whole pixels inside the range. */
22
+ export function clampFontSize(size: number, range: FontSizeRange): number {
23
+ return Math.min(range.max, Math.max(range.min, Math.round(size)));
24
+ }
25
+
26
+ /* The face at another size, with the line as much taller as the page's line is to its text, in whole pixels. */
27
+ export function zoomedFont(base: EditorFont, size: number): EditorFont {
28
+ return { family: base.family, size, lineHeight: Math.round((size * base.lineHeight) / base.size) };
29
+ }
30
+
31
+ /* Turns the wheel with the zoom modifier, or a pinch, into steps of one pixel: up and apart make the text larger. */
32
+ export class WheelZoom {
33
+ private pending = 0;
34
+
35
+ /* -1, 0 or 1. A mouse notch is one step however far the platform says it went. */
36
+ step(event: { deltaY: number; deltaMode: number }): number {
37
+ if (event.deltaY === 0) {
38
+ return 0;
39
+ }
40
+
41
+ if (event.deltaMode !== 0 || Math.abs(event.deltaY) >= PIXELS_PER_STEP) {
42
+ this.pending = 0;
43
+
44
+ return event.deltaY < 0 ? 1 : -1;
45
+ }
46
+
47
+ if (Math.sign(this.pending) !== 0 && Math.sign(this.pending) !== Math.sign(event.deltaY)) {
48
+ this.pending = 0;
49
+ }
50
+
51
+ this.pending += event.deltaY;
52
+
53
+ if (Math.abs(this.pending) < PIXELS_PER_STEP) {
54
+ return 0;
55
+ }
56
+
57
+ const direction = this.pending < 0 ? 1 : -1;
58
+ this.pending = 0;
59
+
60
+ return direction;
61
+ }
62
+ }