@adecore/editor 0.41.1 → 0.42.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/todos.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { DEFAULT_TODO_WORDS, type DocumentModel, type TodoMatch } from '@adecore/editor-core';
2
+ import { overlayTokens } from './semantic.ts';
3
+ import type { LineToken } from './types.ts';
4
+
5
+ const TODO_COLOR = 'var(--editor-todo)';
6
+ const ITALIC = 1;
7
+ /* The marks in the scroll track read the whole document, so they wait for a pause in editing as the folds do. */
8
+ const TODO_DELAY_MS = 300;
9
+ const TODO_LIMIT = 2_000_000;
10
+ const CACHED_LINES = 600;
11
+
12
+ /* The text is kept with the mark, since the list is only read again after a pause and the offsets may be stale until then. */
13
+ export type TodoMark = TodoMatch & { readonly text: string };
14
+
15
+ /*
16
+ * The TODO comments of a view: drawn in their own color over the grammar's on the lines being painted,
17
+ * and listed for the whole document a moment after the last edit, for the ticks in the scroll track.
18
+ */
19
+ export class TodoMarks {
20
+ private readonly model: DocumentModel;
21
+ private readonly changed: () => void;
22
+ private words: readonly string[] = DEFAULT_TODO_WORDS;
23
+ /* Empty until the first `configure`, which reads the document. */
24
+ private language = '';
25
+ private revision = -1;
26
+ private readonly lines = new Map<number, { base: readonly LineToken[] | null; result: readonly LineToken[] | null }>();
27
+ private timer: ReturnType<typeof setTimeout> | undefined;
28
+ private all: readonly TodoMark[] = [];
29
+ /* Counts every new list, so a painter can tell its ticks are stale. */
30
+ version = 0;
31
+
32
+ constructor(model: DocumentModel, changed: () => void) {
33
+ this.model = model;
34
+ this.changed = changed;
35
+ }
36
+
37
+ get marks(): readonly TodoMark[] {
38
+ return this.all;
39
+ }
40
+
41
+ configure(language: string | undefined, words: readonly string[] | undefined): void {
42
+ const nextLanguage = language ?? 'plaintext';
43
+ const nextWords = words ?? DEFAULT_TODO_WORDS;
44
+
45
+ if (nextLanguage === this.language && nextWords.join('\n') === this.words.join('\n')) {
46
+ return;
47
+ }
48
+
49
+ this.language = nextLanguage;
50
+ this.words = nextWords;
51
+ this.revision = -1;
52
+ this.refresh();
53
+ }
54
+
55
+ /* The line's tokens with its TODOs drawn over them, the same array for as long as neither changed. */
56
+ styled(line: number, base: readonly LineToken[] | null): readonly LineToken[] | null {
57
+ if (this.words.length === 0) {
58
+ return base;
59
+ }
60
+
61
+ const revision = this.model.getRevision();
62
+
63
+ if (revision !== this.revision || this.lines.size > CACHED_LINES) {
64
+ this.revision = revision;
65
+ this.lines.clear();
66
+ }
67
+
68
+ const cached = this.lines.get(line);
69
+
70
+ if (cached !== undefined && cached.base === base) {
71
+ return cached.result;
72
+ }
73
+
74
+ const matches = this.model.findTodos(this.language, { words: this.words }, line, line);
75
+ let result = base;
76
+
77
+ if (matches.length > 0) {
78
+ const { start, end } = this.model.getLine(line);
79
+ const spans = matches.map((match) => ({ from: match.from - start, to: match.to - start, color: TODO_COLOR, fontStyle: ITALIC }));
80
+ result = overlayTokens(base ?? [{ length: end - start, color: '', fontStyle: 0 }], spans);
81
+ }
82
+
83
+ this.lines.set(line, { base, result });
84
+
85
+ return result;
86
+ }
87
+
88
+ schedule(): void {
89
+ clearTimeout(this.timer);
90
+ this.timer = setTimeout(() => this.refresh(), TODO_DELAY_MS);
91
+ }
92
+
93
+ /* Reads the TODOs of the whole document now, which otherwise waits for a pause in editing. */
94
+ refresh(): void {
95
+ clearTimeout(this.timer);
96
+ const found = this.words.length === 0 || this.model.getLength() > TODO_LIMIT ? [] : this.model.findTodos(this.language, { words: this.words });
97
+ const next = found.map((match) => ({ ...match, text: this.model.slice(match.from, match.to) }));
98
+
99
+ if (next.length === 0 && this.all.length === 0) {
100
+ return;
101
+ }
102
+
103
+ this.all = next;
104
+ this.version++;
105
+ this.changed();
106
+ }
107
+
108
+ dispose(): void {
109
+ clearTimeout(this.timer);
110
+ }
111
+ }
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,20 @@ export type EditorViewCommand =
22
25
  | 'expandAllToLevel3'
23
26
  | 'expandAllToLevel4'
24
27
  | 'expandAllToLevel5'
25
- | 'toggleColumnMode';
28
+ | 'toggleColumnMode'
29
+ | 'increaseFontSize'
30
+ | 'decreaseFontSize'
31
+ | 'resetFontSize';
32
+
33
+ /* How far a person may zoom the text, and whether the wheel does. */
34
+ export interface EditorZoom {
35
+ /* In pixels; 12 by default, and never less. */
36
+ readonly min?: number;
37
+ /* In pixels; 90 by default. */
38
+ readonly max?: number;
39
+ /* Ctrl with the wheel (Cmd on macOS), or a pinch, zooms; on unless false. */
40
+ readonly wheel?: boolean;
41
+ }
26
42
 
27
43
  /* Everything `runCommand` runs. */
28
44
  export type EditorRunCommand = EditorCommand | EditorViewCommand;
@@ -87,6 +103,11 @@ export interface EditorOptions {
87
103
  readonly column?: number;
88
104
  /* In pixels, where the view opens; without it the cursor's line is brought into view. */
89
105
  readonly scrollTop?: number;
106
+ /* 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. */
107
+ readonly todoWords?: readonly string[];
108
+ /* In pixels, a size a person zoomed to that the host kept; without it the code has the page's `--code-font-size`. */
109
+ readonly fontSize?: number;
110
+ readonly zoom?: EditorZoom;
90
111
  }
91
112
 
92
113
  /* The few sentences the editor says on its own. */
@@ -623,6 +644,8 @@ export interface Editor {
623
644
  /* The column to draw a line at; null takes it away. */
624
645
  setRightMargin(column: number | null): void;
625
646
  setFoldOutline(outline: EditorFoldOutline): void;
647
+ /* The words that mark a TODO comment; an empty list draws none. Optional so an editor from before it still fits. */
648
+ setTodoWords?(words: readonly string[]): void;
626
649
  setLabel(label: string): void;
627
650
  /* 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
651
  setFoldHints(hints: EditorFoldHints | null): void;
@@ -765,9 +788,23 @@ export interface Editor {
765
788
  trackRange(range: EditorRange): EditorTrackedRange;
766
789
  /* 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
790
  applyEdits(edits: readonly EditorContentChange[], options?: EditorApplyEditsOptions): boolean;
791
+ /*
792
+ * Call right before writing the file: cleans up the whitespace as the options say, as one undo step, and
793
+ * marks the text as saved, so `modified` counts from here next time. Call it on every save, with all off too.
794
+ * False when the text did not change. Optional so an editor from before it still fits.
795
+ */
796
+ cleanUpWhitespace?(options: EditorWhitespaceCleanup): boolean;
797
+ /* Marks the text as saved without a cleanup, such as after a reload from disk. Optional so an editor from before it still fits. */
798
+ markSaved?(): void;
768
799
  setTheme(theme: EditorTheme): void;
769
800
  /* Reads the code face off the page again, after the page changed its size, family or ligatures. */
770
801
  refreshFont(): void;
802
+ /* 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. */
803
+ setFontSize?(size: number | null): void;
804
+ /* The size the code is drawn at, in pixels. Optional so an editor from before it still fits. */
805
+ getFontSize?(): number;
806
+ /* 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. */
807
+ onFontSizeChange?(listener: (size: number | null) => void): () => void;
771
808
  setReadOnly(readOnly: boolean, reason?: string): void;
772
809
  /* Whether the editor refuses edits. Optional so an editor from before it still fits. */
773
810
  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;
@@ -424,6 +431,7 @@ export class EditorView {
424
431
  });
425
432
 
426
433
  this.tokens = new TokenCache({ getLineCount: () => model.getLineCount(), getLineText: (line) => model.getLine(line).text });
434
+ this.todos = new TodoMarks(model, () => this.requestRender());
427
435
  const ResizeObserverClass = document.defaultView?.ResizeObserver;
428
436
 
429
437
  this.blockObserver = ResizeObserverClass
@@ -459,6 +467,7 @@ export class EditorView {
459
467
  this.root.dataset.readonly = String(this.settings.readOnly);
460
468
  this.root.dataset.wrap = String(this.settings.wrap);
461
469
  this.root.dataset.foldOutline = this.settings.foldOutline;
470
+ this.todos.configure(this.settings.language, this.settings.todoWords);
462
471
  this.layoutVersion++;
463
472
  this.layout.configure({ metrics: createMetrics(this.font, this.settings.tabSize, this.document) });
464
473
  this.render();
@@ -470,8 +479,30 @@ export class EditorView {
470
479
  }
471
480
 
472
481
  refreshFont(): void {
473
- this.font = readEditorFont(this.root);
474
- this.applySettings();
482
+ const style = this.root.style;
483
+ style.removeProperty('--code-font-size');
484
+ style.removeProperty('--code-line-height');
485
+ const base = readEditorFont(this.root);
486
+ this.font = this.fontSize === null ? base : zoomedFont(base, this.fontSize);
487
+
488
+ // On the element itself, so everything the editor draws at the code's size follows, and the page's size stays the base.
489
+ if (this.fontSize !== null) {
490
+ style.setProperty('--code-font-size', `${this.font.size}px`);
491
+ style.setProperty('--code-line-height', `${this.font.lineHeight}px`);
492
+ }
493
+
494
+ this.anchorScroll(() => this.applySettings());
495
+ }
496
+
497
+ /* Draws the code at a size of its own in whole pixels; null goes back to the page's. */
498
+ setFontSize(size: number | null): void {
499
+ this.fontSize = size;
500
+ this.refreshFont();
501
+ }
502
+
503
+ /* The size the code is drawn at, in pixels. */
504
+ getFontSize(): number {
505
+ return this.font.size;
475
506
  }
476
507
 
477
508
  setTokenizer(tokenizer: LineTokenizer | null): void {
@@ -591,8 +622,13 @@ export class EditorView {
591
622
  this.render();
592
623
  }
593
624
 
594
- /* The grammar's colors of a line with the servers' laid over them, the same object for as long as neither changed. */
625
+ /* The colors of a line with its TODOs drawn over them, the same object for as long as nothing under them changed. */
595
626
  private styledTokensOf(line: number): readonly LineToken[] | null {
627
+ return this.todos.styled(line, this.semanticTokensOf(line));
628
+ }
629
+
630
+ /* The grammar's colors of a line with the servers' laid over them, the same object for as long as neither changed. */
631
+ private semanticTokensOf(line: number): readonly LineToken[] | null {
596
632
  const base = this.lineTokenProvider === null ? this.tokens.tokensOf(line) : (this.lineTokenProvider(line) ?? null);
597
633
 
598
634
  if (base === null || this.semantic.length === 0 || this.scopeColors === null) {
@@ -841,6 +877,7 @@ export class EditorView {
841
877
  this.find.refresh();
842
878
  this.announceFind();
843
879
  this.scheduleFolds();
880
+ this.todos.schedule();
844
881
  this.colorAhead(true);
845
882
  }
846
883
 
@@ -2963,7 +3000,7 @@ export class EditorView {
2963
3000
  const matches = this.find.matches;
2964
3001
  const current = this.find.current;
2965
3002
  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}`;
3003
+ 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
3004
 
2968
3005
  if (key === this.overviewKey) {
2969
3006
  return;
@@ -2999,6 +3036,12 @@ export class EditorView {
2999
3036
  }
3000
3037
  }
3001
3038
 
3039
+ for (const todo of this.todos.marks) {
3040
+ if (todo.line < this.model.getLineCount()) {
3041
+ spans.push({ kind: 'todo', top: rowTop(todo.line), bottom: rowBottom(todo.line), offset: todo.from, title: todo.text });
3042
+ }
3043
+ }
3044
+
3002
3045
  for (const range of alike) {
3003
3046
  const line = this.model.positionAt(range.from).line;
3004
3047
  spans.push({ kind: 'occurrence', top: rowTop(line), bottom: rowBottom(line) });
@@ -3224,6 +3267,7 @@ export class EditorView {
3224
3267
  this.blockObserver?.disconnect();
3225
3268
  clearTimeout(this.bracketTimer);
3226
3269
  clearTimeout(this.foldTimer);
3270
+ this.todos.dispose();
3227
3271
  clearTimeout(this.colorTimer);
3228
3272
  clearTimeout(this.noticeTimer);
3229
3273
  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
+ }