lognal 0.1.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/core/filter.d.ts +29 -0
  4. package/dist/core/filter.js +82 -0
  5. package/dist/core/layout/entry-lines.d.ts +16 -0
  6. package/dist/core/layout/entry-lines.js +120 -0
  7. package/dist/core/layout/layout.d.ts +152 -0
  8. package/dist/core/layout/layout.js +784 -0
  9. package/dist/core/layout/row-index.d.ts +33 -0
  10. package/dist/core/layout/row-index.js +99 -0
  11. package/dist/core/layout/types.d.ts +128 -0
  12. package/dist/core/layout/types.js +8 -0
  13. package/dist/core/store.d.ts +96 -0
  14. package/dist/core/store.js +204 -0
  15. package/dist/core/text/ansi.d.ts +20 -0
  16. package/dist/core/text/ansi.js +187 -0
  17. package/dist/core/text/graphemes.d.ts +17 -0
  18. package/dist/core/text/graphemes.js +70 -0
  19. package/dist/core/text/line-splitter.d.ts +18 -0
  20. package/dist/core/text/line-splitter.js +56 -0
  21. package/dist/core/text/measure.d.ts +10 -0
  22. package/dist/core/text/measure.js +38 -0
  23. package/dist/core/text/shape.d.ts +24 -0
  24. package/dist/core/text/shape.js +220 -0
  25. package/dist/core/text/unicode-width-data.d.ts +54 -0
  26. package/dist/core/text/unicode-width-data.js +227 -0
  27. package/dist/core/text/width.d.ts +20 -0
  28. package/dist/core/text/width.js +157 -0
  29. package/dist/core/text/wrap.d.ts +14 -0
  30. package/dist/core/text/wrap.js +94 -0
  31. package/dist/core/time.d.ts +10 -0
  32. package/dist/core/time.js +24 -0
  33. package/dist/core/types.d.ts +131 -0
  34. package/dist/core/types.js +9 -0
  35. package/dist/core/value/preview.d.ts +16 -0
  36. package/dist/core/value/preview.js +207 -0
  37. package/dist/index.d.ts +27 -0
  38. package/dist/index.js +24 -0
  39. package/dist/lognal.css +589 -0
  40. package/dist/react/LogViewer.d.ts +32 -0
  41. package/dist/react/LogViewer.js +183 -0
  42. package/dist/react/index.d.ts +1 -0
  43. package/dist/react/index.js +2 -0
  44. package/dist/renderer/canvas/canvas-renderer.d.ts +45 -0
  45. package/dist/renderer/canvas/canvas-renderer.js +464 -0
  46. package/dist/renderer/canvas/palette.d.ts +6 -0
  47. package/dist/renderer/canvas/palette.js +25 -0
  48. package/dist/renderer/theme.d.ts +3 -0
  49. package/dist/renderer/theme.js +52 -0
  50. package/dist/renderer/types.d.ts +82 -0
  51. package/dist/renderer/types.js +1 -0
  52. package/dist/sources/console/format.d.ts +32 -0
  53. package/dist/sources/console/format.js +189 -0
  54. package/dist/sources/console/hook.d.ts +27 -0
  55. package/dist/sources/console/hook.js +94 -0
  56. package/dist/sources/console/recorder.d.ts +41 -0
  57. package/dist/sources/console/recorder.js +239 -0
  58. package/dist/sources/console/snapshot.d.ts +22 -0
  59. package/dist/sources/console/snapshot.js +547 -0
  60. package/dist/sources/console/table.d.ts +9 -0
  61. package/dist/sources/console/table.js +87 -0
  62. package/dist/sources/text/encoding.d.ts +11 -0
  63. package/dist/sources/text/encoding.js +59 -0
  64. package/dist/sources/text/follow-file.d.ts +49 -0
  65. package/dist/sources/text/follow-file.js +109 -0
  66. package/dist/sources/text/read-file.d.ts +63 -0
  67. package/dist/sources/text/read-file.js +82 -0
  68. package/dist/viewer/icons.d.ts +14 -0
  69. package/dist/viewer/icons.js +30 -0
  70. package/dist/viewer/input-line.d.ts +46 -0
  71. package/dist/viewer/input-line.js +144 -0
  72. package/dist/viewer/labels.d.ts +35 -0
  73. package/dist/viewer/labels.js +61 -0
  74. package/dist/viewer/scrollbar.d.ts +27 -0
  75. package/dist/viewer/scrollbar.js +143 -0
  76. package/dist/viewer/theme.d.ts +15 -0
  77. package/dist/viewer/theme.js +105 -0
  78. package/dist/viewer/viewer.d.ts +217 -0
  79. package/dist/viewer/viewer.js +1201 -0
  80. package/package.json +92 -0
@@ -0,0 +1,1201 @@
1
+ import { entrySearchText } from '../core/filter.js';
2
+ import { DEFAULT_LAYOUT_OPTIONS, LogLayout } from '../core/layout/layout.js';
3
+ import { DEFAULT_STORE_OPTIONS, LogStore } from '../core/store.js';
4
+ import { measureCells } from '../core/text/measure.js';
5
+ import { formatTimestamp } from '../core/time.js';
6
+ import { CanvasRenderer } from '../renderer/canvas/canvas-renderer.js';
7
+ import { createConsole, hookConsole } from '../sources/console/hook.js';
8
+ import { snapshotValue } from '../sources/console/snapshot.js';
9
+ import { createIcon } from './icons.js';
10
+ import { InputLine } from './input-line.js';
11
+ import { labelsFor } from './labels.js';
12
+ import { Scrollbar } from './scrollbar.js';
13
+ import { readFont, readTheme } from './theme.js';
14
+ const DEFAULT_TOOLBAR = {
15
+ follow: true,
16
+ clear: true,
17
+ scroll: true,
18
+ wrap: true,
19
+ filter: true,
20
+ levels: true
21
+ };
22
+ /** Padding around the text, in CSS pixels. */
23
+ const PADDING_LEFT = 8;
24
+ const PADDING_RIGHT = 16;
25
+ const PADDING_TOP = 4;
26
+ const PADDING_BOTTOM = 8;
27
+ /** Cells of the column with the level marker. */
28
+ const MARKER_CELLS = 3;
29
+ /**
30
+ * The tallest scroll area the viewer creates. Browsers cap the height of an element; Firefox is
31
+ * the lowest at about 17.9 million pixels. Taller content is mapped onto this height.
32
+ */
33
+ const MAX_SCROLL_HEIGHT = 15000000;
34
+ /** How close to the bottom, in CSS pixels, still counts as following. */
35
+ const FOLLOW_THRESHOLD = 4;
36
+ /** How often the status bar and the screen reader mirror update, in milliseconds. */
37
+ const ACCESSORY_DELAY = 200;
38
+ const FILTER_DELAY = 120;
39
+ const DRAG_THRESHOLD = 4;
40
+ /** The most entries laid out exactly when a frame starts. Past it, row counts are estimated. */
41
+ const SYNC_BUDGET = 2000;
42
+ /** Rows laid out exactly above and below the view, so a short scroll finds them ready. */
43
+ const MEASURE_MARGIN_ROWS = 50;
44
+ /** How long one slice of background layout may run, and how many entries it takes at a time. */
45
+ const MEASURE_SLICE_MS = 8;
46
+ const MEASURE_BATCH = 200;
47
+ const LEVEL_OPTIONS = [
48
+ { value: '', label: 'levelAll' },
49
+ { value: 'log', label: 'levelLog' },
50
+ { value: 'info', label: 'levelInfo' },
51
+ { value: 'warn', label: 'levelWarn' },
52
+ { value: 'error', label: 'levelError' }
53
+ ];
54
+ /** Captures a pointer, ignoring the error a pointer that is no longer active throws. */
55
+ const capturePointer = (element, pointerId) => {
56
+ try {
57
+ element.setPointerCapture(pointerId);
58
+ }
59
+ catch {
60
+ // The pointer ended before the capture could start.
61
+ }
62
+ };
63
+ const comparePositions = (a, b) => {
64
+ return a.entryId - b.entryId || a.line - b.line || a.cell - b.cell;
65
+ };
66
+ const pick = (source, keys) => {
67
+ const result = {};
68
+ for (const key of keys) {
69
+ if (source[key] !== undefined) {
70
+ result[key] = source[key];
71
+ }
72
+ }
73
+ return result;
74
+ };
75
+ const STORE_KEYS = Object.keys(DEFAULT_STORE_OPTIONS);
76
+ const LAYOUT_KEYS = Object.keys(DEFAULT_LAYOUT_OPTIONS);
77
+ /**
78
+ * A log viewer: a toolbar, the log drawn by a renderer, an optional input line and a status
79
+ * bar. The viewer does the scrolling, selection, keyboard handling and accessibility, and
80
+ * hands each frame to the renderer.
81
+ *
82
+ * Import `lognal/style.css` once for the layout and the themes.
83
+ */
84
+ export class LogViewer {
85
+ store;
86
+ layout;
87
+ element;
88
+ ownerDocument;
89
+ renderer;
90
+ options;
91
+ body;
92
+ viewport;
93
+ spacer;
94
+ mirror;
95
+ verticalScrollbar;
96
+ horizontalScrollbar;
97
+ newLogsButton;
98
+ toolbarElement = null;
99
+ statusElement = null;
100
+ inputLine = null;
101
+ controls = new Map();
102
+ metrics = { width: 8, height: 20, baseline: 14 };
103
+ width = 0;
104
+ height = 0;
105
+ following;
106
+ hasUnseen = false;
107
+ topPixels = 0;
108
+ firstRow = 0;
109
+ visibleRows = [];
110
+ expectedScrollTop = null;
111
+ lastScrollTop = 0;
112
+ anchor = null;
113
+ columns = 1;
114
+ measureTimer;
115
+ selection = null;
116
+ drag = null;
117
+ autoScrollFrame = 0;
118
+ lastPointer = null;
119
+ frame = 0;
120
+ accessoryTimer;
121
+ filterTimer;
122
+ filter = null;
123
+ listeners = new Map();
124
+ cleanups = [];
125
+ consoleObject = null;
126
+ /** The wrapping mode the toolbar button restores after turning wrapping off. */
127
+ wrapMode = 'word';
128
+ cachedNumberFormat = null;
129
+ disposed = false;
130
+ constructor(container, options = {}) {
131
+ this.ownerDocument = container.ownerDocument;
132
+ const core = options.core ?? {};
133
+ this.store = options.store ?? new LogStore(pick(core, STORE_KEYS));
134
+ if (options.store && Object.keys(pick(core, STORE_KEYS)).length > 0) {
135
+ this.store.setOptions(pick(core, STORE_KEYS));
136
+ }
137
+ this.layout = new LogLayout(this.store, pick(core, LAYOUT_KEYS));
138
+ this.options = this.resolveOptions(options);
139
+ this.following = options.follow ?? true;
140
+ const doc = this.ownerDocument;
141
+ this.element = doc.createElement('div');
142
+ this.element.className = 'lognal';
143
+ this.body = doc.createElement('div');
144
+ this.body.className = 'lognal-body';
145
+ this.viewport = doc.createElement('div');
146
+ this.viewport.className = 'lognal-viewport';
147
+ this.viewport.tabIndex = 0;
148
+ this.spacer = doc.createElement('div');
149
+ this.spacer.className = 'lognal-spacer';
150
+ this.viewport.append(this.spacer);
151
+ this.mirror = doc.createElement('ul');
152
+ this.mirror.className = 'lognal-mirror';
153
+ this.renderer = options.renderer ? options.renderer(doc) : new CanvasRenderer(doc);
154
+ this.verticalScrollbar = new Scrollbar(doc, 'vertical', this.viewport);
155
+ this.horizontalScrollbar = new Scrollbar(doc, 'horizontal', this.viewport);
156
+ this.newLogsButton = doc.createElement('button');
157
+ this.newLogsButton.type = 'button';
158
+ this.newLogsButton.className = 'lognal-new-logs';
159
+ this.newLogsButton.hidden = true;
160
+ this.newLogsButton.addEventListener('click', () => this.scrollToBottom());
161
+ this.body.append(this.renderer.element, this.viewport, this.mirror, this.verticalScrollbar.element, this.horizontalScrollbar.element, this.newLogsButton);
162
+ this.element.append(this.body);
163
+ container.append(this.element);
164
+ this.buildChrome();
165
+ this.applyTheme();
166
+ this.applyFont();
167
+ if (core.filter) {
168
+ this.setFilter(core.filter);
169
+ }
170
+ this.bindEvents();
171
+ this.onResize();
172
+ }
173
+ /** An object with the console methods that writes to this viewer's store. */
174
+ get console() {
175
+ if (!this.consoleObject) {
176
+ this.consoleObject = createConsole(this.store);
177
+ }
178
+ return this.consoleObject;
179
+ }
180
+ /** Whether the view follows new entries. */
181
+ get isFollowing() {
182
+ return this.following;
183
+ }
184
+ /** Changes options after creation. Only the options given are changed. */
185
+ setOptions(options) {
186
+ const core = options.core ?? {};
187
+ const storeOptions = pick(core, STORE_KEYS);
188
+ const layoutOptions = pick(core, LAYOUT_KEYS);
189
+ if (Object.keys(storeOptions).length > 0) {
190
+ this.store.setOptions(storeOptions);
191
+ }
192
+ if (Object.keys(layoutOptions).length > 0) {
193
+ this.layout.setOptions(layoutOptions);
194
+ this.syncWrapButton();
195
+ }
196
+ if (core.filter !== undefined) {
197
+ this.setFilter(core.filter);
198
+ }
199
+ const previous = this.options;
200
+ this.options = this.resolveOptions({ ...this.unresolvedOptions(), ...options });
201
+ if (options.toolbar !== undefined ||
202
+ options.statusBar !== undefined ||
203
+ options.input !== undefined ||
204
+ options.labels !== undefined ||
205
+ 'locale' in options) {
206
+ this.buildChrome();
207
+ }
208
+ if (options.theme !== undefined && options.theme !== previous.theme) {
209
+ this.applyTheme();
210
+ }
211
+ if (options.font !== undefined || options.timestamps !== undefined) {
212
+ this.applyFont();
213
+ }
214
+ if (options.follow !== undefined) {
215
+ this.setFollowing(options.follow);
216
+ }
217
+ this.requestRender();
218
+ }
219
+ /** Adds text as one entry. */
220
+ write(text, options) {
221
+ this.store.write(text, options);
222
+ }
223
+ /** Adds text as one entry per line. */
224
+ writeLines(text, options) {
225
+ this.store.writeLines(text, options);
226
+ }
227
+ /**
228
+ * Starts recording a console, `console` by default, into this viewer's store. Returns a
229
+ * function that stops recording. Recording also stops when the viewer is disposed.
230
+ */
231
+ hookConsole(target = console, options) {
232
+ const unhook = hookConsole(target, this.store, options);
233
+ this.cleanups.push(unhook);
234
+ return unhook;
235
+ }
236
+ /** Removes every entry. */
237
+ clear() {
238
+ this.store.clear();
239
+ this.selection = null;
240
+ this.hasUnseen = false;
241
+ }
242
+ /** Sets the filter. `null` shows every entry. */
243
+ setFilter(filter) {
244
+ this.filter = filter;
245
+ const compiled = this.layout.setFilter(filter);
246
+ const input = this.controls.get('filter');
247
+ const levels = this.controls.get('levels');
248
+ if (input instanceof HTMLInputElement) {
249
+ if (input.value !== (filter?.text ?? '')) {
250
+ input.value = filter?.text ?? '';
251
+ }
252
+ input.toggleAttribute('aria-invalid', compiled.error !== null);
253
+ input.title = compiled.error ? this.options.labels.invalidFilter : '';
254
+ }
255
+ if (levels instanceof HTMLSelectElement) {
256
+ levels.value = filter?.minLevel ?? '';
257
+ }
258
+ this.emit('filter', filter);
259
+ this.requestRender();
260
+ }
261
+ getFilter() {
262
+ return this.filter;
263
+ }
264
+ /** Turns following new entries on or off. Turning it on scrolls to the newest entry. */
265
+ setFollowing(following) {
266
+ if (this.following === following) {
267
+ return;
268
+ }
269
+ this.following = following;
270
+ if (following) {
271
+ this.hasUnseen = false;
272
+ }
273
+ const button = this.controls.get('follow');
274
+ if (button) {
275
+ button.setAttribute('aria-pressed', String(following));
276
+ }
277
+ this.emit('follow', following);
278
+ this.requestRender();
279
+ }
280
+ scrollToTop() {
281
+ this.setFollowing(false);
282
+ this.layout.sync(SYNC_BUDGET);
283
+ const first = this.layout.entryAt(0);
284
+ this.anchor = first ? { entryId: first.id, entryRow: 0, offset: PADDING_TOP } : null;
285
+ this.viewport.scrollTop = 0;
286
+ this.lastScrollTop = 0;
287
+ this.requestRender();
288
+ }
289
+ scrollToBottom() {
290
+ this.setFollowing(true);
291
+ this.requestRender();
292
+ }
293
+ /** Scrolls so that an entry is at the top of the view. */
294
+ scrollToEntry(entryId) {
295
+ this.layout.sync(SYNC_BUDGET);
296
+ if (this.layout.indexOf(entryId) < 0) {
297
+ return;
298
+ }
299
+ this.setFollowing(false);
300
+ this.anchor = { entryId, entryRow: 0, offset: 0 };
301
+ this.requestRender();
302
+ }
303
+ /** Returns the selected text, or an empty string. */
304
+ getSelectionText() {
305
+ if (!this.selection || comparePositions(this.selection.anchor, this.selection.head) === 0) {
306
+ return '';
307
+ }
308
+ this.layout.sync();
309
+ return this.layout.getText(this.selection.anchor, this.selection.head);
310
+ }
311
+ /** Selects the text of every visible entry. */
312
+ selectAll() {
313
+ this.layout.sync();
314
+ const first = this.layout.entryAt(0);
315
+ const last = this.layout.entryAt(this.layout.visibleCount - 1);
316
+ if (!first || !last) {
317
+ return;
318
+ }
319
+ this.selection = {
320
+ anchor: { entryId: first.id, line: 0, cell: 0 },
321
+ head: { entryId: last.id, line: Number.MAX_SAFE_INTEGER, cell: Number.MAX_SAFE_INTEGER }
322
+ };
323
+ this.emit('selection', this.getSelectionText());
324
+ this.requestRender();
325
+ }
326
+ clearSelection() {
327
+ if (!this.selection) {
328
+ return;
329
+ }
330
+ this.selection = null;
331
+ this.emit('selection', '');
332
+ this.requestRender();
333
+ }
334
+ /** Copies the selected text to the clipboard. Resolves to whether anything was copied. */
335
+ async copySelection() {
336
+ const text = this.getSelectionText();
337
+ if (!text) {
338
+ return false;
339
+ }
340
+ const view = this.ownerDocument.defaultView;
341
+ const clipboard = view?.navigator.clipboard;
342
+ if (clipboard?.writeText && view?.isSecureContext) {
343
+ try {
344
+ await clipboard.writeText(text);
345
+ return true;
346
+ }
347
+ catch {
348
+ // Fall back to the copy command below.
349
+ }
350
+ }
351
+ const textarea = this.ownerDocument.createElement('textarea');
352
+ textarea.value = text;
353
+ textarea.setAttribute('readonly', '');
354
+ textarea.className = 'lognal-clipboard';
355
+ this.element.append(textarea);
356
+ textarea.select();
357
+ let copied;
358
+ try {
359
+ copied = this.ownerDocument.execCommand('copy');
360
+ }
361
+ catch {
362
+ copied = false;
363
+ }
364
+ textarea.remove();
365
+ this.viewport.focus({ preventScroll: true });
366
+ return copied;
367
+ }
368
+ /** Moves keyboard focus to the input line, or to the log when there is no input line. */
369
+ focus() {
370
+ if (this.inputLine) {
371
+ this.inputLine.focus();
372
+ }
373
+ else {
374
+ this.viewport.focus();
375
+ }
376
+ }
377
+ /** Reads the theme and the font from CSS again, for example after the page changed them. */
378
+ refresh() {
379
+ this.applyTheme();
380
+ this.applyFont();
381
+ }
382
+ /** Calls a listener for an event. Returns a function that removes the listener. */
383
+ on(name, listener) {
384
+ const listeners = this.listeners.get(name) ?? new Set();
385
+ listeners.add(listener);
386
+ this.listeners.set(name, listeners);
387
+ return () => {
388
+ listeners.delete(listener);
389
+ };
390
+ }
391
+ /** Removes the viewer from the page and stops everything it started. */
392
+ dispose() {
393
+ if (this.disposed) {
394
+ return;
395
+ }
396
+ this.disposed = true;
397
+ cancelAnimationFrame(this.frame);
398
+ cancelAnimationFrame(this.autoScrollFrame);
399
+ clearTimeout(this.accessoryTimer);
400
+ clearTimeout(this.filterTimer);
401
+ clearTimeout(this.measureTimer);
402
+ for (const cleanup of this.cleanups.splice(0)) {
403
+ cleanup();
404
+ }
405
+ this.layout.dispose();
406
+ this.renderer.dispose();
407
+ this.verticalScrollbar.dispose();
408
+ this.horizontalScrollbar.dispose();
409
+ this.inputLine?.dispose();
410
+ this.listeners.clear();
411
+ this.element.remove();
412
+ }
413
+ resolveOptions(options) {
414
+ const labelOverrides = options.labels ?? {};
415
+ const labels = { ...labelsFor(options.locale), ...labelOverrides };
416
+ const toolbar = options.toolbar === false
417
+ ? null
418
+ : options.toolbar === true || options.toolbar === undefined
419
+ ? DEFAULT_TOOLBAR
420
+ : { ...DEFAULT_TOOLBAR, ...options.toolbar };
421
+ const timestamps = options.timestamps === false
422
+ ? null
423
+ : options.timestamps === true || options.timestamps === undefined
424
+ ? 'time'
425
+ : options.timestamps;
426
+ return {
427
+ theme: options.theme ?? 'auto',
428
+ font: options.font ?? {},
429
+ timestamps,
430
+ toolbar,
431
+ statusBar: options.statusBar ?? true,
432
+ input: options.input ?? null,
433
+ locale: options.locale,
434
+ labels,
435
+ labelOverrides
436
+ };
437
+ }
438
+ unresolvedOptions() {
439
+ const { theme, font, timestamps, toolbar, statusBar, input, locale, labelOverrides } = this.options;
440
+ return {
441
+ theme,
442
+ font,
443
+ timestamps: timestamps ?? false,
444
+ toolbar: toolbar ?? false,
445
+ statusBar,
446
+ input,
447
+ locale,
448
+ labels: labelOverrides
449
+ };
450
+ }
451
+ emit(name, value) {
452
+ for (const listener of this.listeners.get(name) ?? []) {
453
+ listener(value);
454
+ }
455
+ }
456
+ listen(target, type, handler, options) {
457
+ target.addEventListener(type, handler, options);
458
+ this.cleanups.push(() => target.removeEventListener(type, handler, options));
459
+ }
460
+ bindEvents() {
461
+ const view = this.ownerDocument.defaultView;
462
+ this.listen(this.viewport, 'scroll', this.onScroll, { passive: true });
463
+ this.listen(this.viewport, 'pointerdown', this.onPointerDown);
464
+ this.listen(this.viewport, 'pointermove', this.onPointerMove);
465
+ this.listen(this.viewport, 'pointerup', this.onPointerUp);
466
+ this.listen(this.viewport, 'pointercancel', this.onPointerUp);
467
+ this.listen(this.viewport, 'dblclick', this.onDoubleClick);
468
+ this.listen(this.viewport, 'keydown', this.onKeyDown);
469
+ this.listen(this.viewport, 'copy', this.onCopy);
470
+ this.cleanups.push(this.store.subscribe(this.onStoreChange));
471
+ this.renderer.onFontsChanged(() => this.applyFont());
472
+ if (view && 'ResizeObserver' in view) {
473
+ const observer = new view.ResizeObserver(() => this.onResize());
474
+ observer.observe(this.body);
475
+ this.cleanups.push(() => observer.disconnect());
476
+ }
477
+ if (view?.matchMedia) {
478
+ const scheme = view.matchMedia('(prefers-color-scheme: dark)');
479
+ this.listen(scheme, 'change', () => {
480
+ if (this.options.theme === 'auto') {
481
+ this.applyTheme();
482
+ }
483
+ });
484
+ this.watchPixelRatio();
485
+ }
486
+ const fonts = this.ownerDocument.fonts;
487
+ if (fonts?.ready) {
488
+ fonts.ready.then(() => {
489
+ if (!this.disposed) {
490
+ this.applyFont();
491
+ }
492
+ });
493
+ }
494
+ }
495
+ watchPixelRatio() {
496
+ const view = this.ownerDocument.defaultView;
497
+ if (!view?.matchMedia) {
498
+ return;
499
+ }
500
+ const query = view.matchMedia(`(resolution: ${view.devicePixelRatio}dppx)`);
501
+ const onChange = () => {
502
+ query.removeEventListener('change', onChange);
503
+ if (!this.disposed) {
504
+ this.onResize();
505
+ this.watchPixelRatio();
506
+ }
507
+ };
508
+ query.addEventListener('change', onChange);
509
+ this.cleanups.push(() => query.removeEventListener('change', onChange));
510
+ }
511
+ buildChrome() {
512
+ const doc = this.ownerDocument;
513
+ const { labels, toolbar, statusBar, input } = this.options;
514
+ this.toolbarElement?.remove();
515
+ this.statusElement?.remove();
516
+ this.controls.clear();
517
+ this.element.setAttribute('role', 'region');
518
+ this.element.setAttribute('aria-label', labels.viewer);
519
+ this.viewport.setAttribute('aria-label', labels.viewer);
520
+ this.mirror.setAttribute('aria-label', labels.entryList);
521
+ this.newLogsButton.replaceChildren(createIcon(doc, 'arrowDown'), doc.createTextNode(labels.newLogs));
522
+ if (toolbar) {
523
+ this.toolbarElement = this.buildToolbar(toolbar, labels);
524
+ this.element.prepend(this.toolbarElement);
525
+ }
526
+ else {
527
+ this.toolbarElement = null;
528
+ }
529
+ if (input) {
530
+ const lineOptions = {
531
+ onSubmit: (command) => this.onCommand(command),
532
+ prompt: input.prompt ?? '>',
533
+ placeholder: input.placeholder ?? labels.inputPlaceholder,
534
+ label: labels.input,
535
+ historySize: input.historySize ?? 100
536
+ };
537
+ if (this.inputLine) {
538
+ this.inputLine.setOptions(lineOptions);
539
+ }
540
+ else {
541
+ this.inputLine = new InputLine(doc, lineOptions);
542
+ }
543
+ this.body.after(this.inputLine.element);
544
+ }
545
+ else if (this.inputLine) {
546
+ this.inputLine.dispose();
547
+ this.inputLine = null;
548
+ }
549
+ if (statusBar) {
550
+ this.statusElement = doc.createElement('div');
551
+ this.statusElement.className = 'lognal-statusbar';
552
+ const count = doc.createElement('span');
553
+ const follow = doc.createElement('span');
554
+ count.className = 'lognal-status-count';
555
+ follow.className = 'lognal-status-follow';
556
+ this.statusElement.append(count, follow);
557
+ this.element.append(this.statusElement);
558
+ }
559
+ else {
560
+ this.statusElement = null;
561
+ }
562
+ this.updateAccessories();
563
+ }
564
+ buildToolbar(toolbar, labels) {
565
+ const doc = this.ownerDocument;
566
+ const element = doc.createElement('div');
567
+ const start = doc.createElement('div');
568
+ const end = doc.createElement('div');
569
+ element.className = 'lognal-toolbar';
570
+ element.setAttribute('role', 'toolbar');
571
+ element.setAttribute('aria-label', labels.toolbar);
572
+ start.className = 'lognal-toolbar-group';
573
+ end.className = 'lognal-toolbar-group lognal-toolbar-end';
574
+ const button = (name, icon, label, onClick, pressed) => {
575
+ const control = doc.createElement('button');
576
+ control.type = 'button';
577
+ control.className = 'lognal-button';
578
+ control.title = label;
579
+ control.setAttribute('aria-label', label);
580
+ control.append(createIcon(doc, icon));
581
+ if (pressed !== undefined) {
582
+ control.setAttribute('aria-pressed', String(pressed));
583
+ }
584
+ control.addEventListener('click', onClick);
585
+ start.append(control);
586
+ this.controls.set(name, control);
587
+ };
588
+ const separator = () => {
589
+ if (start.lastElementChild &&
590
+ !start.lastElementChild.classList.contains('lognal-separator')) {
591
+ const line = doc.createElement('span');
592
+ line.className = 'lognal-separator';
593
+ line.setAttribute('aria-hidden', 'true');
594
+ start.append(line);
595
+ }
596
+ };
597
+ if (toolbar.follow) {
598
+ button('follow', 'follow', labels.follow, () => this.setFollowing(!this.following), this.following);
599
+ }
600
+ if (toolbar.clear) {
601
+ button('clear', 'clear', labels.clear, () => this.clear());
602
+ }
603
+ if (toolbar.scroll) {
604
+ separator();
605
+ button('top', 'top', labels.scrollToTop, () => this.scrollToTop());
606
+ button('bottom', 'bottom', labels.scrollToBottom, () => this.scrollToBottom());
607
+ }
608
+ if (toolbar.wrap) {
609
+ separator();
610
+ button('wrap', 'wrap', labels.wrap, () => {
611
+ const current = this.layout.getOptions().wrap;
612
+ if (current !== 'none') {
613
+ this.wrapMode = current;
614
+ }
615
+ this.layout.setOptions({ wrap: current === 'none' ? this.wrapMode : 'none' });
616
+ this.syncWrapButton();
617
+ this.requestRender();
618
+ }, this.layout.getOptions().wrap !== 'none');
619
+ }
620
+ if (toolbar.filter) {
621
+ const field = doc.createElement('label');
622
+ const input = doc.createElement('input');
623
+ field.className = 'lognal-filter';
624
+ field.append(createIcon(doc, 'search'));
625
+ input.type = 'search';
626
+ input.className = 'lognal-filter-input';
627
+ input.placeholder = labels.filter;
628
+ input.setAttribute('aria-label', labels.filter);
629
+ input.spellcheck = false;
630
+ input.value = this.filter?.text ?? '';
631
+ input.addEventListener('input', () => {
632
+ clearTimeout(this.filterTimer);
633
+ this.filterTimer = setTimeout(() => {
634
+ this.setFilter({ ...this.filter, text: input.value });
635
+ }, FILTER_DELAY);
636
+ });
637
+ field.append(input);
638
+ end.append(field);
639
+ this.controls.set('filter', input);
640
+ }
641
+ if (toolbar.levels) {
642
+ const select = doc.createElement('select');
643
+ select.className = 'lognal-levels';
644
+ select.setAttribute('aria-label', labels.levels);
645
+ select.title = labels.levels;
646
+ for (const option of LEVEL_OPTIONS) {
647
+ const element = doc.createElement('option');
648
+ element.value = option.value;
649
+ element.textContent = labels[option.label];
650
+ select.append(element);
651
+ }
652
+ select.value = this.filter?.minLevel ?? '';
653
+ select.addEventListener('change', () => {
654
+ // The menu sets a minimum level, which replaces a list of levels set through code.
655
+ this.setFilter({
656
+ ...this.filter,
657
+ levels: undefined,
658
+ minLevel: (select.value || undefined)
659
+ });
660
+ });
661
+ end.append(select);
662
+ this.controls.set('levels', select);
663
+ }
664
+ element.append(start, end);
665
+ return element;
666
+ }
667
+ syncWrapButton() {
668
+ this.controls
669
+ .get('wrap')
670
+ ?.setAttribute('aria-pressed', String(this.layout.getOptions().wrap !== 'none'));
671
+ }
672
+ applyTheme() {
673
+ this.element.dataset.theme = this.options.theme;
674
+ this.renderer.setTheme(readTheme(this.element));
675
+ this.requestRender();
676
+ }
677
+ applyFont() {
678
+ if (this.disposed) {
679
+ return;
680
+ }
681
+ this.metrics = this.renderer.setFont(readFont(this.element, this.options.font));
682
+ this.element.style.setProperty('--lognal-cell-height', `${this.metrics.height}px`);
683
+ this.updateColumns();
684
+ this.requestRender();
685
+ }
686
+ timestampCells() {
687
+ const format = this.options.timestamps;
688
+ if (format === null) {
689
+ return 0;
690
+ }
691
+ return measureCells(formatTimestamp(Date.now(), format)) + 2;
692
+ }
693
+ contentLeft() {
694
+ return PADDING_LEFT + (this.timestampCells() + MARKER_CELLS) * this.metrics.width;
695
+ }
696
+ updateColumns() {
697
+ const available = this.width - this.contentLeft() - PADDING_RIGHT;
698
+ this.columns = Math.max(1, Math.floor(available / this.metrics.width));
699
+ this.layout.setColumns(this.columns);
700
+ }
701
+ scrollScale() {
702
+ const content = this.contentHeight();
703
+ const client = this.viewport.clientHeight;
704
+ const scrollable = Math.min(content, MAX_SCROLL_HEIGHT);
705
+ if (content <= scrollable || scrollable <= client) {
706
+ return 1;
707
+ }
708
+ return (content - client) / (scrollable - client);
709
+ }
710
+ contentHeight() {
711
+ return PADDING_TOP + this.layout.rowCount * this.metrics.height + PADDING_BOTTOM;
712
+ }
713
+ requestRender() {
714
+ if (this.frame || this.disposed) {
715
+ return;
716
+ }
717
+ const view = this.ownerDocument.defaultView;
718
+ this.frame = (view ?? globalThis).requestAnimationFrame(() => {
719
+ this.frame = 0;
720
+ this.render();
721
+ });
722
+ }
723
+ render() {
724
+ if (this.disposed || this.width <= 0 || this.height <= 0) {
725
+ return;
726
+ }
727
+ this.layout.sync(SYNC_BUDGET);
728
+ const viewport = this.viewport;
729
+ const rowHeight = this.metrics.height;
730
+ const client = viewport.clientHeight;
731
+ const screenRows = Math.ceil(this.height / rowHeight) + 1;
732
+ const scrollTop = viewport.scrollTop;
733
+ // The view was scrolled since the last frame, and its scroll event has not arrived yet.
734
+ // Follow the new position rather than the old anchor, and stop following the bottom.
735
+ if (Math.abs(scrollTop - Math.min(this.lastScrollTop, viewport.scrollHeight - client)) >= 1) {
736
+ this.anchor = null;
737
+ if (this.following && viewport.scrollHeight - client - scrollTop > FOLLOW_THRESHOLD) {
738
+ this.setFollowing(false);
739
+ }
740
+ }
741
+ if (!this.following && !this.anchor) {
742
+ this.anchor = this.anchorAt(scrollTop * this.scrollScale());
743
+ }
744
+ // Lay out the rows on screen exactly before reading them. The rest of the log keeps its
745
+ // estimated row counts until `measurePending` gets to it.
746
+ if (this.following) {
747
+ this.layout.measureAround(null, screenRows + MEASURE_MARGIN_ROWS, 1);
748
+ }
749
+ else if (this.anchor) {
750
+ this.layout.measureAround(this.anchor.entryId, MEASURE_MARGIN_ROWS, screenRows + MEASURE_MARGIN_ROWS);
751
+ }
752
+ const content = this.contentHeight();
753
+ const spacerHeight = Math.min(content, MAX_SCROLL_HEIGHT);
754
+ const wide = this.layout.getOptions().wrap === 'none' || this.layout.maxCells > this.columns;
755
+ this.spacer.style.height = `${spacerHeight}px`;
756
+ this.spacer.style.width = wide
757
+ ? `${this.contentLeft() + this.layout.maxCells * this.metrics.width + PADDING_RIGHT}px`
758
+ : '';
759
+ const maxTop = Math.max(0, content - client);
760
+ const scale = this.scrollScale();
761
+ let topPixels = Math.min(maxTop, viewport.scrollTop * scale);
762
+ if (this.following) {
763
+ topPixels = maxTop;
764
+ }
765
+ else if (this.anchor) {
766
+ const entryStart = this.layout.rowOfEntry(this.anchor.entryId);
767
+ if (entryStart < 0) {
768
+ this.anchor = null;
769
+ }
770
+ else {
771
+ const entryRow = Math.min(this.anchor.entryRow, Math.max(0, this.layout.rowsOf(this.anchor.entryId) - 1));
772
+ topPixels = Math.min(maxTop, Math.max(0, PADDING_TOP + (entryStart + entryRow) * rowHeight - this.anchor.offset));
773
+ }
774
+ }
775
+ const targetScrollTop = topPixels / scale;
776
+ if (Math.abs(viewport.scrollTop - targetScrollTop) >= 1) {
777
+ this.expectedScrollTop = targetScrollTop;
778
+ viewport.scrollTop = targetScrollTop;
779
+ }
780
+ const firstRow = Math.max(0, Math.floor((topPixels - PADDING_TOP) / rowHeight));
781
+ const offsetY = PADDING_TOP + firstRow * rowHeight - topPixels;
782
+ const rows = this.layout.getRows(firstRow, Math.ceil((this.height - offsetY) / rowHeight) + 1);
783
+ this.topPixels = topPixels;
784
+ this.firstRow = firstRow;
785
+ this.visibleRows = rows;
786
+ this.lastScrollTop = viewport.scrollTop;
787
+ this.anchor =
788
+ this.following || !rows[0]
789
+ ? null
790
+ : { entryId: rows[0].entry.id, entryRow: rows[0].entryRow, offset: offsetY };
791
+ this.renderer.render({
792
+ rows,
793
+ decorations: rows.map((row) => this.decorationFor(row)),
794
+ offsetY,
795
+ scrollX: viewport.scrollLeft,
796
+ paddingLeft: PADDING_LEFT,
797
+ timestampCells: this.timestampCells(),
798
+ markerCells: MARKER_CELLS,
799
+ formatTime: (time) => formatTimestamp(time, this.options.timestamps ?? 'time')
800
+ });
801
+ this.verticalScrollbar.update();
802
+ this.horizontalScrollbar.update();
803
+ this.newLogsButton.hidden = this.following || !this.hasUnseen;
804
+ this.updateStatus();
805
+ this.scheduleAccessories();
806
+ this.scheduleMeasure();
807
+ }
808
+ /** Returns an anchor for the row at a pixel position of the whole log. */
809
+ anchorAt(topPixels) {
810
+ const row = Math.max(0, Math.floor((topPixels - PADDING_TOP) / this.metrics.height));
811
+ const located = this.layout.locateRow(row);
812
+ if (!located) {
813
+ return null;
814
+ }
815
+ return {
816
+ entryId: located.entry.id,
817
+ entryRow: located.entryRow,
818
+ offset: PADDING_TOP + row * this.metrics.height - topPixels
819
+ };
820
+ }
821
+ /**
822
+ * Lays out the entries that still have estimated row counts, a slice at a time between
823
+ * frames, starting with the ones nearest to the view.
824
+ */
825
+ scheduleMeasure() {
826
+ if (this.measureTimer !== undefined || this.disposed || this.layout.pendingCount === 0) {
827
+ return;
828
+ }
829
+ this.measureTimer = setTimeout(() => {
830
+ this.measureTimer = undefined;
831
+ const started = performance.now();
832
+ const focus = this.following ? null : (this.anchor?.entryId ?? null);
833
+ let changed = false;
834
+ while (this.layout.pendingCount > 0 && performance.now() - started < MEASURE_SLICE_MS) {
835
+ changed = this.layout.measurePending(MEASURE_BATCH, focus) || changed;
836
+ }
837
+ if (changed) {
838
+ this.requestRender();
839
+ }
840
+ this.scheduleMeasure();
841
+ }, 0);
842
+ }
843
+ decorationFor(row) {
844
+ const decoration = {};
845
+ const selection = this.selection;
846
+ if (selection && comparePositions(selection.anchor, selection.head) !== 0) {
847
+ const [start, end] = comparePositions(selection.anchor, selection.head) < 0
848
+ ? [selection.anchor, selection.head]
849
+ : [selection.head, selection.anchor];
850
+ const line = { entryId: row.entry.id, line: row.line, cell: 0 };
851
+ const afterStart = row.entry.id > start.entryId || (row.entry.id === start.entryId && row.line >= start.line);
852
+ const beforeEnd = row.entry.id < end.entryId || (row.entry.id === end.entryId && row.line <= end.line);
853
+ if (afterStart && beforeEnd) {
854
+ const rowEnd = row.startCell + row.cells;
855
+ const isStartLine = line.entryId === start.entryId && line.line === start.line;
856
+ const isEndLine = line.entryId === end.entryId && line.line === end.line;
857
+ const from = Math.max(row.startCell, isStartLine ? start.cell : 0);
858
+ const to = Math.min(rowEnd, isEndLine ? end.cell : rowEnd);
859
+ const lineBreak = !isEndLine && to === rowEnd ? 1 : 0;
860
+ if (to > from || lineBreak) {
861
+ decoration.selection = [
862
+ row.indent + from - row.startCell,
863
+ row.indent + to - row.startCell + lineBreak
864
+ ];
865
+ }
866
+ }
867
+ }
868
+ const pattern = this.layout.getFilter().pattern;
869
+ if (pattern) {
870
+ decoration.matches = this.findMatches(row, pattern);
871
+ }
872
+ return decoration;
873
+ }
874
+ findMatches(row, pattern) {
875
+ const columns = [];
876
+ let text = '';
877
+ let end = row.indent;
878
+ for (const run of row.runs) {
879
+ if (run.icon) {
880
+ continue;
881
+ }
882
+ if (run.simple) {
883
+ for (let index = 0; index < run.text.length; index++) {
884
+ columns.push(run.column + index);
885
+ }
886
+ text += run.text;
887
+ }
888
+ else {
889
+ let column = run.column;
890
+ run.clusters.forEach((cluster, index) => {
891
+ // The filter text is composed (NFC), so the row text is compared composed too.
892
+ const composed = cluster.normalize('NFC');
893
+ for (let unit = 0; unit < composed.length; unit++) {
894
+ columns.push(column);
895
+ }
896
+ text += composed;
897
+ column += run.widths[index] ?? 1;
898
+ });
899
+ }
900
+ end = run.column + run.cells;
901
+ }
902
+ columns.push(end);
903
+ const matches = [];
904
+ pattern.lastIndex = 0;
905
+ for (let match = pattern.exec(text); match && matches.length < 64; match = pattern.exec(text)) {
906
+ if (match[0].length === 0) {
907
+ pattern.lastIndex++;
908
+ continue;
909
+ }
910
+ matches.push([columns[match.index], columns[match.index + match[0].length] ?? end]);
911
+ }
912
+ return matches;
913
+ }
914
+ scheduleAccessories() {
915
+ if (this.accessoryTimer !== undefined) {
916
+ return;
917
+ }
918
+ this.accessoryTimer = setTimeout(() => {
919
+ this.accessoryTimer = undefined;
920
+ this.updateAccessories();
921
+ }, ACCESSORY_DELAY);
922
+ }
923
+ /** Updates the status bar. Cheap enough for every frame, since text is only written when it changes. */
924
+ updateStatus() {
925
+ if (!this.statusElement) {
926
+ return;
927
+ }
928
+ const { labels, locale } = this.options;
929
+ const [count, follow] = Array.from(this.statusElement.children);
930
+ const numberFormat = this.numberFormat(locale);
931
+ const countText = labels.entries(this.layout.visibleCount, this.store.size, (value) => numberFormat.format(value));
932
+ const followText = this.following ? labels.following : labels.paused;
933
+ if (count.textContent !== countText) {
934
+ count.textContent = countText;
935
+ }
936
+ if (follow.textContent !== followText) {
937
+ follow.textContent = followText;
938
+ follow.classList.toggle('is-following', this.following);
939
+ }
940
+ }
941
+ updateAccessories() {
942
+ if (this.disposed) {
943
+ return;
944
+ }
945
+ this.updateStatus();
946
+ const doc = this.ownerDocument;
947
+ const seen = new Set();
948
+ const items = [];
949
+ for (const row of this.visibleRows) {
950
+ if (seen.has(row.entry.id)) {
951
+ continue;
952
+ }
953
+ seen.add(row.entry.id);
954
+ const item = doc.createElement('li');
955
+ const prefix = row.entry.level === 'error' || row.entry.level === 'warn' ? `${row.entry.level}: ` : '';
956
+ item.textContent = `${prefix}${entrySearchText(row.entry)}`;
957
+ items.push(item);
958
+ }
959
+ this.mirror.replaceChildren(...items);
960
+ }
961
+ numberFormat(locale) {
962
+ if (!this.cachedNumberFormat || this.cachedNumberFormat.locale !== locale) {
963
+ this.cachedNumberFormat = { locale, format: new Intl.NumberFormat(locale) };
964
+ }
965
+ return this.cachedNumberFormat.format;
966
+ }
967
+ hitTest(event) {
968
+ const rect = this.viewport.getBoundingClientRect();
969
+ const x = event.clientX - rect.left;
970
+ const y = event.clientY - rect.top;
971
+ const row = Math.floor((y + this.topPixels - PADDING_TOP) / this.metrics.height);
972
+ const exactColumn = (x + this.viewport.scrollLeft - this.contentLeft()) / this.metrics.width;
973
+ const visualRow = this.visibleRows[row - this.firstRow];
974
+ const cellColumn = Math.floor(exactColumn);
975
+ const run = visualRow?.runs.find((item) => cellColumn >= item.column && cellColumn < item.column + item.cells);
976
+ return {
977
+ row,
978
+ column: Math.max(0, Math.round(exactColumn)),
979
+ visualRow,
980
+ action: run?.action
981
+ };
982
+ }
983
+ positionFrom(hit) {
984
+ if (hit.row < 0) {
985
+ const first = this.layout.entryAt(0);
986
+ return first ? { entryId: first.id, line: 0, cell: 0 } : null;
987
+ }
988
+ return this.layout.positionAt(hit.row, hit.column);
989
+ }
990
+ onStoreChange = (change) => {
991
+ if (change.type === 'append' && !this.following) {
992
+ this.hasUnseen = true;
993
+ }
994
+ else if (change.type === 'clear') {
995
+ this.hasUnseen = false;
996
+ }
997
+ this.requestRender();
998
+ };
999
+ onResize = () => {
1000
+ const view = this.ownerDocument.defaultView;
1001
+ const width = this.body.clientWidth;
1002
+ const height = this.body.clientHeight;
1003
+ this.width = width;
1004
+ this.height = height;
1005
+ this.renderer.resize(width, height, view?.devicePixelRatio ?? 1);
1006
+ this.updateColumns();
1007
+ this.requestRender();
1008
+ };
1009
+ onScroll = () => {
1010
+ const top = this.viewport.scrollTop;
1011
+ if (this.expectedScrollTop !== null && Math.abs(top - this.expectedScrollTop) < 1) {
1012
+ this.expectedScrollTop = null;
1013
+ }
1014
+ else {
1015
+ this.expectedScrollTop = null;
1016
+ this.anchor = null;
1017
+ const atBottom = top + this.viewport.clientHeight >= this.viewport.scrollHeight - FOLLOW_THRESHOLD;
1018
+ this.setFollowing(atBottom);
1019
+ }
1020
+ this.lastScrollTop = top;
1021
+ this.verticalScrollbar.update(true);
1022
+ this.horizontalScrollbar.update(true);
1023
+ this.requestRender();
1024
+ };
1025
+ onPointerDown = (event) => {
1026
+ if (event.button !== 0 || event.pointerType === 'touch') {
1027
+ return;
1028
+ }
1029
+ event.preventDefault();
1030
+ this.viewport.focus({ preventScroll: true });
1031
+ const hit = this.hitTest(event);
1032
+ const position = this.positionFrom(hit);
1033
+ this.drag = {
1034
+ pointerId: event.pointerId,
1035
+ x: event.clientX,
1036
+ y: event.clientY,
1037
+ action: hit.action ? hit : null,
1038
+ moved: false
1039
+ };
1040
+ this.lastPointer = { x: event.clientX, y: event.clientY };
1041
+ capturePointer(this.viewport, event.pointerId);
1042
+ if (!position) {
1043
+ this.clearSelection();
1044
+ return;
1045
+ }
1046
+ if (event.shiftKey && this.selection) {
1047
+ this.selection = { anchor: this.selection.anchor, head: position };
1048
+ }
1049
+ else {
1050
+ this.selection = { anchor: position, head: position };
1051
+ }
1052
+ this.requestRender();
1053
+ };
1054
+ onPointerMove = (event) => {
1055
+ if (!this.drag || event.pointerId !== this.drag.pointerId) {
1056
+ const hit = this.hitTest(event);
1057
+ this.viewport.classList.toggle('has-action', Boolean(hit.action));
1058
+ return;
1059
+ }
1060
+ this.lastPointer = { x: event.clientX, y: event.clientY };
1061
+ if (Math.hypot(event.clientX - this.drag.x, event.clientY - this.drag.y) > DRAG_THRESHOLD) {
1062
+ this.drag.moved = true;
1063
+ }
1064
+ this.extendSelection();
1065
+ this.updateAutoScroll();
1066
+ };
1067
+ onPointerUp = (event) => {
1068
+ if (!this.drag || event.pointerId !== this.drag.pointerId) {
1069
+ return;
1070
+ }
1071
+ const { action, moved } = this.drag;
1072
+ this.drag = null;
1073
+ cancelAnimationFrame(this.autoScrollFrame);
1074
+ this.autoScrollFrame = 0;
1075
+ if (this.viewport.hasPointerCapture(event.pointerId)) {
1076
+ this.viewport.releasePointerCapture(event.pointerId);
1077
+ }
1078
+ if (action?.action && action.visualRow && !moved) {
1079
+ this.selection = null;
1080
+ this.layout.runAction(action.visualRow.entry.id, action.action);
1081
+ this.requestRender();
1082
+ return;
1083
+ }
1084
+ const text = this.getSelectionText();
1085
+ if (!text) {
1086
+ this.selection = null;
1087
+ }
1088
+ this.emit('selection', text);
1089
+ this.requestRender();
1090
+ };
1091
+ extendSelection() {
1092
+ if (!this.selection || !this.lastPointer) {
1093
+ return;
1094
+ }
1095
+ const rect = this.viewport.getBoundingClientRect();
1096
+ const clientY = Math.min(Math.max(this.lastPointer.y, rect.top - 1), rect.bottom + 1);
1097
+ const position = this.positionFrom(this.hitTest({ clientX: this.lastPointer.x, clientY }));
1098
+ if (position) {
1099
+ this.selection = { anchor: this.selection.anchor, head: position };
1100
+ this.requestRender();
1101
+ }
1102
+ }
1103
+ updateAutoScroll() {
1104
+ if (this.autoScrollFrame || !this.drag || !this.lastPointer) {
1105
+ return;
1106
+ }
1107
+ const view = this.ownerDocument.defaultView ?? globalThis;
1108
+ const step = () => {
1109
+ this.autoScrollFrame = 0;
1110
+ if (!this.drag || !this.lastPointer) {
1111
+ return;
1112
+ }
1113
+ const rect = this.viewport.getBoundingClientRect();
1114
+ const above = rect.top - this.lastPointer.y;
1115
+ const below = this.lastPointer.y - rect.bottom;
1116
+ const distance = above > 0 ? -above : below > 0 ? below : 0;
1117
+ if (distance === 0) {
1118
+ return;
1119
+ }
1120
+ this.setFollowing(false);
1121
+ this.viewport.scrollTop +=
1122
+ Math.sign(distance) * Math.min(this.metrics.height * 3, Math.abs(distance));
1123
+ this.extendSelection();
1124
+ this.autoScrollFrame = view.requestAnimationFrame(step);
1125
+ };
1126
+ this.autoScrollFrame = view.requestAnimationFrame(step);
1127
+ }
1128
+ onDoubleClick = (event) => {
1129
+ const hit = this.hitTest(event);
1130
+ if (hit.action || !hit.visualRow) {
1131
+ return;
1132
+ }
1133
+ const position = this.positionFrom(hit);
1134
+ if (!position) {
1135
+ return;
1136
+ }
1137
+ const range = this.layout.wordAt(position);
1138
+ if (range) {
1139
+ this.selection = { anchor: range[0], head: range[1] };
1140
+ this.emit('selection', this.getSelectionText());
1141
+ this.requestRender();
1142
+ }
1143
+ };
1144
+ onKeyDown = (event) => {
1145
+ const modifier = event.ctrlKey || event.metaKey;
1146
+ const key = event.key.toLowerCase();
1147
+ if (modifier && key === 'c') {
1148
+ if (this.selection) {
1149
+ void this.copySelection();
1150
+ }
1151
+ return;
1152
+ }
1153
+ if (modifier && key === 'a') {
1154
+ event.preventDefault();
1155
+ this.selectAll();
1156
+ return;
1157
+ }
1158
+ if (event.key === 'Escape') {
1159
+ this.clearSelection();
1160
+ }
1161
+ };
1162
+ onCopy = (event) => {
1163
+ const text = this.getSelectionText();
1164
+ if (text && event.clipboardData) {
1165
+ event.clipboardData.setData('text/plain', text);
1166
+ event.preventDefault();
1167
+ }
1168
+ };
1169
+ onCommand(command) {
1170
+ const input = this.options.input;
1171
+ if (!input) {
1172
+ return;
1173
+ }
1174
+ if (input.echo !== false) {
1175
+ this.store.append({ kind: 'input', parts: [{ type: 'text', text: command }] });
1176
+ }
1177
+ this.setFollowing(true);
1178
+ let result;
1179
+ try {
1180
+ result = input.onSubmit(command, this);
1181
+ }
1182
+ catch (error) {
1183
+ this.printReply(error, true);
1184
+ return;
1185
+ }
1186
+ if (result && typeof result.then === 'function') {
1187
+ result.then((value) => this.printReply(value, false), (error) => this.printReply(error, true));
1188
+ return;
1189
+ }
1190
+ this.printReply(result, false);
1191
+ }
1192
+ printReply(value, failed) {
1193
+ if (this.disposed || (value === undefined && !failed)) {
1194
+ return;
1195
+ }
1196
+ const part = typeof value === 'string'
1197
+ ? { type: 'text', text: value }
1198
+ : { type: 'value', value: snapshotValue(value) };
1199
+ this.store.append({ kind: 'output', level: failed ? 'error' : 'log', parts: [part] });
1200
+ }
1201
+ }