@adecore/editor 0.17.0-beta.1 → 0.17.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/LICENSE CHANGED
@@ -6,7 +6,7 @@ FSL-1.1-MIT
6
6
 
7
7
  ## Notice
8
8
 
9
- Copyright 2026 Bas Milius
9
+ Copyright 2026 - present Bas Milius
10
10
 
11
11
  ## Terms and Conditions
12
12
 
package/README.md CHANGED
@@ -1,82 +1,57 @@
1
1
  # @adecore/editor
2
2
 
3
- Read the [browser editor handbook](https://adecore.dev/editor/handbook/) for the complete setup, concepts, host integration and testing guides.
3
+ [![npm](https://img.shields.io/npm/v/@adecore/editor)](https://www.npmjs.com/package/@adecore/editor)
4
+ [![Docs](https://img.shields.io/badge/docs-adecore.dev-blue)](https://adecore.dev/editor/)
4
5
 
5
- The DOM view of the smart editor. It draws a `DocumentModel` from `@adecore/editor-core` with its own layout and implements the `EditorEngine` contract. Language features live in `@adecore/editor-react`; the engine itself needs no language service.
6
+ A code editor for the DOM. It draws a [`DocumentModel`](https://adecore.dev/editor-core/) with a layout of its own, renders only the rows in view, and takes the decorations, rows and marks a host hands it: problems, inlay hints, semantic colors, change marks, widgets between lines, code vision, agent attribution and ghost text. It knows no language server; [`@adecore/editor-react`](https://adecore.dev/editor-react/) adds one.
6
7
 
7
- ```ts
8
- const engine = createSmartEditorEngine({
9
- tokenizer: shikiTokenizers(() => highlighter),
10
- handBack: shellShortcuts,
11
- apple
12
- });
13
- const editor = engine.mount(element, { text, language: 'typescript', theme: 'github-dark' });
14
- ```
15
-
16
- Import `@adecore/ui/theme.css`, then `@adecore/editor/editor.css`. The element needs a positioned box sized by its parent. Editor, find, change and attribution colors have defaults on Adecore theme tokens. The code face uses `--font-mono`, `--code-font-size` and `--code-line-height`; the latter two default to `--text-code` and `--text-code--line-height`. Override them after the editor CSS, and call `refreshFont()` when they change.
17
-
18
- ## How it is put together
19
-
20
- - `layout.ts`: the rows of the document, free of the DOM. A row is a line, a widget or a fold's first line. It owns wrapping into visual lines, tab stops, inlays, folded lines, caret and selection boxes, hit testing and moving by visual line. Only the rows on screen are measured; the others stand on an estimate that the first draw corrects.
21
- - `view.ts`: the DOM, the scroll (native: the gutter and the pinned headers stick to the corner of the one scroll container, and the scroll event draws the rows, a viewport of them on each side, in the frame it shows in), the folds and what is drawn behind and above the text. `paint.ts` holds the drawing of rows, the gutter, the selection and the carets. Rows are absolutely placed runs of text, so the layout decides where each character is. A draw reads the viewport's scroll position and size once at its start, since a read after the first write of a frame makes the browser lay the page out again, and a host setter whose value did not change draws nothing. The horizontal scroll extent is the widest line measured since the text last changed, inlays included, plus three columns as on the platform; a host row follows the scroll and clips what it draws past its width, since an overflow there would grow the extent with every scroll and carry the sticky gutter out of view.
22
- - `controller.ts`: what a person does. Keys come from one table (`keymap-table.ts`, which the client prints in its menus as well) and are mapped in `keymap.ts` to the model's commands, typing goes through `DocumentModel.typeText`, a paste through `DocumentModel.paste`, the mouse through `pointer.ts`. A copy or cut of a bare caret remembers its text for every editor of the page, and pasting that same text again, in any of them, lands as whole lines. Adding a caret above or below is the double tap of a modifier and an arrow (`modifier-gesture.ts`). The smart keys (`EditorSmartKeys`: pairing, surrounding, tabbing out, smart Enter, indent on paste, the smart semicolon and the camel humps) are options of the editor, set at mounting and changed with `setSmartKeys`; all are on except the camel humps. The textarea (`native-input.ts`) is only the sink for the platform's typing, composition and paste, and carries the text around the caret for assistive technology.
23
- - `tokens.ts` and `shiki.ts`: coloring. A grammar needs the state the previous line ended in, so lines are colored from a frontier down, a slice at a time, on the screen plus a margin. An edit moves the frontier to the changed line and stops as soon as a line ends in the state it had before. The tokenizer is injected (`TokenizerSource`); `shikiTokenizers` adapts the viewer's Shiki highlighter. It loads the languages a grammar embeds only on demand (a style block's `lang="scss"`, a Markdown fence) when the document names them, since Shiki rebuilds the host grammar when one loads later, and a tokenizer says so with `stale` so the colors start over from the top. A language added to a document after it opened (a new fence) is colored once the file opens again. `php-html.ts` is the grammar for a PHP file as a document (HTML with PHP between its tags), which Shiki does not bundle; PHP inside an HTML attribute is not colored.
24
- - `outline.ts`: the blocks of the document with a header line, read from braces and indentation until the host hands over better ones with `setBlocks` (a language server's symbols). Sticky scroll pins the headers of the blocks scrolled out of sight, and `onScope` reports the named blocks around the caret for a breadcrumb.
25
- - `overview.ts`: the ticks in the scroll track for the host's change marks (`setChangeMarks`, also drawn in the gutter) and the find matches.
26
- - `find.ts`: the matches of the host's find bar. They are marked in the editor and never touch the selection until `endFind`.
27
- - `semantic.ts` and `theme-scopes.ts`: what a language server classified, drawn over the grammar's colors. The host hands scopes (`setSemanticTokens`), and the theme's own rules decide the color (`scopeColors`, `shikiScopeColors`), so a class is the theme's class color. A scope the theme says nothing about keeps what the grammar made.
28
- - `scroll.ts` and `scroll-animation.ts`: where a scroll goes (the platform's scrolling model: margins, and a jump a third from the top) and how it gets there, over up to a tenth of a second and never for a line or less.
29
- - `keymap-table.ts`: the keys of every editor and language command for macOS and the other platforms, which the client prints in its menus too (`@adecore/editor/keymap`).
30
- - `engine.ts`: the `Editor` contract over a model and a view.
31
-
32
- ## What a language feature gets
33
-
34
- The editor knows no language server. It reports every change of the text as LSP content changes (`onTextChange`, with its source), says where the caret is (`getCaret`, `onCaret`) and where a character is on the screen (`rectAt`, in the page's pixels so a popup is right at any canvas zoom), and takes what a host draws or does: `setMarkers` (squiggles, faded and struck text, a lane in the scroll track), `setInlayHints`, `setSemanticTokens`, `setHighlights`, `setBlocks`, `setWidgets` (rows of the host's own DOM next to lines, drawn in the layout's block rows; `placement: 'above'` puts one over its line, and each owner has its own set, see below), `setCodeVision` (a quiet row above a declaration, see below), `setGhostText` (a suggestion after the caret, see below), `setGutterAction` (one button in the gutter, such as a lightbulb, which `onGutterAction` reports), `setGutterMarkers` (small buttons in the gutter per owner, such as the mark of a saved inline edit, which `onGutterMarker` reports by id; a marker stays on its line until its owner sets them again), `setLink` (a name underlined as a link while a modifier is held) `trackRange` (a range that follows the text through edits and reads null once an edit touched it, inside or across an end; text inserted exactly at an end does not count, so a range that was a selection still holds the same text) and `applyEdits`. A marker's `message` is what the tick of the problem says in the scroll track, where a press on the tick goes to the problem. `getSelection`, `getSelections`, `setSelections` (the last range is the primary one), `getVisibleRange` and `getIndentation` say what a feature asks about, and `setSelection` selects a range, such as the stop of a snippet. `onHover` reports the character under the pointer, `onClick` offers a press on a character first (Mod+click follows a name) and `onContextMenu` asks the host for its menu, which the editor does not draw, `onKeyDown` lets a host take a key before the editor does, and `onViewChange` says when the screen position of a character moved.
35
-
36
- `setText` applies the difference as a line diff in one batch (`changedSpans`), one undo step reported as an external change, so a reload from disk or a sync between two editors on a file keeps the scroll, carets, folds, markers, inlays and code vision rows wherever the text around them stayed. A language server hears it as the one stretch from the first change to the last.
37
-
38
- `runCommand` runs an editing command by name, which is how a menu or the palette reaches the editor; the folding commands (collapse and expand a region, all, recursively, a selection, documentation comments and "expand all to level 1 through 5") and column mode are the view's own.
39
-
40
- A collapsed fold is one row for the line commands, as the platform has it: delete, duplicate, comment, move and a copy or cut with no selection take the whole fold (a moved fold stays collapsed on its new lines), a word move steps over it and add caret above and below skips it. The view hands the commands its rows through `lineSpan`.
8
+ **[Documentation with live demos](https://adecore.dev/editor/)**
41
9
 
42
- Folds are kept by line (`getFolds`, and `folds` on mounting), the way scroll and caret are. A fold has a role (the file header, imports, documentation comments, regions, the body of a function, a method or a class, object and array literals, tags, attributes and so on, `FoldRole` of the core), and `foldDefaults` on mounting lists the roles that fold by themselves in a file that has no folds of its own. `setFoldHints` hands over a language server's symbols and folding ranges, which name the bodies and tags the text cannot, and the defaults fold what they name once: not a fold somebody opened or closed, not one that holds the caret, and nothing after the first edit. `foldOutline` (`off`, `hover` or `always`) says when the arrow shows in the gutter. Indent guides, whitespace and a right margin are options and setters (`guides`, `whitespace`, `rightMargin`). The find takes `inSelection`, `selectFindMatches` puts a caret on every match, `setReplacePreview` draws what a regular expression writes under the current match, and `findFromCursor` steps without a find bar.
10
+ ## Install
43
11
 
44
- `handBack` shortcuts are not handled by the editor and not prevented, so the page's own listeners get them. Mod+S is `onSave`.
45
-
46
- ## Agent marks
47
-
48
- `setAttributionMarks` draws a three pixel bar in the gutter, where the change marks are, for each run of lines (`id`, one-based `startLine` and `endLine`, `color`). The color is a custom property of the page (`--agent-1` to `--agent-6`, `AGENT_COLORS`, defined in `editor.css` for both themes, with `--agent-ink` for text on them) or any CSS color. On a line that has both, the bar stands in the place of the git change mark, which the scroll track still shows; a removal triangle between lines stays. A run follows its text through edits until the host sets the marks again, and an edit costs one mapping of two offsets per run, so thousands of runs stay smooth. `onAttributionHover` reports the id and the bar's box on the line under the pointer (a few pixels either side of the bar count), and null when it leaves.
49
-
50
- `setRemoteCursors` draws the caret of an agent (`id`, `position`, `name`, `color`, the same colors) over the text, two pixels wide with its name in a label above it, the label's lower left corner square. Where there is no room above the caret (the first line, or under the pinned headers) the name goes under it. It follows its text through edits until set again, is not interactive, and is left out on a line a fold hides. The editor's own caret and selection never move for it.
51
-
52
- ## Review and conflict rows
53
-
54
- `setLineHighlights` tints lines behind the text (`startLine` and `endLine` one-based, a `color`, an optional `sign` such as `+` in the gutter beside the number, an optional `fill` that paints the rows as it stands instead of the 14% tint of the color), one box per row so a widget row between two tinted lines stays clear. It follows its text through edits like the marks do. `renderCode(container, text, options)` fills a widget row's element with lines that are not in the document, such as the lines an agent removed or the other side of a conflict, in the editor's face and token colors, with a gutter column as wide as the editor's, so the text lines up with the document's (`firstLine` for numbers, `sign`, `color` for the tint and the bar, `emphasis` for character ranges per line drawn in a stronger tint, such as the words a change replaced, and `faded` for text that is gone). Both take the same colors as the attribution marks. A widget has `placement: 'above'` for a row over its line. `setLineActions(actions, owner?)` puts the host's own DOM after the last character of a line (`id`, zero-based `line`, `render(container)` once per action), such as the buttons of a change under review. It is no row: it takes no height, follows its line through edits, is hidden where a fold hides the line, and a press on it never moves the caret. A long line with no wrap pushes it past the edge of the view.
55
-
56
- ## Widgets per owner
12
+ ```sh
13
+ bun add @adecore/editor
14
+ ```
57
15
 
58
- `setWidgets(widgets, owner?)` replaces the rows of one owner, `default` without one, and leaves every other owner's alone, so a peek, the review rows of an agent's change and a conflict stand together. Rows next to the same line are ordered by their owners' names and then in the order each owner gave them, not by who set them last, so they never swap places. An empty list takes the owner's rows away.
16
+ ## Set up
59
17
 
60
- ## Code vision rows
18
+ ```css
19
+ @import '@adecore/ui/theme.css';
20
+ @import '@adecore/editor/editor.css';
21
+ ```
61
22
 
62
- `setCodeVision` takes a row per declaration: the line, and the entries of the row (`id`, `text`, an optional `user` or `users` icon, and `activate`, which gets the box of the pressed entry in the page's pixels). The row is a block row above the line (`lens` on a `BlockWidget`, `lens.ts` for its buttons), one code line high whatever its entries are, placed at the indentation of the declaration and never measured, so it holds its height with no entries and the text does not move when they arrive. It follows its declaration through edits like every block does, is kept apart from `setWidgets` (a peek under a name never takes a row away), and is drawn again only when the words of its entries change. An entry stands a text size from the next one, is muted, and lights up as a link with an underline under the pointer; a press on it never moves the caret or takes the focus.
23
+ ```ts
24
+ import { createSmartEditorEngine, shikiTokenizers } from '@adecore/editor';
63
25
 
64
- ## Ghost text
26
+ const engine = createSmartEditorEngine({ tokenizer: shikiTokenizers(getHighlighter) });
27
+ const editor = engine.mount(element, { text, language: 'typescript', theme: 'github-light' });
28
+ ```
65
29
 
66
- `setGhostText({ position, text, accessory? })` draws a suggestion after the caret that is not in the document, such as a continuation from a model, and `null` takes it away. The first line of `text` sits in the line in the code face and the editor's faint color, italic, and the caret stays in front of it. Further lines are a row under the line, in the same color and with the gutter column of the document, so the lines below move down while it is shown. `accessory` fills an element drawn after the end of the line, such as the keys that take the suggestion. The editor never accepts it: the host binds the keys (Tab, `acceptGhostWord`) and inserts the text with `applyEdits`, which is one undo step. Any change of the text takes the suggestion away, so the host sets it again for the text it was made for; moving the caret does not, and the host decides what that means.
30
+ The element needs a position and a size from its parent.
67
31
 
68
- ## Limits
32
+ ## Entry points
69
33
 
70
- - No rendered documentation blocks.
71
- - Wrapped lines break at spaces and inside a word that does not fit, and continue at the line's indentation plus two characters. A click past the end of a visual line draws the caret at its end until the caret moves; an arrow key onto that offset draws it at the start of the next line.
72
- - A line over 20,000 characters is not colored.
73
- - Every edit rebuilds the row list, which is linear in the number of lines.
74
- - No bidirectional text, and no screen reader testing beyond the textarea's own context.
34
+ | Import | What it holds |
35
+ |---|---|
36
+ | `@adecore/editor` | `createSmartEditorEngine`, the `Editor` contract and its types, the Shiki adapters |
37
+ | `@adecore/editor/editor.css` | The styles |
38
+ | `@adecore/editor/shiki` | The Shiki adapters and grammar loading |
39
+ | `@adecore/editor/keymap` | The key table, `resolveKeymap`, `chordOf` and `parseChord` |
40
+ | `@adecore/editor/fake` | `FakeEditorEngine`, for tests of host code |
41
+ | `@adecore/editor/testing` | `mountEditor` on a LinkeDOM page |
75
42
 
76
- ## Keymaps and package exports
43
+ ## Documentation
77
44
 
78
- `@adecore/editor/keymap` exports `KEYMAP`, `resolveKeymap`, `chordOf` and `parseChord`. Pass the same resolved keymap and `apple` flag to the engine and to `ProjectLanguage` so editor keys, language commands and menu hints agree. Application collisions belong in host overrides. AI commands have no universal bindings. `handBack` remains the host's global shortcut list.
45
+ | Page | What it covers |
46
+ |---|---|
47
+ | [Getting started](https://adecore.dev/editor/getting-started) | Install, styles, mounting and the engine options |
48
+ | [Options and keymaps](https://adecore.dev/editor/options) | Every option and setter, smart keys and the key table |
49
+ | [Text and events](https://adecore.dev/editor/editing) | Positions, edits, selections, events and tracked ranges |
50
+ | [Decorations](https://adecore.dev/editor/decorations), [Rows and widgets](https://adecore.dev/editor/rows), [Agents](https://adecore.dev/editor/agents) | What a host draws into the editor |
51
+ | [Find and folding](https://adecore.dev/editor/find-folding) | The find matcher, folds and sticky scroll |
52
+ | [Theme and syntax](https://adecore.dev/editor/theme) | CSS tokens, Shiki and a tokenizer of your own |
53
+ | [Testing](https://adecore.dev/editor/testing) | `mountEditor` and `FakeEditorEngine` |
79
54
 
80
- The root exports the engine and its types. `/shiki` supplies the Shiki adapter, `/fake` supplies the DOM-free `FakeEditorEngine`, and `/testing` supplies DOM fixtures backed by LinkeDOM. `/editor.css` supplies the required editor chrome.
55
+ ## License
81
56
 
82
- This unpublished package is private at version `0.0.0` and retains FSL-1.1-MIT. Source consumers enable the `source` condition in the bundler, TypeScript and Bun. Default exports use compiled JavaScript and declarations. Run `bun run build`, `bun run typecheck` and `bun run test` from this package after workspace installation. See the editor migration guide for consumer adapters and the transferred revision.
57
+ FSL-1.1-MIT
@@ -51,7 +51,6 @@ const VIEW_COMMANDS = new Set([
51
51
  let lineClip = null;
52
52
  /* Typing in the same stretch is one undo step until a pause this long. */
53
53
  const TYPING_PAUSE_MS = 750;
54
- const PAIR_CHARACTER = /^[()[\]{}'"`;<]$/;
55
54
  /*
56
55
  * What a person does to the editor: keys, typing, the clipboard, an input method, the mouse. It turns
57
56
  * each into a command on the model and leaves the drawing to the view.
@@ -256,6 +255,7 @@ export class InputController {
256
255
  smartEnter: keys.smartIndentOnEnter,
257
256
  indentOnPaste: keys.indentOnPaste,
258
257
  smartSemicolon: keys.smartSemicolon,
258
+ smartArrow: keys.smartArrow,
259
259
  language: settings.language ?? 'plaintext',
260
260
  camelCase: keys.camelHumps,
261
261
  visualLine: (offset) => this.view.rowOf(offset),
@@ -369,7 +369,8 @@ export class InputController {
369
369
  for (const selection of this.view.model.getSelections()) {
370
370
  this.view.ensureVisible(selection.head);
371
371
  }
372
- if (PAIR_CHARACTER.test(text)) {
372
+ // Every key goes through the model, which decides by the key before it too, as for the arrow of PHP.
373
+ if (text.length === 1) {
373
374
  this.view.model.typeText(text, { ...this.commandOptions(), historyGroup: this.inputGroup() });
374
375
  this.view.revealCaret();
375
376
  }
package/dist/editor.css CHANGED
@@ -8,7 +8,7 @@
8
8
  --find-current-text: var(--text);
9
9
  --editor-selection: color-mix(in srgb, var(--accent) 16%, transparent);
10
10
  --editor-occurrence: color-mix(in srgb, var(--text) 7%, transparent);
11
- --editor-guide: color-mix(in srgb, var(--text) 8%, transparent);
11
+ --editor-guide: color-mix(in srgb, var(--text) 5%, transparent);
12
12
  --editor-guide-active: color-mix(in srgb, var(--text) 24%, transparent);
13
13
  --editor-margin: color-mix(in srgb, var(--text) 14%, transparent);
14
14
  --editor-whitespace: color-mix(in srgb, var(--text) 30%, transparent);
@@ -29,7 +29,7 @@
29
29
  --find-match: color-mix(in srgb, var(--status-needs-you) 24%, transparent);
30
30
  --editor-selection: color-mix(in srgb, var(--accent) 28%, transparent);
31
31
  --editor-occurrence: color-mix(in srgb, var(--text) 9%, transparent);
32
- --editor-guide: color-mix(in srgb, var(--text) 10%, transparent);
32
+ --editor-guide: color-mix(in srgb, var(--text) 6%, transparent);
33
33
  --editor-guide-active: color-mix(in srgb, var(--text) 28%, transparent);
34
34
  --editor-margin: color-mix(in srgb, var(--text) 16%, transparent);
35
35
  --editor-whitespace: color-mix(in srgb, var(--text) 32%, transparent);
@@ -728,13 +728,15 @@
728
728
 
729
729
  /*
730
730
  * A code vision row is quiet text one code line high above a declaration, in the declaration's
731
- * indentation. Its entries stand a text size apart and light up as a link under the pointer.
731
+ * indentation, a step below the code as the platform draws it. Its entries stand a text size apart
732
+ * and light up as a link under the pointer.
732
733
  */
733
734
  .se-block.se-lens {
734
735
  display: flex;
735
736
  align-items: center;
736
737
  gap: var(--text-xs);
737
738
  padding: 0;
739
+ font-size: max(12px, calc(var(--code-font-size, 13px) - 2px));
738
740
  white-space: nowrap;
739
741
  }
740
742
  .se-lens-entry {
package/dist/engine.js CHANGED
@@ -5,6 +5,7 @@ import { emit, subscribe } from './listeners.js';
5
5
  import { fillLens, lensSignature } from './lens.js';
6
6
  import { EditorView } from './view.js';
7
7
  const DEFAULT_TAB_SIZE = 4;
8
+ const DEFAULT_LABEL = 'Code editor';
8
9
  const DEFAULT_WIDGET_OWNER = 'default';
9
10
  class SmartEditor {
10
11
  model;
@@ -41,6 +42,7 @@ class SmartEditor {
41
42
  wrap: options.wrap ?? false,
42
43
  smartKeys: resolveSmartKeys(options.smartKeys),
43
44
  messages: options.messages ?? {},
45
+ label: options.label ?? DEFAULT_LABEL,
44
46
  guides: options.guides !== false,
45
47
  whitespace: options.whitespace === true,
46
48
  rightMargin: options.rightMargin ?? null,
@@ -522,6 +524,10 @@ class SmartEditor {
522
524
  this.settings.foldOutline = outline;
523
525
  this.view.applySettings();
524
526
  }
527
+ setLabel(label) {
528
+ this.settings.label = label;
529
+ this.view.applySettings();
530
+ }
525
531
  setFoldHints(hints) {
526
532
  this.view.setFoldHints(hints === null
527
533
  ? null
@@ -531,11 +537,18 @@ class SmartEditor {
531
537
  to: this.offsetAt(symbol.range.end),
532
538
  body: symbol.body
533
539
  })),
534
- ranges: (hints.ranges ?? []).map((range) => ({
535
- from: this.model.getLine(Math.min(range.startLine, this.model.getLineCount() - 1)).start,
536
- to: this.model.getLine(Math.min(range.endLine, this.model.getLineCount() - 1)).start,
537
- ...(range.kind === undefined ? {} : { kind: range.kind })
538
- }))
540
+ ranges: (hints.ranges ?? []).map((range) => {
541
+ const first = this.model.getLine(Math.min(range.startLine, this.model.getLineCount() - 1));
542
+ const last = this.model.getLine(Math.min(range.endLine, this.model.getLineCount() - 1));
543
+ const within = (line, character) => Math.min(line.end, line.start + Math.max(0, character));
544
+ return {
545
+ from: first.start,
546
+ to: last.start,
547
+ ...(range.kind === undefined ? {} : { kind: range.kind }),
548
+ ...(range.startCharacter === undefined ? {} : { head: within(first, range.startCharacter) }),
549
+ ...(range.endCharacter === undefined ? {} : { tail: within(last, range.endCharacter) })
550
+ };
551
+ })
539
552
  });
540
553
  }
541
554
  setSmartKeys(keys) {
package/dist/fake.d.ts CHANGED
@@ -111,6 +111,8 @@ export declare class FakeEditor implements Editor {
111
111
  foldOutline: EditorFoldOutline;
112
112
  foldHints: EditorFoldHints | null;
113
113
  setFoldOutline(outline: EditorFoldOutline): void;
114
+ label: string;
115
+ setLabel(label: string): void;
114
116
  setFoldHints(hints: EditorFoldHints | null): void;
115
117
  setSmartKeys(keys: Partial<EditorSmartKeys>): void;
116
118
  setIndentation(indentation: EditorIndentation): void;
package/dist/fake.js CHANGED
@@ -43,6 +43,7 @@ export class FakeEditor {
43
43
  this.theme = options.theme;
44
44
  this.readOnly = options.readOnly ?? false;
45
45
  this.readOnlyReason = options.readOnlyReason;
46
+ this.label = options.label ?? 'Code editor';
46
47
  this.revealedLine = options.line ?? null;
47
48
  this.column = options.column ?? null;
48
49
  this.scrollTop = options.scrollTop ?? null;
@@ -372,6 +373,10 @@ export class FakeEditor {
372
373
  setFoldOutline(outline) {
373
374
  this.foldOutline = outline;
374
375
  }
376
+ label;
377
+ setLabel(label) {
378
+ this.label = label;
379
+ }
375
380
  setFoldHints(hints) {
376
381
  this.foldHints = hints;
377
382
  }
package/dist/index.d.ts CHANGED
@@ -2,4 +2,4 @@ export { AGENT_COLORS } from './attribution.ts';
2
2
  export { createSmartEditorEngine } from './engine.ts';
3
3
  export { DEFAULT_SMART_KEYS } from './smart-keys.ts';
4
4
  export { shikiScopeColors, shikiTokenizers } from './shiki.ts';
5
- export type { Editor, EditorAttributionHover, EditorAttributionMark, EditorMarkColor, EditorBlock, EditorChangeKind, EditorChangeMark, EditorCodeVision, EditorCodeVisionEntry, EditorClick, EditorCodeBlockOptions, EditorClickHandler, EditorCommand, EditorContextMenu, EditorEngine, EditorFindQuery, EditorFoldHints, EditorFoldOutline, EditorFoldRange, EditorFoldSymbol, EditorFolds, EditorFindState, EditorReplaceOptions, EditorGutterAction, EditorGutterMarker, EditorIndentation, EditorContentChange, EditorHighlight, EditorHighlightKind, EditorHover, EditorGhostText, EditorInlayHint, EditorKeyHandler, EditorLineAction, EditorLineHighlight, EditorMarker, EditorMessages, EditorMarkerSeverity, EditorReveal, EditorRect, EditorSmartKeys, EditorOptions, EditorPosition, EditorRemoteCursor, EditorRange, EditorTextChange, EditorTrackedRange, EditorRunCommand, EditorSelectionRanges, EditorTheme, EditorViewCommand, EditorWidget, FoldRole, KeyChord, LineToken, ScopeColorSource, ScopeColors, ScopeStyle, EditorSemanticToken, LineTokenizer, SmartEditorEngineOptions, TokenizedLine, TokenizerSource } from './types.ts';
5
+ export type { Editor, EditorAttributionHover, EditorAttributionMark, EditorMarkColor, EditorBlock, EditorChangeKind, EditorChangeMark, EditorCodeVision, EditorCodeVisionEntry, EditorClick, EditorCodeBlockOptions, EditorClickHandler, EditorCommand, EditorContextMenu, EditorEngine, EditorFindQuery, EditorFoldHints, EditorFoldRangeHint, EditorFoldOutline, EditorFoldRange, EditorFoldSymbol, EditorFolds, EditorFindState, EditorReplaceOptions, EditorGutterAction, EditorGutterMarker, EditorIndentation, EditorContentChange, EditorHighlight, EditorHighlightKind, EditorHover, EditorGhostText, EditorInlayHint, EditorKeyHandler, EditorLineAction, EditorLineHighlight, EditorMarker, EditorMessages, EditorMarkerSeverity, EditorReveal, EditorRect, EditorSmartKeys, EditorOptions, EditorPosition, EditorRemoteCursor, EditorRange, EditorTextChange, EditorTrackedRange, EditorRunCommand, EditorSelectionRanges, EditorTheme, EditorViewCommand, EditorWidget, FoldRole, KeyChord, LineToken, ScopeColorSource, ScopeColors, ScopeStyle, EditorSemanticToken, LineTokenizer, SmartEditorEngineOptions, TokenizedLine, TokenizerSource } from './types.ts';
package/dist/layout.d.ts CHANGED
@@ -40,6 +40,7 @@ export interface FoldState {
40
40
  endLine: number;
41
41
  collapsed: boolean;
42
42
  closer?: number;
43
+ head?: number;
43
44
  }
44
45
  interface RowBase {
45
46
  key: string;
@@ -52,6 +53,7 @@ export interface TextRow extends RowBase {
52
53
  kind: 'text';
53
54
  lastLine: number;
54
55
  tail: number | null;
56
+ head: number | null;
55
57
  subRows: number;
56
58
  }
57
59
  export interface BlockRow extends RowBase {
@@ -139,6 +141,7 @@ export declare class EditorLayout {
139
141
  indentX(line: number): number;
140
142
  rowForLine(line: number): LayoutRow;
141
143
  isHidden(line: number): boolean;
144
+ hides(offset: number): boolean;
142
145
  visibleRows(top: number, height: number, overscan?: number): LayoutRow[];
143
146
  geometry(row: TextRow): LineGeometry;
144
147
  private stopOf;
package/dist/layout.js CHANGED
@@ -278,6 +278,7 @@ export class EditorLayout {
278
278
  const fold = folds.get(line);
279
279
  const last = Math.max(line, Math.min(count - 1, fold?.endLine ?? line));
280
280
  const tail = fold?.closer !== undefined && last > line ? this.tailOf(fold.closer, last) : null;
281
+ const head = fold?.head !== undefined && last > line ? this.tailOf(fold.head, line) : null;
281
282
  const index = this.rows.length;
282
283
  const bounds = this.wrapWidth === null ? null : this.document.getLine(line);
283
284
  const subRows = this.rowCountOf(line, bounds === null ? 0 : bounds.end - bounds.start);
@@ -288,6 +289,7 @@ export class EditorLayout {
288
289
  line,
289
290
  lastLine: last,
290
291
  tail,
292
+ head,
291
293
  subRows,
292
294
  top: 0,
293
295
  height: subRows * this.metrics.lineHeight
@@ -302,7 +304,7 @@ export class EditorLayout {
302
304
  }
303
305
  this.reflow();
304
306
  }
305
- /* The closer a fold ends in, clamped to its last line since the fold may be a moment older than an edit. */
307
+ /* An offset of a fold clamped to its line, since the fold may be a moment older than an edit. */
306
308
  tailOf(closer, line) {
307
309
  const bounds = this.document.getLine(line);
308
310
  return Math.min(bounds.end, Math.max(bounds.start, closer));
@@ -382,6 +384,12 @@ export class EditorLayout {
382
384
  const row = this.rowForLine(line);
383
385
  return row.kind === 'text' && line > row.line && line <= row.lastLine;
384
386
  }
387
+ /* Whether a fold hides the offset: on a line it hides, or past where it starts on its first line. */
388
+ hides(offset) {
389
+ const line = this.document.positionAt(offset).line;
390
+ const row = this.rowForLine(line);
391
+ return row.kind === 'text' && (this.isHidden(line) || (row.head !== null && line === row.line && offset > row.head));
392
+ }
385
393
  visibleRows(top, height, overscan = 120) {
386
394
  const result = [];
387
395
  for (let index = this.rowAt(top - overscan).index; index < this.rows.length && this.rows[index].top < top + height + overscan; index++) {
@@ -390,6 +398,12 @@ export class EditorLayout {
390
398
  return result;
391
399
  }
392
400
  geometry(row) {
401
+ if (row.head !== null) {
402
+ // The first line of a fold that starts within it, laid out up to there; seldom drawn, so never kept.
403
+ const line = this.document.getLine(row.line);
404
+ const shown = { ...line, end: row.head, text: line.text.slice(0, row.head - line.start) };
405
+ return scanLine(shown, this.metrics, this.wrapWidth, (this.inlaysByLine.get(row.line) ?? []).filter((inlay) => inlay.at <= row.head));
406
+ }
393
407
  const cached = this.geometryCache.get(row.line);
394
408
  if (cached) {
395
409
  return cached;
@@ -6,6 +6,7 @@ export const DEFAULT_SMART_KEYS = {
6
6
  smartIndentOnEnter: true,
7
7
  indentOnPaste: true,
8
8
  smartSemicolon: true,
9
+ smartArrow: true,
9
10
  camelHumps: false
10
11
  };
11
12
  /* The keys with what a host left out filled in from the defaults. */
package/dist/types.d.ts CHANGED
@@ -28,6 +28,7 @@ export interface EditorOptions {
28
28
  readonly rightMargin?: number | null;
29
29
  readonly foldOutline?: EditorFoldOutline;
30
30
  readonly messages?: Partial<EditorMessages>;
31
+ readonly label?: string;
31
32
  readonly folds?: EditorFolds;
32
33
  readonly foldDefaults?: readonly FoldRole[];
33
34
  readonly line?: number;
@@ -45,17 +46,21 @@ export interface EditorSmartKeys {
45
46
  readonly smartIndentOnEnter: boolean;
46
47
  readonly indentOnPaste: boolean;
47
48
  readonly smartSemicolon: boolean;
49
+ readonly smartArrow: boolean;
48
50
  readonly camelHumps: boolean;
49
51
  }
50
52
  export interface EditorFoldSymbol {
51
53
  readonly range: EditorRange;
52
54
  readonly body: 'function' | 'method' | 'class' | 'value';
53
55
  }
56
+ export interface EditorFoldRangeHint extends EditorFoldRange {
57
+ readonly kind?: string;
58
+ readonly startCharacter?: number;
59
+ readonly endCharacter?: number;
60
+ }
54
61
  export interface EditorFoldHints {
55
62
  readonly symbols?: readonly EditorFoldSymbol[];
56
- readonly ranges?: readonly (EditorFoldRange & {
57
- readonly kind?: string;
58
- })[];
63
+ readonly ranges?: readonly EditorFoldRangeHint[];
59
64
  }
60
65
  export interface EditorBlock {
61
66
  readonly startLine: number;
@@ -258,6 +263,7 @@ export interface Editor {
258
263
  setWhitespace(whitespace: boolean): void;
259
264
  setRightMargin(column: number | null): void;
260
265
  setFoldOutline(outline: EditorFoldOutline): void;
266
+ setLabel(label: string): void;
261
267
  setFoldHints(hints: EditorFoldHints | null): void;
262
268
  setChangeMarks(marks: readonly EditorChangeMark[]): void;
263
269
  setBlocks(blocks: readonly EditorBlock[] | null): void;
package/dist/view.d.ts CHANGED
@@ -13,6 +13,7 @@ export interface ViewSettings {
13
13
  wrap: boolean;
14
14
  smartKeys: EditorSmartKeys;
15
15
  messages: Partial<EditorMessages>;
16
+ label: string;
16
17
  guides: boolean;
17
18
  whitespace: boolean;
18
19
  rightMargin: number | null;
@@ -275,6 +276,7 @@ export declare class EditorView {
275
276
  private effectiveIndent;
276
277
  private activeGuide;
277
278
  private scopeGuide;
279
+ private caretOnGuide;
278
280
  private guideRects;
279
281
  selectionAtPoint(clientX: number, clientY: number): EditorSelection | null;
280
282
  setColumnMode(on: boolean): void;
package/dist/view.js CHANGED
@@ -208,7 +208,6 @@ export class EditorView {
208
208
  this.input.setAttribute(name, 'off');
209
209
  }
210
210
  this.input.setAttribute('aria-multiline', 'true');
211
- this.input.setAttribute('aria-label', 'Code editor');
212
211
  this.sticky.setAttribute('aria-hidden', 'true');
213
212
  this.sticky.hidden = true;
214
213
  this.overview = make('div', 'se-overview');
@@ -252,6 +251,7 @@ export class EditorView {
252
251
  /* Settings that are read off the page or off `settings` are applied again: after a change to either. */
253
252
  applySettings() {
254
253
  this.input.readOnly = this.settings.readOnly;
254
+ this.input.setAttribute('aria-label', this.settings.label);
255
255
  this.root.dataset.readonly = String(this.settings.readOnly);
256
256
  this.root.dataset.wrap = String(this.settings.wrap);
257
257
  this.root.dataset.foldOutline = this.settings.foldOutline;
@@ -446,7 +446,13 @@ export class EditorView {
446
446
  if (this.foldHints !== null) {
447
447
  this.foldHints = {
448
448
  symbols: this.foldHints.symbols?.map((hint) => ({ ...hint, from: mapOffset(hint.from, changes), to: mapOffset(hint.to, changes) })) ?? [],
449
- ranges: this.foldHints.ranges?.map((hint) => ({ ...hint, from: mapOffset(hint.from, changes), to: mapOffset(hint.to, changes) })) ?? []
449
+ ranges: this.foldHints.ranges?.map((hint) => ({
450
+ ...hint,
451
+ from: mapOffset(hint.from, changes),
452
+ to: mapOffset(hint.to, changes),
453
+ ...(hint.head === undefined ? {} : { head: mapOffset(hint.head, changes) }),
454
+ ...(hint.tail === undefined ? {} : { tail: mapOffset(hint.tail, changes) })
455
+ })) ?? []
450
456
  };
451
457
  }
452
458
  this.customFolds = this.customFolds.map((fold) => ({ from: mapOffset(fold.from, changes), to: mapOffset(fold.to, changes) }));
@@ -583,12 +589,16 @@ export class EditorView {
583
589
  this.render();
584
590
  }
585
591
  foldStates() {
586
- return this.foldRanges.map((range) => ({
587
- startLine: range.startLine,
588
- endLine: range.endLine,
589
- collapsed: this.collapsed.has(range.from),
590
- closer: this.collapsed.has(range.from) ? this.closerOf(range) : undefined
591
- }));
592
+ return this.foldRanges.map((range) => {
593
+ const collapsed = this.collapsed.has(range.from);
594
+ return {
595
+ startLine: range.startLine,
596
+ endLine: range.endLine,
597
+ collapsed,
598
+ closer: collapsed ? (range.tail ?? this.closerOf(range)) : undefined,
599
+ ...(collapsed && range.head !== undefined ? { head: range.head } : {})
600
+ };
601
+ });
592
602
  }
593
603
  /* The folds of the document, a moment after the last edit since they read all of it. */
594
604
  scheduleFolds() {
@@ -875,14 +885,14 @@ export class EditorView {
875
885
  this.moveHiddenCarets();
876
886
  this.render();
877
887
  }
878
- /* A caret in a line a fold hides would be typing where nobody can see. */
888
+ /* A caret in what a fold hides would be typing where nobody can see; it goes to where the fold's first line stops. */
879
889
  moveHiddenCarets() {
880
890
  const visible = (offset) => {
881
- const line = this.model.positionAt(offset).line;
882
- if (!this.layout.isHidden(line)) {
891
+ if (!this.layout.hides(offset)) {
883
892
  return offset;
884
893
  }
885
- return this.model.getLine(this.layout.rowForLine(line).line).end;
894
+ const row = this.layout.rowForLine(this.model.positionAt(offset).line);
895
+ return row.kind === 'text' && row.head !== null ? row.head : this.model.getLine(row.line).end;
886
896
  };
887
897
  const selections = this.model.getSelections();
888
898
  const moved = selections.map((selection) => ({ anchor: visible(selection.anchor), head: visible(selection.head) }));
@@ -893,11 +903,11 @@ export class EditorView {
893
903
  /* Opens the folds that hide an offset, so a caret or a search result is where it can be seen. */
894
904
  ensureVisible(offset) {
895
905
  const line = this.model.positionAt(offset).line;
896
- if (!this.layout.isHidden(line)) {
906
+ if (!this.layout.hides(offset)) {
897
907
  return;
898
908
  }
899
909
  for (const range of this.foldRanges) {
900
- if (range.startLine < line && range.endLine >= line) {
910
+ if ((range.startLine < line && range.endLine >= line) || (range.startLine === line && range.head !== undefined && offset > range.head)) {
901
911
  this.collapsed.delete(range.from);
902
912
  this.settled.add(range.from);
903
913
  }
@@ -1178,11 +1188,24 @@ export class EditorView {
1178
1188
  const last = inside ? reaches(line, 1) : next > indent ? reaches(line + 1, 1) : line - 1;
1179
1189
  return { column, first, last };
1180
1190
  }
1181
- /* A line at each indentation level of the rows on screen, joined where the lines go on, and the one of the scope around the caret apart. */
1191
+ /* The line and the indentation column of a caret in the whitespace that leads its line, past the first column, where it can stand on a guide. */
1192
+ caretOnGuide() {
1193
+ const position = this.model.positionAt(this.model.getPrimary().head);
1194
+ const lead = this.model.getLine(position.line).text.slice(0, position.column);
1195
+ if (position.column === 0 || !/^[\t ]*$/.test(lead)) {
1196
+ return null;
1197
+ }
1198
+ return { line: position.line, column: indentationColumn(lead, this.settings.tabSize) };
1199
+ }
1200
+ /*
1201
+ * A line at each indentation level of the rows on screen, joined where the lines go on. Apart from them stand the one
1202
+ * of the scope around the caret, and as the platform does, the one the caret stands on.
1203
+ */
1182
1204
  guideRects(rows) {
1183
1205
  const size = this.settings.tabSize;
1184
1206
  const charWidth = this.layout.metrics.charWidth;
1185
1207
  const active = this.activeGuide();
1208
+ const caret = this.caretOnGuide();
1186
1209
  const plain = [];
1187
1210
  const marked = [];
1188
1211
  const open = new Map();
@@ -1190,7 +1213,9 @@ export class EditorView {
1190
1213
  const segment = open.get(column);
1191
1214
  open.delete(column);
1192
1215
  const rect = { x: column * charWidth, y: segment.top, width: 1, height: segment.bottom - segment.top };
1193
- (active !== null && active.column === column && segment.last >= active.first && segment.first <= active.last ? marked : plain).push(rect);
1216
+ const scope = active !== null && active.column === column && segment.last >= active.first && segment.first <= active.last;
1217
+ const under = caret !== null && caret.column === column && segment.first <= caret.line && caret.line <= segment.last;
1218
+ (scope || under ? marked : plain).push(rect);
1194
1219
  };
1195
1220
  for (const row of rows) {
1196
1221
  if (row.kind !== 'text') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/editor",
3
- "version": "0.17.0-beta.1",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -39,7 +39,7 @@
39
39
  "test": "bun test --conditions=source"
40
40
  },
41
41
  "dependencies": {
42
- "@adecore/editor-core": "0.17.0-beta.1",
42
+ "@adecore/editor-core": "0.17.0",
43
43
  "shiki": "^4.4.3",
44
44
  "linkedom": "^0.18.13"
45
45
  },
package/src/controller.ts CHANGED
@@ -77,7 +77,6 @@ let lineClip: string | null = null;
77
77
 
78
78
  /* Typing in the same stretch is one undo step until a pause this long. */
79
79
  const TYPING_PAUSE_MS = 750;
80
- const PAIR_CHARACTER = /^[()[\]{}'"`;<]$/;
81
80
 
82
81
  /*
83
82
  * What a person does to the editor: keys, typing, the clipboard, an input method, the mouse. It turns
@@ -293,6 +292,7 @@ export class InputController {
293
292
  smartEnter: keys.smartIndentOnEnter,
294
293
  indentOnPaste: keys.indentOnPaste,
295
294
  smartSemicolon: keys.smartSemicolon,
295
+ smartArrow: keys.smartArrow,
296
296
  language: settings.language ?? 'plaintext',
297
297
  camelCase: keys.camelHumps,
298
298
  visualLine: (offset) => this.view.rowOf(offset),
@@ -415,7 +415,8 @@ export class InputController {
415
415
  for (const selection of this.view.model.getSelections()) {
416
416
  this.view.ensureVisible(selection.head);
417
417
  }
418
- if (PAIR_CHARACTER.test(text)) {
418
+ // Every key goes through the model, which decides by the key before it too, as for the arrow of PHP.
419
+ if (text.length === 1) {
419
420
  this.view.model.typeText(text, { ...this.commandOptions(), historyGroup: this.inputGroup() });
420
421
  this.view.revealCaret();
421
422
  } else {
package/src/editor.css CHANGED
@@ -8,7 +8,7 @@
8
8
  --find-current-text: var(--text);
9
9
  --editor-selection: color-mix(in srgb, var(--accent) 16%, transparent);
10
10
  --editor-occurrence: color-mix(in srgb, var(--text) 7%, transparent);
11
- --editor-guide: color-mix(in srgb, var(--text) 8%, transparent);
11
+ --editor-guide: color-mix(in srgb, var(--text) 5%, transparent);
12
12
  --editor-guide-active: color-mix(in srgb, var(--text) 24%, transparent);
13
13
  --editor-margin: color-mix(in srgb, var(--text) 14%, transparent);
14
14
  --editor-whitespace: color-mix(in srgb, var(--text) 30%, transparent);
@@ -29,7 +29,7 @@
29
29
  --find-match: color-mix(in srgb, var(--status-needs-you) 24%, transparent);
30
30
  --editor-selection: color-mix(in srgb, var(--accent) 28%, transparent);
31
31
  --editor-occurrence: color-mix(in srgb, var(--text) 9%, transparent);
32
- --editor-guide: color-mix(in srgb, var(--text) 10%, transparent);
32
+ --editor-guide: color-mix(in srgb, var(--text) 6%, transparent);
33
33
  --editor-guide-active: color-mix(in srgb, var(--text) 28%, transparent);
34
34
  --editor-margin: color-mix(in srgb, var(--text) 16%, transparent);
35
35
  --editor-whitespace: color-mix(in srgb, var(--text) 32%, transparent);
@@ -728,13 +728,15 @@
728
728
 
729
729
  /*
730
730
  * A code vision row is quiet text one code line high above a declaration, in the declaration's
731
- * indentation. Its entries stand a text size apart and light up as a link under the pointer.
731
+ * indentation, a step below the code as the platform draws it. Its entries stand a text size apart
732
+ * and light up as a link under the pointer.
732
733
  */
733
734
  .se-block.se-lens {
734
735
  display: flex;
735
736
  align-items: center;
736
737
  gap: var(--text-xs);
737
738
  padding: 0;
739
+ font-size: max(12px, calc(var(--code-font-size, 13px) - 2px));
738
740
  white-space: nowrap;
739
741
  }
740
742
  .se-lens-entry {
package/src/engine.ts CHANGED
@@ -51,6 +51,7 @@ import { fillLens, lensSignature } from './lens.ts';
51
51
  import { EditorView, type ViewSettings } from './view.ts';
52
52
 
53
53
  const DEFAULT_TAB_SIZE = 4;
54
+ const DEFAULT_LABEL = 'Code editor';
54
55
  const DEFAULT_WIDGET_OWNER = 'default';
55
56
 
56
57
  class SmartEditor implements Editor {
@@ -90,6 +91,7 @@ class SmartEditor implements Editor {
90
91
  wrap: options.wrap ?? false,
91
92
  smartKeys: resolveSmartKeys(options.smartKeys),
92
93
  messages: options.messages ?? {},
94
+ label: options.label ?? DEFAULT_LABEL,
93
95
  guides: options.guides !== false,
94
96
  whitespace: options.whitespace === true,
95
97
  rightMargin: options.rightMargin ?? null,
@@ -659,6 +661,11 @@ class SmartEditor implements Editor {
659
661
  this.view.applySettings();
660
662
  }
661
663
 
664
+ setLabel(label: string): void {
665
+ this.settings.label = label;
666
+ this.view.applySettings();
667
+ }
668
+
662
669
  setFoldHints(hints: EditorFoldHints | null): void {
663
670
  this.view.setFoldHints(
664
671
  hints === null
@@ -669,11 +676,19 @@ class SmartEditor implements Editor {
669
676
  to: this.offsetAt(symbol.range.end),
670
677
  body: symbol.body
671
678
  })),
672
- ranges: (hints.ranges ?? []).map((range) => ({
673
- from: this.model.getLine(Math.min(range.startLine, this.model.getLineCount() - 1)).start,
674
- to: this.model.getLine(Math.min(range.endLine, this.model.getLineCount() - 1)).start,
675
- ...(range.kind === undefined ? {} : { kind: range.kind })
676
- }))
679
+ ranges: (hints.ranges ?? []).map((range) => {
680
+ const first = this.model.getLine(Math.min(range.startLine, this.model.getLineCount() - 1));
681
+ const last = this.model.getLine(Math.min(range.endLine, this.model.getLineCount() - 1));
682
+ const within = (line: { start: number; end: number }, character: number): number =>
683
+ Math.min(line.end, line.start + Math.max(0, character));
684
+ return {
685
+ from: first.start,
686
+ to: last.start,
687
+ ...(range.kind === undefined ? {} : { kind: range.kind }),
688
+ ...(range.startCharacter === undefined ? {} : { head: within(first, range.startCharacter) }),
689
+ ...(range.endCharacter === undefined ? {} : { tail: within(last, range.endCharacter) })
690
+ };
691
+ })
677
692
  }
678
693
  );
679
694
  }
package/src/fake.ts CHANGED
@@ -91,6 +91,7 @@ export class FakeEditor implements Editor {
91
91
  this.theme = options.theme;
92
92
  this.readOnly = options.readOnly ?? false;
93
93
  this.readOnlyReason = options.readOnlyReason;
94
+ this.label = options.label ?? 'Code editor';
94
95
  this.revealedLine = options.line ?? null;
95
96
  this.column = options.column ?? null;
96
97
  this.scrollTop = options.scrollTop ?? null;
@@ -487,6 +488,12 @@ export class FakeEditor implements Editor {
487
488
  this.foldOutline = outline;
488
489
  }
489
490
 
491
+ label: string;
492
+
493
+ setLabel(label: string): void {
494
+ this.label = label;
495
+ }
496
+
490
497
  setFoldHints(hints: EditorFoldHints | null): void {
491
498
  this.foldHints = hints;
492
499
  }
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ export type {
20
20
  EditorEngine,
21
21
  EditorFindQuery,
22
22
  EditorFoldHints,
23
+ EditorFoldRangeHint,
23
24
  EditorFoldOutline,
24
25
  EditorFoldRange,
25
26
  EditorFoldSymbol,
package/src/layout.ts CHANGED
@@ -49,6 +49,8 @@ export interface FoldState {
49
49
  collapsed: boolean;
50
50
  /* Where the closing delimiter starts on `endLine`; a collapsed fold keeps it drawn after its placeholder. */
51
51
  closer?: number;
52
+ /* Where a collapsed fold starts hiding on `startLine`; without it the whole first line stays drawn. */
53
+ head?: number;
52
54
  }
53
55
 
54
56
  interface RowBase {
@@ -66,6 +68,8 @@ export interface TextRow extends RowBase {
66
68
  lastLine: number;
67
69
  /* The offset on `lastLine` from which a collapsed fold draws what stays visible after its placeholder. */
68
70
  tail: number | null;
71
+ /* The offset on `line` up to which a collapsed fold draws its first line; null when it draws the whole line. */
72
+ head: number | null;
69
73
  /* Visual lines, which is more than one for a wrapped line. */
70
74
  subRows: number;
71
75
  }
@@ -429,6 +433,7 @@ export class EditorLayout {
429
433
  const fold = folds.get(line);
430
434
  const last = Math.max(line, Math.min(count - 1, fold?.endLine ?? line));
431
435
  const tail = fold?.closer !== undefined && last > line ? this.tailOf(fold.closer, last) : null;
436
+ const head = fold?.head !== undefined && last > line ? this.tailOf(fold.head, line) : null;
432
437
  const index = this.rows.length;
433
438
  const bounds = this.wrapWidth === null ? null : this.document.getLine(line);
434
439
  const subRows = this.rowCountOf(line, bounds === null ? 0 : bounds.end - bounds.start);
@@ -439,6 +444,7 @@ export class EditorLayout {
439
444
  line,
440
445
  lastLine: last,
441
446
  tail,
447
+ head,
442
448
  subRows,
443
449
  top: 0,
444
450
  height: subRows * this.metrics.lineHeight
@@ -454,7 +460,7 @@ export class EditorLayout {
454
460
  this.reflow();
455
461
  }
456
462
 
457
- /* The closer a fold ends in, clamped to its last line since the fold may be a moment older than an edit. */
463
+ /* An offset of a fold clamped to its line, since the fold may be a moment older than an edit. */
458
464
  private tailOf(closer: number, line: number): number {
459
465
  const bounds = this.document.getLine(line);
460
466
  return Math.min(bounds.end, Math.max(bounds.start, closer));
@@ -541,6 +547,13 @@ export class EditorLayout {
541
547
  return row.kind === 'text' && line > row.line && line <= row.lastLine;
542
548
  }
543
549
 
550
+ /* Whether a fold hides the offset: on a line it hides, or past where it starts on its first line. */
551
+ hides(offset: number): boolean {
552
+ const line = this.document.positionAt(offset).line;
553
+ const row = this.rowForLine(line);
554
+ return row.kind === 'text' && (this.isHidden(line) || (row.head !== null && line === row.line && offset > row.head));
555
+ }
556
+
544
557
  visibleRows(top: number, height: number, overscan = 120): LayoutRow[] {
545
558
  const result: LayoutRow[] = [];
546
559
  for (let index = this.rowAt(top - overscan).index; index < this.rows.length && this.rows[index]!.top < top + height + overscan; index++) {
@@ -550,6 +563,17 @@ export class EditorLayout {
550
563
  }
551
564
 
552
565
  geometry(row: TextRow): LineGeometry {
566
+ if (row.head !== null) {
567
+ // The first line of a fold that starts within it, laid out up to there; seldom drawn, so never kept.
568
+ const line = this.document.getLine(row.line);
569
+ const shown = { ...line, end: row.head, text: line.text.slice(0, row.head - line.start) };
570
+ return scanLine(
571
+ shown,
572
+ this.metrics,
573
+ this.wrapWidth,
574
+ (this.inlaysByLine.get(row.line) ?? []).filter((inlay) => inlay.at <= row.head!)
575
+ );
576
+ }
553
577
  const cached = this.geometryCache.get(row.line);
554
578
  if (cached) {
555
579
  return cached;
package/src/smart-keys.ts CHANGED
@@ -8,6 +8,7 @@ export const DEFAULT_SMART_KEYS: EditorSmartKeys = {
8
8
  smartIndentOnEnter: true,
9
9
  indentOnPaste: true,
10
10
  smartSemicolon: true,
11
+ smartArrow: true,
11
12
  camelHumps: false
12
13
  };
13
14
 
package/src/types.ts CHANGED
@@ -68,6 +68,8 @@ export interface EditorOptions {
68
68
  readonly foldOutline?: EditorFoldOutline;
69
69
  /* What the editor says to a person itself, in the host's words. */
70
70
  readonly messages?: Partial<EditorMessages>;
71
+ /* The accessible name of the editor, such as "Commit message", in the host's language; `Code editor` without one. */
72
+ readonly label?: string;
71
73
  /* The folds a host kept when the file was last open. */
72
74
  readonly folds?: EditorFolds;
73
75
  /* Without remembered folds, the folds of these roles fold, as they do the first time a file opens, and again as a language server names more of them. */
@@ -101,6 +103,8 @@ export interface EditorSmartKeys {
101
103
  readonly indentOnPaste: boolean;
102
104
  /* A `;` typed inside a call goes to the end of the statement. */
103
105
  readonly smartSemicolon: boolean;
106
+ /* In PHP a `-` typed after a variable, a member or a `)` or `]` becomes `->`. */
107
+ readonly smartArrow: boolean;
104
108
  /* Moving by word also stops inside `camelCase` and `snake_case` words. Off. */
105
109
  readonly camelHumps: boolean;
106
110
  }
@@ -112,9 +116,16 @@ export interface EditorFoldSymbol {
112
116
  readonly body: 'function' | 'method' | 'class' | 'value';
113
117
  }
114
118
 
119
+ /* A range a server folds; with characters, a collapsed fold hides from `startCharacter` on its first line up to `endCharacter` on its last, as LSP says. */
120
+ export interface EditorFoldRangeHint extends EditorFoldRange {
121
+ readonly kind?: string;
122
+ readonly startCharacter?: number;
123
+ readonly endCharacter?: number;
124
+ }
125
+
115
126
  export interface EditorFoldHints {
116
127
  readonly symbols?: readonly EditorFoldSymbol[];
117
- readonly ranges?: readonly (EditorFoldRange & { readonly kind?: string })[];
128
+ readonly ranges?: readonly EditorFoldRangeHint[];
118
129
  }
119
130
 
120
131
  /* A part of the document with a header line, such as a function, a class or a method. */
@@ -491,6 +502,7 @@ export interface Editor {
491
502
  /* The column to draw a line at; null takes it away. */
492
503
  setRightMargin(column: number | null): void;
493
504
  setFoldOutline(outline: EditorFoldOutline): void;
505
+ setLabel(label: string): void;
494
506
  /* 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. */
495
507
  setFoldHints(hints: EditorFoldHints | null): void;
496
508
  /* Marks that follow their lines through edits until the host sets them again. */
package/src/view.ts CHANGED
@@ -67,6 +67,7 @@ export interface ViewSettings {
67
67
  wrap: boolean;
68
68
  smartKeys: EditorSmartKeys;
69
69
  messages: Partial<EditorMessages>;
70
+ label: string;
70
71
  /* A line at each indentation level, and the one of the scope around the caret stronger. */
71
72
  guides: boolean;
72
73
  /* Spaces drawn as dots and tabs as arrows. */
@@ -299,7 +300,6 @@ export class EditorView {
299
300
  this.input.setAttribute(name, 'off');
300
301
  }
301
302
  this.input.setAttribute('aria-multiline', 'true');
302
- this.input.setAttribute('aria-label', 'Code editor');
303
303
  this.sticky.setAttribute('aria-hidden', 'true');
304
304
  this.sticky.hidden = true;
305
305
  this.overview = make('div', 'se-overview');
@@ -345,6 +345,7 @@ export class EditorView {
345
345
  /* Settings that are read off the page or off `settings` are applied again: after a change to either. */
346
346
  applySettings(): void {
347
347
  this.input.readOnly = this.settings.readOnly;
348
+ this.input.setAttribute('aria-label', this.settings.label);
348
349
  this.root.dataset.readonly = String(this.settings.readOnly);
349
350
  this.root.dataset.wrap = String(this.settings.wrap);
350
351
  this.root.dataset.foldOutline = this.settings.foldOutline;
@@ -569,7 +570,14 @@ export class EditorView {
569
570
  this.foldHints = {
570
571
  symbols:
571
572
  this.foldHints.symbols?.map((hint) => ({ ...hint, from: mapOffset(hint.from, changes), to: mapOffset(hint.to, changes) })) ?? [],
572
- ranges: this.foldHints.ranges?.map((hint) => ({ ...hint, from: mapOffset(hint.from, changes), to: mapOffset(hint.to, changes) })) ?? []
573
+ ranges:
574
+ this.foldHints.ranges?.map((hint) => ({
575
+ ...hint,
576
+ from: mapOffset(hint.from, changes),
577
+ to: mapOffset(hint.to, changes),
578
+ ...(hint.head === undefined ? {} : { head: mapOffset(hint.head, changes) }),
579
+ ...(hint.tail === undefined ? {} : { tail: mapOffset(hint.tail, changes) })
580
+ })) ?? []
573
581
  };
574
582
  }
575
583
  this.customFolds = this.customFolds.map((fold) => ({ from: mapOffset(fold.from, changes), to: mapOffset(fold.to, changes) }));
@@ -722,12 +730,16 @@ export class EditorView {
722
730
  }
723
731
 
724
732
  private foldStates(): FoldState[] {
725
- return this.foldRanges.map((range) => ({
726
- startLine: range.startLine,
727
- endLine: range.endLine,
728
- collapsed: this.collapsed.has(range.from),
729
- closer: this.collapsed.has(range.from) ? this.closerOf(range) : undefined
730
- }));
733
+ return this.foldRanges.map((range) => {
734
+ const collapsed = this.collapsed.has(range.from);
735
+ return {
736
+ startLine: range.startLine,
737
+ endLine: range.endLine,
738
+ collapsed,
739
+ closer: collapsed ? (range.tail ?? this.closerOf(range)) : undefined,
740
+ ...(collapsed && range.head !== undefined ? { head: range.head } : {})
741
+ };
742
+ });
731
743
  }
732
744
 
733
745
  /* The folds of the document, a moment after the last edit since they read all of it. */
@@ -1045,14 +1057,14 @@ export class EditorView {
1045
1057
  this.render();
1046
1058
  }
1047
1059
 
1048
- /* A caret in a line a fold hides would be typing where nobody can see. */
1060
+ /* A caret in what a fold hides would be typing where nobody can see; it goes to where the fold's first line stops. */
1049
1061
  private moveHiddenCarets(): void {
1050
1062
  const visible = (offset: number): number => {
1051
- const line = this.model.positionAt(offset).line;
1052
- if (!this.layout.isHidden(line)) {
1063
+ if (!this.layout.hides(offset)) {
1053
1064
  return offset;
1054
1065
  }
1055
- return this.model.getLine(this.layout.rowForLine(line).line).end;
1066
+ const row = this.layout.rowForLine(this.model.positionAt(offset).line);
1067
+ return row.kind === 'text' && row.head !== null ? row.head : this.model.getLine(row.line).end;
1056
1068
  };
1057
1069
  const selections = this.model.getSelections();
1058
1070
  const moved = selections.map((selection) => ({ anchor: visible(selection.anchor), head: visible(selection.head) }));
@@ -1064,11 +1076,11 @@ export class EditorView {
1064
1076
  /* Opens the folds that hide an offset, so a caret or a search result is where it can be seen. */
1065
1077
  ensureVisible(offset: number): void {
1066
1078
  const line = this.model.positionAt(offset).line;
1067
- if (!this.layout.isHidden(line)) {
1079
+ if (!this.layout.hides(offset)) {
1068
1080
  return;
1069
1081
  }
1070
1082
  for (const range of this.foldRanges) {
1071
- if (range.startLine < line && range.endLine >= line) {
1083
+ if ((range.startLine < line && range.endLine >= line) || (range.startLine === line && range.head !== undefined && offset > range.head)) {
1072
1084
  this.collapsed.delete(range.from);
1073
1085
  this.settled.add(range.from);
1074
1086
  }
@@ -1374,11 +1386,25 @@ export class EditorView {
1374
1386
  return { column, first, last };
1375
1387
  }
1376
1388
 
1377
- /* A line at each indentation level of the rows on screen, joined where the lines go on, and the one of the scope around the caret apart. */
1389
+ /* The line and the indentation column of a caret in the whitespace that leads its line, past the first column, where it can stand on a guide. */
1390
+ private caretOnGuide(): { line: number; column: number } | null {
1391
+ const position = this.model.positionAt(this.model.getPrimary().head);
1392
+ const lead = this.model.getLine(position.line).text.slice(0, position.column);
1393
+ if (position.column === 0 || !/^[\t ]*$/.test(lead)) {
1394
+ return null;
1395
+ }
1396
+ return { line: position.line, column: indentationColumn(lead, this.settings.tabSize) };
1397
+ }
1398
+
1399
+ /*
1400
+ * A line at each indentation level of the rows on screen, joined where the lines go on. Apart from them stand the one
1401
+ * of the scope around the caret, and as the platform does, the one the caret stands on.
1402
+ */
1378
1403
  private guideRects(rows: readonly LayoutRow[]): { plain: LayoutRect[]; active: LayoutRect[] } {
1379
1404
  const size = this.settings.tabSize;
1380
1405
  const charWidth = this.layout.metrics.charWidth;
1381
1406
  const active = this.activeGuide();
1407
+ const caret = this.caretOnGuide();
1382
1408
  const plain: LayoutRect[] = [];
1383
1409
  const marked: LayoutRect[] = [];
1384
1410
  interface Open {
@@ -1392,7 +1418,9 @@ export class EditorView {
1392
1418
  const segment = open.get(column)!;
1393
1419
  open.delete(column);
1394
1420
  const rect = { x: column * charWidth, y: segment.top, width: 1, height: segment.bottom - segment.top };
1395
- (active !== null && active.column === column && segment.last >= active.first && segment.first <= active.last ? marked : plain).push(rect);
1421
+ const scope = active !== null && active.column === column && segment.last >= active.first && segment.first <= active.last;
1422
+ const under = caret !== null && caret.column === column && segment.first <= caret.line && caret.line <= segment.last;
1423
+ (scope || under ? marked : plain).push(rect);
1396
1424
  };
1397
1425
  for (const row of rows) {
1398
1426
  if (row.kind !== 'text') {