@adecore/editor 0.42.0 → 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/dist/layout.js CHANGED
@@ -11,6 +11,8 @@ export function subRowBand(top, subRows, lineHeight, window, block = 1) {
11
11
  last: Math.min(subRows - 1, Math.ceil((last + 1) / block) * block - 1)
12
12
  };
13
13
  }
14
+ /* The side of a swatch in pixels, without its gaps. */
15
+ export const SWATCH_SIZE = 12;
14
16
  export const TEXT_PADDING = 0;
15
17
  export const BOTTOM_PADDING = 8;
16
18
  export const RIGHT_PADDING = 16;
@@ -154,9 +156,12 @@ export function scanDenseLine(line, metrics, limit, lineInlays) {
154
156
  flushRun(offset);
155
157
  const inlay = lineInlays[nextInlay++];
156
158
  const shown = visibleBidi(inlay.text);
159
+ const gaps = (inlay.gapLeft ?? 0) + (inlay.gapRight ?? 0);
157
160
  const width = inlay.composition || inlay.ghost
158
161
  ? metrics.measureText(shown)
159
- : Math.max(8, metrics.measureInlay(shown) + 12) + (inlay.gapLeft ?? 0) + (inlay.gapRight ?? 0);
162
+ : inlay.swatch !== undefined
163
+ ? SWATCH_SIZE + gaps
164
+ : Math.max(8, metrics.measureInlay(shown) + 12) + gaps;
160
165
  geometry.inlays.push({ inlay, x, width, subRow: row });
161
166
  x += width;
162
167
  }
package/dist/outline.d.ts CHANGED
@@ -37,6 +37,7 @@ export declare class Outline {
37
37
  setStructure(entries: OutlineEntry[]): void;
38
38
  setProvided(blocks: readonly EditorBlock[] | null, document: OutlineDocument): void;
39
39
  edited(batches: readonly (readonly DocumentChange[])[]): void;
40
+ declarationStops(document: OutlineDocument): number[];
40
41
  blocks(document: OutlineDocument): readonly OutlineBlock[];
41
42
  }
42
43
  export interface StickyRows {
package/dist/outline.js CHANGED
@@ -140,6 +140,16 @@ export class Outline {
140
140
  this.provided = this.provided === null ? null : move(this.provided);
141
141
  this.version++;
142
142
  }
143
+ /* Where the named blocks of the structure declare their name, in no order: the stops of Method Up and Down. */
144
+ declarationStops(document) {
145
+ return this.structure.flatMap((entry) => {
146
+ if (entry.name === undefined) {
147
+ return [];
148
+ }
149
+ const header = document.getLine(document.positionAt(entry.from).line);
150
+ return [header.start + Math.max(0, header.text.indexOf(entry.name))];
151
+ });
152
+ }
143
153
  /* Outermost first. Of two blocks that start on one line, only the larger one. */
144
154
  blocks(document) {
145
155
  if (this.cache?.version !== this.version) {
package/dist/paint.js CHANGED
@@ -102,10 +102,20 @@ export function lineElement(document, geometry, tokens, lineHeight, band, whites
102
102
  continue;
103
103
  }
104
104
  const span = document.createElement('span');
105
- span.className = box.inlay.ghost ? 'se-ghost' : box.inlay.composition ? 'se-composition' : 'se-inlay';
105
+ span.className = box.inlay.ghost
106
+ ? 'se-ghost'
107
+ : box.inlay.composition
108
+ ? 'se-composition'
109
+ : box.inlay.swatch !== undefined
110
+ ? 'se-inlay se-swatch'
111
+ : 'se-inlay';
106
112
  span.dataset.inlayId = box.inlay.id;
107
113
  const { parts } = box.inlay;
108
- if (parts === undefined) {
114
+ if (box.inlay.swatch !== undefined) {
115
+ span.style.backgroundColor = box.inlay.swatch;
116
+ span.setAttribute('aria-label', box.inlay.text);
117
+ }
118
+ else if (parts === undefined) {
109
119
  span.textContent = visibleBidi(box.inlay.text);
110
120
  }
111
121
  else {
package/dist/types.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { EditorCommand, FoldRole, WhitespaceCleanupOptions } from '@adecore
4
4
  export type { EditorCommand, FoldRole };
5
5
  export type EditorWhitespaceCleanup = WhitespaceCleanupOptions;
6
6
  export type EditorFoldOutline = 'off' | 'hover' | 'always';
7
- export type EditorViewCommand = 'collapseRegion' | 'expandRegion' | 'collapseAllRegions' | 'expandAllRegions' | 'collapseRegionRecursively' | 'expandRegionRecursively' | 'foldSelection' | 'collapseDocComments' | 'expandDocComments' | 'expandAllToLevel1' | 'expandAllToLevel2' | 'expandAllToLevel3' | 'expandAllToLevel4' | 'expandAllToLevel5' | 'toggleColumnMode' | 'increaseFontSize' | 'decreaseFontSize' | 'resetFontSize';
7
+ export type EditorViewCommand = 'collapseRegion' | 'expandRegion' | 'collapseAllRegions' | 'expandAllRegions' | 'collapseRegionRecursively' | 'expandRegionRecursively' | 'foldSelection' | 'collapseDocComments' | 'expandDocComments' | 'expandAllToLevel1' | 'expandAllToLevel2' | 'expandAllToLevel3' | 'expandAllToLevel4' | 'expandAllToLevel5' | 'toggleColumnMode' | 'increaseFontSize' | 'decreaseFontSize' | 'resetFontSize' | 'methodUp' | 'methodDown' | 'nextChange' | 'previousChange' | 'pasteAsPlainText';
8
8
  export interface EditorZoom {
9
9
  readonly min?: number;
10
10
  readonly max?: number;
@@ -139,6 +139,15 @@ export interface EditorContentChange {
139
139
  readonly range: EditorRange;
140
140
  readonly text: string;
141
141
  }
142
+ export interface EditorCopy {
143
+ readonly text: string;
144
+ readonly wholeLines: boolean;
145
+ readonly cut: boolean;
146
+ }
147
+ export interface EditorPasteOptions {
148
+ readonly wholeLines?: boolean;
149
+ readonly reindent?: boolean;
150
+ }
142
151
  export interface EditorApplyEditsOptions {
143
152
  readonly mapSelections?: (range: EditorRange) => EditorRange;
144
153
  readonly mergeUndo?: boolean;
@@ -240,6 +249,7 @@ export interface EditorInlayHint {
240
249
  readonly parts?: readonly string[];
241
250
  readonly paddingLeft?: boolean;
242
251
  readonly paddingRight?: boolean;
252
+ readonly swatch?: string;
243
253
  }
244
254
  export interface EditorInlayTarget {
245
255
  readonly id: string;
@@ -336,6 +346,12 @@ export interface Editor {
336
346
  setTodoWords?(words: readonly string[]): void;
337
347
  setLabel(label: string): void;
338
348
  setFoldHints(hints: EditorFoldHints | null): void;
349
+ setDeclarations?(positions: readonly EditorPosition[] | null): void;
350
+ onNavigate?(listener: (from: EditorPosition) => void): () => void;
351
+ hasFocus?(): boolean;
352
+ setWordSources?(source: (() => readonly string[]) | null): void;
353
+ onCopy?(listener: (copy: EditorCopy) => void): () => void;
354
+ paste?(text: string, options?: EditorPasteOptions): boolean;
339
355
  setChangeMarks(marks: readonly EditorChangeMark[], layer?: string): void;
340
356
  setBlocks(blocks: readonly EditorBlock[] | null): void;
341
357
  onScope(listener: (scope: readonly EditorBlock[]) => void): () => void;
@@ -351,7 +367,7 @@ export interface Editor {
351
367
  setHighlights(highlights: readonly EditorHighlight[]): void;
352
368
  setLink(range: EditorRange | null): void;
353
369
  setSemanticTokens(tokens: readonly EditorSemanticToken[] | null): void;
354
- setInlayHints(hints: readonly EditorInlayHint[]): void;
370
+ setInlayHints(hints: readonly EditorInlayHint[], owner?: string): void;
355
371
  onInlayHover?(listener: (target: EditorInlayTarget | null) => void): () => void;
356
372
  onInlayClick?(handler: EditorInlayClickHandler): () => void;
357
373
  setInlayLink?(target: {
package/dist/view.d.ts CHANGED
@@ -143,6 +143,7 @@ export declare class EditorView {
143
143
  private lenses;
144
144
  private readonly replacements;
145
145
  private occurrences;
146
+ private hippieSource;
146
147
  private replacePreview;
147
148
  private dropCaret;
148
149
  private activeGuideCache;
@@ -157,6 +158,7 @@ export declare class EditorView {
157
158
  private customFolds;
158
159
  private collapsed;
159
160
  private foldHints;
161
+ private declarations;
160
162
  private defaultRoles;
161
163
  private settled;
162
164
  private foldTimer;
@@ -188,6 +190,10 @@ export declare class EditorView {
188
190
  setLenses(lenses: readonly BlockWidget[]): void;
189
191
  private allBlocks;
190
192
  setOccurrences(occurrences: readonly Occurrence[]): void;
193
+ setHippieSource(range: {
194
+ from: number;
195
+ to: number;
196
+ } | null): void;
191
197
  setLink(link: {
192
198
  from: number;
193
199
  to: number;
@@ -239,6 +245,7 @@ export declare class EditorView {
239
245
  private foldStates;
240
246
  private scheduleFolds;
241
247
  refreshFolds(): void;
248
+ structureRanges(): FoldingRange[];
242
249
  private withCustomFolds;
243
250
  private closerOf;
244
251
  private foldAt;
@@ -274,6 +281,10 @@ export declare class EditorView {
274
281
  endLine: number;
275
282
  }[];
276
283
  } | null, defaults?: readonly FoldRole[]): void;
284
+ setDeclarations(offsets: readonly number[] | null): void;
285
+ private methodStops;
286
+ goToChange(direction: -1 | 1): boolean;
287
+ goToMethod(direction: -1 | 1): boolean;
277
288
  setFoldHints(hints: FoldHints | null): void;
278
289
  private applyFoldDefaults;
279
290
  foldRole(role: FoldRole, collapse: boolean): void;
package/dist/view.js CHANGED
@@ -168,6 +168,7 @@ export class EditorView {
168
168
  lenses = [];
169
169
  replacements = new Map();
170
170
  occurrences = [];
171
+ hippieSource = null;
171
172
  replacePreview = null;
172
173
  dropCaret = null;
173
174
  activeGuideCache;
@@ -186,6 +187,7 @@ export class EditorView {
186
187
  collapsed = new Set();
187
188
  /* What a language server said about the folds, in offsets that follow the text through edits. */
188
189
  foldHints = null;
190
+ declarations = null;
189
191
  /* 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. */
190
192
  defaultRoles = null;
191
193
  /* The folds somebody already decided on, a person or the defaults, so a later answer of a server leaves them as they are. */
@@ -427,6 +429,14 @@ export class EditorView {
427
429
  this.occurrences = [...occurrences];
428
430
  this.render();
429
431
  }
432
+ /* The word Hippie completion took from this document, shown until the next key or edit; null takes the mark away. */
433
+ setHippieSource(range) {
434
+ if (range === null && this.hippieSource === null) {
435
+ return;
436
+ }
437
+ this.hippieSource = range;
438
+ this.render();
439
+ }
430
440
  /* A name underlined as a link a press follows, with the pointer to match; null takes it away. */
431
441
  setLink(link) {
432
442
  this.link = link;
@@ -621,6 +631,7 @@ export class EditorView {
621
631
  this.markerVersion++;
622
632
  const anchor = this.topAnchor;
623
633
  this.occurrences = [];
634
+ this.hippieSource = null;
624
635
  this.link = null;
625
636
  this.content.classList.remove('se-content-link');
626
637
  this.outline.edited(batches);
@@ -668,6 +679,9 @@ export class EditorView {
668
679
  if (this.foldHints !== null) {
669
680
  this.foldHints = mapFoldHints(this.foldHints, map);
670
681
  }
682
+ if (this.declarations !== null) {
683
+ this.declarations = this.declarations.map((offset) => map(offset));
684
+ }
671
685
  this.customFolds = this.customFolds.map((fold) => mapRange(fold, map));
672
686
  this.find.mapBounds(changes);
673
687
  for (const [layer, marks] of this.changeLayers) {
@@ -801,20 +815,25 @@ export class EditorView {
801
815
  if (this.disposed || this.settings.folding === false) {
802
816
  return;
803
817
  }
804
- const ranges = this.model.getLength() > FOLD_LIMIT
805
- ? []
806
- : this.model.getFoldingRanges({
807
- indentation: /^(python|py|yaml|yml)$/.test(this.settings.language ?? ''),
808
- tabSize: this.settings.tabSize,
809
- ...(this.settings.language === undefined ? {} : { language: this.settings.language }),
810
- ...(this.foldHints === null ? {} : { hints: this.foldHints })
811
- });
818
+ const ranges = this.structureRanges();
812
819
  this.foldRanges = this.withCustomFolds(ranges);
813
820
  this.outline.setStructure(structuralEntries(ranges, this.model, (from, to) => this.model.slice(from, to), (offset) => this.model.matchBracket(offset, this.settings.language)?.mate));
814
821
  const anchors = new Set(this.foldRanges.map((range) => range.from));
815
822
  this.collapsed = new Set([...this.collapsed].filter((anchor) => anchors.has(anchor)));
816
823
  this.refoldLayout();
817
824
  }
825
+ /* The folding ranges the document has now, a server's included, whether or not the folds are drawn. */
826
+ structureRanges() {
827
+ if (this.model.getLength() > FOLD_LIMIT) {
828
+ return [];
829
+ }
830
+ return this.model.getFoldingRanges({
831
+ indentation: /^(python|py|yaml|yml)$/.test(this.settings.language ?? ''),
832
+ tabSize: this.settings.tabSize,
833
+ ...(this.settings.language === undefined ? {} : { language: this.settings.language }),
834
+ ...(this.foldHints === null ? {} : { hints: this.foldHints })
835
+ });
836
+ }
818
837
  /* The folds a person made of a selection, which the document's own structure does not know, among the ones it does. */
819
838
  withCustomFolds(ranges) {
820
839
  const custom = this.customFolds.flatMap((fold) => {
@@ -1023,6 +1042,49 @@ export class EditorView {
1023
1042
  this.applyFoldDefaults();
1024
1043
  this.refoldLayout();
1025
1044
  }
1045
+ /* 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. */
1046
+ setDeclarations(offsets) {
1047
+ this.declarations = offsets === null ? null : [...new Set(offsets)].sort((left, right) => left - right);
1048
+ }
1049
+ methodStops() {
1050
+ if (this.declarations !== null) {
1051
+ return this.declarations;
1052
+ }
1053
+ const sections = this.foldRanges.filter((range) => range.kind === 'section').map((range) => this.model.getLine(range.startLine).start);
1054
+ return [...new Set([...this.outline.declarationStops(this.model), ...sections])].sort((left, right) => left - right);
1055
+ }
1056
+ /* Moves the one caret to the first line of the next or previous change mark, of any layer. False when there is none. */
1057
+ goToChange(direction) {
1058
+ const line = this.model.positionAt(this.model.getPrimary().head).line;
1059
+ const { runs } = this.resolvedChanges();
1060
+ // A run the caret is in is where it already is; a deleted run is the line its mark sits on.
1061
+ const target = direction === 1 ? runs.find((run) => run.start > line) : runs.findLast((run) => (run.kind === 'deleted' ? run.start : run.end) < line);
1062
+ if (target === undefined) {
1063
+ return false;
1064
+ }
1065
+ const offset = this.model.getLine(Math.min(target.start, this.model.getLineCount() - 1)).start;
1066
+ this.model.setSelections([{ anchor: offset, head: offset }]);
1067
+ this.revealOffset(offset, 'center');
1068
+ return true;
1069
+ }
1070
+ /* Moves the one caret to the previous or next declaration on another line. False when there is none. */
1071
+ goToMethod(direction) {
1072
+ // The structure is read after a pause in typing, so a press right after an edit reads it now.
1073
+ if (this.declarations === null) {
1074
+ this.refreshFolds();
1075
+ }
1076
+ const line = this.model.positionAt(this.model.getPrimary().head).line;
1077
+ const stops = this.methodStops();
1078
+ const target = direction === 1
1079
+ ? stops.find((offset) => this.model.positionAt(offset).line > line)
1080
+ : stops.findLast((offset) => this.model.positionAt(offset).line < line);
1081
+ if (target === undefined) {
1082
+ return false;
1083
+ }
1084
+ this.model.setSelections([{ anchor: target, head: target }]);
1085
+ this.revealOffset(target, direction === 1 ? 'centerDown' : 'centerUp');
1086
+ return true;
1087
+ }
1026
1088
  /* Hands over what a language server knows about the folds; null forgets it. The defaults fold what the answer names, once. */
1027
1089
  setFoldHints(hints) {
1028
1090
  this.foldHints = hints;
@@ -1866,6 +1928,9 @@ export class EditorView {
1866
1928
  });
1867
1929
  }
1868
1930
  }
1931
+ if (this.hippieSource !== null) {
1932
+ marks.push({ className: 'se-hippie-source', rects: this.paintRectangles(this.hippieSource.from, this.hippieSource.to, rows) });
1933
+ }
1869
1934
  const marginX = this.settings.rightMargin === null ? null : this.settings.rightMargin * this.layout.metrics.charWidth;
1870
1935
  // A margin past the content would stretch the scroll extent to itself, so it shows only where the text or the view reaches it.
1871
1936
  if (marginX !== null && marginX < Math.max(this.layout.width, this.viewportWidth)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/editor",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -54,7 +54,7 @@
54
54
  "test": "bun test --conditions=source"
55
55
  },
56
56
  "dependencies": {
57
- "@adecore/editor-core": "0.42.0",
57
+ "@adecore/editor-core": "0.43.0",
58
58
  "linkedom": "^0.18.14",
59
59
  "shiki": "^4.5.0"
60
60
  },
package/src/controller.ts CHANGED
@@ -15,7 +15,9 @@ import type {
15
15
  EditorInlayClick,
16
16
  EditorInlayClickHandler,
17
17
  EditorContextMenu,
18
+ EditorCopy,
18
19
  EditorKeyHandler,
20
+ EditorPasteOptions,
19
21
  EditorPosition,
20
22
  EditorRunCommand,
21
23
  EditorSelectionRanges,
@@ -45,9 +47,12 @@ const MODIFYING = new Set<EditorCommand>([
45
47
  'deleteLine',
46
48
  'moveLineUp',
47
49
  'moveLineDown',
50
+ 'moveStatementUp',
51
+ 'moveStatementDown',
48
52
  'toggleLineComment',
49
53
  'toggleBlockComment',
50
54
  'startNewLine',
55
+ 'completeCurrentStatement',
51
56
  'startNewLineBefore',
52
57
  'splitLine',
53
58
  'joinLines',
@@ -63,9 +68,13 @@ const MODIFYING = new Set<EditorCommand>([
63
68
  'reverseLines',
64
69
  'uniqueLines',
65
70
  'convertIndentsToSpaces',
66
- 'convertIndentsToTabs'
71
+ 'convertIndentsToTabs',
72
+ 'hippieCompletion',
73
+ 'hippieBackwardCompletion'
67
74
  ]);
68
75
 
76
+ const CODE_BLOCK_COMMANDS = new Set<EditorCommand>(['codeBlockStart', 'codeBlockEnd', 'codeBlockStartWithSelection', 'codeBlockEndWithSelection']);
77
+
69
78
  const VIEW_COMMANDS = new Set<EditorViewCommand>([
70
79
  'collapseRegion',
71
80
  'expandRegion',
@@ -84,7 +93,12 @@ const VIEW_COMMANDS = new Set<EditorViewCommand>([
84
93
  'toggleColumnMode',
85
94
  'increaseFontSize',
86
95
  'decreaseFontSize',
87
- 'resetFontSize'
96
+ 'resetFontSize',
97
+ 'methodUp',
98
+ 'methodDown',
99
+ 'nextChange',
100
+ 'previousChange',
101
+ 'pasteAsPlainText'
88
102
  ]);
89
103
 
90
104
  const ZOOM_STEPS = { increaseFontSize: 1, decreaseFontSize: -1, resetFontSize: 0 } as const satisfies Partial<Record<EditorViewCommand, -1 | 0 | 1>>;
@@ -92,6 +106,14 @@ const ZOOM_STEPS = { increaseFontSize: 1, decreaseFontSize: -1, resetFontSize: 0
92
106
  /* The clipboard text of the last copy or cut of a bare caret, which pastes back as whole lines. Shared, since it can be pasted in another editor. */
93
107
  let lineClip: string | null = null;
94
108
 
109
+ /*
110
+ * Makes `text` the copy that pastes back as whole lines (or not), for a paste from a history that kept what the
111
+ * copy was. The clipboard itself is the caller's.
112
+ */
113
+ export function rememberCopy(text: string, wholeLines: boolean): void {
114
+ lineClip = wholeLines ? text : null;
115
+ }
116
+
95
117
  function selectionSignature(selections: readonly Selection[]): string {
96
118
  return selections.map((selection) => `${selection.anchor}:${selection.head}`).join(',');
97
119
  }
@@ -111,6 +133,9 @@ export class InputController {
111
133
  private readonly touch: TouchSelection;
112
134
  private readonly cleanup: (() => void)[] = [];
113
135
  private readonly keyHandlers = new Set<EditorKeyHandler>();
136
+ private readonly navigateListeners = new Set<(from: number) => void>();
137
+ private readonly copyListeners = new Set<(copy: EditorCopy) => void>();
138
+ private wordSources: (() => readonly string[]) | null = null;
114
139
  private readonly gesture: DoubleModifierGesture;
115
140
  private readonly clickHandlers = new Set<EditorClickHandler>();
116
141
  private readonly inlayHandlers = new Set<EditorInlayClickHandler>();
@@ -240,7 +265,7 @@ export class InputController {
240
265
 
241
266
  this.listen(input, 'paste', (event) => {
242
267
  event.preventDefault();
243
- this.paste(event.clipboardData?.getData('text/plain') ?? '');
268
+ this.pasteText(event.clipboardData?.getData('text/plain') ?? '');
244
269
  });
245
270
 
246
271
  this.listen(input, 'copy', (event) => this.copy(event, false));
@@ -312,6 +337,8 @@ export class InputController {
312
337
 
313
338
  this.cleanup.length = 0;
314
339
  this.keyHandlers.clear();
340
+ this.navigateListeners.clear();
341
+ this.copyListeners.clear();
315
342
  }
316
343
 
317
344
  private blockedByReadOnly(): boolean {
@@ -356,16 +383,30 @@ export class InputController {
356
383
  }
357
384
  }
358
385
 
359
- private paste(text: string): void {
386
+ /* Pastes as a paste from the clipboard does; false when a read-only editor refused. */
387
+ pasteText(text: string, options: EditorPasteOptions = {}): boolean {
360
388
  if (this.blockedByReadOnly()) {
361
- return;
389
+ return false;
362
390
  }
363
391
 
364
392
  this.unfoldCarets();
365
393
  this.historyGroup++;
366
- this.view.model.paste(this.native.normalizeNewlines(text), { ...this.commandOptions(), wholeLines: text !== '' && text === lineClip });
394
+
395
+ const pasted = this.view.model.paste(this.native.normalizeNewlines(text), {
396
+ ...this.commandOptions(),
397
+ ...(options.reindent === false ? { indentOnPaste: false } : {}),
398
+ wholeLines: options.wholeLines ?? (text !== '' && text === lineClip)
399
+ });
400
+
367
401
  this.historyGroup++;
368
402
  this.view.revealCaret();
403
+
404
+ return pasted;
405
+ }
406
+
407
+ /* Told what a copy or cut put on the clipboard. */
408
+ onCopy(listener: (copy: EditorCopy) => void): () => void {
409
+ return subscribe(this.copyListeners, listener);
369
410
  }
370
411
 
371
412
  private commandOptions(): CommandOptions {
@@ -381,15 +422,23 @@ export class InputController {
381
422
  tabOutOfClosers: keys.tabOutOfClosers,
382
423
  smartEnter: keys.smartIndentOnEnter,
383
424
  indentOnPaste: keys.indentOnPaste,
425
+ indentGuides: settings.guides,
384
426
  smartSemicolon: keys.smartSemicolon,
385
427
  smartArrow: keys.smartArrow,
386
428
  language: settings.language ?? 'plaintext',
387
429
  camelCase: keys.camelHumps,
388
430
  visualLine: (offset) => this.view.rowOf(offset),
389
- lineSpan: (line) => this.view.lineSpan(line)
431
+ lineSpan: (line) => this.view.lineSpan(line),
432
+ structure: () => this.view.structureRanges(),
433
+ ...(this.wordSources === null ? {} : { wordSources: this.wordSources })
390
434
  };
391
435
  }
392
436
 
437
+ /* The texts Hippie completion takes words from after those of this document; null takes them away. */
438
+ setWordSources(source: (() => readonly string[]) | null): void {
439
+ this.wordSources = source;
440
+ }
441
+
393
442
  /* Folding is the view's, since it knows where the ranges are. */
394
443
  private viewCommand(name: EditorViewCommand): boolean {
395
444
  const { view } = this;
@@ -400,11 +449,43 @@ export class InputController {
400
449
  return true;
401
450
  }
402
451
 
452
+ if (name === 'methodUp' || name === 'methodDown') {
453
+ const from = view.model.getPrimary().head;
454
+ const moved = view.goToMethod(name === 'methodUp' ? -1 : 1);
455
+
456
+ if (moved) {
457
+ emit(this.navigateListeners, from);
458
+ }
459
+
460
+ return moved;
461
+ }
462
+
463
+ if (name === 'pasteAsPlainText') {
464
+ // The clipboard is read once a person asks, and an editor with no way to read it does nothing.
465
+ if (this.blockedByReadOnly() || globalThis.navigator?.clipboard?.readText === undefined) {
466
+ return false;
467
+ }
468
+
469
+ void navigator.clipboard.readText().then(
470
+ (text) => this.pasteText(text, { reindent: false }),
471
+ () => undefined
472
+ );
473
+
474
+ return true;
475
+ }
476
+
477
+ if (name === 'nextChange' || name === 'previousChange') {
478
+ return view.goToChange(name === 'nextChange' ? 1 : -1);
479
+ }
480
+
403
481
  if (view.settings.folding === false && name !== 'toggleColumnMode') {
404
482
  return false;
405
483
  }
406
484
 
407
- const run: Record<Exclude<EditorViewCommand, keyof typeof ZOOM_STEPS>, () => void> = {
485
+ const run: Record<
486
+ Exclude<EditorViewCommand, keyof typeof ZOOM_STEPS | 'methodUp' | 'methodDown' | 'nextChange' | 'previousChange' | 'pasteAsPlainText'>,
487
+ () => void
488
+ > = {
408
489
  collapseRegion: () => view.collapseRegion(),
409
490
  expandRegion: () => view.expandRegion(),
410
491
  collapseAllRegions: () => view.foldAll(true),
@@ -529,7 +610,11 @@ export class InputController {
529
610
  }
530
611
  }
531
612
 
532
- const folded = name === 'moveLineUp' || name === 'moveLineDown' ? this.view.getFolds().collapsed : [];
613
+ const before = model.getPrimary().head;
614
+ const folded =
615
+ name === 'moveLineUp' || name === 'moveLineDown' || name === 'moveStatementUp' || name === 'moveStatementDown'
616
+ ? this.view.getFolds().collapsed
617
+ : [];
533
618
 
534
619
  const result = model.execute(name, {
535
620
  ...this.commandOptions(),
@@ -544,11 +629,24 @@ export class InputController {
544
629
  this.view.markRowEnd(model.getPrimary().head);
545
630
  }
546
631
 
632
+ if (name === 'hippieCompletion' || name === 'hippieBackwardCompletion') {
633
+ this.view.setHippieSource(model.hippieSource);
634
+ }
635
+
547
636
  this.view.revealCaret();
548
637
 
638
+ if (CODE_BLOCK_COMMANDS.has(name) && model.getPrimary().head !== before) {
639
+ emit(this.navigateListeners, before);
640
+ }
641
+
549
642
  return result;
550
643
  }
551
644
 
645
+ /* Told the offset the primary caret left when a command that navigates moved it. */
646
+ onNavigate(listener: (from: number) => void): () => void {
647
+ return subscribe(this.navigateListeners, listener);
648
+ }
649
+
552
650
  private typeText(text: string): void {
553
651
  if (this.blockedByReadOnly()) {
554
652
  return;
@@ -837,6 +935,8 @@ export class InputController {
837
935
  return;
838
936
  }
839
937
 
938
+ this.view.setHippieSource(null);
939
+
840
940
  const added = this.gesture.keydown(event);
841
941
 
842
942
  if (added !== null) {
@@ -933,6 +1033,7 @@ export class InputController {
933
1033
 
934
1034
  event.clipboardData?.setData('text/plain', text);
935
1035
  lineClip = empty ? text : null;
1036
+ emit(this.copyListeners, { text, wholeLines: empty, cut });
936
1037
 
937
1038
  if (!cut || this.blockedByReadOnly()) {
938
1039
  return;
package/src/editor.css CHANGED
@@ -652,11 +652,16 @@
652
652
  .se-find-match,
653
653
  .se-find-current,
654
654
  .se-occurrence,
655
+ .se-hippie-source,
655
656
  .se-bracket-match,
656
657
  .se-bracket-unmatched {
657
658
  position: absolute;
658
659
  border-radius: var(--radius-xs);
659
660
  }
661
+ .se-hippie-source {
662
+ background: color-mix(in srgb, var(--accent) 22%, transparent);
663
+ border-radius: var(--radius-sm);
664
+ }
660
665
  .se-find-match {
661
666
  background: var(--find-match);
662
667
  }
@@ -747,6 +752,14 @@
747
752
  white-space: pre;
748
753
  cursor: default;
749
754
  }
755
+ .se-swatch {
756
+ height: 12px;
757
+ margin-top: calc((var(--code-line-height, 20px) - 12px) / 2);
758
+ padding: 0;
759
+ border-radius: var(--radius-sm);
760
+ box-shadow: inset 0 0 0 1px var(--border);
761
+ cursor: pointer;
762
+ }
750
763
  .se-inlay-link {
751
764
  text-decoration: underline;
752
765
  text-underline-offset: 2px;